diff --git a/src/main/java/be/seeseepuff/webgit/service/GitService.java b/src/main/java/be/seeseepuff/webgit/service/GitService.java new file mode 100644 index 0000000..fef468e --- /dev/null +++ b/src/main/java/be/seeseepuff/webgit/service/GitService.java @@ -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 listRepositories() throws IOException + { + Path gitDirBase = properties.getGitDirPath(); + if (!Files.exists(gitDirBase)) + return List.of(); + + try (Stream dirs = Files.list(gitDirBase)) + { + return dirs + .filter(Files::isDirectory) + .map(p -> p.getFileName().toString()) + .sorted() + .toList(); + } + } + + public List 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 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 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 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); + } +} diff --git a/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java b/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java new file mode 100644 index 0000000..c5c0846 --- /dev/null +++ b/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java @@ -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 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 repos = gitService.listRepositories(); + assertEquals(List.of("alpha", "beta"), repos); + } + + @Test + void listBranchesReturnsDefaultBranch() throws GitAPIException, IOException + { + gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo"); + + List 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 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 modified = gitService.getModifiedFiles("myrepo"); + assertTrue(modified.contains("README.md")); + } + + @Test + void getStagedFilesReturnsEmpty() throws GitAPIException, IOException + { + gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo"); + + List 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 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 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")); + } +}