A squash-merged branch shows ⊂ with its row dimmed, and +305 -42 in main…± immediately beside it. Both are right: ⊂ is a content check against main's tip, main…± is the three-dot diff from the merge-base, and a squash merge doesn't move the merge-base. Read together they contradict each other, and the bright counts carry further than the dim symbol.
The idea: measure main…± as what merging would add rather than what the branch wrote, i.e. git diff <target-tree> <tree from merge-tree --write-tree target branch>. That is the quantity the integration check already computes as a boolean (merge_adds_nothing), so the column and the symbol beside it would be reading the same thing.
Performance
merge-tree already runs on every row. Status is a default column, its required_tasks() include MergeTreeConflicts and WouldMergeAdd, the in-memory merge_tree map collapses both to a single subprocess, and MergeTreeOutcome::Clean already carries the resulting tree SHA. So the added operation is one tree-to-tree diff, and it replaces the three-dot diff rather than adding to it.
| repo |
tree-to-tree (proposed) |
three-dot (today) |
| 259 commits |
11.2 ms ± 0.5 |
10.9 ms ± 0.4 |
| git.git, 81,908 commits |
11.8 ms ± 0.3 |
11.5 ms ± 1.4 |
For scale, a cold wt list over 17 worktrees measured 390 ms against ~160 ms warm, so the entire cold git budget is ~230 ms and this shifts nothing within it.
Caching
Cache the LineDiff, never the tree SHA. merge-tree --write-tree writes objects that nothing references, so a persisted tree SHA can be pruned by git gc and a later read would fail; the computed stats carry no such dependency.
The natural key is merge-add-probe's own, (target_sha, branch_sha). That can be a new kind beside it or a second field on MergeProbeResult. read_json treats a deserialize failure as a miss, so adding a field silently invalidates every existing merge-add-probe entry, where a new kind leaves them intact; both self-heal within one cold run. Warm runs then spawn no extra subprocess at all, exactly as diff-stats behaves today. The diff-stats entries for (main, branch) pairs would go unused and age out through the 5000-entry LRU.
Against it
The number moves for ordinary branches too, not only squash-merged ones. Merging resolves the branch against main's newer content, so wherever main has touched the same regions the totals shift by a few lines.
Measured across one repo's branches
| ahead / behind |
three-dot |
merge-add |
| 1 / 40 |
+77 -5 |
+77 -5 |
| 1 / 4 |
+379 -47 |
+382 -47 |
| 3 / 6 |
+627 -51 |
+662 -46 |
| 4 / 4 |
+1564 -281 |
+1571 -280 |
| 1 / 4 (squash-merged) |
+305 -42 |
+0 -0 |
Being behind is not what moves it; the branch 40 commits behind is identical. Overlap is.
It also doesn't cover every squash merge. When merge-tree conflicts there is no tree to diff, and those rows fall back to the three-dot number. Some are ✗, but patch_id_match rows are integrated precisely because merge-tree conflicted, which is the squash merge where main later touched the same files. So the one integration reason reached through a conflict keeps its inflated count.
Two smaller costs. main…± is named for the three-dot form and preview tab 3 shows that diff, so both would want renaming. And if [list] columns narrows to main…± without Status, merge-tree becomes newly required, where BranchDiff needs only its own task today.
Smaller alternative
Dim or blank the count cells when main_state is integrated. It is known before render and already dims the rest of the row, so extending it to the counts needs no new git work and leaves ordinary branches' numbers untouched. It also covers every integration reason, patch_id_match included, which the merge-tree approach cannot. That settles the contradiction without deciding what the column should mean.
#3280 moved these columns onto the upstream-aware base "the integration column already uses". This is the same idea one step further: taking that check's result, not only its base.
Measurements are from macOS, on a 259-commit repo and on git.git.
This was written by Claude Code on behalf of max-sixty
A squash-merged branch shows
⊂with its row dimmed, and+305 -42inmain…±immediately beside it. Both are right:⊂is a content check against main's tip,main…±is the three-dot diff from the merge-base, and a squash merge doesn't move the merge-base. Read together they contradict each other, and the bright counts carry further than the dim symbol.The idea: measure
main…±as what merging would add rather than what the branch wrote, i.e.git diff <target-tree> <tree from merge-tree --write-tree target branch>. That is the quantity the integration check already computes as a boolean (merge_adds_nothing), so the column and the symbol beside it would be reading the same thing.Performance
merge-treealready runs on every row.Statusis a default column, itsrequired_tasks()includeMergeTreeConflictsandWouldMergeAdd, the in-memorymerge_treemap collapses both to a single subprocess, andMergeTreeOutcome::Cleanalready carries the resulting tree SHA. So the added operation is one tree-to-tree diff, and it replaces the three-dot diff rather than adding to it.For scale, a cold
wt listover 17 worktrees measured 390 ms against ~160 ms warm, so the entire cold git budget is ~230 ms and this shifts nothing within it.Caching
Cache the
LineDiff, never the tree SHA.merge-tree --write-treewrites objects that nothing references, so a persisted tree SHA can be pruned bygit gcand a later read would fail; the computed stats carry no such dependency.The natural key is
merge-add-probe's own,(target_sha, branch_sha). That can be a new kind beside it or a second field onMergeProbeResult.read_jsontreats a deserialize failure as a miss, so adding a field silently invalidates every existingmerge-add-probeentry, where a new kind leaves them intact; both self-heal within one cold run. Warm runs then spawn no extra subprocess at all, exactly asdiff-statsbehaves today. Thediff-statsentries for(main, branch)pairs would go unused and age out through the 5000-entry LRU.Against it
The number moves for ordinary branches too, not only squash-merged ones. Merging resolves the branch against main's newer content, so wherever main has touched the same regions the totals shift by a few lines.
Measured across one repo's branches
Being behind is not what moves it; the branch 40 commits behind is identical. Overlap is.
It also doesn't cover every squash merge. When
merge-treeconflicts there is no tree to diff, and those rows fall back to the three-dot number. Some are✗, butpatch_id_matchrows are integrated precisely becausemerge-treeconflicted, which is the squash merge where main later touched the same files. So the one integration reason reached through a conflict keeps its inflated count.Two smaller costs.
main…±is named for the three-dot form and preview tab 3 shows that diff, so both would want renaming. And if[list] columnsnarrows tomain…±withoutStatus,merge-treebecomes newly required, whereBranchDiffneeds only its own task today.Smaller alternative
Dim or blank the count cells when
main_stateis integrated. It is known before render and already dims the rest of the row, so extending it to the counts needs no new git work and leaves ordinary branches' numbers untouched. It also covers every integration reason,patch_id_matchincluded, which the merge-tree approach cannot. That settles the contradiction without deciding what the column should mean.#3280 moved these columns onto the upstream-aware base "the integration column already uses". This is the same idea one step further: taking that check's result, not only its base.
Measurements are from macOS, on a 259-commit repo and on git.git.