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>
This commit is contained in:
2026-02-27 10:48:34 +01:00
parent eb222716cd
commit 04a69c323e
2 changed files with 97 additions and 21 deletions

View File

@@ -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<PlotLane> 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<Integer> openLanes = new LinkedHashSet<>();
List<CommitInfo> commits = new ArrayList<>();
for (PlotCommit<PlotLane> 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<String> parents = Arrays.stream(pc.getParents())
.map(p -> p.getId().abbreviate(7).name())
.toList();
@@ -382,27 +392,46 @@ public class GitService
}
}
private String buildGraphLine(PlotCommit<PlotLane> pc, int maxLanes)
private String buildGraphLine(PlotCommit<PlotLane> pc, Set<Integer> 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<DiffInfo> getCommitDiff(String name, String commitHash) throws IOException

View File

@@ -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
{