git commit Exits While Its Own Repack Is Still Writing .git
If your program awaits git commit and then deletes, copies or snapshots the repository, a repack that commit started may still be writing into .git. This post traces the mechanism through git's own source, then covers the two config flags Ephesus now passes and the commit latency they cost.
One CI teardown that spent its whole delete budget
Ephesus is a pre-alpha Electron harness that runs terminal coding agents as a crew. They coordinate through a local git repository called the Agora, and a single queue runs every git command against it. One test failed once on Ubuntu CI under git 2.55.0, in its teardown, with ENOTEMPTY: directory not empty, rmdir '.../agora/.git'.
The timing gave it away. The failing test took 2,841 ms, while its seven git-backed siblings took 77 to 107 ms each (PR #62). The teardown called fs.rmSync with maxRetries: 10 and retryDelay: 50.
Node 20's synchronous remover lists a directory's children once, deletes them, then retries only the rmdir, sleeping i × 50 ms before retry i + 1. The sleeps across ten retries sum to 50 × 55 = 2,750 ms. Subtract that from 2,841 and what is left is a normal test body, so the teardown spent its entire budget on one entry that appeared after the listing.
git commit ends by daemonizing its own housekeeping
Something was still writing after the commit queue reported idle. The writer was the commit itself.
git commit finishes by starting git's automatic maintenance. In git v2.55.0, prepare_auto_maintenance() builds that command and switches on its detach flag unless maintenance.autoDetach, or failing that gc.autoDetach, is set to false. With neither set, it detaches. This is the process Ephesus's probe found still running after a commit had returned:
git maintenance run --auto --no-quiet --detach
The detached process calls daemonize(), which forks and lets the parent exit.
Node's execFile callback fires when git commit exits. The maintenance process is a grandchild that left on purpose, so no await in the harness could reach it.
On Windows, daemonize() returns ENOSYS, because Windows builds set NO_POSIX_GOODIES. Git for Windows therefore runs the same housekeeping in the foreground. That is why the project's Windows development machine never produced the failure and Linux CI did.
Two loose objects in objects/17 start a background repack
Whether the detached phase does real work depends on the git version. In v2.53.0, unscheduled maintenance uses the gc strategy. By v2.55.0 it uses geometric, whose geometric-repack task runs, among other triggers, when the loose object count exceeds a limit of 100, rounded up to 256.
The count is an estimate. odb/source-loose.c counts the files in the single shard objects/17 and multiplies by 256. Two loose objects whose IDs start with 17 give 512, which clears 256, and a git repack starts writing into objects/pack after the commit has returned.
The Ephesus investigation caught this live (implementation record). An instrumented copy of the test read /proc at the moment the queue's drained() promise resolved. In 8 of 1,600 teardowns, git was still running there: maintenance, repack and pack-objects.
With the trigger forced through git's own config, the unmodified test failed 12 of 50 runs with ENOTEMPTY: on .git as in CI, on .git/objects/pack, or on the Agora directory that contains .git. git recreates the directories it writes into, so a late repack can put back even a .git the teardown had already deleted. Those runs used WSL2 and a locally built git 2.55.0 in place of CI's binary.
Two -c flags make the commit wait for its own repack
The fix sits in the runner that every Agora git call passes through. git.ts now prepends -c maintenance.autoDetach=false -c gc.autoDetach=false. Command-line config overrides a user's global setting, so a developer who asked for detached housekeeping cannot reintroduce the race.
git 2.47 and later read the first key. The second covers an older git whose automatic gc reads only that one.
After the change, the same instrumented loop found a live writer in 0 of 1,600 teardowns, and the forced trigger passed 50 of 50 runs. The record lists the alternatives it rejected:
- A bigger
maxRetries. The remover never lists the directory again, so one late write defeats any retry count. - Waiting for
objects/maintenance.lockto disappear. git 2.53.0 does not hold that lock through the detached phase, so the wait would pass with the repack still running. maintenance.auto=false. The Agora commits log blobs of up to 4 MiB indefinitely, and with no housekeeping its object store would grow without bound.
The cost lands on boot and quit
With housekeeping in the foreground, an occasional commit is slow. The PR measured it on a production-shaped Agora under WSL2, one run per configuration. Under git 2.55.0, the first housekeeping over 3,200 loose objects took 2,799 ms, and an incremental geometric repack 60 commits later took 348 ms. Under git 2.53.0, a full gc at 7,201 loose objects took 15,456 ms, close to the runner's 20 s timeout.
Delivery into the Agora is a file rename, so it never waits on a commit. Boot and quit do: both await a commit before the window opens or the app exits. If the commit hits the timeout, it has already landed, so the queue's retry finds a clean tree, and the orphaned gc finishes alone as it did before the change.
Pass the flags wherever a commit's return means done
If any code of yours awaits git commit on Linux or macOS and then removes, archives or hashes the repository, add -c maintenance.autoDetach=false to that invocation, and -c gc.autoDetach=false for git older than 2.47. Testing on Windows will not tell you whether you need it, because Git for Windows never detaches.
Then measure your own commit latency on a repository the size you ship. Ephesus accepted an occasional multi-second commit at boot in exchange for "drained" meaning that no git process it started is still writing. Its record names one follow-up, unbuilt, if the boot delay ever matters: skip housekeeping on the two boot commits and leave it to the next ordinary one.