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