One runner for every git call, and a read that stops triggering itself - #70
Conversation
Closes #53. Closes #61. The seam in internal/git was never the parser. Two tickets said it was and both hit the same wall: a sibling package cannot reach runGit, and depguard forbids one service importing another, so every proposed split only compiled by extracting the runner first — which each of them then declined as too expensive and paid for with a ceiling raise instead. run.go is now internal/gitexec, a dependency root beside internal/buildinfo, and it cost 278 lines and one reviewed config change. Less than either raise. What the runner holds is not a service's behavior. It is the conditions every git call must run under, and each of them fails silently when copied wrong: LC_ALL pinned so the not-a-repository check matches git's own words, --no-optional-locks so a read does not rewrite the index, a local deadline against a hung mount and a remote one against a socket that stopped answering, and git's stderr carried out unedited. None of the four has a test that would fail in a second copy of them, which is why there must not be one. Three functions rather than an exported options struct: Read, Write, WriteRemote. The deadline is chosen by naming the operation, not by remembering to set a field, and the fourth combination — a read over the network — does not exist until something needs it. One behavior changed with the move. classify read the local timeout constant regardless of the invocation, so a push cut off at ten minutes reported "timed out after 30s" and sent the user looking for a problem they did not have. It is a method on the invocation now, which makes the fix structural rather than a corrected constant. The other half of the branch is the loop that runner comment describes. An idle window with one project open ran `git status` four times a second, forever: the read opens .git/index, macOS reports the access-time bump as NOTE_ATTRIB, fsnotify surfaces that as Chmod, the resulting batch tells the frontend its status is stale, and the frontend reads again. --no-optional-locks was already stopping the read from writing the index; nothing can stop the kernel reporting the read. The tree's own content reads were in the same loop through a different door. So an event whose only op is Chmod is dropped. What that gives up is stated beside it: `chmod +x` on a tracked file, with nothing else happening, no longer refreshes the badges. The precise alternative needs a per-path metadata table and ctime, which Go exposes only through platform-specific syscall types, and DESIGN.md targets Windows. Suppressing events around a read would swallow a real concurrent change and nothing later corrects that. The test that would have caught it had to talk to real git: a fake cannot produce an event that only exists because a real read touched a real file and a real kernel reported it. Five status reads of an unchanged repository now publish nothing; it fails at three batches without the fix. Its counterweight asserts that `git add` still publishes, so the loop is not closed by going deaf. Two config changes rather than suppressions. wrapcheck gains ignore-package-globs for internal/gitexec — its errors are already the finished message, and internal/app is what puts the operation's name in front of them, so a wrap in between would turn "pulling in /repo: git -C /repo pull: ..." into "pulling in /repo: pull: git -C /repo pull: ...". gosec's G204 path list swaps internal/git for internal/gitexec, which is a narrowing: internal/git starts no process at all now. Ratchets, in the direction they are supposed to move. internal/git 1100 -> 950 LOC on a measured 882, surface unchanged at 25 because run.go exported nothing. internal/gitexec pinned at 320 and 5 — deliberately the smallest allowance in the table, because the only thing that would fit under a larger one is knowledge of what git prints, and that would couple every git reader in the repository to it. internal/watch's ceiling does not move: it is at 1288 of 1300 and the next arrival there should be a decomposition. The #9 paragraph claiming the seam was the parser is struck rather than left to be read as still true.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #70 +/- ##
==========================================
+ Coverage 94.72% 94.83% +0.10%
==========================================
Files 42 42
Lines 2179 2186 +7
==========================================
+ Hits 2064 2073 +9
+ Misses 71 70 -1
+ Partials 44 43 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
CI note, for the record rather than for action on this PR. The Test job failed on the first run and passed on re-run with no change: It is not a finding against this diff and it is not a CI wrapper error, so it is neither of the two things CLAUDE.md says a post-verify failure usually is. The cause is a registration race in that package's own test: All checks green on the re-run. |
Closes #53. Closes #61.
Two tickets, one subsystem: the git read path. #53 is the extraction that makes a second git reader possible; #61 is the loop that the extracted runner's own comment describes and could not close on its own.
#53 — the seam was the runner, not the parser
internal/gitwas at 1072 of its 1100-line ceiling with #35 still to come. Two prior tickets looked for the split and both landed on the parsers, and both hit the same wall: a sibling package cannot reachrunGit, anddepguardforbids one service importing another, so every proposed split only compiled by extracting the runner first — which each of them then declined as too expensive and paid for with a ceiling raise instead. The #9 note called "a third package and a second dependency-root exception" the argument against, while naming the parser as the seam that did exist. Both halves were backwards: the runner is what has more than one consumer, and the parsers have exactly one each.run.gois nowinternal/gitexec, a dependency root besideinternal/buildinfo. It cost 278 lines and one reviewed config change — less than either raise taken instead of paying it. That paragraph inpackage_budget_test.gois struck rather than left to read as still true.What the runner holds is not a service's behavior. It is the conditions every git call must run under, and each fails silently when copied wrong:
LC_ALLpinned so the not-a-repository check matches git's own words,--no-optional-locksso a read does not rewrite the index, a local deadline against a hung mount and a remote one against a socket that stopped answering, and git's stderr carried out unedited. None of the four has a test that would fail in a second copy of them, which is why there must not be one.The seam is three functions, not an exported options struct.
Read,Write,WriteRemote. The deadline is chosen by naming the operation rather than by remembering to set a field, and the fourth combination — a read over the network — does not exist until something needs it.#61 — a status read fed the watcher that triggered it
An idle window with one project open ran
git status --porcelain=v2about four times a second, forever. The read opens.git/index; macOS reports the access-time bump asNOTE_ATTRIB; fsnotify surfaces that asChmod; the resulting batch tells the frontend its status is stale; the frontend reads again.--no-optional-lockswas already stopping the read from writing the index — nothing can stop the kernel reporting the read. The tree's own content reads (ReadFile,ReadPrefixes) sat in the same loop through a different door, so this was never only a git problem.An event whose only op is
Chmodis now dropped, and what that gives up is stated beside it:chmod +xon a tracked file, with nothing else happening, no longer refreshes the badges — git reports a mode change, and that badge waits for the next real event. Everything that edits, adds, moves or removes still arrives asWrite,Create,RenameorRemove, and a checkout that changes a mode writes.git/HEAD, which is watched.The two alternatives were rejected in the diff rather than in a comment thread. Stat-ing on each attribute event and comparing against the last-known mode needs a metadata table that grows with every path touched, and the field that actually separates a chmod from a read is
ctime, which Go exposes only through platform-specific syscall types — DESIGN.md targets Windows too. Suppressing events around a read is timing-dependent and would swallow a real concurrent change, which nothing later corrects.The test that would have caught it
It had to talk to real git. A fake cannot produce an event that only exists because a real read touched a real file and a real kernel reported it. Five status reads of an unchanged repository now publish nothing; verified to fail at three batches with the fix removed. Its counterweight asserts
git addstill publishes, so the loop is not closed by going deaf. A third test pins the predicate itself on a hand-built watcher —Chmodalone is dropped,Write|Chmodis not, because kqueue combines bits and a fix written as "has the Chmod bit" would drop real writes with nothing else noticing.Things to look at
One behavior changed that #53 did not ask for.
classifyread the local timeout constant regardless of the invocation, so a push cut off at ten minutes reportedtimed out after 30sand sent the user looking for a problem they did not have. It is a method on the invocation now, with a test. I fixed it rather than move a message I knew was wrong — but it is scope you did not authorize, and reverting it is a small diff.Two config changes, not suppressions.
wrapcheckgainsignore-package-globsforinternal/gitexec: its errors are already the finished message — they name the argv, which contains the repository since git runs with-C, and carry git's stderr verbatim — andinternal/appis what puts the operation's name in front of them. A wrap in between turnspulling in /repo: git -C /repo pull: …intopulling in /repo: pull: git -C /repo pull: …. Scoped to that one package; every other cross-package error still has to be wrapped. If you would rather eat the doubled message, wrapping instead is a five-line change.gosec's G204 path list swapsinternal/gitforinternal/gitexec, which is a narrowing —internal/gitstarts no process at all now.internal/git's ceiling ratcheted down rather than making room for #35. #53's criterion reads "back under its ceiling with room for #35's parser, and its ceiling ratchets down to what it actually measures", which pulls two ways. I took the second: #35's diff viewer now lands as its own package overgitexec, which was the point of the extraction, so it does not need room here. Say the word if you read it the other way.Ratchets
internal/gitLOCinternal/gitexportedrun.goexported nothinginternal/gitexecinternal/watchLOCinternal/gitexecgets deliberately the smallest allowance in the table. The only thing that would fit under a larger one is knowledge of what git prints, and that would couple every git reader in the repository to it.internal/watchis at 1288 of 1300 and the ceiling does not move for a bug fix's doc comment — I trimmed the comment instead. The pin now says the next arrival there should be a decomposition, and names the split that exists:fsnotify.goandpoll.goare two change-detection strategies behind oneEventsseam.depguardgains one rule (gitexec-is-a-dependency-root) and one allow entry, reviewed once here rather than argued about in #35. The import graph pin carries the new edge and the note thatinternal/appmust never acquire one — the binding layer talks to the git service, not to the binary.Gates
make verifygreen. Patch coverage 100% (92/92 changed lines), lint 0 issues,internal/gitexec100% covered. The new watcher tests run clean 10× under-race.