From 04a69c323e923b014462fe3326e883f8d81e44ae Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Fri, 27 Feb 2026 10:48:34 +0100 Subject: [PATCH] Fix commit graph rendering for branching history Track active lanes via explicit state (openLanes set) rather than relying on PlotCommit.getPassingLanes() which is not available. Draw '-' between passing lanes and a commit when the commit's parent is on a lane to the left (branch convergence), giving output like: * <- C (lane 0) | * <- D (lane 0 passing, D on lane 1, no convergence) * | <- C (lane 0, lane 1 passing) |-* <- B (lane 1, converges to A on lane 0) * <- A (lane 0) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../seeseepuff/webgit/service/GitService.java | 71 +++++++++++++------ .../webgit/service/GitServiceTest.java | 47 ++++++++++++ 2 files changed, 97 insertions(+), 21 deletions(-) diff --git a/src/main/java/be/seeseepuff/webgit/service/GitService.java b/src/main/java/be/seeseepuff/webgit/service/GitService.java index 2de7179..695ed4e 100644 --- a/src/main/java/be/seeseepuff/webgit/service/GitService.java +++ b/src/main/java/be/seeseepuff/webgit/service/GitService.java @@ -36,8 +36,10 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Stream; @Service @@ -349,17 +351,25 @@ public class GitService 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; - } + // Track which lane positions are currently "open" (started but parent not yet seen) + Set openLanes = new LinkedHashSet<>(); List commits = new ArrayList<>(); for (PlotCommit pc : plotCommitList) { - String graphLine = buildGraphLine(pc, maxLanes); + int lane = pc.getLane() != null ? pc.getLane().getPosition() : 0; + openLanes.add(lane); // ensure this lane is marked active + + String graphLine = buildGraphLine(pc, new LinkedHashSet<>(openLanes)); + + // Advance lane state: close this lane, open parent lanes + openLanes.remove(lane); + for (RevCommit parent : pc.getParents()) + { + if (parent instanceof PlotCommit pp && pp.getLane() != null) + openLanes.add(pp.getLane().getPosition()); + } + List parents = Arrays.stream(pc.getParents()) .map(p -> p.getId().abbreviate(7).name()) .toList(); @@ -382,27 +392,46 @@ public class GitService } } - private String buildGraphLine(PlotCommit pc, int maxLanes) + private String buildGraphLine(PlotCommit pc, Set activeLanes) { - int lanePos = pc.getLane() != null ? pc.getLane().getPosition() : 0; - int width = Math.max(maxLanes, lanePos + 1); - char[] line = new char[width]; - Arrays.fill(line, ' '); + int commitLane = pc.getLane() != null ? pc.getLane().getPosition() : 0; - // Mark passing-through lanes - for (int i = 0; i < pc.getChildCount(); i++) + // Find the leftmost parent lane that is to the left of this commit's lane. + // Dashes are drawn from that lane to this commit to show convergence. + int mergeFromLane = commitLane; + for (RevCommit parent : pc.getParents()) { - PlotCommit child = (PlotCommit) pc.getChild(i); - if (child.getLane() != null) + if (parent instanceof PlotCommit pp && pp.getLane() != null) { - int childPos = child.getLane().getPosition(); - if (childPos < width) - line[childPos] = '|'; + int pLane = pp.getLane().getPosition(); + if (pLane < commitLane) + mergeFromLane = Math.min(mergeFromLane, pLane); } } - line[lanePos] = '*'; - return new String(line); + int width = activeLanes.stream().mapToInt(Integer::intValue).max().orElse(0) + 1; + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < width; i++) + { + if (i == commitLane) + sb.append('*'); + else if (activeLanes.contains(i)) + sb.append('|'); + else + sb.append(' '); + + if (i < width - 1) + { + // Draw '-' between mergeFromLane and commitLane to show branch convergence + if (i >= mergeFromLane && i < commitLane) + sb.append('-'); + else + sb.append(' '); + } + } + + return sb.toString().stripTrailing(); } public List getCommitDiff(String name, String commitHash) throws IOException diff --git a/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java b/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java index cce2fbf..f49f5d4 100644 --- a/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java +++ b/src/test/java/be/seeseepuff/webgit/service/GitServiceTest.java @@ -349,6 +349,53 @@ class GitServiceTest assertNotNull(commits.getFirst().graphLine()); } + @Test + void listCommitsGraphLineShowsBranchingCorrectly() throws GitAPIException, IOException + { + // Create repo with two branches from initial commit: main (C) and feature (B) + gitService.cloneRepository(bareRemote.toUri().toString(), "myrepo"); + + // Make commit C on main + Files.writeString(worktreePath.resolve("myrepo/main.txt"), "main"); + gitService.stageFiles("myrepo", List.of("main.txt")); + gitService.commit("myrepo", "C"); + + // Create feature branch from initial commit (A) and make commit B + gitService.createAndCheckoutBranch("myrepo", "feature"); + // Rewind feature to initial commit + try (Git git = Git.open(worktreePath.resolve("myrepo").toFile())) + { + var initialCommit = gitService.listCommits("myrepo").stream() + .filter(c -> c.message().equals("Initial commit")) + .findFirst() + .orElseThrow(); + git.reset().setMode(org.eclipse.jgit.api.ResetCommand.ResetType.HARD) + .setRef(initialCommit.hash()).call(); + } + Files.writeString(worktreePath.resolve("myrepo/feature.txt"), "feature"); + gitService.stageFiles("myrepo", List.of("feature.txt")); + gitService.commit("myrepo", "B"); + + // Switch back to main + gitService.checkoutBranch("myrepo", "master".equals(gitService.listBranches("myrepo").stream() + .filter(b -> b.equals("master") || b.equals("main")).findFirst().orElse("master")) + ? "master" : "main"); + + var commits = gitService.listCommits("myrepo"); + + // Lane-0 commits should have graph lines starting with '*' + var lane0Commits = commits.stream() + .filter(c -> c.graphLine().startsWith("*")) + .toList(); + // Lane-1 commit (B) should have '|' then '-' then '*' + var lane1Commits = commits.stream() + .filter(c -> c.graphLine().startsWith("|-")) + .toList(); + + assertFalse(lane0Commits.isEmpty(), "Expected commits on lane 0"); + assertFalse(lane1Commits.isEmpty(), "Expected branching commit with |-* pattern"); + } + @Test void getCommitDiffReturnsDiffs() throws GitAPIException, IOException {