Skip to content

Commit 9ac99e0

Browse files
authored
Merge pull request #3 from skunkworker/ref-qualification-and-test-sweep
Ref qualification and test sweep
2 parents e692903 + ff2493d commit 9ac99e0

3 files changed

Lines changed: 1141 additions & 49 deletions

File tree

docs/test-coverage-gaps.md

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# git_pruner — test coverage gaps
2+
3+
An analysis of `main_test.go` (2026-08-17) proposing tests for everyday git edge cases the
4+
suite does not cover. Git-behavior claims below were reproduced in throwaway repositories
5+
before being recorded, following the same rule as `improvements.md`.
6+
7+
## Current coverage assessment
8+
9+
The 25 existing tests are strong on the deletion-safety core: safe-vs-force semantics
10+
ground-truthed against real git (`TestSafeDeletableMatchesGit`), gone-branch risk guarding,
11+
deferred remote deletes, the force-retry flow, and rendering width invariants.
12+
13+
What is missing is almost entirely **environmental variation**: every test runs in the same
14+
happy-path repo shape — clean checkout on `main`, one healthy file-URL remote named
15+
`origin`/`upstream`, born HEAD, no tags.
16+
17+
---
18+
19+
## Tier 1 — expose confirmed or likely bugs — **DONE 2026-08-17**
20+
21+
### 1. Tag shadowing a branch name *(was a confirmed bug — fixed)*
22+
23+
A tag sharing a branch's name silently shadowed the branch: git's ref search order puts
24+
`refs/tags/<name>` ahead of `refs/heads/<name>`, and `riskCommitCount` / `loadDiff` passed
25+
bare names to `git cherry` and `git diff`. Reproduced: `git cherry main feat` reported
26+
**zero** at-risk commits for a branch that had one, with only a `refname 'feat' is
27+
ambiguous` warning on stderr — so the risk warning said nothing was at stake on exactly the
28+
branch a `-D` was about to destroy. Tagging a release branch with its own name (`v1.2`,
29+
`release-3`) makes this an everyday shape.
30+
31+
Writing the test exposed a **deeper root cause than the bare-name calls**: `loadBranches`
32+
read `%(refname:short)`, which returns the shortest *unambiguous* name. The moment the tag
33+
exists, the branch loads as `heads/feature/x` rather than `feature/x`, which breaks every
34+
name-keyed lookup and any ref rebuilt from the name. `%(upstream:short)` has the same
35+
failure, disambiguating to `remotes/origin/x` and splitting into a bogus remote named
36+
`remotes`.
37+
38+
Fixed by reading full refs and stripping the namespace ourselves:
39+
40+
- `shortRef()` strips `refs/heads/` / `refs/remotes/`; `loadBranches` now reads
41+
`%(refname)` and `%(upstream)`, and `mergedSet` reads `%(refname)`.
42+
- `branchRef()` qualifies a local branch name for git; used by `riskCommitCount` and
43+
`loadDiff`.
44+
- `qualifyRef()` probes `refs/heads/` then `refs/remotes/` for refs whose namespace is not
45+
known statically (the base). Resolved once into the new `model.riskBaseRef`, since it
46+
costs up to two `rev-parse` calls.
47+
48+
`riskBase` stays short for display; git is handed the qualified form. `git branch -d` was
49+
never affected — it always operates in `refs/heads/`.
50+
51+
Covered by `TestTagShadowingBranchName` (`branch_name`, `upstream_name`, `base_name`).
52+
53+
### 2. Selections survive `p` *(was a real wipe — fixed)*
54+
55+
`fetchDoneMsg` called `applyBranches` with freshly loaded structs, so every manually
56+
selected / `R`-armed branch was silently wiped when the user pressed `p`. A fetch mutates
57+
nothing local, so the marks now survive it: `carryMarks()` copies `selected` and
58+
`deleteRemote` onto the new set by name.
59+
60+
One deliberate exception: an armed remote delete is **dropped** when the fetch reveals the
61+
upstream is gone, because the push it would run can only fail.
62+
63+
Covered by `TestFetchPreservesSelections` and `TestFetchDisarmsRemoteForGoneBranch`.
64+
65+
### 3. Gone *current* branch *(behavior was already correct — now locked in)*
66+
67+
The user is sitting on `feature/x`, the PR merged, the remote branch was deleted, and they
68+
press `p`. The fetch handler's `isCurrent` guard already kept it out of auto-selection and
69+
out of the "press d to prune" count, but nothing tested it. The test also pins the
70+
second line of defence: selected by hand, git refuses the delete, and because the refusal
71+
is not "not fully merged" it correctly does **not** raise the force prompt.
72+
73+
Covered by `TestGoneCurrentBranchIsNotPruned`.
74+
75+
---
76+
77+
## Tier 2 — everyday repo environments never constructed in tests — **DONE 2026-08-17**
78+
79+
No new production bugs here: every shape already behaved correctly, and the tests now pin
80+
that. Two of the predictions above were wrong about git's behavior and were corrected
81+
against real repos before being written up.
82+
83+
### 4. No remotes at all *(covered by `TestLocalOnlyRepo`)*
84+
85+
A scratch or never-pushed project. `initialModel` works, `riskBase` falls back to local
86+
`main` (with `riskBaseRef` `refs/heads/main`), `loadDiff` bases on local `main`, the risk
87+
warning still names the real cost, and `p` succeeds as a no-op — `git fetch --all --prune`
88+
exits 0 with no remotes configured.
89+
90+
### 5. Unborn HEAD *(covered by `TestUnbornHeadRepo`)*
91+
92+
`git init`, zero commits: empty branch list, no base resolves, `listView` renders the empty
93+
message, and every cursor/selection key is a no-op rather than a panic. `git branch --merged
94+
HEAD` hard-fails here (`fatal: malformed object name HEAD`); `mergedSet` absorbs it.
95+
96+
### 6. Detached HEAD *(covered by `TestDetachedHead`)*
97+
98+
Mid-bisect, mid-rebase, or on a checked-out tag. No branch is `isCurrent`, so `a` selects
99+
everything — there is no current branch to spare — and `headMerged` is computed against the
100+
detached commit. Deleting the branch HEAD is parked on is legal and safe: the commits stay
101+
reachable from HEAD.
102+
103+
Noted: `git branch --merged HEAD --format=%(refname)` emits a `(HEAD detached at …)`
104+
pseudo-entry, which lands in `mergedSet` as a junk key. Harmless — branch names cannot
105+
contain spaces, so it can never collide with a real lookup.
106+
107+
### 7. Remote default is neither `main` nor `master`, `origin/HEAD` unset *(covered by `TestNonStandardDefaultBranch`)*
108+
109+
E.g. `trunk`/`develop`. `origin/HEAD` only exists after a clone, not after `remote add` +
110+
push — verified: `symbolic-ref refs/remotes/origin/HEAD` fails with `not a symbolic ref`.
111+
With no `origin/main`, `origin/master`, or local `main`/`master` either, `riskBase == ""`,
112+
which is the path both "no base branch to compare against" messages hang off. Now asserted
113+
in `riskWarning`, `confirmView`, and `forcePromptView`.
114+
115+
The second subtest covers `remoteDefault`'s `symbolic-ref` path, which had **no coverage at
116+
all**`setupRepo` never sets `origin/HEAD`. After `git remote set-head origin trunk` it
117+
resolves to `origin/trunk`.
118+
119+
### 8. Upstream's remote no longer exists *(covered by `TestUnreachableRemote`)*
120+
121+
**Correction:** the prediction that `git remote remove origin` leaves the branch showing
122+
"gone" is wrong. Removing a remote also unsets `branch.<name>.remote`/`merge` and deletes
123+
its remote-tracking refs, so the branches simply become upstream-less — which is case 4,
124+
not a distinct one.
125+
126+
The case that does exercise the error path is a remote that is still configured but
127+
**unreachable** (server moved, repo deleted, laptop offline). Remote-tracking refs are
128+
local, so the upstream and all merge state survive; only network operations fail. The test
129+
pins that a failed fetch reaches `m.err` and clears `fetching` so `p` can be pressed again,
130+
and that a failed `push --delete` is captured in `remoteErr` and reported on the results
131+
screen without taking the successful local delete down with it.
132+
133+
This also closes Tier 3 item 11 (fetch failure), which shares the shape.
134+
135+
---
136+
137+
## Tier 3 — everyday operations on the happy repo — **DONE 2026-08-17**
138+
139+
### 9. Diverged branch (ahead AND behind) *(covered by `TestDivergedBranch`)*
140+
141+
`trackRe`'s `behind` capture group and `sortAheadBehind` had only ever seen hand-built
142+
structs. Now driven from real `[ahead 2, behind 1]` output, asserting both arrows render and
143+
that a branch ahead of its upstream is not safely deletable.
144+
145+
### 10. Remote delete race *(covered by `TestRemoteDeleteRace`)*
146+
147+
**Correction:** the prediction that this surfaces an error is no longer true, because of the
148+
qualified refspec introduced in the simplify pass (below). `git push origin --delete
149+
refs/heads/x` treats an already-absent ref as a no-op and exits 0, where the bare form fails
150+
with "remote ref does not exist".
151+
152+
That is a deliberate trade: the bare form's error is more informative in this one case, but
153+
the bare form *also silently deletes nothing* when the remote carries a tag of the same name
154+
(git rejects it as "src refspec matches more than one"). Idempotence in the race is worth
155+
more than a message, since the race ends in exactly the state the user armed. The test pins
156+
the idempotent outcome; `TestUnreachableRemote` still covers genuine push failures.
157+
158+
### 11. Fetch failure — **DONE**, see Tier 2 item 8
159+
160+
### 12. Current-branch UI protection *(covered by `TestCurrentBranchCannotBeMarked`)*
161+
162+
`space`, `r`, and `a` all skipping `isCurrent` was only ever implicit in other tests. Now
163+
explicit: the checked-out branch can never be marked or swept into a select-all.
164+
165+
### 13. `loadDiff` HEAD fallback *(covered by `TestLoadDiffFallsBackToHead`)*
166+
167+
Reached with `setupTrunkRepo`, where no remote default and no local `main`/`master` resolve.
168+
Asserts the diff is still correct against HEAD and that the diff header names the base it
169+
actually used.
170+
171+
### 14. Odd-but-legal content *(covered by `TestUnicodeNameAndEmptySubject`)*
172+
173+
An empty commit subject (`--allow-empty-message`) leaves a trailing empty field in the
174+
NUL-delimited `for-each-ref` output — a stricter field count would drop the whole branch.
175+
Combined with a multibyte branch name, pinned end to end through parse, render, and delete.
176+
177+
---
178+
179+
## Ref handling — the rule this work established
180+
181+
**Never use git's `%(refname:short)`, `%(upstream:short)`, or `symbolic-ref --short`.** They
182+
return the shortest *unambiguous* name, which silently grows a `heads/` or `remotes/` prefix
183+
the moment a tag shares the name. Instead:
184+
185+
- Resolvers return **fully qualified refs**. `remoteDefault`, `localDefaultBranch` and
186+
`baseBranch` all do; resolution is the only point where the namespace is known for
187+
certain, so it is carried forward from there rather than re-guessed later.
188+
- `shortRef()` shortens at the **display boundary** only. The model keeps both forms:
189+
`riskBase`/`remoteDefault` for views, `riskBaseRef` for git.
190+
- `branchRef()` qualifies a local branch name. Correct because `loadBranches` only ever
191+
reads `refs/heads`, so the mapping is total.
192+
- `refExists()` wraps the `rev-parse --verify --quiet` probe and must be given a qualified
193+
ref — a bare name would match a tag, which is the bug it exists to avoid.
194+
195+
An earlier iteration used a `qualifyRef()` helper that probed both namespaces at the point
196+
of *use*. That was the wrong altitude: it cost up to two subprocesses per call, silently
197+
fell through to the shadowable bare name when both probes missed, and left the real hole
198+
open in `localDefaultBranch`. It has been deleted.
199+
200+
## Status
201+
202+
All three tiers are complete (2026-08-17): **14 tests added, 43 total**, passing under
203+
`-race`, with `gofmt` and `go vet` clean.
204+
205+
- Tier 1 fixed two real bugs — tag shadowing on the risk path, and `p` wiping selections.
206+
- Tier 2 found no new bugs; it pins five repo environments that had no coverage, and
207+
corrected two wrong predictions in this document against real git behavior.
208+
- Tier 3 pins five everyday operations and corrected one more prediction.
209+
- A `/simplify` pass then found **two further live instances of the tag-shadowing bug** that
210+
Tier 1 had missed (`localDefaultBranch` and `push --delete`), removed the `qualifyRef`
211+
probe, and brought startup back from 11 git subprocesses to 7 — the pre-change baseline.
212+
213+
Fixtures compose rather than duplicate: `initRepo` (bare init + identity) →
214+
`setupLocalRepo` (branch shapes) → `setupRepo` (+ `addOrigin` + a tracking branch), with
215+
`setupTrunkRepo` reusing `initRepo` and `addOrigin`.

0 commit comments

Comments
 (0)