Skip to content

Drive deletes through the live path and cap git concurrency - #4

Merged
skunkworker merged 2 commits into
masterfrom
ref-qualification-and-test-sweep
Aug 23, 2026
Merged

Drive deletes through the live path and cap git concurrency#4
skunkworker merged 2 commits into
masterfrom
ref-qualification-and-test-sweep

Conversation

@skunkworker

@skunkworker skunkworker commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Three pieces of work that ended up sharing the same test file.

Test the delete path users actually run. performDeletions was test-only by its own comment, so the async completion logic — branchDeletedMsg → deletesDone → the stateForcePrompt/stateResult transition — was never fed through Update. All 18 delete call sites now drive the real path, the force prompt is answered with a keystroke rather than by calling forceDeleteUnmerged directly, and performDeletions is gone. Five new tests cover what only the async path can get wrong; each was verified to fail against a deliberately broken Update before being kept.

Measure risk concurrently. Every git cherry is its own subprocess and refreshMergeInfo ran one per gone branch in sequence — on a repo where 100 branches are gone that is 1.14s of startup, again on every fetch and every prune. The cost is process spawn, not git work, so the calls now run 8 at a time: startup 1.14s → 0.56s, reload 1.12s → 0.33s, the confirm screen 1.09s → 0.25s.

Bound how much git runs at once. tea.Batch gives every selected branch its own goroutine, so arming remotes on 100 branches meant 100 simultaneous push --delete — connections a remote would throttle or refuse, and a failed push there leaves a branch deleted locally whose only other copy is still out on the remote. runGit is the single door every invocation passes through, so the cap lives there: 8 local processes, 3 network ones, with networkBound() picking which applies. The cap is measured on the subprocesses rather than on itself, so removing it fails the test instead of silently muting the counter.

Also recorded in docs/improvements.md: rows wrap on terminals narrower than 68 + the name column, so an 80-column terminal wraps every row once branch names reach 13 cells; and two dead ends, so they are not re-investigated.

skunkworker and others added 2 commits August 22, 2026 23:19
Three pieces of work that ended up sharing the same test file.

Test the delete path users actually run. `performDeletions` was test-only
by its own comment, so the async completion logic — branchDeletedMsg →
deletesDone → the stateForcePrompt/stateResult transition — was never fed
through Update. All 18 delete call sites now drive the real path, the
force prompt is answered with a keystroke rather than by calling
forceDeleteUnmerged directly, and `performDeletions` is gone. Five new
tests cover what only the async path can get wrong; each was verified to
fail against a deliberately broken Update before being kept.

Measure risk concurrently. Every `git cherry` is its own subprocess and
refreshMergeInfo ran one per gone branch in sequence — on a repo where
100 branches are gone that is 1.14s of startup, again on every fetch and
every prune. The cost is process spawn, not git work, so the calls now
run 8 at a time: startup 1.14s → 0.56s, reload 1.12s → 0.33s, the
confirm screen 1.09s → 0.25s.

Bound how much git runs at once. tea.Batch gives every selected branch
its own goroutine, so arming remotes on 100 branches meant 100
simultaneous `push --delete` — connections a remote would throttle or
refuse, and a failed push there leaves a branch deleted locally whose
only other copy is still out on the remote. runGit is the single door
every invocation passes through, so the cap lives there: 8 local
processes, 3 network ones, with networkBound() picking which applies.
The cap is measured on the subprocesses rather than on itself, so
removing it fails the test instead of silently muting the counter.

Also recorded in docs/improvements.md: rows wrap on terminals narrower
than 68 + the name column, so an 80-column terminal wraps every row once
branch names reach 13 cells; and two dead ends, so they are not
re-investigated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157b1zyhs2FVFBmaNWS6wjS
Three performance findings, all of them measured before and after.

Stop spawning a `git cherry` per merged branch. A branch whose tip is
already an ancestor of the base has an empty base..branch range, so
`git cherry` can only report nothing — and on this tool's headline case,
a repo full of branches that were merged and then pruned, that is nearly
every one of them. refreshMergeInfo now runs one `branch --merged <base>`
into m.baseMerged and measureRisk reads the set. On a 101-branch repo
where 100 are gone, startup went 529ms → 45ms and peak concurrent git
processes 8 → 2. TestMergedBranchesCostNoSubprocess asserts a literal
process bound rather than a time; it fails at 39 processes when the
shortcut is removed.

Window the screens that ask a question. confirmView wrote every selected
branch in full: 40 selected produced 206 rows into a 24-row terminal with
"Delete these branches?" on row 205, so the user answered a prompt they
could not read. The confirm, force-prompt, deleting and results screens
now go through page(header, body, footer), which renders a window and a
position line, with scrollKeys adding ↑/↓, space, ctrl+d/u, pgup/pgdn and
g/G plus the mouse wheel. Each screen answers its own keys first, so y, R
and n are never swallowed by the scroll handler.

Take startup from six serial subprocesses to two rounds. On this repo
startup was 34ms and all of it was process-start latency — one git costs
~6.5ms to spawn and the chain ran rev-parse → for-each-ref → remote →
symbolic-ref → branch -r --merged → branch --merged <base> in sequence.
loadRemoteRefs reads refs/remotes once with %(refname)%00%(symref),
replacing `git remote`, a symbolic-ref per remote and a rev-parse per
candidate; %(symref) yields the full ref, so the tag-shadowing guarantee
survives where symbolic-ref --short would break it. localDefaultBranch
takes a has(name) predicate, so refreshMergeInfo answers from the branch
list it already holds. loadRepo then starts loadBranches, localMergedSet
and loadRemoteRefs together, initialModel runs the repo check beside them
rather than ahead of them, and the two --merged queries form round two.
Startup 34ms → 19ms here, and 529ms → 17ms on the 100-gone fixture.

Remote names now come out of refs/remotes rather than `git remote`. A
remote with no fetched refs holds no ref a default branch could resolve
to, so nothing a caller could use is lost.

Recorded as items 16-18 in docs/improvements.md, with the numbers and the
501-branch case where for-each-ref's own commit-object reads, not chain
depth, are the floor. Item 5's stale 370ms residue is corrected to 16ms,
and a note about applyBranches dropping selections is removed — carryMarks
fixed that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnKYkFZWMaTCoYumjAkTGD
@skunkworker
skunkworker merged commit ab9fb92 into master Aug 23, 2026
2 checks passed
@skunkworker
skunkworker deleted the ref-qualification-and-test-sweep branch August 23, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant