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