From fdc520cfafcd13af8b154f10bd954a9d9eb0e68f Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Fri, 27 Feb 2026 09:44:38 +0100 Subject: [PATCH] Add commits page with graph, diff, file browser, and checkout New features: - Commits list with ASCII graph, hash, message, author, date - Single commit diff view with per-file diffs - File tree browser at any commit - File content viewer at any commit - Checkout entire commit (detached HEAD) - Checkout selected files from a commit New GitService methods: listCommits, getCommitDiff, listFilesAtCommit, getFileContentAtCommit, checkoutCommit, checkoutFilesFromCommit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../webgit/controller/RepoController.java | 59 +++++ .../seeseepuff/webgit/model/CommitInfo.java | 15 ++ .../be/seeseepuff/webgit/model/DiffInfo.java | 10 + .../be/seeseepuff/webgit/model/FileInfo.java | 8 + .../seeseepuff/webgit/service/GitService.java | 221 ++++++++++++++++++ src/main/resources/templates/blob.html | 31 +++ src/main/resources/templates/commit.html | 52 +++++ src/main/resources/templates/commits.html | 40 ++++ src/main/resources/templates/nav.html | 1 + src/main/resources/templates/tree.html | 34 +++ .../webgit/controller/RepoControllerTest.java | 77 ++++++ .../webgit/service/GitServiceTest.java | 86 +++++++ 12 files changed, 634 insertions(+) create mode 100644 src/main/java/be/seeseepuff/webgit/model/CommitInfo.java create mode 100644 src/main/java/be/seeseepuff/webgit/model/DiffInfo.java create mode 100644 src/main/java/be/seeseepuff/webgit/model/FileInfo.java create mode 100644 src/main/resources/templates/blob.html create mode 100644 src/main/resources/templates/commit.html create mode 100644 src/main/resources/templates/commits.html create mode 100644 src/main/resources/templates/tree.html diff --git a/src/main/java/be/seeseepuff/webgit/controller/RepoController.java b/src/main/java/be/seeseepuff/webgit/controller/RepoController.java index a960468..d759547 100644 --- a/src/main/java/be/seeseepuff/webgit/controller/RepoController.java +++ b/src/main/java/be/seeseepuff/webgit/controller/RepoController.java @@ -58,6 +58,65 @@ public class RepoController return "manage"; } + @GetMapping("/repo/{name}/commits") + public String commits(@PathVariable String name, Model model) throws IOException + { + model.addAttribute("name", name); + model.addAttribute("commits", gitService.listCommits(name)); + return "commits"; + } + + @GetMapping("/repo/{name}/commit/{hash}") + public String commitDetail(@PathVariable String name, @PathVariable String hash, Model model) throws IOException + { + model.addAttribute("name", name); + model.addAttribute("hash", hash); + model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length()))); + model.addAttribute("diffs", gitService.getCommitDiff(name, hash)); + return "commit"; + } + + @GetMapping("/repo/{name}/tree/{hash}") + public String tree(@PathVariable String name, @PathVariable String hash, + @RequestParam(required = false, defaultValue = "") String path, Model model) throws IOException + { + model.addAttribute("name", name); + model.addAttribute("hash", hash); + model.addAttribute("shortHash", hash.substring(0, Math.min(7, hash.length()))); + model.addAttribute("path", path); + model.addAttribute("files", gitService.listFilesAtCommit(name, hash, path)); + return "tree"; + } + + @GetMapping("/repo/{name}/blob/{hash}/**") + public String blob(@PathVariable String name, @PathVariable String hash, + jakarta.servlet.http.HttpServletRequest request, Model model) throws IOException + { + String fullPath = request.getRequestURI(); + String prefix = "/repo/" + name + "/blob/" + hash + "/"; + String filePath = fullPath.substring(prefix.length()); + model.addAttribute("name", name); + model.addAttribute("hash", hash); + model.addAttribute("filePath", filePath); + model.addAttribute("content", gitService.getFileContentAtCommit(name, hash, filePath)); + return "blob"; + } + + @PostMapping("/repo/{name}/checkout-commit") + public String checkoutCommit(@PathVariable String name, @RequestParam String hash) throws IOException, GitAPIException + { + gitService.checkoutCommit(name, hash); + return "redirect:/repo/" + name + "/commits"; + } + + @PostMapping("/repo/{name}/checkout-files") + public String checkoutFiles(@PathVariable String name, @RequestParam String hash, + @RequestParam List files) throws IOException, GitAPIException + { + gitService.checkoutFilesFromCommit(name, hash, files); + return "redirect:/repo/" + name + "/changes"; + } + @PostMapping("/repo/{name}/checkout") public String checkout(@PathVariable String name, @RequestParam String branch) throws IOException, GitAPIException { diff --git a/src/main/java/be/seeseepuff/webgit/model/CommitInfo.java b/src/main/java/be/seeseepuff/webgit/model/CommitInfo.java new file mode 100644 index 0000000..5dc7d74 --- /dev/null +++ b/src/main/java/be/seeseepuff/webgit/model/CommitInfo.java @@ -0,0 +1,15 @@ +package be.seeseepuff.webgit.model; + +import java.util.List; + +public record CommitInfo( + String hash, + String shortHash, + String message, + String author, + String date, + List parentHashes, + String graphLine +) +{ +} diff --git a/src/main/java/be/seeseepuff/webgit/model/DiffInfo.java b/src/main/java/be/seeseepuff/webgit/model/DiffInfo.java new file mode 100644 index 0000000..6be802a --- /dev/null +++ b/src/main/java/be/seeseepuff/webgit/model/DiffInfo.java @@ -0,0 +1,10 @@ +package be.seeseepuff.webgit.model; + +public record DiffInfo( + String changeType, + String oldPath, + String newPath, + String diff +) +{ +} diff --git a/src/main/java/be/seeseepuff/webgit/model/FileInfo.java b/src/main/java/be/seeseepuff/webgit/model/FileInfo.java new file mode 100644 index 0000000..808715f --- /dev/null +++ b/src/main/java/be/seeseepuff/webgit/model/FileInfo.java @@ -0,0 +1,8 @@ +package be.seeseepuff.webgit.model; + +public record FileInfo( + String path, + String type +) +{ +} diff --git a/src/main/java/be/seeseepuff/webgit/service/GitService.java b/src/main/java/be/seeseepuff/webgit/service/GitService.java index 414f78c..5747c66 100644 --- a/src/main/java/be/seeseepuff/webgit/service/GitService.java +++ b/src/main/java/be/seeseepuff/webgit/service/GitService.java @@ -1,18 +1,40 @@ package be.seeseepuff.webgit.service; import be.seeseepuff.webgit.config.WebgitProperties; +import be.seeseepuff.webgit.model.CommitInfo; +import be.seeseepuff.webgit.model.DiffInfo; +import be.seeseepuff.webgit.model.FileInfo; import lombok.RequiredArgsConstructor; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.diff.DiffEntry; +import org.eclipse.jgit.diff.DiffFormatter; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectLoader; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.StoredConfig; +import org.eclipse.jgit.revplot.PlotCommit; +import org.eclipse.jgit.revplot.PlotCommitList; +import org.eclipse.jgit.revplot.PlotLane; +import org.eclipse.jgit.revplot.PlotWalk; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevTree; import org.eclipse.jgit.storage.file.FileRepositoryBuilder; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.eclipse.jgit.treewalk.filter.PathFilter; import org.springframework.stereotype.Service; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -244,6 +266,205 @@ public class GitService } } + public List listCommits(String name) throws IOException + { + try (Git git = openRepository(name)) + { + Repository repo = git.getRepository(); + try (PlotWalk plotWalk = new PlotWalk(repo)) + { + ObjectId head = repo.resolve("HEAD"); + if (head == null) + return List.of(); + plotWalk.markStart(plotWalk.parseCommit(head)); + + PlotCommitList plotCommitList = new PlotCommitList<>(); + plotCommitList.source(plotWalk); + plotCommitList.fillTo(Integer.MAX_VALUE); + + int maxLanes = 0; + for (PlotCommit pc : plotCommitList) + { + if (pc.getLane() != null && pc.getLane().getPosition() + 1 > maxLanes) + maxLanes = pc.getLane().getPosition() + 1; + } + + List commits = new ArrayList<>(); + for (PlotCommit pc : plotCommitList) + { + String graphLine = buildGraphLine(pc, maxLanes); + List parents = Arrays.stream(pc.getParents()) + .map(p -> p.getId().abbreviate(7).name()) + .toList(); + + DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + .withZone(ZoneId.systemDefault()); + + commits.add(new CommitInfo( + pc.getId().getName(), + pc.getId().abbreviate(7).name(), + pc.getShortMessage(), + pc.getAuthorIdent().getName(), + fmt.format(Instant.ofEpochSecond(pc.getCommitTime())), + parents, + graphLine + )); + } + return commits; + } + } + } + + private String buildGraphLine(PlotCommit pc, int maxLanes) + { + int lanePos = pc.getLane() != null ? pc.getLane().getPosition() : 0; + int width = Math.max(maxLanes, lanePos + 1); + char[] line = new char[width]; + Arrays.fill(line, ' '); + + // Mark passing-through lanes + for (int i = 0; i < pc.getChildCount(); i++) + { + PlotCommit child = (PlotCommit) pc.getChild(i); + if (child.getLane() != null) + { + int childPos = child.getLane().getPosition(); + if (childPos < width) + line[childPos] = '|'; + } + } + + line[lanePos] = '*'; + return new String(line); + } + + public List getCommitDiff(String name, String commitHash) throws IOException + { + try (Git git = openRepository(name)) + { + Repository repo = git.getRepository(); + ObjectId commitId = repo.resolve(commitHash); + try (var walk = new org.eclipse.jgit.revwalk.RevWalk(repo)) + { + RevCommit commit = walk.parseCommit(commitId); + RevTree newTree = commit.getTree(); + RevTree oldTree = null; + if (commit.getParentCount() > 0) + { + RevCommit parent = walk.parseCommit(commit.getParent(0)); + oldTree = parent.getTree(); + } + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (DiffFormatter df = new DiffFormatter(out)) + { + df.setRepository(repo); + List diffs = df.scan(oldTree, newTree); + List result = new ArrayList<>(); + for (DiffEntry diff : diffs) + { + out.reset(); + df.format(diff); + result.add(new DiffInfo( + diff.getChangeType().name(), + diff.getOldPath(), + diff.getNewPath(), + out.toString(StandardCharsets.UTF_8) + )); + } + return result; + } + } + } + } + + public List listFilesAtCommit(String name, String commitHash, String dirPath) throws IOException + { + try (Git git = openRepository(name)) + { + Repository repo = git.getRepository(); + ObjectId commitId = repo.resolve(commitHash); + try (var walk = new org.eclipse.jgit.revwalk.RevWalk(repo)) + { + RevCommit commit = walk.parseCommit(commitId); + RevTree tree = commit.getTree(); + + List files = new ArrayList<>(); + try (TreeWalk tw = new TreeWalk(repo)) + { + tw.addTree(tree); + tw.setRecursive(false); + if (dirPath != null && !dirPath.isEmpty()) + { + tw.setFilter(PathFilter.create(dirPath)); + // Walk into the directory + while (tw.next()) + { + if (tw.isSubtree() && tw.getPathString().equals(dirPath)) + { + tw.enterSubtree(); + break; + } + } + } + while (tw.next()) + { + if (dirPath != null && !dirPath.isEmpty() && !tw.getPathString().startsWith(dirPath + "/")) + continue; + String type = tw.isSubtree() ? "tree" : "blob"; + files.add(new FileInfo(tw.getPathString(), type)); + } + } + return files; + } + } + } + + public String getFileContentAtCommit(String name, String commitHash, String filePath) throws IOException + { + try (Git git = openRepository(name)) + { + Repository repo = git.getRepository(); + ObjectId commitId = repo.resolve(commitHash); + try (var walk = new org.eclipse.jgit.revwalk.RevWalk(repo)) + { + RevCommit commit = walk.parseCommit(commitId); + RevTree tree = commit.getTree(); + + try (TreeWalk tw = TreeWalk.forPath(repo, filePath, tree)) + { + if (tw == null) + return null; + ObjectLoader loader = repo.open(tw.getObjectId(0)); + return new String(loader.getBytes(), StandardCharsets.UTF_8); + } + } + } + } + + public void checkoutCommit(String name, String commitHash) throws IOException, GitAPIException + { + try (Git git = openRepository(name)) + { + git.checkout() + .setName(commitHash) + .call(); + } + } + + public void checkoutFilesFromCommit(String name, String commitHash, List files) throws IOException, GitAPIException + { + try (Git git = openRepository(name)) + { + var checkout = git.checkout().setStartPoint(commitHash); + for (String file : files) + { + checkout.addPath(file); + } + checkout.call(); + } + } + public void push(String name) throws IOException, GitAPIException { try (Git git = openRepository(name)) diff --git a/src/main/resources/templates/blob.html b/src/main/resources/templates/blob.html new file mode 100644 index 0000000..d0d40e1 --- /dev/null +++ b/src/main/resources/templates/blob.html @@ -0,0 +1,31 @@ + + + +File + + +

file.txt

+ +

+< Back to tree +| +< Back to commits +

+ + + + + +
+
+ + + +
+
+ +

Content

+
File content here
+ + + diff --git a/src/main/resources/templates/commit.html b/src/main/resources/templates/commit.html new file mode 100644 index 0000000..6eea09a --- /dev/null +++ b/src/main/resources/templates/commit.html @@ -0,0 +1,52 @@ + + + +Commit + + +

Commit

+ +

< Back to commits

+ + + + + + +
+
+ + +
+
+Browse files +
+ +

Changed Files

+ +
+ + + + + + + + + + + + +
ChangeFile
+
+ +
+ +

Diff

+ +

+

+
+ + + diff --git a/src/main/resources/templates/commits.html b/src/main/resources/templates/commits.html new file mode 100644 index 0000000..5ff0168 --- /dev/null +++ b/src/main/resources/templates/commits.html @@ -0,0 +1,40 @@ + + + +Commits + + +

Commits

+ + + + + + + + + + + + + + + + + + + + + + +
GraphHashMessageAuthorDate
Browse +
+ + +
+
Diff
+ +

No commits yet.

+ + + diff --git a/src/main/resources/templates/nav.html b/src/main/resources/templates/nav.html index 2c3b798..f9a39a4 100644 --- a/src/main/resources/templates/nav.html +++ b/src/main/resources/templates/nav.html @@ -20,6 +20,7 @@

Staging
+Commits
Branches
Remote
Manage
diff --git a/src/main/resources/templates/tree.html b/src/main/resources/templates/tree.html new file mode 100644 index 0000000..47cdf2c --- /dev/null +++ b/src/main/resources/templates/tree.html @@ -0,0 +1,34 @@ + + + +Browse + + +

Browse

+ +

< Back to commits

+ +

+Path: +
+< Parent directory +

+ + + + + + + + + + +
TypeName
+ + +
+ +

Empty directory.

+ + + diff --git a/src/test/java/be/seeseepuff/webgit/controller/RepoControllerTest.java b/src/test/java/be/seeseepuff/webgit/controller/RepoControllerTest.java index 3cead9f..94be4f4 100644 --- a/src/test/java/be/seeseepuff/webgit/controller/RepoControllerTest.java +++ b/src/test/java/be/seeseepuff/webgit/controller/RepoControllerTest.java @@ -213,4 +213,81 @@ class RepoControllerTest verify(gitService).deleteRepository("myrepo"); } + + @Test + void commitsPageShowsCommits() throws Exception + { + when(gitService.listCommits("myrepo")).thenReturn(List.of( + new be.seeseepuff.webgit.model.CommitInfo("abc1234567890", "abc1234", "Initial commit", "author", "2026-01-01 12:00", List.of(), "*") + )); + + mockMvc.perform(get("/repo/myrepo/commits")) + .andExpect(status().isOk()) + .andExpect(view().name("commits")) + .andExpect(model().attribute("name", "myrepo")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("abc1234"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Initial commit"))); + } + + @Test + void commitDetailShowsDiff() throws Exception + { + when(gitService.getCommitDiff("myrepo", "abc1234")).thenReturn(List.of( + new be.seeseepuff.webgit.model.DiffInfo("ADD", "/dev/null", "file.txt", "+hello") + )); + + mockMvc.perform(get("/repo/myrepo/commit/abc1234")) + .andExpect(status().isOk()) + .andExpect(view().name("commit")) + .andExpect(model().attribute("hash", "abc1234")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("file.txt"))); + } + + @Test + void treeShowsFiles() throws Exception + { + when(gitService.listFilesAtCommit("myrepo", "abc1234", "")).thenReturn(List.of( + new be.seeseepuff.webgit.model.FileInfo("README.md", "blob") + )); + + mockMvc.perform(get("/repo/myrepo/tree/abc1234")) + .andExpect(status().isOk()) + .andExpect(view().name("tree")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("README.md"))); + } + + @Test + void blobShowsFileContent() throws Exception + { + when(gitService.getFileContentAtCommit("myrepo", "abc1234", "README.md")).thenReturn("# Hello"); + + mockMvc.perform(get("/repo/myrepo/blob/abc1234/README.md")) + .andExpect(status().isOk()) + .andExpect(view().name("blob")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("# Hello"))); + } + + @Test + void checkoutCommitRedirectsToCommits() throws Exception + { + mockMvc.perform(post("/repo/myrepo/checkout-commit") + .param("hash", "abc1234")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/repo/myrepo/commits")); + + verify(gitService).checkoutCommit("myrepo", "abc1234"); + } + + @Test + void checkoutFilesRedirectsToChanges() throws Exception + { + mockMvc.perform(post("/repo/myrepo/checkout-files") + .param("hash", "abc1234") + .param("files", "a.txt") + .param("files", "b.txt")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/repo/myrepo/changes")); + + verify(gitService).checkoutFilesFromCommit("myrepo", "abc1234", List.of("a.txt", "b.txt")); + } } diff --git a/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java b/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java index 8adb733..9c0d9ff 100644 --- a/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java +++ b/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java @@ -333,4 +333,90 @@ class GitServiceTest var remotes = gitService.listRemotes("myrepo"); assertEquals("https://new-url.com/repo.git", remotes.get("origin")); } + + @Test + void listCommitsReturnsCommitHistory() throws GitAPIException, IOException + { + gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo"); + + var commits = gitService.listCommits("myrepo"); + assertFalse(commits.isEmpty()); + assertEquals("Initial commit", commits.getFirst().message()); + assertNotNull(commits.getFirst().hash()); + assertNotNull(commits.getFirst().shortHash()); + assertNotNull(commits.getFirst().author()); + assertNotNull(commits.getFirst().date()); + assertNotNull(commits.getFirst().graphLine()); + } + + @Test + void getCommitDiffReturnsDiffs() throws GitAPIException, IOException + { + gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo"); + + var commits = gitService.listCommits("myrepo"); + var diffs = gitService.getCommitDiff("myrepo", commits.getFirst().hash()); + assertFalse(diffs.isEmpty()); + assertEquals("ADD", diffs.getFirst().changeType()); + assertEquals("README.md", diffs.getFirst().newPath()); + assertFalse(diffs.getFirst().diff().isEmpty()); + } + + @Test + void listFilesAtCommitReturnsFiles() throws GitAPIException, IOException + { + gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo"); + + var commits = gitService.listCommits("myrepo"); + var files = gitService.listFilesAtCommit("myrepo", commits.getFirst().hash(), ""); + assertFalse(files.isEmpty()); + assertTrue(files.stream().anyMatch(f -> f.path().equals("README.md"))); + } + + @Test + void getFileContentAtCommitReturnsContent() throws GitAPIException, IOException + { + gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo"); + + var commits = gitService.listCommits("myrepo"); + String content = gitService.getFileContentAtCommit("myrepo", commits.getFirst().hash(), "README.md"); + assertEquals("# Test", content); + } + + @Test + void getFileContentAtCommitReturnsNullForMissingFile() throws GitAPIException, IOException + { + gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo"); + + var commits = gitService.listCommits("myrepo"); + String content = gitService.getFileContentAtCommit("myrepo", commits.getFirst().hash(), "nonexistent.txt"); + assertNull(content); + } + + @Test + void checkoutCommitDetachesHead() throws GitAPIException, IOException + { + gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo"); + + var commits = gitService.listCommits("myrepo"); + gitService.checkoutCommit("myrepo", commits.getFirst().hash()); + + String head = gitService.getCurrentBranch("myrepo"); + // Detached HEAD returns the commit hash + assertTrue(head.matches("[0-9a-f]+")); + } + + @Test + void checkoutFilesFromCommitRestoresFiles() throws GitAPIException, IOException + { + gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo"); + // Modify a file + Path readme = worktreePath.resolve("myrepo").resolve("README.md"); + Files.writeString(readme, "modified"); + + var commits = gitService.listCommits("myrepo"); + gitService.checkoutFilesFromCommit("myrepo", commits.getFirst().hash(), List.of("README.md")); + + assertEquals("# Test", Files.readString(readme)); + } }