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:
189
src/main/java/be/seeseepuff/webgit/service/GitService.java
Normal file
189
src/main/java/be/seeseepuff/webgit/service/GitService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
219
src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java
Normal file
219
src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user