Add GitService with clone, list, branch, commit, push, pull

Implements all core Git operations using JGit with separate
worktree and git-dir paths. Includes comprehensive unit tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-26 08:41:25 +01:00
parent a7d03b3410
commit 239b5367a3
2 changed files with 408 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
package be.seeseepuff.webgit.service;
import be.seeseepuff.webgit.config.WebgitProperties;
import lombok.RequiredArgsConstructor;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
@Service
@RequiredArgsConstructor
public class GitService
{
private final WebgitProperties properties;
public void cloneRepository(String url, String name) throws GitAPIException, IOException
{
Path worktree = properties.getWorktreePath().resolve(name);
Path gitDir = properties.getGitDirPath().resolve(name);
Files.createDirectories(worktree);
Files.createDirectories(gitDir);
try (Git git = Git.cloneRepository()
.setURI(url)
.setDirectory(worktree.toFile())
.setGitDir(gitDir.toFile())
.call())
{
// clone complete
}
}
public List<String> listRepositories() throws IOException
{
Path gitDirBase = properties.getGitDirPath();
if (!Files.exists(gitDirBase))
return List.of();
try (Stream<Path> dirs = Files.list(gitDirBase))
{
return dirs
.filter(Files::isDirectory)
.map(p -> p.getFileName().toString())
.sorted()
.toList();
}
}
public List<String> listBranches(String name) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
return git.branchList()
.call()
.stream()
.map(Ref::getName)
.map(ref -> ref.startsWith("refs/heads/") ? ref.substring("refs/heads/".length()) : ref)
.toList();
}
}
public String getCurrentBranch(String name) throws IOException
{
try (Git git = openRepository(name))
{
return git.getRepository().getBranch();
}
}
public void checkoutBranch(String name, String branch) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
git.checkout()
.setName(branch)
.call();
}
}
public void createAndCheckoutBranch(String name, String branch) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
git.checkout()
.setCreateBranch(true)
.setName(branch)
.call();
}
}
public List<String> getModifiedFiles(String name) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
var status = git.status().call();
return Stream.of(
status.getModified().stream(),
status.getUntracked().stream(),
status.getMissing().stream()
)
.flatMap(s -> s)
.sorted()
.toList();
}
}
public List<String> getStagedFiles(String name) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
var status = git.status().call();
return Stream.of(
status.getAdded().stream(),
status.getChanged().stream(),
status.getRemoved().stream()
)
.flatMap(s -> s)
.sorted()
.toList();
}
}
public void stageFiles(String name, List<String> files) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
for (String file : files)
{
if (Files.exists(properties.getWorktreePath().resolve(name).resolve(file)))
{
git.add().addFilepattern(file).call();
}
else
{
git.rm().addFilepattern(file).call();
}
}
}
}
public void commit(String name, String message) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
git.commit()
.setMessage(message)
.call();
}
}
public void push(String name) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
git.push().call();
}
}
public void pull(String name) throws IOException, GitAPIException
{
try (Git git = openRepository(name))
{
git.pull().call();
}
}
private Git openRepository(String name) throws IOException
{
Path gitDir = properties.getGitDirPath().resolve(name);
Path worktree = properties.getWorktreePath().resolve(name);
Repository repo = new FileRepositoryBuilder()
.setGitDir(gitDir.toFile())
.setWorkTree(worktree.toFile())
.build();
return new Git(repo);
}
}

View File

@@ -0,0 +1,219 @@
package be.seeseepuff.webgit.service;
import be.seeseepuff.webgit.config.WebgitProperties;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class GitServiceTest
{
@TempDir
Path tempDir;
private Path worktreePath;
private Path gitDirPath;
private Path bareRemote;
private GitService gitService;
@BeforeEach
void setUp() throws GitAPIException
{
worktreePath = tempDir.resolve("worktrees");
gitDirPath = tempDir.resolve("gitdirs");
bareRemote = tempDir.resolve("remote.git");
// Create a bare remote with an initial commit so we have something to clone
Git.init().setDirectory(bareRemote.toFile()).setBare(true).call().close();
// Create a temp working copy to make an initial commit
Path tmpWork = tempDir.resolve("tmp-work");
try (Git tmp = Git.cloneRepository()
.setURI(bareRemote.toUri().toString())
.setDirectory(tmpWork.toFile())
.call())
{
Files.writeString(tmpWork.resolve("README.md"), "# Test");
tmp.add().addFilepattern("README.md").call();
tmp.commit().setMessage("Initial commit").call();
tmp.push().call();
}
catch (IOException e)
{
throw new RuntimeException(e);
}
WebgitProperties props = new WebgitProperties();
props.setWorktreePath(worktreePath);
props.setGitDirPath(gitDirPath);
gitService = new GitService(props);
}
@Test
void cloneCreatesWorktreeAndGitDir() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
assertTrue(Files.exists(worktreePath.resolve("myrepo/README.md")));
assertTrue(Files.exists(gitDirPath.resolve("myrepo/HEAD")));
}
@Test
void listRepositoriesReturnsEmptyWhenNothingCloned() throws IOException
{
List<String> repos = gitService.listRepositories();
assertTrue(repos.isEmpty());
}
@Test
void listRepositoriesReturnsClonedRepos() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "alpha");
gitService.cloneRepository(bareRemote.toUri().toString(), "beta");
List<String> repos = gitService.listRepositories();
assertEquals(List.of("alpha", "beta"), repos);
}
@Test
void listBranchesReturnsDefaultBranch() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
List<String> branches = gitService.listBranches("myrepo");
assertEquals(1, branches.size());
assertTrue(branches.contains("master") || branches.contains("main"));
}
@Test
void getCurrentBranchReturnsDefaultBranch() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
String branch = gitService.getCurrentBranch("myrepo");
assertTrue("master".equals(branch) || "main".equals(branch));
}
@Test
void createAndCheckoutBranchCreatesNewBranch() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
gitService.createAndCheckoutBranch("myrepo", "feature-x");
assertEquals("feature-x", gitService.getCurrentBranch("myrepo"));
assertTrue(gitService.listBranches("myrepo").contains("feature-x"));
}
@Test
void checkoutBranchSwitchesBranch() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
String defaultBranch = gitService.getCurrentBranch("myrepo");
gitService.createAndCheckoutBranch("myrepo", "feature-y");
assertEquals("feature-y", gitService.getCurrentBranch("myrepo"));
gitService.checkoutBranch("myrepo", defaultBranch);
assertEquals(defaultBranch, gitService.getCurrentBranch("myrepo"));
}
@Test
void getModifiedFilesDetectsChanges() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
// Modify an existing file
Files.writeString(worktreePath.resolve("myrepo/README.md"), "# Changed");
// Create a new untracked file
Files.writeString(worktreePath.resolve("myrepo/newfile.txt"), "hello");
List<String> modified = gitService.getModifiedFiles("myrepo");
assertTrue(modified.contains("README.md"));
assertTrue(modified.contains("newfile.txt"));
}
@Test
void getModifiedFilesDetectsDeletedFiles() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.delete(worktreePath.resolve("myrepo/README.md"));
List<String> modified = gitService.getModifiedFiles("myrepo");
assertTrue(modified.contains("README.md"));
}
@Test
void getStagedFilesReturnsEmpty() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
List<String> staged = gitService.getStagedFiles("myrepo");
assertTrue(staged.isEmpty());
}
@Test
void stageFilesAndGetStagedFiles() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.writeString(worktreePath.resolve("myrepo/newfile.txt"), "hello");
gitService.stageFiles("myrepo", List.of("newfile.txt"));
List<String> staged = gitService.getStagedFiles("myrepo");
assertTrue(staged.contains("newfile.txt"));
}
@Test
void stageDeletedFile() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.delete(worktreePath.resolve("myrepo/README.md"));
gitService.stageFiles("myrepo", List.of("README.md"));
List<String> staged = gitService.getStagedFiles("myrepo");
assertTrue(staged.contains("README.md"));
assertTrue(gitService.getModifiedFiles("myrepo").isEmpty());
}
@Test
void commitCreatesCommit() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.writeString(worktreePath.resolve("myrepo/newfile.txt"), "hello");
gitService.stageFiles("myrepo", List.of("newfile.txt"));
gitService.commit("myrepo", "Add newfile");
// After committing, staged should be empty
assertTrue(gitService.getStagedFiles("myrepo").isEmpty());
assertTrue(gitService.getModifiedFiles("myrepo").isEmpty());
}
@Test
void pushSendsToRemote() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
Files.writeString(worktreePath.resolve("myrepo/newfile.txt"), "hello");
gitService.stageFiles("myrepo", List.of("newfile.txt"));
gitService.commit("myrepo", "Add newfile");
assertDoesNotThrow(() -> gitService.push("myrepo"));
}
@Test
void pullFetchesFromRemote() throws GitAPIException, IOException
{
gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo");
assertDoesNotThrow(() -> gitService.pull("myrepo"));
}
}