fix(modules): re-clone a work tree git cannot use, instead of wedging on it #40

Merged
julian merged 11 commits from fix/issue-28-wedged-clone into main 2026-09-02 13:05:14 +02:00
Owner

Closes #28.

The wedge

syncRepo picked clone-vs-fetch from the presence of <srcDir>/.git, never from its validity:

const exists = await Deno.stat(`${dir}/.git`).then(() => true).catch(() => false);

A SIGKILL mid-clone — an OOM kill, docker stop past the grace period, a host reset — leaves a partial clone behind. Every boot after that fails the same non-transient way, isRetryableFailure is false, the retry loop never takes the repo, and the module stays disabled on that boot and every boot after it. Manual rm -rf /data/modules/src/<slug> was the only recovery, and nothing named it.

The fix: a fast-path probe, and bounded recovery on failure

Seven reviews of this PR proved the same lesson seven times: a probe is a guess about what git will accept, and only the operation knows — and the corollary, that an operation refusing is not the same as the tree being unusable. The first predicate (.git exists) kept fetch-phase residue; the second (rev-parse --verify HEAD) caught that and still missed the checkout-phase kill and its stale index.lock. The third review then proved the inverse overcorrection — recovery-on-failure with no bound condemns the tree for faults that are not the tree's, and a typo'd pin paid a full clone on every boot — the fourth proved that the bound's identity was wrong in both of its halves, the fifth that the mechanism which fixed the identity had quietly taken the module's name with it, the sixth that the recovery was still willing to destroy a tree git had only declined to check out, and the seventh that three of the mechanisms above were claimed rather than pinned. So:

  • isUsableWorkTreerev-parse --show-toplevel real-path-compared against srcDir (a repo, and ours), plus rev-parse --verify HEAD (a finished clone) — only decides whether the cheap fetch is worth attempting. It is documented as, and is, the fast path.
  • A dirty work tree is not an unusable one, and every checkout now passes --force. checkout --detach refuses to run when a locally-modified tracked file would be overwritten. That refusal is git answering non-transiently, so the tree became the suspect, was removed and re-cloned — and the recovery then succeeded, which is why the bound could not see the class at all: clearFault() ran, no signature was ever written, and the next boot did it again. Four boots with something writing a tracked file into srcDir between them bought four full clones from thirty-three lines before Deno.serve, and destroyed every untracked file under srcDir each time. Git said no about the checkout, not about the repository. --force is the checkout this file already argues for — the leftover branch states the invariant, that everything under srcDir came from the clone, so there are no local changes worth keeping. What it leaves alone is an untracked file at a path the target commit does not track. One at a path the commit does track is overwritten — but plain checkout refuses outright there too ("The following untracked working tree files would be overwritten"), so that is one more refusal class --force closes rather than a file it costs: the alternative was the re-clone, which removes the whole tree. On main this shape merely disabled the module; the recovery made it destructive, so it was a regression this PR introduced.
  • When fetch or checkout fails inside a probe-accepted tree and the failure is git answering (GitExitError, non-transient), the tree is re-cloned — once per fault, not once per boot. When the recovery clone fails the same way as the fault that triggered it, that is the proof the tree is innocent: the signature is persisted beside srcDir and the same fault skips the recovery on every later boot. Any successful sync clears it; a fault with a different message is new evidence and is recovered again. Only git answering may persist a signature — a recovery that died on a network blip or the 120 s self-kill proves nothing about futility, and persisting it would wedge a genuinely broken tree behind a recovery that could still succeed. A write that fails — the full disk that produced the fault is exactly where it fails — is now logged rather than swallowed, since the visible symptom of losing it is a clone per boot with nothing saying why.
  • The fault's identity is (work tree, configured ref), and the signature file is keyed on both — <srcDir>.recovery-fault-<fnv1a(ref)>. …#main and …#dev slugify to one work tree (external-retry.ts says so and tests it) and do not share a fault: keyed on the directory alone, the entry that synced fine deleted the failing entry's evidence before it was ever read, and two failing entries were each other's "new evidence" — either way a full clone per boot, from thirty-three lines before Deno.serve, which is the entire reason the bound exists. The digest rather than the ref itself, because a branch name is a path (release/1.2) and this is a filename.
  • The retry loop resolves that pair too. ModuleFailure now carries configuredRef, and the chain looks the config entry up by (url, ref) rather than by url alone. Pre-existing, but the per-ref bound is what made it matter: a #dev failure retried with the #main entry checks out the wrong ref and writes or clears the wrong ref's signature. One chain per URL is unchanged and deliberate — two chains in one work tree would fight over index.lock — and the url-only lookup stays as the fallback for a failure raised outside prepareExternalModule, where there is no ref.
  • The fault's identity also survives the recovery renaming its subcommand, in the two ways it could move. git clone performs a checkout of its own, so a checkout fault reproduced by the recovery came back worded git clone failed: … with clone's advice appended (Clone succeeded, but checkout failed) and never matched the git checkout failed: … that condemned the tree — leaving every checkout fault except the pin unbounded, each recovery a completed transfer. Both clones now pass --no-checkout and check out through the same checkoutConfiguredRef the fetch path uses, so the wording matches by construction (and the doubled checkout goes away: clone's was of the default branch, which a pinned ref replaced on the next line). On top of that, faultSignature() strips the leading git <cmd> failed: before comparing and persisting, which covers the fetch-vs-clone case — a disk that fills mid-transfer says No space left under both names — while the two texts that genuinely differ (does not appear to be a git repository against repository … does not exist) stay different.
  • --no-checkout has a cost, and it is paid. Clone's checkout of the default branch was also what left a manifest on disk for a repo whose configured checkout then failed — a pin no ref satisfies, a tree git refuses to write — and readManifestName recovered the module's name from it in the failure record. Without it that work tree is empty, on that boot and every boot after, and the record fell back to the slug: the bell read Module "<slug>" is not running and /system filed the repo under unidentified. Same on a tree that ran for months, was wrecked, and was re-cloned under a bad pin. The blob is in the object store regardless — HEAD is the default branch clone pointed it at — so the fallback in prepareExternalModule's catch now reads git -C <srcDir> show HEAD:opsdeck.module.json (manifestNameOf, same schema, same "readable, not acceptable" bar) when the file is not in the work tree. What leaves the slug standing is nothing on disk at all, or a HEAD that never resolved.
  • The local-only git calls have their own 10 s budget (LOCAL_GIT_TIMEOUT_MS) rather than GIT_TIMEOUT_MS: the name lookup above, repoOrigin's config --get, and isUsableWorkTree's two rev-parses. None of them touches a network and all of them run before prepareExternalModule returns, which is the pre-Deno.serve path — a syncRepo that just spent two minutes on a wedged /data must not be able to spend two more, per probe, for a nicety. What each gives up by stopping early is small and named: the manifest name costs the slug in one failure record, and a probe that cannot answer rethrows as an ordinary clone-stage failure rather than condemning anything. (Only the name lookup had this in the previous round; review pointed out that the other two make the same argument and did not get it.)
  • checkoutConfiguredRef reports the answer that matters. It tries the ref as given, then origin/<ref> — and the bare catch reported the second attempt, which for a tag that exists but whose tree git refuses to write is a ref-lookup failure (--detach does not take a path argument 'origin/v2.0.0') standing in for the real error: invalid path '.git'. That message is the persisted fault identity, the bell body and the /system text. The second attempt is now made only when rev-parse --verify --quiet origin/<ref>^{commit} says there is such a branch; otherwise the first attempt's error stands. Visible consequence: a pin no ref satisfies is now worded by the first attempt too — … 'v9.9.9' rather than … 'origin/v9.9.9' — which is the name the operator typed.
  • Per-ref keying means a signature outlives a pin an operator corrects by writing a different ref, or a repo they drop from the config. sweepFaultSignatures() runs once at the top of prepareExternalModules, before anything writes a new one, and removes every .recovery-fault-<hex> file no configured ref could still read. isFile first: . survives slugify, so a repo whose URL ends in .recovery-fault owns a work tree of that exact shape, and the sweep must never be what removes a checkout. Scoped honestly: this is the smallest of the leftovers a dropped repo leaves and the only one swept — its srcDir stays, as it always did on main, and so do the <srcDir>.recovering / <srcDir>.replaced a kill inside a recovery leaves, which only the next sync of that same repo removes. Sweeping a few hundred bytes is a readDir and a filename pattern; sweeping checkouts is a decision about which directories under /data are ours, and that is more machinery than the disk it saves.
  • The failure record carries both halves: <fault> (re-clone also failed: <e2>), or <fault> (a fresh clone failed the same way) when identical. An earlier revision threw the recovery's error alone, so the bell described a network blip about a tree whose origin was gone — and retryability was decided by the wrong error. It is now decided over both messages: a tree fault whose re-clone died on a transient network error is retryable, a reproduced fault is not.
  • The fresh clone lands beside the suspect tree (<srcDir>.recovering) and is swapped in only after its checkout finishes — at the cost of briefly doubling the repo's footprint under /data during a recovery. A recovery that fails must not consume the old checkout, whose manifest is what names the module's failure (readManifestName in the prepareExternalModule catch). The swap itself is two renames rather than remove-then-rename: Deno.remove(dir) followed by a rename that threw — an ordinary EACCES or EBUSY, no kill required — left neither copy standing and the catch removed the fresh one too, so the record fell back to the slug on a boot that had already lost the module. The suspect is renamed aside (<srcDir>.replaced), the fresh clone takes its name, and the suspect is dropped only after that; a rename that fails puts the old tree back. A kill landing inside the window still costs that checkout — the window is two filesystem ops, not the clone — and what it leaves behind is swept by the next sync, as .recovering already was. "Briefly" is now a word a test holds: both removals — the suspect after a successful swap, the half-built clone on the failure path — could be deleted with the suite green, and an unnamed second copy of a repo under /data is the smell this PR exists to end.
  • A failure that is not git answering — no git on PATH, the 120 s self-kill on a wedged /data, a transient network error — rethrows and surfaces as an ordinary clone-stage failure, on the typed GitExitError + gitAnswered, not message sniffing.
  • The garbage shapes stay handled where they were: whatever the probe rejects is removed (lstat, so a dangling symlink is seen) and cloned over.
  • The default-branch path keeps its real second chance: remoteDefaultBranch() asks the remote (git remote set-head origin --auto) and re-reads, instead of the old literal origin/HEAD fallback that composed origin/origin/HEAD.
  • slugify appends an 8-hex FNV-1a digest of the credential-free URL, so two configured URLs sharing a directory takes a 32-bit collision on a prefix-colliding pair (~1 in 4·10⁹) rather than an everyday fold (the readable part is lossy: / and : fold to the same -, 71 chars truncate, the all-dots rule is many-to-one). Digest over the credential-free form, so rotating a token does not orphan the clone.
  • ref.url is redacted (redactSecrets) in every log line that carries it, in the error text git echoes it back into, and in the persisted fault signature (compared redacted on both sides — redaction is deterministic, so equality survives it).
  • git() error messages name the subcommand, not args[0].
  • Known trade, kept: there is no cheap tier — a stale index.lock, a zero-byte file git itself says to delete, costs a full re-transfer. Consistent with "don't guess"; at this repo size the wasted clone is cheaper than a third predicate.

Slug migration, paid for

The digest suffix moves every existing deployment's srcDir, and both costs of that rotation are handled:

  • The old clone at /data/modules/src/<legacy> is migrated, not stranded. When it is this url's own clone and the new directory does not exist yet, it is renamed into place — no re-clone, and a first post-upgrade failure can still recover its manifest name from it. The adoption gate (repoOrigin) compares credential-free forms (shared with slugify via credentialFreeUrl): the digest is credential-free precisely so rotating a token does not orphan the clone, and a verbatim comparison would have had this migration remove the very checkout it exists to keep from a deployment that rotated its token between the two versions. It answers three ways rather than two, for the rule the rest of the file follows — only git answering may condemn a directory: a leftover whose origin is some other URL is removed, and one git could not answer for at all (no git on PATH, a wedged /data) is left where it is until a boot that can ask. That question is now asked whether or not srcDir is occupied. Adoption is only possible while it is free, but removal is the same rm -rf either way, and skipping the question once srcDir exists made the steady-state path the one path that removed on no evidence at all. The legacy formula is kept verbatim (legacyModuleSlug), with guards for its old floor bugs (""/all-dots would name srcRoot or its parent).
  • A bell row stored under the pre-digest externalFailureKey is rekeyed by a boot sweep (migrateExternalAlertKeys, called once in main.ts after Deno.serve, before anything writes under the new keys) rather than inside the alert calls. The deployment likeliest to hold a legacy row is the one whose module upgrades and then simply works — that boot raises neither a failure nor a recovery, so a migration living in those calls never reached it, and the old row stood in the bell forever. The sweep also takes the keyed UPDATE off the alert hot path: it is now one UPDATE per configured repo per boot. Retiring it outright would need a persisted marker — more machinery than the UPDATE it saves, so it deliberately stays.

The bell

briefly() cuts out of the middle, and a chained record half by half. The chained clone record runs past 300 characters with a real URL in both halves, so trimming the tail dropped the parenthesised half whole and left the notification inside a clause it never closed; one middle cut over the whole string kept both reasons and ate the (re-clone also failed: between them, so the two errors read as one run-on sentence closing a parenthesis it never opened. The record's delimiter is one exported constant (RECOVERY_CHAIN, written by syncRepo and read by the bell), and the budget is split on it: each half gets its own middle cut, the delimiter and the closing paren are kept, and a short half hands what it does not need to the other. A git message carries its label at the front and its reason at the end, which is what a middle cut keeps. The trade, named: for a frontend-build body — the builder's whole stdout+stderr — the last 150 characters are whatever the bundler printed last rather than more of the first error. /system carries the full text either way.

That mechanism was entirely unexecuted by the suite until the last round: the bell test's chained record is 268 characters after redaction, under the budget, so all three of its chained assertions passed trivially and reverting briefly to main's tail cut passed the whole file. It now has a direct unit test — and this round the handoff got the same treatment, for the same reason: the lopsided fixture was 203 characters, so briefly returned at the length guard and the split never ran, leaving both handoff terms deletable with 176/0 green. Two fixtures now exceed the budget with one half short, asserted on length, because the handoff is precisely what keeps the total at the full 300: dropping Math.min(fault.length, …) comes out at 184 and dropping Math.max(…, budget − recovery.length) at 185.

middleCut is clamped and indexed absolutely while it is in hand. For max <= 2 the tail arithmetic reaches zero, where text.slice(-0) is text.slice(0) — the whole string plus an ellipsis, longer than its own budget. Unreachable from briefly today (when a cut is needed neither share drops below 137), but three Math calls were what stood between them.

No longer in this PR

The rateLimit() sweep fix rode along on a stale premise — a Deno 2.5.6 pin that no longer exists. Main's request-path sweep supersedes it and this branch carries main's version untouched. CLAUDE.md's claim that 2.5.6 is the Dockerfile/CI pin is corrected instead: the single pin is docker/Dockerfile's FROM denoland/deno:… line (2.9.5 today), and .forgejo/deno.sh derives CI's image from that same line.

Tests

Twenty-two around this in packages/server/tests/external_modules_test.ts (12 → 34):

  • a clone the kernel killed is re-cloned, not wedged forever — the stand-in upload-pack sleeps 5 s so the orphan expires with the test run.
  • a clone the kernel killed during checkout is re-cloned, not wedged on its lock — git writes the residue: a required smudge filter pins a real git clone --no-local inside its checkout, the appearance of .git/index.lock proves the kill lands in that phase, then SIGKILL. The stale lock is asserted where the kill was final (Deno.build.os !== "windows"), so where the kill misses, the test can no longer silently degrade into a copy of the healthy-tree test — the Windows-wrapper degradation is documented, and CI (linux, the container's git) runs the lock path for real. It also now asserts that the successful swap left nothing beside srcDir; dropping Deno.remove(aside, …) fails it.
  • a work tree git only declined to check out is kept, not condemned — a healthy tree, a tracked file edited on the box, and a commit that moves that same file on the remote. Both halves are needed or there is nothing for checkout to overwrite and it exits 0 either way. The untracked marker proves the tree was fetched rather than re-cloned, the tracked file proves the new commit actually landed over the edit, and no signature is written. Dropping --force fails it.
  • a healthy tree whose origin went unreachable survives the bootgitAnswered is the single predicate deciding whether a work tree gets rm -rf'd, and replacing its body with return true passed all thirty of the tests before it. This one points the tree's origin at git://127.0.0.1:1/nope.git while the config still names the reachable path, so a re-clone would succeed and take the tree with it: the marker survives, the record is the fault alone and transient, and there is no signature and no .recovering. return true fails it.
  • the bell keeps both halves of a chained record, and the seam — directly over briefly for the reason above. An uncredentialed fault + recovery past the budget: the delimiter and the closing paren survive, each half is cut out of its middle (label at the front, reason at the end), and an unchained body is one middle cut. main's tail cut fails it. Two lopsided fixtures over 300 now pin the budget handoff and assert briefly(record).length === 300; each of the two handoff terms fails one of them (184 and 185).
  • an unsatisfiable pin costs one recovery clone, not one per boot — a healthy tree, then #v9.9.9. Boot 1 recovers and the record says the fresh clone failed the same way; boot 2 skips the recovery and the record is the fault alone; the old checkout's marker file survives both (the tree is never consumed) and keeps naming the module. The pin then becoming satisfiable (the tag is created) fetches into the same tree and retires the signature — the evidence is about a fault, not about a directory. It also asserts that the failed recovery consumed its own half-built clone (dropping Deno.remove(fresh, …) fails it) and that the failure record carries configuredRef (dropping it fails it, which is what silently reverts the (url, ref) fix).
  • a retry takes the entry its failure came from, ref and all — the other half of that fix, and the only ordering in which it is observable: two entries for one repo, #main first, and only #dev failing. The retry chain's work tree must end up on dev's commit; reverting the lookup to url-only fails it.
  • refs that share a work tree do not share a bound — one good ref and two bad pins over three boots. Two signatures stand at once, boot 1 records a fresh clone failed the same way for each, boots 2–3 record the fault alone however the good entry is interleaved, and the shared work tree is never consumed under it.
  • a checkout fault a fresh clone reproduces is bounded too — a commit whose tree holds a blob named .git, which every git refuses to write (error: invalid path '.git') in a clone as much as in a work tree. It asserts the fault is worded by our checkout, that boot 2's recovery reproduces it and persists, that boot 3 buys no clone, and that what is on disk is the message with its subcommand stripped. No filter binary and no process-wide environment, so it is safe under the parallel runner.
  • a pin that cannot be checked out still names the module — a first-ever clone under #v9.9.9 names the module from the manifest with no opsdeck.module.json in the work tree (asserted absent, so the name provably came from the object store), holds that name through the recovery boot and the bounded boot after it, and holds it again for a tree that ran once, was wrecked (.git removed), and was re-cloned under the bad pin.
  • a pinned ref that resolves but cannot be checked out reports the checkout's answer — the uncheckoutable commit tagged v2.0.0; the record carries invalid path '.git' and not does not take a path argument. Then a ref that is a remote branch (#release, the seed commit, no local branch of that name) still gets its second attempt and syncs.
  • "the record is the fault alone" means it: the boots that must buy no clone assert neither fresh clone nor re-clone in the record (assertNoRecovery) — the old !includes("fresh clone") also passed for a recovery that ran and failed differently.
  • a signature no configured ref could read is swept at boot — a failing #v9.9.9, then a boot configured #v1.0.0, and prepareExternalModules leaves nothing behind.
  • a fault keeps its identity when the recovery renames the subcommand — a unit test over faultSignature: git fetch failed: … and git clone failed: … of one text match, the two texts that genuinely differ do not, and credentials are gone from what lands on disk.
  • a disabled module raises one alert, keyed, with no credentials in it — also asserts the chained record survives the bell's budget at both ends and at the seam, under 400 characters, credentials still not.
  • garbage at srcDir that was never a repo is removed / a dangling symlink at srcDir is removed / a work tree that lost origin/HEAD re-asks the remote / a healthy work tree is still fetched / the two slug tests — as before.
  • the pre-digest slug's own clone is adopted, not re-cloned or left behind — a real prepared clone moved to the digestless name is renamed back, a file not from the remote survives, nothing lingers.
  • a pre-digest leftover that is not this url's clone is removed, never adopted — seeds a real clone of a different repo at the legacy name, so the URL comparison itself refuses it.
  • a bell row stored under the pre-digest key is adopted at boot, not stranded — seeds a legacy-keyed row, runs the boot sweep, and asserts the next failure and recovery address it in place.
  • a clone failure after a previous success recovers the manifest name — also asserts the chained record: the error carries both the fetch fault that condemned the tree and the recovery clone's own failure.

Gates on this box: deno task check, deno lint packages/server/, deno fmt --check, and the full packages/server/tests/ suite (177 passed, 0 failed) are green at the head commit, which is merged onto current main. The same suite through ./.forgejo/deno.sh — the derived image built from the Dockerfile's FROM (denoland/deno:2.9.5 + debian git) — is 177 passed, 0 failed as well. Every mutation named above was run, not reasoned about.

Not covered

  • The probe is a pure optimisation with no coverage of its own. Reverting isUsableWorkTree to the literal #28 predicate (Deno.stat(dir + "/.git")) still passes the file, because recovery-on-failure catches the killed clone anyway. That is the architecture — the probe only decides whether the cheap fetch is worth attempting — but it means a clone the kernel killed no longer proves the probe, and it belongs here rather than in the claims above. remoteDefaultBranch likewise: reverting it to the old literal still passes a work tree that lost origin/HEAD re-asks the remote, because the recovery clone rescues it.
  • The probe-failure guard (git absent from PATH, a hung /data) and repoOrigin's unknown branch need the same unavailable git — simulating either means mutating PATH for the whole test process, which the parallel runner makes unsafe; both are gitAnswered() plus the typed GitExitError by construction. The network half of gitAnswered — the half that decides whether an innocent tree is deleted — is covered by a healthy tree whose origin went unreachable; the GitExitError half alone (!isTransientGitError removed) still passes.
  • No test drives a kill inside the recovery clone, or inside the two-rename swap window, or a Deno.rename that throws there — forcing that means mocking Deno.rename. The .recovering / .replaced sweep and the restore-on-failure path are the containment, and the swap comment claims exactly what it guarantees. What is pinned now is only that a recovery which completes — either way — leaves neither directory behind.
  • The disk-full half of the signature stripping is covered by the unit test rather than by a full disk; the write failure on such a disk is logged rather than silent, which is the visible part.
  • The credential-rotation half of the adoption gate has no end-to-end test — local test repos are paths and cannot carry embedded credentials — so it is covered by the slug test (moduleSlug credential-free) plus the shared credentialFreeUrl being the same function on both sides. The git show HEAD:… fallback's own failure modes (HEAD unborn, no git) are the slug, which is what they were before.
  • LOCAL_GIT_TIMEOUT_MS is not pinned by anything: it is a budget, and driving it means a git that hangs for ten seconds in three places.
  • The bell row is still keyed on the URL alone, while the fault signature, the failure record and the retry chain are all per-ref now. U#main and U#dev share one notification row. Pre-existing; this PR is what made the pair first-class everywhere else, so it is named here rather than fixed.
Closes #28. ## The wedge `syncRepo` picked clone-vs-fetch from the *presence* of `<srcDir>/.git`, never from its validity: ```ts const exists = await Deno.stat(`${dir}/.git`).then(() => true).catch(() => false); ``` A SIGKILL mid-clone — an OOM kill, `docker stop` past the grace period, a host reset — leaves a partial clone behind. Every boot after that fails the same non-transient way, `isRetryableFailure` is false, the retry loop never takes the repo, and the module stays disabled on that boot **and every boot after it**. Manual `rm -rf /data/modules/src/<slug>` was the only recovery, and nothing named it. ## The fix: a fast-path probe, and bounded recovery on failure Seven reviews of this PR proved the same lesson seven times: a probe is a guess about what git will accept, and only the operation knows — and the corollary, that an operation refusing is not the same as the tree being unusable. The first predicate (`.git` exists) kept fetch-phase residue; the second (`rev-parse --verify HEAD`) caught that and still missed the checkout-phase kill and its stale `index.lock`. The third review then proved the inverse overcorrection — recovery-on-failure with no bound condemns the tree for faults that are not the tree's, and a typo'd pin paid a full clone on every boot — the fourth proved that the bound's *identity* was wrong in both of its halves, the fifth that the mechanism which fixed the identity had quietly taken the module's name with it, the sixth that the recovery was still willing to destroy a tree git had only declined to *check out*, and the seventh that three of the mechanisms above were claimed rather than pinned. So: - `isUsableWorkTree` — `rev-parse --show-toplevel` real-path-compared against `srcDir` (a repo, and *ours*), plus `rev-parse --verify HEAD` (a *finished* clone) — only decides whether the cheap fetch is worth attempting. It is documented as, and is, the fast path. - **A dirty work tree is not an unusable one, and every checkout now passes `--force`.** `checkout --detach` refuses to run when a locally-modified *tracked* file would be overwritten. That refusal is git answering non-transiently, so the tree became the suspect, was removed and re-cloned — and the recovery then **succeeded**, which is why the bound could not see the class at all: `clearFault()` ran, no signature was ever written, and the next boot did it again. Four boots with something writing a tracked file into `srcDir` between them bought four full clones from thirty-three lines before `Deno.serve`, and destroyed every untracked file under `srcDir` each time. Git said no about the *checkout*, not about the repository. `--force` is the checkout this file already argues for — the leftover branch states the invariant, that everything under `srcDir` came from the clone, so there are no local changes worth keeping. What it leaves alone is an untracked file at a path the target commit does **not** track. One at a path the commit *does* track is overwritten — but plain `checkout` refuses outright there too ("The following untracked working tree files would be overwritten"), so that is one more refusal class `--force` closes rather than a file it costs: the alternative was the re-clone, which removes the whole tree. On `main` this shape merely disabled the module; the recovery made it destructive, so it was a regression this PR introduced. - When fetch or checkout fails inside a probe-accepted tree and the failure is git *answering* (`GitExitError`, non-transient), the tree is re-cloned — **once per fault, not once per boot**. When the recovery clone fails the same way as the fault that triggered it, that is the proof the tree is innocent: the signature is persisted beside `srcDir` and the same fault skips the recovery on every later boot. Any successful sync clears it; a fault with a different message is new evidence and is recovered again. Only git answering may persist a signature — a recovery that died on a network blip or the 120 s self-kill proves nothing about futility, and persisting it would wedge a genuinely broken tree behind a recovery that could still succeed. A write that *fails* — the full disk that produced the fault is exactly where it fails — is now logged rather than swallowed, since the visible symptom of losing it is a clone per boot with nothing saying why. - **The fault's identity is (work tree, configured ref)**, and the signature file is keyed on both — `<srcDir>.recovery-fault-<fnv1a(ref)>`. `…#main` and `…#dev` slugify to one work tree (`external-retry.ts` says so and tests it) and do not share a fault: keyed on the directory alone, the entry that synced fine deleted the failing entry's evidence before it was ever read, and two failing entries were each other's "new evidence" — either way a full clone per boot, from thirty-three lines before `Deno.serve`, which is the entire reason the bound exists. The digest rather than the ref itself, because a branch name is a path (`release/1.2`) and this is a filename. - **The retry loop resolves that pair too.** `ModuleFailure` now carries `configuredRef`, and the chain looks the config entry up by (url, ref) rather than by url alone. Pre-existing, but the per-ref bound is what made it matter: a `#dev` failure retried with the `#main` entry checks out the wrong ref and writes or clears the wrong ref's signature. One chain per URL is unchanged and deliberate — two chains in one work tree would fight over `index.lock` — and the url-only lookup stays as the fallback for a failure raised outside `prepareExternalModule`, where there is no ref. - **The fault's identity also survives the recovery renaming its subcommand**, in the two ways it could move. `git clone` performs a checkout of its own, so a checkout fault reproduced by the recovery came back worded `git clone failed: …` with clone's advice appended (`Clone succeeded, but checkout failed`) and never matched the `git checkout failed: …` that condemned the tree — leaving every checkout fault except the pin unbounded, each recovery a *completed* transfer. Both clones now pass `--no-checkout` and check out through the same `checkoutConfiguredRef` the fetch path uses, so the wording matches by construction (and the doubled checkout goes away: clone's was of the default branch, which a pinned ref replaced on the next line). On top of that, `faultSignature()` strips the leading `git <cmd> failed: ` before comparing and persisting, which covers the fetch-vs-clone case — a disk that fills mid-transfer says `No space left` under both names — while the two texts that genuinely differ (`does not appear to be a git repository` against `repository … does not exist`) stay different. - **`--no-checkout` has a cost, and it is paid.** Clone's checkout of the default branch was also what left a manifest on disk for a repo whose *configured* checkout then failed — a pin no ref satisfies, a tree git refuses to write — and `readManifestName` recovered the module's name from it in the failure record. Without it that work tree is empty, on that boot and every boot after, and the record fell back to the slug: the bell read `Module "<slug>" is not running` and `/system` filed the repo under `unidentified`. Same on a tree that ran for months, was wrecked, and was re-cloned under a bad pin. The blob is in the object store regardless — HEAD is the default branch clone pointed it at — so the fallback in `prepareExternalModule`'s catch now reads `git -C <srcDir> show HEAD:opsdeck.module.json` (`manifestNameOf`, same schema, same "readable, not acceptable" bar) when the file is not in the work tree. What leaves the slug standing is nothing on disk at all, or a HEAD that never resolved. - **The local-only git calls have their own 10 s budget** (`LOCAL_GIT_TIMEOUT_MS`) rather than `GIT_TIMEOUT_MS`: the name lookup above, `repoOrigin`'s `config --get`, and `isUsableWorkTree`'s two `rev-parse`s. None of them touches a network and all of them run before `prepareExternalModule` returns, which is the pre-`Deno.serve` path — a `syncRepo` that just spent two minutes on a wedged `/data` must not be able to spend two more, per probe, for a nicety. What each gives up by stopping early is small and named: the manifest name costs the slug in one failure record, and a probe that cannot answer rethrows as an ordinary clone-stage failure rather than condemning anything. (Only the name lookup had this in the previous round; review pointed out that the other two make the same argument and did not get it.) - **`checkoutConfiguredRef` reports the answer that matters.** It tries the ref as given, then `origin/<ref>` — and the bare catch reported the *second* attempt, which for a tag that exists but whose tree git refuses to write is a ref-lookup failure (`--detach does not take a path argument 'origin/v2.0.0'`) standing in for the real `error: invalid path '.git'`. That message is the persisted fault identity, the bell body and the `/system` text. The second attempt is now made only when `rev-parse --verify --quiet origin/<ref>^{commit}` says there is such a branch; otherwise the first attempt's error stands. Visible consequence: a pin no ref satisfies is now worded by the first attempt too — `… 'v9.9.9'` rather than `… 'origin/v9.9.9'` — which is the name the operator typed. - Per-ref keying means a signature outlives a pin an operator corrects by writing a *different* ref, or a repo they drop from the config. `sweepFaultSignatures()` runs once at the top of `prepareExternalModules`, before anything writes a new one, and removes every `.recovery-fault-<hex>` file no configured ref could still read. `isFile` first: `.` survives `slugify`, so a repo whose URL ends in `.recovery-fault` owns a work tree of that exact shape, and the sweep must never be what removes a checkout. Scoped honestly: this is the smallest of the leftovers a dropped repo leaves and the only one swept — its `srcDir` stays, as it always did on `main`, and so do the `<srcDir>.recovering` / `<srcDir>.replaced` a kill inside a recovery leaves, which only the next sync of that same repo removes. Sweeping a few hundred bytes is a `readDir` and a filename pattern; sweeping checkouts is a decision about which directories under `/data` are ours, and that is more machinery than the disk it saves. - The failure record carries **both halves**: `<fault> (re-clone also failed: <e2>)`, or `<fault> (a fresh clone failed the same way)` when identical. An earlier revision threw the recovery's error alone, so the bell described a network blip about a tree whose origin was gone — and retryability was decided by the wrong error. It is now decided over both messages: a tree fault whose re-clone died on a transient network error is retryable, a reproduced fault is not. - The fresh clone lands **beside** the suspect tree (`<srcDir>.recovering`) and is swapped in only after its checkout finishes — at the cost of briefly doubling the repo's footprint under `/data` during a recovery. A recovery that fails must not consume the old checkout, whose manifest is what names the module's failure (`readManifestName` in the `prepareExternalModule` catch). **The swap itself is two renames rather than remove-then-rename**: `Deno.remove(dir)` followed by a rename that *threw* — an ordinary EACCES or EBUSY, no kill required — left neither copy standing and the catch removed the fresh one too, so the record fell back to the slug on a boot that had already lost the module. The suspect is renamed aside (`<srcDir>.replaced`), the fresh clone takes its name, and the suspect is dropped only after that; a rename that fails puts the old tree back. A kill landing inside the window still costs that checkout — the window is two filesystem ops, not the clone — and what it leaves behind is swept by the next sync, as `.recovering` already was. **"Briefly" is now a word a test holds**: both removals — the suspect after a successful swap, the half-built clone on the failure path — could be deleted with the suite green, and an unnamed second copy of a repo under `/data` is the smell this PR exists to end. - A failure that is not git answering — no git on PATH, the 120 s self-kill on a wedged `/data`, a transient network error — rethrows and surfaces as an ordinary clone-stage failure, on the typed `GitExitError` + `gitAnswered`, not message sniffing. - The garbage shapes stay handled where they were: whatever the probe rejects is removed (`lstat`, so a dangling symlink is seen) and cloned over. - The default-branch path keeps its real second chance: `remoteDefaultBranch()` asks the remote (`git remote set-head origin --auto`) and re-reads, instead of the old literal `origin/HEAD` fallback that composed `origin/origin/HEAD`. - `slugify` appends an 8-hex FNV-1a digest of the credential-free URL, so two configured URLs sharing a directory takes a 32-bit collision on a prefix-colliding pair (~1 in 4·10⁹) rather than an everyday fold (the readable part is lossy: `/` and `:` fold to the same `-`, 71 chars truncate, the all-dots rule is many-to-one). Digest over the credential-free form, so rotating a token does not orphan the clone. - `ref.url` is redacted (`redactSecrets`) in every log line that carries it, in the error text git echoes it back into, and in the persisted fault signature (compared redacted on both sides — redaction is deterministic, so equality survives it). - `git()` error messages name the subcommand, not `args[0]`. - Known trade, kept: there is no cheap tier — a stale `index.lock`, a zero-byte file git itself says to delete, costs a full re-transfer. Consistent with "don't guess"; at this repo size the wasted clone is cheaper than a third predicate. ## Slug migration, paid for The digest suffix moves every existing deployment's srcDir, and both costs of that rotation are handled: - The old clone at `/data/modules/src/<legacy>` is migrated, not stranded. When it is this url's own clone and the new directory does not exist yet, it is **renamed** into place — no re-clone, and a first post-upgrade failure can still recover its manifest name from it. The adoption gate (`repoOrigin`) compares **credential-free** forms (shared with `slugify` via `credentialFreeUrl`): the digest is credential-free precisely so rotating a token does not orphan the clone, and a verbatim comparison would have had this migration *remove* the very checkout it exists to keep from a deployment that rotated its token between the two versions. It answers three ways rather than two, for the rule the rest of the file follows — only git *answering* may condemn a directory: a leftover whose origin is some other URL is removed, and one git could not answer for at all (no git on PATH, a wedged `/data`) is left where it is until a boot that can ask. **That question is now asked whether or not `srcDir` is occupied.** Adoption is only possible while it is free, but *removal* is the same `rm -rf` either way, and skipping the question once `srcDir` exists made the steady-state path the one path that removed on no evidence at all. The legacy formula is kept verbatim (`legacyModuleSlug`), with guards for its old floor bugs (`""`/all-dots would name srcRoot or its parent). - A bell row stored under the pre-digest `externalFailureKey` is rekeyed by a **boot sweep** (`migrateExternalAlertKeys`, called once in `main.ts` after `Deno.serve`, before anything writes under the new keys) rather than inside the alert calls. The deployment likeliest to hold a legacy row is the one whose module upgrades and then simply *works* — that boot raises neither a failure nor a recovery, so a migration living in those calls never reached it, and the old row stood in the bell forever. The sweep also takes the keyed `UPDATE` off the alert hot path: it is now one `UPDATE` per configured repo per boot. Retiring it outright would need a persisted marker — more machinery than the `UPDATE` it saves, so it deliberately stays. ## The bell `briefly()` cuts out of the **middle**, and a chained record **half by half**. The chained clone record runs past 300 characters with a real URL in both halves, so trimming the tail dropped the parenthesised half whole and left the notification inside a clause it never closed; one middle cut over the whole string kept both reasons and ate the `(re-clone also failed:` between them, so the two errors read as one run-on sentence closing a parenthesis it never opened. The record's delimiter is one exported constant (`RECOVERY_CHAIN`, written by `syncRepo` and read by the bell), and the budget is split on it: each half gets its own middle cut, the delimiter and the closing paren are kept, and a short half hands what it does not need to the other. A git message carries its label at the front and its reason at the end, which is what a middle cut keeps. The trade, named: for a `frontend-build` body — the builder's whole stdout+stderr — the last 150 characters are whatever the bundler printed last rather than more of the first error. `/system` carries the full text either way. That mechanism was **entirely unexecuted by the suite** until the last round: the bell test's chained record is 268 characters *after* redaction, under the budget, so all three of its chained assertions passed trivially and reverting `briefly` to `main`'s tail cut passed the whole file. It now has a direct unit test — and this round the **handoff** got the same treatment, for the same reason: the lopsided fixture was 203 characters, so `briefly` returned at the length guard and the split never ran, leaving both handoff terms deletable with 176/0 green. Two fixtures now exceed the budget *with one half short*, asserted on **length**, because the handoff is precisely what keeps the total at the full 300: dropping `Math.min(fault.length, …)` comes out at 184 and dropping `Math.max(…, budget − recovery.length)` at 185. `middleCut` is clamped and indexed absolutely while it is in hand. For `max <= 2` the tail arithmetic reaches zero, where `text.slice(-0)` is `text.slice(0)` — the whole string plus an ellipsis, longer than its own budget. Unreachable from `briefly` today (when a cut is needed neither share drops below 137), but three `Math` calls were what stood between them. ## No longer in this PR The `rateLimit()` sweep fix rode along on a stale premise — a Deno 2.5.6 pin that no longer exists. Main's request-path sweep supersedes it and this branch carries main's version untouched. `CLAUDE.md`'s claim that 2.5.6 is the Dockerfile/CI pin is corrected instead: the single pin is `docker/Dockerfile`'s `FROM denoland/deno:…` line (2.9.5 today), and `.forgejo/deno.sh` derives CI's image from that same line. ## Tests Twenty-two around this in `packages/server/tests/external_modules_test.ts` (12 → 34): - **a clone the kernel killed is re-cloned, not wedged forever** — the stand-in upload-pack sleeps 5 s so the orphan expires with the test run. - **a clone the kernel killed during checkout is re-cloned, not wedged on its lock** — git writes the residue: a required smudge filter pins a real `git clone --no-local` inside its checkout, the appearance of `.git/index.lock` proves the kill lands in that phase, then SIGKILL. The stale lock is **asserted** where the kill was final (`Deno.build.os !== "windows"`), so where the kill misses, the test can no longer silently degrade into a copy of the healthy-tree test — the Windows-wrapper degradation is documented, and CI (linux, the container's git) runs the lock path for real. It also now asserts that the successful swap left nothing beside `srcDir`; dropping `Deno.remove(aside, …)` fails it. - **a work tree git only declined to check out is kept, not condemned** — a healthy tree, a tracked file edited on the box, and a commit that moves that same file on the remote. Both halves are needed or there is nothing for checkout to overwrite and it exits 0 either way. The untracked marker proves the tree was fetched rather than re-cloned, the tracked file proves the new commit actually landed over the edit, and no signature is written. Dropping `--force` fails it. - **a healthy tree whose origin went unreachable survives the boot** — `gitAnswered` is the single predicate deciding whether a work tree gets `rm -rf`'d, and replacing its body with `return true` passed all thirty of the tests before it. This one points the *tree's* origin at `git://127.0.0.1:1/nope.git` while the config still names the reachable path, so a re-clone would succeed and take the tree with it: the marker survives, the record is the fault alone and transient, and there is no signature and no `.recovering`. `return true` fails it. - **the bell keeps both halves of a chained record, and the seam** — directly over `briefly` for the reason above. An uncredentialed fault + recovery past the budget: the delimiter and the closing paren survive, each half is cut out of its middle (label at the front, reason at the end), and an unchained body is one middle cut. `main`'s tail cut fails it. **Two lopsided fixtures over 300 now pin the budget handoff** and assert `briefly(record).length === 300`; each of the two handoff terms fails one of them (184 and 185). - **an unsatisfiable pin costs one recovery clone, not one per boot** — a healthy tree, then `#v9.9.9`. Boot 1 recovers and the record says the fresh clone failed the same way; boot 2 skips the recovery and the record is the fault alone; the old checkout's marker file survives both (the tree is never consumed) and keeps naming the module. The pin then becoming satisfiable (the tag is created) fetches into the same tree and retires the signature — the evidence is about a fault, not about a directory. It also asserts that the failed recovery consumed its own half-built clone (dropping `Deno.remove(fresh, …)` fails it) and that the failure record carries `configuredRef` (dropping it fails it, which is what silently reverts the (url, ref) fix). - **a retry takes the entry its failure came from, ref and all** — the other half of that fix, and the only ordering in which it is observable: two entries for one repo, `#main` first, and only `#dev` failing. The retry chain's work tree must end up on `dev`'s commit; reverting the lookup to url-only fails it. - **refs that share a work tree do not share a bound** — one good ref and two bad pins over three boots. Two signatures stand at once, boot 1 records `a fresh clone failed the same way` for each, boots 2–3 record the fault alone however the good entry is interleaved, and the shared work tree is never consumed under it. - **a checkout fault a fresh clone reproduces is bounded too** — a commit whose tree holds a blob named `.git`, which every git refuses to write (`error: invalid path '.git'`) in a clone as much as in a work tree. It asserts the fault is worded by *our* checkout, that boot 2's recovery reproduces it and persists, that boot 3 buys no clone, and that what is on disk is the message with its subcommand stripped. No filter binary and no process-wide environment, so it is safe under the parallel runner. - **a pin that cannot be checked out still names the module** — a first-ever clone under `#v9.9.9` names the module from the manifest with no `opsdeck.module.json` in the work tree (asserted absent, so the name provably came from the object store), holds that name through the recovery boot and the bounded boot after it, and holds it again for a tree that ran once, was wrecked (`.git` removed), and was re-cloned under the bad pin. - **a pinned ref that resolves but cannot be checked out reports the checkout's answer** — the uncheckoutable commit tagged `v2.0.0`; the record carries `invalid path '.git'` and not `does not take a path argument`. Then a ref that *is* a remote branch (`#release`, the seed commit, no local branch of that name) still gets its second attempt and syncs. - **"the record is the fault alone" means it**: the boots that must buy no clone assert neither `fresh clone` nor `re-clone` in the record (`assertNoRecovery`) — the old `!includes("fresh clone")` also passed for a recovery that ran and failed *differently*. - **a signature no configured ref could read is swept at boot** — a failing `#v9.9.9`, then a boot configured `#v1.0.0`, and `prepareExternalModules` leaves nothing behind. - **a fault keeps its identity when the recovery renames the subcommand** — a unit test over `faultSignature`: `git fetch failed: …` and `git clone failed: …` of one text match, the two texts that genuinely differ do not, and credentials are gone from what lands on disk. - **a disabled module raises one alert, keyed, with no credentials in it** — also asserts the chained record survives the bell's budget at both ends *and at the seam*, under 400 characters, credentials still not. - **garbage at srcDir that was never a repo is removed** / **a dangling symlink at srcDir is removed** / **a work tree that lost origin/HEAD re-asks the remote** / **a healthy work tree is still fetched** / the two slug tests — as before. - **the pre-digest slug's own clone is adopted, not re-cloned or left behind** — a real prepared clone moved to the digestless name is renamed back, a file not from the remote survives, nothing lingers. - **a pre-digest leftover that is not this url's clone is removed, never adopted** — seeds a **real clone of a different repo** at the legacy name, so the URL comparison itself refuses it. - **a bell row stored under the pre-digest key is adopted at boot, not stranded** — seeds a legacy-keyed row, runs the boot sweep, and asserts the next failure and recovery address it in place. - **a clone failure after a previous success recovers the manifest name** — also asserts the chained record: the error carries both the fetch fault that condemned the tree and the recovery clone's own failure. Gates on this box: `deno task check`, `deno lint packages/server/`, `deno fmt --check`, and the full `packages/server/tests/` suite (**177 passed, 0 failed**) are green at the head commit, which is merged onto current `main`. The same suite through `./.forgejo/deno.sh` — the derived image built from the Dockerfile's `FROM` (`denoland/deno:2.9.5` + debian git) — is **177 passed, 0 failed** as well. Every mutation named above was run, not reasoned about. ## Not covered - **The probe is a pure optimisation with no coverage of its own.** Reverting `isUsableWorkTree` to the literal #28 predicate (`Deno.stat(dir + "/.git")`) still passes the file, because recovery-on-failure catches the killed clone anyway. That *is* the architecture — the probe only decides whether the cheap fetch is worth attempting — but it means *a clone the kernel killed* no longer proves the probe, and it belongs here rather than in the claims above. **`remoteDefaultBranch` likewise**: reverting it to the old literal still passes *a work tree that lost origin/HEAD re-asks the remote*, because the recovery clone rescues it. - The probe-failure guard (git absent from PATH, a hung `/data`) and `repoOrigin`'s `unknown` branch need the same unavailable git — simulating either means mutating `PATH` for the whole test process, which the parallel runner makes unsafe; both are `gitAnswered()` plus the typed `GitExitError` by construction. The *network* half of `gitAnswered` — the half that decides whether an innocent tree is deleted — is covered by *a healthy tree whose origin went unreachable*; the `GitExitError` half alone (`!isTransientGitError` removed) still passes. - No test drives a kill *inside the recovery clone*, or inside the two-rename swap window, or a `Deno.rename` that throws there — forcing that means mocking `Deno.rename`. The `.recovering` / `.replaced` sweep and the restore-on-failure path are the containment, and the swap comment claims exactly what it guarantees. What is pinned now is only that a recovery which *completes* — either way — leaves neither directory behind. - The disk-full half of the signature stripping is covered by the unit test rather than by a full disk; the *write* failure on such a disk is logged rather than silent, which is the visible part. - The credential-rotation half of the adoption gate has no end-to-end test — local test repos are paths and cannot carry embedded credentials — so it is covered by the slug test (`moduleSlug` credential-free) plus the shared `credentialFreeUrl` being the same function on both sides. The `git show HEAD:…` fallback's own failure modes (HEAD unborn, no git) are the slug, which is what they were before. - `LOCAL_GIT_TIMEOUT_MS` is not pinned by anything: it is a budget, and driving it means a git that hangs for ten seconds in three places. - **The bell row is still keyed on the URL alone**, while the fault signature, the failure record and the retry chain are all per-ref now. `U#main` and `U#dev` share one notification row. Pre-existing; this PR is what made the pair first-class everywhere else, so it is named here rather than fixed.
fix(modules): re-clone a work tree git cannot use, instead of wedging on it
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m36s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m1s
77c963d30f
`syncRepo` picked clone-vs-fetch from the presence of `<srcDir>/.git`, never
from its validity. A SIGKILL mid-clone — an OOM kill, `docker stop` past the
grace period, a host reset — leaves `. .. .git` behind. Every later git command
then answers "fatal: not a git repository", that matches no fragment of
TRANSIENT_GIT_ERRORS, and the retry loop never takes the repo, so the module
stays disabled on that boot and every boot after it. Manual
`rm -rf /data/modules/src/<slug>` was the only recovery, and nothing said so.

Probe with `git -C <dir> rev-parse --show-toplevel` and compare real paths.
`--git-dir` walks upwards and would answer yes from inside somebody else's
work tree, where a fetch has no business running. On a miss, remove the
directory and clone fresh — everything under srcDir came from the remote, so
removing it costs nothing.

A probe that fails for a reason unrelated to the tree (no git on PATH, the
120 s self-kill on a wedged /data) is let out rather than read as "not a
repo", which would remove a work tree that was fine.

slugify can no longer return "" or a name made only of dots. `/` was already
stripped, but "" and "." are srcRoot itself and ".." is its parent, and the
repair path above removes what it finds at srcDir.

Closes #28

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thisilike requested changes 2026-08-21 20:36:32 +02:00
Dismissed
thisilike left a comment

Re-review at 77c963d, checked against a running git rather than against the description.

Verified first

  • The gates reproduce: deno task check, deno lint packages/server/, deno fmt --check and packages/server/tests/ (113 passed) are green at the head commit. Merged onto current main (cc65b8c, which has since rewritten seedRepo in this very file) it is clean and 123 passed.
  • The regression claim holds. With external.ts reverted to its parent and the tests kept, a clone the kernel killed fails (git -C failed: fatal: not a git repository) and a slug is a directory name fails (https://.. -> ..), while a healthy work tree is still fetched passes either way. The proof is real.
  • --show-toplevel over --git-dir is the right call for the reason stated, and the realPath comparison on both sides is needed, not defensive.

Blocking: #28 is not closed

The wedge is real, but the shape this fixes is not the shape a killed clone leaves. git clone writes .git in stages: init first, then [remote "origin"] url, then the fetch — and only after the fetch the fetch refspec, refs/remotes/origin/HEAD and the checkout. A SIGKILL during the fetch, which is the long phase and therefore the OOM window, leaves a .git that git accepts as a repository:

$ timeout -s KILL 3 git clone --quiet --upload-pack 'sh -c "sleep 60"' $src $wt
$ cat $wt/.git/config
[core] ...
[remote "origin"]
	url = /tmp/.../src          # a url, and no fetch refspec
$ ls $wt/.git
HEAD  branches  config  description  hooks  info  objects  refs
$ git -C $wt rev-parse --show-toplevel
/tmp/.../wt                     # isOwnWorkTree() answers TRUE

So syncRepo takes the fetch branch, and from there:

$ git -C $wt fetch --quiet --tags origin      # exit 0, and creates NO refs: there is no refspec
$ git -C $wt symbolic-ref refs/remotes/origin/HEAD
fatal: ref refs/remotes/origin/HEAD is not a symbolic ref
$ git -C $wt checkout --quiet --detach origin/HEAD
fatal: git checkout: --detach does not take a path argument 'origin/HEAD'
$ git -C $wt checkout --quiet --detach origin/origin/HEAD
fatal: git checkout: --detach does not take a path argument 'origin/origin/HEAD'

End to end against this branch, letting git write the wreckage instead of a test seeding it (git clone spawned, SIGKILL after 1.5 s, then prepareExternalModule twice):

boot 1: FAILED stage=clone git -C failed: fatal: git checkout: --detach does not take a path argument 'origin/origin/HEAD'
boot 2: FAILED stage=clone git -C failed: fatal: git checkout: --detach does not take a path argument 'origin/origin/HEAD'

Non-transient, stage: "clone", so isRetryableFailure is false, the retry loop never takes it, and the module stays disabled on that boot and every boot after — the issue verbatim, with rm -rf /data/modules/src/<slug> still the only recovery and still nothing naming it. Reproduced on git 2.55 and on git 2.39.5, which is what debian:bookworm-slim — the runtime layer of docker/Dockerfile — ships.

The probe asks "does git call this a repository". The question that decides whether a fetch can recover the directory is "is this a finished clone". git -C <dir> rev-parse --verify HEAD separates them: a completed clone has a commit at HEAD, the killed one has an unborn refs/heads/master (fatal: Needed a single revision). It also stays right for a kill during the checkout phase — HEAD resolves there, and the checkout --detach further down syncRepo finishes the work tree, so that case must not be re-cloned. A marker file written after a successful syncRepo, with its absence meaning "unusable", would cover every partial shape and would not depend on reading git's mind. Either way the else branch you added then does the recovery it was written for.

Related, and the reason this is terminal rather than merely noisy: when symbolic-ref refs/remotes/origin/HEAD fails, target defaults to the literal origin/HEAD, so the catch fallback composes origin/origin/HEAD — the default-branch path has no working second chance by construction. Worth fixing in the same pass.

Blocking: the tests seed wreckage git never writes

Test 1's two shapes — .git as an empty directory, .git as an empty regular file — are hand-built. A killed clone produces neither (see the ls .git above), and nothing in git writes an empty .git file at all; the gitfile form only ever comes from git worktree add/submodules, where it is valid. So the test proves the probe rejects garbage, which is worth having, but not the claim in its own name. The repro above is three lines and seeds it for real.

Non-blocking

  • Deno.stat for leftover follows symlinks, so a dangling symlink at srcDir reads as absent, nothing is removed, and git clone then dies with fatal: could not create work tree dir '…': File exists — non-transient, wedged again. Deno.lstat sees it and Deno.remove handles it; one word.
  • msg.startsWith("git ") pins the guard to git()'s message format, and nothing tests the coupling. If that format ever changes, the fallthrough flips from "rethrow" to "return false", and false now means delete the work tree. A typed error thrown by git() carries the same information without the string. Related: every call passes -C first, so args[0] is -C and operators read git -C failed: … in /system and in the bell for clone, fetch and checkout alike — pre-existing, but this PR adds a fourth -C caller and quotes those strings in its own body.
  • slugify's fallback is the constant "repo", so https://. and https://.. land on the same srcDir; the second repo then finds a valid work tree, fetches the first one's origin, and reports the first one's manifest name. Exotic, but a short digest suffix retires the class rather than one instance of it.
  • log.info("cloning", { url: ref.url }) — a line this diff moves — writes a credentialed URL into the log unredacted, while redactSecrets exists and every other egress path (/system, the alerts) uses it. Same for the external module skipped error log below it.

The direction is right and the slugify hardening is a good catch on the way past. It is the recovery predicate that needs to change, plus a test that lets git produce the input.

Re-review at `77c963d`, checked against a running git rather than against the description. ## Verified first - The gates reproduce: `deno task check`, `deno lint packages/server/`, `deno fmt --check` and `packages/server/tests/` (113 passed) are green at the head commit. Merged onto current `main` (`cc65b8c`, which has since rewritten `seedRepo` in this very file) it is clean and 123 passed. - The regression claim holds. With `external.ts` reverted to its parent and the tests kept, *a clone the kernel killed* fails (`git -C failed: fatal: not a git repository`) and *a slug is a directory name* fails (`https://.. -> ..`), while *a healthy work tree is still fetched* passes either way. The proof is real. - `--show-toplevel` over `--git-dir` is the right call for the reason stated, and the `realPath` comparison on both sides is needed, not defensive. ## Blocking: #28 is not closed The wedge is real, but the shape this fixes is not the shape a killed clone leaves. `git clone` writes `.git` in stages: `init` first, then `[remote "origin"] url`, then the fetch — and only *after* the fetch the fetch refspec, `refs/remotes/origin/HEAD` and the checkout. A SIGKILL during the fetch, which is the long phase and therefore the OOM window, leaves a `.git` that **git accepts as a repository**: ``` $ timeout -s KILL 3 git clone --quiet --upload-pack 'sh -c "sleep 60"' $src $wt $ cat $wt/.git/config [core] ... [remote "origin"] url = /tmp/.../src # a url, and no fetch refspec $ ls $wt/.git HEAD branches config description hooks info objects refs $ git -C $wt rev-parse --show-toplevel /tmp/.../wt # isOwnWorkTree() answers TRUE ``` So `syncRepo` takes the fetch branch, and from there: ``` $ git -C $wt fetch --quiet --tags origin # exit 0, and creates NO refs: there is no refspec $ git -C $wt symbolic-ref refs/remotes/origin/HEAD fatal: ref refs/remotes/origin/HEAD is not a symbolic ref $ git -C $wt checkout --quiet --detach origin/HEAD fatal: git checkout: --detach does not take a path argument 'origin/HEAD' $ git -C $wt checkout --quiet --detach origin/origin/HEAD fatal: git checkout: --detach does not take a path argument 'origin/origin/HEAD' ``` End to end against this branch, letting git write the wreckage instead of a test seeding it (`git clone` spawned, `SIGKILL` after 1.5 s, then `prepareExternalModule` twice): ``` boot 1: FAILED stage=clone git -C failed: fatal: git checkout: --detach does not take a path argument 'origin/origin/HEAD' boot 2: FAILED stage=clone git -C failed: fatal: git checkout: --detach does not take a path argument 'origin/origin/HEAD' ``` Non-transient, `stage: "clone"`, so `isRetryableFailure` is false, the retry loop never takes it, and the module stays disabled on that boot and every boot after — the issue verbatim, with `rm -rf /data/modules/src/<slug>` still the only recovery and still nothing naming it. Reproduced on git 2.55 and on git 2.39.5, which is what `debian:bookworm-slim` — the runtime layer of `docker/Dockerfile` — ships. The probe asks "does git call this a repository". The question that decides whether a fetch can recover the directory is "is this a *finished* clone". `git -C <dir> rev-parse --verify HEAD` separates them: a completed clone has a commit at HEAD, the killed one has an unborn `refs/heads/master` (`fatal: Needed a single revision`). It also stays right for a kill during the checkout phase — HEAD resolves there, and the `checkout --detach` further down `syncRepo` finishes the work tree, so that case must *not* be re-cloned. A marker file written after a successful `syncRepo`, with its absence meaning "unusable", would cover every partial shape and would not depend on reading git's mind. Either way the `else` branch you added then does the recovery it was written for. Related, and the reason this is terminal rather than merely noisy: when `symbolic-ref refs/remotes/origin/HEAD` fails, `target` defaults to the literal `origin/HEAD`, so the `catch` fallback composes `origin/origin/HEAD` — the default-branch path has no working second chance by construction. Worth fixing in the same pass. ## Blocking: the tests seed wreckage git never writes Test 1's two shapes — `.git` as an empty directory, `.git` as an empty regular file — are hand-built. A killed clone produces neither (see the `ls .git` above), and nothing in git writes an empty `.git` file at all; the gitfile form only ever comes from `git worktree add`/submodules, where it is valid. So the test proves the probe rejects garbage, which is worth having, but not the claim in its own name. The repro above is three lines and seeds it for real. ## Non-blocking - `Deno.stat` for `leftover` follows symlinks, so a dangling symlink at `srcDir` reads as absent, nothing is removed, and `git clone` then dies with `fatal: could not create work tree dir '…': File exists` — non-transient, wedged again. `Deno.lstat` sees it and `Deno.remove` handles it; one word. - `msg.startsWith("git ")` pins the guard to `git()`'s message format, and nothing tests the coupling. If that format ever changes, the fallthrough flips from "rethrow" to "return false", and `false` now means *delete the work tree*. A typed error thrown by `git()` carries the same information without the string. Related: every call passes `-C` first, so `args[0]` is `-C` and operators read `git -C failed: …` in `/system` and in the bell for clone, fetch and checkout alike — pre-existing, but this PR adds a fourth `-C` caller and quotes those strings in its own body. - `slugify`'s fallback is the constant `"repo"`, so `https://.` and `https://..` land on the same `srcDir`; the second repo then finds a valid work tree, fetches the first one's `origin`, and reports the first one's manifest name. Exotic, but a short digest suffix retires the class rather than one instance of it. - `log.info("cloning", { url: ref.url })` — a line this diff moves — writes a credentialed URL into the log unredacted, while `redactSecrets` exists and every other egress path (`/system`, the alerts) uses it. Same for the `external module skipped` error log below it. The direction is right and the `slugify` hardening is a good catch on the way past. It is the recovery predicate that needs to change, plus a test that lets git produce the input.
@ -195,1 +199,4 @@
log.warn("unusable work tree, re-cloning", { dir });
await Deno.remove(dir, { recursive: true });
}
log.info("cloning", { url: ref.url });
Owner

Pre-existing, but this diff moves the line: ref.url may embed credentials (the interface above says so, and slugify strips them for exactly this reason), and this writes it to the log unredacted while redactSecrets exists. Same for the external module skipped log in the caller.

Pre-existing, but this diff moves the line: `ref.url` may embed credentials (the interface above says so, and `slugify` strips them for exactly this reason), and this writes it to the log unredacted while `redactSecrets` exists. Same for the `external module skipped` log in the caller.
@ -192,3 +191,1 @@
false
);
if (!exists) {
if (await isOwnWorkTree(dir)) {
Owner

This asks "does git call dir a repository", but the branch below needs "is this a finished clone". A SIGKILL during the clone's fetch leaves .git with HEAD, config (url only, no fetch refspec), objects and refs — git accepts it, --show-toplevel prints dir, so this returns true and syncRepo fetches into a repo that can never resolve a ref. Verified end to end on this branch: stage=clone git -C failed: fatal: git checkout: --detach does not take a path argument 'origin/origin/HEAD', on every boot. git -C dir rev-parse --verify HEAD separates the two (unborn HEAD in the killed one), and stays correct for a kill during the checkout phase, which must NOT be re-cloned.

This asks "does git call `dir` a repository", but the branch below needs "is this a finished clone". A SIGKILL during the clone's fetch leaves `.git` with HEAD, config (url only, no fetch refspec), objects and refs — git accepts it, `--show-toplevel` prints `dir`, so this returns true and `syncRepo` fetches into a repo that can never resolve a ref. Verified end to end on this branch: `stage=clone git -C failed: fatal: git checkout: --detach does not take a path argument 'origin/origin/HEAD'`, on every boot. `git -C dir rev-parse --verify HEAD` separates the two (unborn HEAD in the killed one), and stays correct for a kill during the checkout phase, which must NOT be re-cloned.
@ -195,0 +194,4 @@
// whatever is at `dir` is not a repo we can fetch into. Removing it is the
// only path back to a running module and it costs nothing: everything
// under srcDir came from the clone, so the remote still has all of it.
const leftover = await Deno.stat(dir).then(() => true).catch(() => false);
Owner

Deno.stat follows symlinks, so a dangling symlink at dir throws NotFound here, nothing is removed, and the clone below dies with fatal: could not create work tree dir '…': File exists — non-transient, so wedged again. Deno.lstat sees the link and Deno.remove already handles it.

`Deno.stat` follows symlinks, so a dangling symlink at `dir` throws NotFound here, nothing is removed, and the clone below dies with `fatal: could not create work tree dir '…': File exists` — non-transient, so wedged again. `Deno.lstat` sees the link and `Deno.remove` already handles it.
@ -229,0 +256,4 @@
// PATH, or the 120 s self-kill on a wedged /data, would otherwise read as
// "not a repo" and remove a work tree that was fine. Let those out — the
// caller reports them as a clone-stage failure, which is what they are.
if (!msg.startsWith("git ") || isTransientGitError(msg)) throw e;
Owner

The guard's correctness rests on git() formatting messages as git <arg0> …, and nothing tests that. If the format changes, this stops rethrowing and starts answering "not a repo" — which now means the caller deletes the work tree. A typed error from git() (or a field on it) carries the same distinction without depending on a prefix.

The guard's correctness rests on `git()` formatting messages as `git <arg0> …`, and nothing tests that. If the format changes, this stops rethrowing and starts answering "not a repo" — which now means the caller deletes the work tree. A typed error from `git()` (or a field on it) carries the same distinction without depending on a prefix.
@ -304,0 +345,4 @@
// still a path and not a directory: "" and "." are srcRoot itself and ".."
// is its parent, and syncRepo now REMOVES what it finds at srcDir before
// re-cloning. `https://..` is a configured URL away from that.
.replace(/^\.*$/, "repo");
Owner

Fixes the traversal, but the fallback is a constant: https://., https://.. and https:// all become repo and share one srcDir. The second one then finds a valid work tree, fetches the first one's origin and reports the first one's manifest name. A short digest suffix would close the class instead of the instance.

Fixes the traversal, but the fallback is a constant: `https://.`, `https://..` and `https://` all become `repo` and share one `srcDir`. The second one then finds a valid work tree, fetches the first one's `origin` and reports the first one's manifest name. A short digest suffix would close the class instead of the instance.
@ -266,0 +296,4 @@
// what SIGKILL mid-clone leaves on disk: `. .. .git`, and nothing inside
// .git. SIGTERM — what `git()`'s own AbortController sends — leaves
// nothing, so this is the only shape that survives to the next boot
await Deno.mkdir(`${srcDir}/.git`, { recursive: true });
Owner

This shape is hand-built — a real killed clone leaves .git holding HEAD, config, objects and refs, not an empty directory, and nothing in git ever writes an empty .git FILE (the gitfile form only comes from git worktree add/submodules, where it is valid). Let git write the input instead: spawn git clone --upload-pack 'sh -c "sleep 60"', SIGKILL it, then call prepareExternalModule — that residue still fails today, twice in a row.

This shape is hand-built — a real killed clone leaves `.git` holding HEAD, config, objects and refs, not an empty directory, and nothing in git ever writes an empty `.git` FILE (the gitfile form only comes from `git worktree add`/submodules, where it is valid). Let git write the input instead: spawn `git clone --upload-pack 'sh -c "sleep 60"'`, SIGKILL it, then call `prepareExternalModule` — that residue still fails today, twice in a row.
The per-limiter setInterval had no owner and no clearInterval, so every
test that builds an app leaked two intervals and failed the op sanitizer
on the pinned Deno 2.5.6. The map only grows when requests arrive, so the
sweep can ride on them: an idle limiter has nothing worth sweeping, and a
busy one is swept at the same five-minute cadence as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(modules): a work tree is usable only when its clone finished
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m34s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m10s
7ff2f98d57
The first review of this fix proved the probe question wrong: a SIGKILL
during the clone's fetch — the long phase, so the OOM window — leaves a
.git that git happily calls a repository (HEAD unborn, config holding the
origin url and no fetch refspec), so "is this a repo" kept the wreckage
and the module wedged on every boot. The probe now also asks "is this a
finished clone" (rev-parse --verify HEAD); a checkout-phase kill keeps
HEAD resolvable and is correctly kept, because syncRepo's own checkout
finishes that tree.

In the same pass, everything else the review named:

- The default branch gets a real second chance: remote set-head --auto
  and re-read, instead of the literal "origin/HEAD" that resolved through
  the very ref whose absence triggered it and then composed
  "origin/origin/HEAD" in the checkout catch.
- lstat for the leftover check: stat follows a dangling symlink into
  "nothing here", and the clone then died on File exists — wedged again.
- git() throws a typed GitExitError for a non-zero exit, so the probe's
  "git answered" / "probe failed" split no longer sniffs a message
  prefix. Messages name the subcommand instead of args[0], which was
  "-C" for every repo-bound call.
- slugify appends an FNV-1a digest of the credential-free URL: the
  readable part is lossy three ways, and two URLs sharing a directory
  means the second fetches the first one's origin and reports the first
  one's manifest. Existing deployments re-clone once into the new name.
- redactSecrets on the cloning and skipped log lines, including the
  error string — git echoes the credentialed URL in its own stderr.

The killed-clone test now lets git write the input: spawn a clone whose
upload-pack never answers, SIGKILL it once config gains the origin
remote, and recover twice. The hand-built shapes moved under an honest
name, the lost-origin/HEAD scenario is pinned with followRemoteHEAD
never (git >= 2.47 otherwise recreates the ref on fetch and the
regression evaporates on a modern box), and a dangling-symlink test
self-skips where symlinks need privilege.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Reworked at 7ff2f98. Every item, blocking and not, in review order:

The recovery predicate (blocking, comment on isOwnWorkTree): reproduced your fetch-phase kill on git 2.55/Windows before changing anything — same residue (.git with HEAD, config holding the url and no refspec, objects, refs; --show-toplevel answers yes, rev-parse --verify HEAD answers fatal: Needed a single revision). The probe is now isUsableWorkTree: toplevel + real-path compare as before, then rev-parse --verify HEAD. Your checkout-phase caveat is honored — HEAD resolves there, the probe keeps the tree, and syncRepo's own checkout finishes it. Chose the git-side predicate over the marker file: it reads the same state git will act on, needs no write after every sync, and the marker would itself be one more thing a kill can half-write.

origin/origin/HEAD: fixed in the same pass as asked. remoteDefaultBranch() reads the symbolic ref, and on a miss runs git remote set-head origin --auto and reads again — a real second chance that asks the remote, with a failure after that surfacing as itself instead of as --detach does not take a path argument.

Tests seed wreckage git never writes (blocking): the killed-clone test now uses your recipe — killCloneMidFetch spawns git clone --no-local --upload-pack "sh -c 'sleep 60'", waits until config gains [remote "origin"] (past that point the clone is inside a fetch that can never complete), SIGKILLs, and asserts recovery on that boot and the next. The hand-built shapes moved to their own honestly-named test — they still prove the probe rejects what git never wrote. One wrinkle you'll care about when re-verifying: on git ≥ 2.47 a plain fetch recreates refs/remotes/origin/HEAD, so the lost-origin/HEAD regression evaporates on a modern box. The test pins it with remote.origin.followRemoteHEAD never — ignored by 2.39.5, honored by 2.47+ — and set-head --auto writes the ref on either vintage. Verified all three regression tests fail with the source change stashed (on git 2.55), and the healthy-work-tree test passes either way.

Deno.stat misses a dangling symlink: lstat, one word, as said — plus a test. The test self-skips on a Windows box without the symlink privilege; CI runs it for real.

msg.startsWith("git "): gone. git() throws a typed GitExitError for a non-zero exit; the self-kill and a spawn failure stay plain, so gitAnswered() distinguishes "git said no" from "the probe failed" without reading a prefix. The -C gripe too: messages now name the subcommand (git fetch failed: / git checkout failed:), which also fixes what /system and the bell show for the pre-existing callers.

slugify constant fallback: digest suffix, applied always rather than only in the fallback — the readable part is lossy in three ways (/ and : both fold to -, truncation, the dots rule), and any two URLs sharing a directory makes the second repo fetch the first one's origin. Digest is FNV-1a over the credential-free URL, so rotating a token keeps the clone. Cost stated in the description: existing deployments re-clone once into the new name; dist cache unaffected.

Credentialed URL in logs: both lines redacted, and the skipped line's error string too — git echoes the URL back inside its own stderr, so redacting only the field left the same token one line lower.

Also: rateLimit()'s per-limiter setInterval leaked from every test that builds an app and failed the suite's op sanitizer on pinned Deno 2.5.6, so the gates could not be green around it — the sweep now rides on the request path (separate commit). Only behavior change: an idle limiter's stale buckets linger until the next request instead of being swept on a clock, which bounds the same memory either way.

Gates at head: deno task check, deno lint packages/server/, deno fmt --check, full packages/server/tests/ — 117 passed. Description updated to match all of the above.

Reworked at `7ff2f98`. Every item, blocking and not, in review order: **The recovery predicate** (blocking, comment on `isOwnWorkTree`): reproduced your fetch-phase kill on git 2.55/Windows before changing anything — same residue (`.git` with HEAD, config holding the url and no refspec, objects, refs; `--show-toplevel` answers yes, `rev-parse --verify HEAD` answers `fatal: Needed a single revision`). The probe is now `isUsableWorkTree`: toplevel + real-path compare as before, then `rev-parse --verify HEAD`. Your checkout-phase caveat is honored — HEAD resolves there, the probe keeps the tree, and `syncRepo`'s own checkout finishes it. Chose the git-side predicate over the marker file: it reads the same state git will act on, needs no write after every sync, and the marker would itself be one more thing a kill can half-write. **`origin/origin/HEAD`**: fixed in the same pass as asked. `remoteDefaultBranch()` reads the symbolic ref, and on a miss runs `git remote set-head origin --auto` and reads again — a real second chance that asks the remote, with a failure after that surfacing as itself instead of as `--detach does not take a path argument`. **Tests seed wreckage git never writes** (blocking): the killed-clone test now uses your recipe — `killCloneMidFetch` spawns `git clone --no-local --upload-pack "sh -c 'sleep 60'"`, waits until config gains `[remote "origin"]` (past that point the clone is inside a fetch that can never complete), SIGKILLs, and asserts recovery on that boot and the next. The hand-built shapes moved to their own honestly-named test — they still prove the probe rejects what git never wrote. One wrinkle you'll care about when re-verifying: on git ≥ 2.47 a plain fetch recreates `refs/remotes/origin/HEAD`, so the lost-origin/HEAD regression evaporates on a modern box. The test pins it with `remote.origin.followRemoteHEAD never` — ignored by 2.39.5, honored by 2.47+ — and `set-head --auto` writes the ref on either vintage. Verified all three regression tests fail with the source change stashed (on git 2.55), and the healthy-work-tree test passes either way. **`Deno.stat` misses a dangling symlink**: `lstat`, one word, as said — plus a test. The test self-skips on a Windows box without the symlink privilege; CI runs it for real. **`msg.startsWith("git ")`**: gone. `git()` throws a typed `GitExitError` for a non-zero exit; the self-kill and a spawn failure stay plain, so `gitAnswered()` distinguishes "git said no" from "the probe failed" without reading a prefix. The `-C` gripe too: messages now name the subcommand (`git fetch failed:` / `git checkout failed:`), which also fixes what `/system` and the bell show for the pre-existing callers. **slugify constant fallback**: digest suffix, applied always rather than only in the fallback — the readable part is lossy in three ways (`/` and `:` both fold to `-`, truncation, the dots rule), and any two URLs sharing a directory makes the second repo fetch the first one's origin. Digest is FNV-1a over the credential-free URL, so rotating a token keeps the clone. Cost stated in the description: existing deployments re-clone once into the new name; dist cache unaffected. **Credentialed URL in logs**: both lines redacted, and the `skipped` line's error string too — git echoes the URL back inside its own stderr, so redacting only the field left the same token one line lower. **Also**: `rateLimit()`'s per-limiter `setInterval` leaked from every test that builds an app and failed the suite's op sanitizer on pinned Deno 2.5.6, so the gates could not be green around it — the sweep now rides on the request path (separate commit). Only behavior change: an idle limiter's stale buckets linger until the next request instead of being swept on a clock, which bounds the same memory either way. Gates at head: `deno task check`, `deno lint packages/server/`, `deno fmt --check`, full `packages/server/tests/` — 117 passed. Description updated to match all of the above.
thisilike requested changes 2026-08-21 23:14:01 +02:00
Dismissed
thisilike left a comment

Re-review at 7ff2f98. Everything from the last round re-verified against a running git, plus a fresh hunt for what the new predicate still cannot see.

Last round's items, checked

  • The fetch-phase kill is fixed for real. Ran my own repro again — a git clone --no-local --upload-pack "sh -c 'sleep 60'" SIGKILLed once its config gains [remote "origin"], then prepareExternalModule twice: boot 1: OK, boot 2: OK. Was FAILED stage=clone on both before.
  • origin/origin/HEAD gone, and remoteDefaultBranch is a real second chance. lstat done. GitExitError/gitAnswered does replace the prefix sniffing, and the self-kill and spawn failures stay plain Error, so they rethrow — the distinction holds. Subcommand naming works for ["-C", dir, …] and for clone. Redaction covers both log lines and the error text.
  • Regression proof holds. Against the source at 77c963d: killed-clone, dangling-symlink, lost-origin/HEAD and two-distinct-URLs all fail, healthy-work-tree and garbage pass. Against the merge base only healthy passes. Your "garbage passes either way" is true of the previous revision, not of main — worth saying which, since against main it fails too.
  • Gates reproduce: deno task check, deno lint packages/server/, deno fmt --check, packages/server/tests/ 117 passed. Ran the new tests on the toolchain CI actually uses (./.forgejo/deno.sh, so denoland/deno:2.9.5 + debian git 2.39.5): 16 passed. Merged onto current main and ran the whole suite in that same image: 492 passed, 0 failed. killCloneMidFetch is stable — 5 consecutive runs of the file, no flake.

Blocking: the checkout-phase kill wedges, and the description says it doesn't

a checkout-phase kill leaves HEAD resolvable and correctly passes, because syncRepo's own checkout finishes that work tree — re-cloning it would be wrong

It cannot finish it. Clone holds .git/index.lock across the whole checkout, so a kill in that phase leaves the lock behind, and every later git checkout in that repository refuses to run. Reproduced with a real clone (4000-file repo, --no-local), waiting for index.lock to appear so the kill is provably inside the checkout, then prepareExternalModule three times:

kill landed during the checkout (index.lock seen): true
residue holds index.lock: true
boot 1: FAILED stage=clone :: git checkout failed: fatal: Unable to create '…/.git/index.lock': File exists.
boot 2: FAILED stage=clone :: git checkout failed: fatal: Unable to create '…/.git/index.lock': File exists.
boot 3: FAILED stage=clone :: git checkout failed: fatal: Unable to create '…/.git/index.lock': File exists.

isUsableWorkTree says yes — toplevel matches, rev-parse --verify HEAD resolves, because both are reads and neither needs the index — the fetch succeeds, and the checkout fails the same way forever. Non-transient, stage: "clone", not retryable: issue #28 again, in the phase right after the one you just fixed, and with a window as wide (the checkout of a large repo is seconds to minutes of OOM exposure, and the lock is held for all of it).

The pattern is worth naming, because this is the second predicate that was almost right: a probe is a guess about what git will accept, and the only thing that knows is the operation. rev-parse --verify HEAD is a better guess than stat(.git) and it is still a guess — it misses a stale index.lock, and it would miss a corrupt pack or a ref file truncated mid-write for the same reason. Recover on failure instead, and the guess stops carrying the weight: wrap the fetch+checkout in a try, and on a non-transient GitExitError, remove dir and clone fresh once before giving up. That is fewer lines than the probe, it closes every partial shape including the ones neither of us has thought of, and it costs one wasted clone on a genuinely bad pin. Keep the probe as the fast path — it is right about the fetch-phase residue and saves the failed fetch — but it must not be the only way back.

Non-blocking

  • The slug change has two undocumented costs. The description says existing deployments "re-clone once into the new directory name" — but nothing removes the old one, so every external module leaves a full clone at /data/modules/src/<old-slug> forever, and nothing names it (the same "manual rm -rf and nothing said so" that this PR is about, at one remove). And externalFailureKey is moduleSlug, whose own comment says the slug exists so the key "survives the name changing" — every stored alert key rotates, so a deployment upgrading while a module is failing keeps its old bell row standing forever: the next failure and alertModuleRecovered both address the new key. Both are one-time and cheap to handle; neither is in the description.
  • killCloneMidFetch orphans a process per run. SIGKILL on the clone leaves its sh -c 'sleep 60' upload-pack behind — verified, sleep 60 still running after the test exits. sleep 5 hangs the transport just as well across the millisecond between the config check and the kill, without leaving a minute of CI residue.
  • The rate-limit fix is bundled on a stale premise. Deno 2.5.6 does fail a leaked setInterval (docker run denoland/deno:2.5.6 deno test on a two-line file: FAILED) — but nothing pins 2.5.6 any more: docker/Dockerfile is on 2.9.5 and .forgejo/deno.sh derives CI's image from that same FROM line, so there is one pin and it is 2.9.5, where the same file passes. With ratelimit.ts reverted to main's version I get 117/117 on packages/server/tests/ and 458 passed on the full suite, no sanitizer complaint. So the gates were never blocked on it — the change is fine on its own merits (and I'd keep it), but it is an unrelated fix riding along, and the belief comes from CLAUDE.md, which still says 2.5.6 is what the Dockerfile and CI pin. That line is worth correcting wherever you touch it next.

The core mechanism is in much better shape than last round, and the tests now use git's own wreckage as input, which is the part that matters. One more phase to cover, and I'd rather it be covered by recovering on failure than by a third predicate.

Re-review at `7ff2f98`. Everything from the last round re-verified against a running git, plus a fresh hunt for what the new predicate still cannot see. ## Last round's items, checked - **The fetch-phase kill is fixed for real.** Ran my own repro again — a `git clone --no-local --upload-pack "sh -c 'sleep 60'"` SIGKILLed once its config gains `[remote "origin"]`, then `prepareExternalModule` twice: `boot 1: OK`, `boot 2: OK`. Was `FAILED stage=clone` on both before. - **`origin/origin/HEAD`** gone, and `remoteDefaultBranch` is a real second chance. **`lstat`** done. **`GitExitError`/`gitAnswered`** does replace the prefix sniffing, and the self-kill and spawn failures stay plain `Error`, so they rethrow — the distinction holds. **Subcommand naming** works for `["-C", dir, …]` and for `clone`. **Redaction** covers both log lines and the error text. - **Regression proof holds.** Against the source at `77c963d`: killed-clone, dangling-symlink, lost-origin/HEAD and two-distinct-URLs all fail, healthy-work-tree and garbage pass. Against the merge base only healthy passes. Your "garbage passes either way" is true of the previous revision, not of `main` — worth saying which, since against `main` it fails too. - **Gates reproduce**: `deno task check`, `deno lint packages/server/`, `deno fmt --check`, `packages/server/tests/` 117 passed. Ran the new tests on the toolchain CI actually uses (`./.forgejo/deno.sh`, so `denoland/deno:2.9.5` + debian git **2.39.5**): 16 passed. Merged onto current `main` and ran the whole suite in that same image: **492 passed, 0 failed**. `killCloneMidFetch` is stable — 5 consecutive runs of the file, no flake. ## Blocking: the checkout-phase kill wedges, and the description says it doesn't > a checkout-phase kill leaves HEAD resolvable and correctly passes, because `syncRepo`'s own checkout finishes that work tree — re-cloning it would be wrong It cannot finish it. Clone holds `.git/index.lock` across the whole checkout, so a kill in that phase leaves the lock behind, and every later `git checkout` in that repository refuses to run. Reproduced with a real clone (4000-file repo, `--no-local`), waiting for `index.lock` to appear so the kill is provably inside the checkout, then `prepareExternalModule` three times: ``` kill landed during the checkout (index.lock seen): true residue holds index.lock: true boot 1: FAILED stage=clone :: git checkout failed: fatal: Unable to create '…/.git/index.lock': File exists. boot 2: FAILED stage=clone :: git checkout failed: fatal: Unable to create '…/.git/index.lock': File exists. boot 3: FAILED stage=clone :: git checkout failed: fatal: Unable to create '…/.git/index.lock': File exists. ``` `isUsableWorkTree` says yes — toplevel matches, `rev-parse --verify HEAD` resolves, because both are reads and neither needs the index — the fetch succeeds, and the checkout fails the same way forever. Non-transient, `stage: "clone"`, not retryable: issue #28 again, in the phase right after the one you just fixed, and with a window as wide (the checkout of a large repo is seconds to minutes of OOM exposure, and the lock is held for all of it). The pattern is worth naming, because this is the second predicate that was almost right: **a probe is a guess about what git will accept, and the only thing that knows is the operation.** `rev-parse --verify HEAD` is a better guess than `stat(.git)` and it is still a guess — it misses a stale `index.lock`, and it would miss a corrupt pack or a ref file truncated mid-write for the same reason. Recover on *failure* instead, and the guess stops carrying the weight: wrap the fetch+checkout in a try, and on a non-transient `GitExitError`, remove `dir` and clone fresh once before giving up. That is fewer lines than the probe, it closes every partial shape including the ones neither of us has thought of, and it costs one wasted clone on a genuinely bad pin. Keep the probe as the fast path — it is right about the fetch-phase residue and saves the failed fetch — but it must not be the only way back. ## Non-blocking - **The slug change has two undocumented costs.** The description says existing deployments "re-clone once into the new directory name" — but nothing removes the old one, so every external module leaves a full clone at `/data/modules/src/<old-slug>` forever, and nothing names it (the same "manual `rm -rf` and nothing said so" that this PR is about, at one remove). And `externalFailureKey` is `moduleSlug`, whose own comment says the slug exists so the key "survives the name changing" — every stored alert key rotates, so a deployment upgrading *while* a module is failing keeps its old bell row standing forever: the next failure and `alertModuleRecovered` both address the new key. Both are one-time and cheap to handle; neither is in the description. - **`killCloneMidFetch` orphans a process per run.** SIGKILL on the clone leaves its `sh -c 'sleep 60'` upload-pack behind — verified, `sleep 60` still running after the test exits. `sleep 5` hangs the transport just as well across the millisecond between the config check and the kill, without leaving a minute of CI residue. - **The rate-limit fix is bundled on a stale premise.** Deno **2.5.6** does fail a leaked `setInterval` (`docker run denoland/deno:2.5.6 deno test` on a two-line file: FAILED) — but nothing pins 2.5.6 any more: `docker/Dockerfile` is on `2.9.5` and `.forgejo/deno.sh` derives CI's image from that same `FROM` line, so there is one pin and it is 2.9.5, where the same file passes. With `ratelimit.ts` reverted to `main`'s version I get 117/117 on `packages/server/tests/` and 458 passed on the full suite, no sanitizer complaint. So the gates were never blocked on it — the change is fine on its own merits (and I'd keep it), but it is an unrelated fix riding along, and the belief comes from `CLAUDE.md`, which still says 2.5.6 is what the Dockerfile and CI pin. That line is worth correcting wherever you touch it next. The core mechanism is in much better shape than last round, and the tests now use git's own wreckage as input, which is the part that matters. One more phase to cover, and I'd rather it be covered by recovering on failure than by a third predicate.
@ -195,3 +194,1 @@
log.info("cloning", { url: ref.url });
await git(["clone", "--quiet", ref.url, dir]);
} else {
if (await isUsableWorkTree(dir)) {
Owner

The shape worth changing: this makes a probe the only way back, and a probe can only guess what git will accept. rev-parse --verify HEAD is a better guess than stat(.git) and still misses a stale index.lock, a corrupt pack, a ref truncated mid-write. Wrap the fetch+checkout below in a try and, on a non-transient GitExitError, remove dir and clone fresh once before giving up — fewer lines than the probe, closes every partial shape, and costs one wasted clone on a bad pin. Keep the probe as the fast path; just don't make it load-bearing.

The shape worth changing: this makes a probe the only way back, and a probe can only guess what git will accept. `rev-parse --verify HEAD` is a better guess than `stat(.git)` and still misses a stale `index.lock`, a corrupt pack, a ref truncated mid-write. Wrap the fetch+checkout below in a try and, on a non-transient `GitExitError`, remove `dir` and clone fresh once before giving up — fewer lines than the probe, closes every partial shape, and costs one wasted clone on a bad pin. Keep the probe as the fast path; just don't make it load-bearing.
@ -229,0 +270,4 @@
* `syncRepo` then fails identically on every boot. So "is this a repository"
* is the wrong question, and `rev-parse --verify HEAD` asks the right one: a
* finished clone has a commit at HEAD, a killed one does not. A kill during
* the checkout phase leaves HEAD resolvable, and `syncRepo`'s own checkout
Owner

This claim does not hold, and it is the blocking finding. Clone holds .git/index.lock across the entire checkout, so a kill in that phase leaves the lock — and syncRepo's own checkout then cannot finish anything: fatal: Unable to create '…/.git/index.lock': File exists. Reproduced with a real clone of a 4000-file repo, waiting for index.lock to appear so the kill is provably inside the checkout: FAILED stage=clone on boots 1, 2 and 3. The probe passes because both of its questions are reads that never touch the index.

This claim does not hold, and it is the blocking finding. Clone holds `.git/index.lock` across the entire checkout, so a kill in that phase leaves the lock — and `syncRepo`'s own checkout then cannot finish anything: `fatal: Unable to create '…/.git/index.lock': File exists`. Reproduced with a real clone of a 4000-file repo, waiting for `index.lock` to appear so the kill is provably inside the checkout: FAILED stage=clone on boots 1, 2 and 3. The probe passes because both of its questions are reads that never touch the index.
@ -304,0 +413,4 @@
// valid work tree, fetch the FIRST one's origin and report the first one's
// manifest. The digest is over the credential-free URL, so rotating a token
// does not orphan the clone.
return `${name}-${fnv1a(bare)}`;
Owner

Two costs the description doesn't carry. Nothing removes the old directory, so every external module leaves a full clone at /data/modules/src/<old-slug> permanently, unnamed — the same shape of silent leftover this PR exists to end. And externalFailureKey is built from moduleSlug; its comment says the slug is there so the key survives a name change, so rotating it means a deployment upgrading while a module is failing keeps that bell row forever, since the next failure and alertModuleRecovered both address the new key.

Two costs the description doesn't carry. Nothing removes the old directory, so every external module leaves a full clone at `/data/modules/src/<old-slug>` permanently, unnamed — the same shape of silent leftover this PR exists to end. And `externalFailureKey` is built from `moduleSlug`; its comment says the slug is there so the key survives a name change, so rotating it means a deployment upgrading while a module is failing keeps that bell row forever, since the next failure and `alertModuleRecovered` both address the new key.
@ -22,0 +16,4 @@
// rather than a setInterval: the map only grows when requests arrive, an
// idle limiter has nothing to sweep, and an unstoppable timer per limiter
// leaks out of every test that builds an app
let lastSweepMs = Date.now();
Owner

The change is good, the premise is stale. Deno 2.5.6 does fail on a leaked setInterval, but nothing pins 2.5.6: docker/Dockerfile is on 2.9.5 and .forgejo/deno.sh reads CI's image from that same FROM line. With this file reverted to main's version I get 117/117 on packages/server/tests/ and 458 passed on the full suite, no sanitizer complaint — so the gates were not blocked on it, and this is an unrelated fix riding along. CLAUDE.md still claims 2.5.6 is the pin, which is where the belief comes from.

The change is good, the premise is stale. Deno 2.5.6 does fail on a leaked `setInterval`, but nothing pins 2.5.6: `docker/Dockerfile` is on 2.9.5 and `.forgejo/deno.sh` reads CI's image from that same FROM line. With this file reverted to main's version I get 117/117 on `packages/server/tests/` and 458 passed on the full suite, no sanitizer complaint — so the gates were not blocked on it, and this is an unrelated fix riding along. `CLAUDE.md` still claims 2.5.6 is the pin, which is where the belief comes from.
@ -266,0 +308,4 @@
dest,
],
stdout: "null",
stderr: "null",
Owner

SIGKILL on the clone orphans its upload-pack: sleep 60 is still running after the test process exits (verified). sleep 5 hangs the transport just as reliably across the millisecond between the config check and the kill, without leaving a minute of residue on every CI run.

SIGKILL on the clone orphans its upload-pack: `sleep 60` is still running after the test process exits (verified). `sleep 5` hangs the transport just as reliably across the millisecond between the config check and the kill, without leaving a minute of residue on every CI run.
# Conflicts:
#	packages/server/src/modules/external.ts
#	packages/server/src/util/ratelimit.ts
#	packages/server/tests/external_modules_test.ts
fix(modules): recover a wedged work tree by re-cloning on failure, not by probing harder
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m48s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 54s
30dae2bcca
The last review proved the second predicate almost-right the same way the
first one was: a SIGKILL during the clone's checkout phase leaves HEAD
resolvable - the probe says "finished clone" - and .git/index.lock standing,
so every later checkout refuses to run: issue #28 again, one phase later. A
probe is a guess about what git will accept, and only the operation knows.

So recovery now rides on the operation: when fetch or checkout fails inside
a work tree the probe accepted, and the failure is git ANSWERING (a
GitExitError that is not transient), the tree is removed and cloned fresh,
once. The fresh clone lands beside the suspect tree and is swapped in only
after its checkout finishes: a recovery that fails - the credential was
revoked, the network went away mid-clone - must not consume the old
checkout, whose manifest is what names the module's failure (main's
readManifestName recovery depends on it). A genuinely bad pin costs one
wasted clone and surfaces from the fresh checkout. The probe stays as the
fast path; it is no longer load-bearing.

The slug digest's two migration costs are paid rather than documented away:

- the clone at the digestless directory name is ADOPTED (renamed) when it
  is this url's own clone - no re-clone, and a first post-upgrade failure
  can still name itself from its manifest. A leftover whose origin is some
  other URL (the legacy fold is many-to-one) or that git cannot answer for
  is removed, never adopted.
- a bell row stored under the pre-digest key is rekeyed
  (NotificationStore.rekey) before the next failure or recovery writes, so
  a deployment that upgrades while a module is failing keeps ONE row both
  keep addressing, instead of an immortal orphan.

Also from the review:

- killCloneMidFetch's stand-in upload-pack sleeps 5s, not 60: the SIGKILL
  reaches git alone, and the orphan should expire with the test run.
- the checkout-phase kill has its own test, with git writing the residue: a
  required smudge filter that stalls pins a real clone inside its checkout
  (the lock file's appearance proves the phase), then SIGKILL. On a Windows
  dev box git.exe on PATH is a wrapper and the real clone survives the
  kill; the test degrades to the healthy path there, and CI runs the lock
  path for real.
- the ratelimit change is dropped from this branch: main's request-path
  sweep supersedes it, and the premise (a 2.5.6 pin) was stale. CLAUDE.md's
  claim that 2.5.6 is the Dockerfile/CI pin is corrected to point at the
  single FROM-line pin (2.9.5 today).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Rework for the second review is up at 30dae2b (merge of current main in dd8f5d8, rework on top), description rewritten to match. Point by point:

Blocking — the checkout-phase kill. Taken in the shape you suggested: recovery rides on the operation, the probe is only the fast path. When fetch or checkout fails inside a probe-accepted tree with a non-transient GitExitError, syncRepo re-clones once. One deviation from the literal "remove dir and clone fresh": the fresh clone lands beside the tree (<srcDir>.recovering) and is swapped in only after its checkout finishes. Removing first turned out to collide with main's d1647be (merged here): a recovery that fails — revoked credential, network gone — would have consumed the old checkout, and with it the manifest name that readManifestName recovers in the failure record. Your repro is now a test, with git writing the residue: a required smudge filter (sh -c 'sleep 5; cat') pins a real --no-local clone inside its checkout, the appearance of .git/index.lock proves the phase, then SIGKILL. Caveat stated in the test and description: on a Windows dev box git.exe on PATH is a wrapper, the kill misses the real clone and the test degrades to the healthy path — the lock path runs for real on CI's git.

Slug migration costs. Both paid, not documented away. The old directory is adopted (renamed) when it is this url's own clone — remote.origin.url must match exactly and the new directory must not exist — so no re-clone and no stranded checkout; anything else at the legacy name is removed, never adopted (the legacy fold is many-to-one, and adopting a stranger is exactly the collision the digest retires). The stored bell row is rekeyed in place (NotificationStore.rekey, guarded against an active row already under the new key) before any failure/recovery write. Three new tests cover adopt, refuse-and-remove, and the rekeyed bell row.

Orphaned upload-pack. sleep 5 now, with a comment saying why 5 is enough.

Rate limit / CLAUDE.md. The ratelimit change is dropped — the branch carries main's request-path sweep untouched (your read was right: the premise was the stale 2.5.6 pin). CLAUDE.md now states the single pin: the Dockerfile FROM line (2.9.5 today), with .forgejo/deno.sh deriving CI's image from it.

Gates at 30dae2b: deno task check, deno lint packages/server/, deno fmt --check, packages/server/tests/ — 143 passed, 0 failed. Merge conflicts against current main (readManifestName recovery, rate-limit sweep, TEST_GIT_SPAWN) are resolved in the merge commit, so the branch is mergeable again.

Rework for the second review is up at `30dae2b` (merge of current `main` in `dd8f5d8`, rework on top), description rewritten to match. Point by point: **Blocking — the checkout-phase kill.** Taken in the shape you suggested: recovery rides on the operation, the probe is only the fast path. When fetch or checkout fails inside a probe-accepted tree with a non-transient `GitExitError`, `syncRepo` re-clones once. One deviation from the literal "remove `dir` and clone fresh": the fresh clone lands beside the tree (`<srcDir>.recovering`) and is swapped in only after its checkout finishes. Removing first turned out to collide with `main`'s `d1647be` (merged here): a recovery that fails — revoked credential, network gone — would have consumed the old checkout, and with it the manifest name that `readManifestName` recovers in the failure record. Your repro is now a test, with git writing the residue: a required smudge filter (`sh -c 'sleep 5; cat'`) pins a real `--no-local` clone inside its checkout, the appearance of `.git/index.lock` proves the phase, then SIGKILL. Caveat stated in the test and description: on a Windows dev box `git.exe` on PATH is a wrapper, the kill misses the real clone and the test degrades to the healthy path — the lock path runs for real on CI's git. **Slug migration costs.** Both paid, not documented away. The old directory is *adopted* (renamed) when it is this url's own clone — `remote.origin.url` must match exactly and the new directory must not exist — so no re-clone and no stranded checkout; anything else at the legacy name is removed, never adopted (the legacy fold is many-to-one, and adopting a stranger is exactly the collision the digest retires). The stored bell row is rekeyed in place (`NotificationStore.rekey`, guarded against an active row already under the new key) before any failure/recovery write. Three new tests cover adopt, refuse-and-remove, and the rekeyed bell row. **Orphaned upload-pack.** `sleep 5` now, with a comment saying why 5 is enough. **Rate limit / CLAUDE.md.** The ratelimit change is dropped — the branch carries `main`'s request-path sweep untouched (your read was right: the premise was the stale 2.5.6 pin). CLAUDE.md now states the single pin: the Dockerfile `FROM` line (2.9.5 today), with `.forgejo/deno.sh` deriving CI's image from it. Gates at `30dae2b`: `deno task check`, `deno lint packages/server/`, `deno fmt --check`, `packages/server/tests/` — 143 passed, 0 failed. Merge conflicts against current `main` (`readManifestName` recovery, rate-limit sweep, `TEST_GIT_SPAWN`) are resolved in the merge commit, so the branch is mergeable again.
thisilike requested changes 2026-08-23 20:59:45 +02:00
Dismissed
thisilike left a comment

Re-review at 30dae2b, checked against a running git rather than against the description. The branch's merge base is current main (9d9c48e), so nothing here is stale.

Verified first

  • Gates reproduce. deno task check, deno lint packages/server/, deno fmt --check green. packages/server/tests/ through the pinned toolchain (./.forgejo/deno.sh test -A packages/server/tests/, so denoland/deno:2.9.5 + debian git): 143 passed, 0 failed — the claim holds. On my host the whole-suite run dies with exit 139 (SIGSEGV) after the last test; that reproduces identically on the merge base, so it is my box, not this branch.
  • The checkout-phase kill is fixed for real. My own repro, not your test: real git clone --no-local with a stalling required smudge filter, wait for .git/index.lock to appear, SIGKILL, then prepareExternalModule twice. index.lock seen: true / survives the kill: true / probe HEAD resolves: true — so the probe rightly says yes — then sync failed in an existing work tree, re-cloning and boot 1: ok=true, boot 2: ok=true. Same on host git 2.55.0 and inside the pinned image. With only external.ts swapped back to 7ff2f98, the same lab gives BOOT 1: ok=false :: git checkout failed: fatal: Unable to create '…/.git/index.lock': File exists and the same on boot 2. Recovery-on-failure was the right answer to the last round.
  • The other shapes hold. A hand-placed stale index.lock on an otherwise healthy tree recovers. A seeded <srcDir>.recovering is swept and the boot succeeds with nothing left behind. The legacy migration adopts this url's own clone (the file not from the remote survives, nothing lingers at the old name) and removes a leftover — including the case no test covers: a legacy directory that is a real clone of a different origin is removed, and the module clones fresh and reports mine.
  • GitExitError/gitAnswered, remoteDefaultBranch, lstat, subcommand naming, redaction are all as described. The rate-limit change is genuinely gone (5 files), and the CLAUDE.md correction is accurate: docker/Dockerfile:14 is FROM denoland/deno:2.9.5@sha256:… and .forgejo/deno.sh derives CI's image from that line.

Blocking: the recovery has no bound, and an ordinary bad pin pays for it on every boot

The cost of a bad pin stays one wasted clone.

It does not. gitAnswered(e) is true for every non-transient answer, including the ones a fresh clone reproduces exactly — so the tree gets condemned for faults that are not the tree's.

Verified with OPSDECK_EXTERNAL_MODULES=<repo>#v9.9.9 against a repo that has no such ref, three consecutive prepareExternalModule calls:

BOOT 1: ok=false   cloning → checkout failed: --detach does not take a path argument 'origin/v9.9.9'
BOOT 2: ok=false   sync failed in an existing work tree, re-cloning → cloning → same checkout failure
BOOT 3: ok=false   sync failed in an existing work tree, re-cloning → cloning → same checkout failure

One full clone per boot, for as long as the pin is wrong — and prepareExternalModules is main.ts:155, thirty-three lines before Deno.serve. That is the rule this repo already writes down for the retry loop ("nothing is served until Deno.serve, so a second of backoff is a second of unreachable UI"), spent on work that cannot succeed. A typo'd #tag is an ordinary operator mistake, and before this PR it failed in milliseconds.

Same shape and same cost for anything else that lets a clone finish and a checkout not: a .gitattributes naming a required filter the container has no binary for, a path the filesystem refuses, or a Deno.remove(dir) at line 328 that keeps failing. And the classification can flip: because the record carries the recovery's error (next item), a permanent tree fault whose re-clone dies on a network blip becomes retryable, and the retry loop then buys another full clone per attempt.

This does not need a third predicate — it needs the recovery bounded rather than unconditional. Either:

  • Ask git whether the request is satisfiable before blaming the tree: after the fetch, rev-parse --verify --quiet <ref>^{commit} / origin/<ref>^{commit}. If the configured ref does not resolve, the checkout failure is the pin's and the tree is innocent. Not a guess about what git will accept — the same question the checkout asks.
  • Or make it once-per-fault instead of once-per-boot: persist the failure signature beside srcDir when a recovery clone fails the same way, and skip the recovery while it is unchanged. That also covers the filter / unwritable-path / remove-fails cases a ref check does not.

Keep the probe, keep recovery-on-failure. Just stop paying for it again every boot.

Blocking: the failure record names the second attempt, not the fault

throw e2 (line 333) discards the error that triggered the recovery, so /system and the bell describe the re-clone and the thing that actually happened to the deployment survives only as a warn line in container stdout.

Your own retargeted test shows it: the old checkout's fetch fails with 'origin' does not appear to be a git repository, and the record carries git clone failed: … Could not resolve host: git.example.com. The operator is told the network is down about a work tree that is wedged. It also decides retryability — the original is non-transient, the recorded one is transient — so that failure now schedules a retry chain it previously would not have.

Chain them (throw new Error(\${first} (re-clone also failed: ${e2})`)` or equivalent). The recovery's error is usually the actionable half; it must not be the only half.

Non-blocking

  • The adoption gate is credential-sensitive while the slug deliberately is not. originUrlIs compares remote.origin.url to ref.url verbatim (line 273), while the digest is taken over the credential-free form precisely "so rotating a token does not orphan the clone". A deployment that rotated its token between the two versions therefore has this migration remove the very clone it exists to keep. Normalize both sides the way slugify already does and the gate matches the identity the slug uses.
  • The swap is two operations (328/329), so the invariant its comment states — the old checkout survives a failed recovery — holds against a failed clone but not against a kill inside the swap: srcDir is then absent and the next failure is named by the slug, which is exactly what swap-only-on-success was written to prevent. Small window and the cost is one clone, but the comment claims more than the code guarantees.
  • The rekey migration misses the case it is likeliest to meet, and never retires. It runs inside alertModuleFailure/alertModuleRecovered, so a deployment that upgraded while a module was failing and whose module then simply works on the next boot gets neither call — nothing on the startup path raises a recovery — and the legacy row stands forever anyway. (That gap is main's, not yours; it just means the cost is not fully paid.) Meanwhile the UPDATE runs on every external failure alert forever, long after any deployment could still hold a pre-digest key.
  • The test named after the origin gate does not exercise the origin gate. external_modules_test.ts:753 seeds a plain directory with one file, so originUrlIs refuses it in its catch (not a repo at all), never by comparing URLs. Clone a second repo there instead — I did, and it is removed correctly, so this is a one-line change to a test that currently proves the weaker thing.
  • The checkout-kill test asserts nothing about the lock, so wherever the kill misses (your documented Windows case, or a slower git) it silently becomes a second copy of the healthy-tree test. The helper already waited for index.lock; assert it where Deno.build.os !== "windows" and the honesty note stops being load-bearing.
  • Clone-beside doubles the peak footprint under /data for the module's repo during a recovery. Deliberate and right, but it is the other cost of swap-on-success and the description does not name it.
  • Two overclaims in the description. "one wasted clone", above; and "two configured URLs can never share a directory" — it is a 32-bit FNV-1a, so roughly 1 in 4·10⁹ per prefix-colliding pair. The code comment ("plenty for a handful of configured repos") is honest; the body is not.
  • No cheap tier. A stale index.lock — a zero-byte file git itself tells you to delete — costs a full re-transfer of the repo. Consistent with "don't guess" and probably the right trade at this size; worth knowing it is a trade.

The mechanism is right, and the third round found the right answer where two predicates failed. What is left is bounding it, and not losing the original error on the way out.

Re-review at `30dae2b`, checked against a running git rather than against the description. The branch's merge base is current `main` (`9d9c48e`), so nothing here is stale. ## Verified first - **Gates reproduce.** `deno task check`, `deno lint packages/server/`, `deno fmt --check` green. `packages/server/tests/` through the pinned toolchain (`./.forgejo/deno.sh test -A packages/server/tests/`, so `denoland/deno:2.9.5` + debian git): **143 passed, 0 failed** — the claim holds. On my host the whole-suite run dies with exit 139 (SIGSEGV) after the last test; that reproduces identically on the merge base, so it is my box, not this branch. - **The checkout-phase kill is fixed for real.** My own repro, not your test: real `git clone --no-local` with a stalling `required` smudge filter, wait for `.git/index.lock` to appear, SIGKILL, then `prepareExternalModule` twice. `index.lock seen: true / survives the kill: true / probe HEAD resolves: true` — so the probe rightly says yes — then `sync failed in an existing work tree, re-cloning` and `boot 1: ok=true`, `boot 2: ok=true`. Same on host git 2.55.0 and inside the pinned image. With only `external.ts` swapped back to `7ff2f98`, the same lab gives `BOOT 1: ok=false :: git checkout failed: fatal: Unable to create '…/.git/index.lock': File exists` and the same on boot 2. Recovery-on-failure was the right answer to the last round. - **The other shapes hold.** A hand-placed stale `index.lock` on an otherwise healthy tree recovers. A seeded `<srcDir>.recovering` is swept and the boot succeeds with nothing left behind. The legacy migration adopts this url's own clone (the file not from the remote survives, nothing lingers at the old name) and removes a leftover — including the case no test covers: a legacy directory that is a real clone of a *different* origin is removed, and the module clones fresh and reports `mine`. - **`GitExitError`/`gitAnswered`, `remoteDefaultBranch`, `lstat`, subcommand naming, redaction** are all as described. The rate-limit change is genuinely gone (5 files), and the `CLAUDE.md` correction is accurate: `docker/Dockerfile:14` is `FROM denoland/deno:2.9.5@sha256:…` and `.forgejo/deno.sh` derives CI's image from that line. ## Blocking: the recovery has no bound, and an ordinary bad pin pays for it on every boot > The cost of a bad pin stays one wasted clone. It does not. `gitAnswered(e)` is true for *every* non-transient answer, including the ones a fresh clone reproduces exactly — so the tree gets condemned for faults that are not the tree's. Verified with `OPSDECK_EXTERNAL_MODULES=<repo>#v9.9.9` against a repo that has no such ref, three consecutive `prepareExternalModule` calls: ``` BOOT 1: ok=false cloning → checkout failed: --detach does not take a path argument 'origin/v9.9.9' BOOT 2: ok=false sync failed in an existing work tree, re-cloning → cloning → same checkout failure BOOT 3: ok=false sync failed in an existing work tree, re-cloning → cloning → same checkout failure ``` One full clone per boot, for as long as the pin is wrong — and `prepareExternalModules` is `main.ts:155`, thirty-three lines before `Deno.serve`. That is the rule this repo already writes down for the retry loop ("nothing is served until `Deno.serve`, so a second of backoff is a second of unreachable UI"), spent on work that cannot succeed. A typo'd `#tag` is an ordinary operator mistake, and before this PR it failed in milliseconds. Same shape and same cost for anything else that lets a clone finish and a checkout not: a `.gitattributes` naming a `required` filter the container has no binary for, a path the filesystem refuses, or a `Deno.remove(dir)` at line 328 that keeps failing. And the classification can flip: because the record carries the *recovery's* error (next item), a permanent tree fault whose re-clone dies on a network blip becomes `retryable`, and the retry loop then buys another full clone per attempt. This does not need a third predicate — it needs the recovery bounded rather than unconditional. Either: - Ask git whether the *request* is satisfiable before blaming the tree: after the fetch, `rev-parse --verify --quiet <ref>^{commit}` / `origin/<ref>^{commit}`. If the configured ref does not resolve, the checkout failure is the pin's and the tree is innocent. Not a guess about what git will accept — the same question the checkout asks. - Or make it once-per-fault instead of once-per-boot: persist the failure signature beside `srcDir` when a recovery clone fails the same way, and skip the recovery while it is unchanged. That also covers the filter / unwritable-path / `remove`-fails cases a ref check does not. Keep the probe, keep recovery-on-failure. Just stop paying for it again every boot. ## Blocking: the failure record names the second attempt, not the fault `throw e2` (line 333) discards the error that triggered the recovery, so `/system` and the bell describe the re-clone and the thing that actually happened to the deployment survives only as a `warn` line in container stdout. Your own retargeted test shows it: the old checkout's fetch fails with `'origin' does not appear to be a git repository`, and the record carries `git clone failed: … Could not resolve host: git.example.com`. The operator is told the network is down about a work tree that is wedged. It also decides retryability — the original is non-transient, the recorded one is transient — so that failure now schedules a retry chain it previously would not have. Chain them (`throw new Error(\`${first} (re-clone also failed: ${e2})\`)` or equivalent). The recovery's error is usually the actionable half; it must not be the only half. ## Non-blocking - **The adoption gate is credential-sensitive while the slug deliberately is not.** `originUrlIs` compares `remote.origin.url` to `ref.url` verbatim (line 273), while the digest is taken over the credential-free form precisely "so rotating a token does not orphan the clone". A deployment that rotated its token between the two versions therefore has this migration *remove* the very clone it exists to keep. Normalize both sides the way `slugify` already does and the gate matches the identity the slug uses. - **The swap is two operations** (328/329), so the invariant its comment states — the old checkout survives a failed recovery — holds against a failed clone but not against a kill inside the swap: `srcDir` is then absent and the next failure is named by the slug, which is exactly what swap-only-on-success was written to prevent. Small window and the cost is one clone, but the comment claims more than the code guarantees. - **The `rekey` migration misses the case it is likeliest to meet, and never retires.** It runs inside `alertModuleFailure`/`alertModuleRecovered`, so a deployment that upgraded while a module was failing and whose module then simply *works* on the next boot gets neither call — nothing on the startup path raises a recovery — and the legacy row stands forever anyway. (That gap is `main`'s, not yours; it just means the cost is not fully paid.) Meanwhile the `UPDATE` runs on every external failure alert forever, long after any deployment could still hold a pre-digest key. - **The test named after the origin gate does not exercise the origin gate.** `external_modules_test.ts:753` seeds a plain directory with one file, so `originUrlIs` refuses it in its `catch` (not a repo at all), never by comparing URLs. Clone a second repo there instead — I did, and it is removed correctly, so this is a one-line change to a test that currently proves the weaker thing. - **The checkout-kill test asserts nothing about the lock**, so wherever the kill misses (your documented Windows case, or a slower git) it silently becomes a second copy of the healthy-tree test. The helper already waited for `index.lock`; assert it where `Deno.build.os !== "windows"` and the honesty note stops being load-bearing. - **Clone-beside doubles the peak footprint under `/data`** for the module's repo during a recovery. Deliberate and right, but it is the other cost of swap-on-success and the description does not name it. - **Two overclaims in the description.** "one wasted clone", above; and "two configured URLs can never share a directory" — it is a 32-bit FNV-1a, so roughly 1 in 4·10⁹ per prefix-colliding pair. The code comment ("plenty for a handful of configured repos") is honest; the body is not. - **No cheap tier.** A stale `index.lock` — a zero-byte file git itself tells you to delete — costs a full re-transfer of the repo. Consistent with "don't guess" and probably the right trade at this size; worth knowing it is a trade. The mechanism is right, and the third round found the right answer where two predicates failed. What is left is bounding it, and not losing the original error on the way out.
@ -206,0 +250,4 @@
if (dir === srcDir) return;
if (!await Deno.lstat(dir).then(() => true).catch(() => false)) return;
const occupied = await Deno.lstat(srcDir).then(() => true).catch(() => false);
if (!occupied && await originUrlIs(dir, url)) {
Owner

This gate is credential-sensitive and the slug deliberately is not: originUrlIs compares remote.origin.url to ref.url verbatim, while the digest is taken over the credential-free URL precisely "so rotating a token does not orphan the clone". A deployment that rotated its token between the two versions therefore falls through to the removal branch and loses the clone this migration exists to keep. Normalize both sides the way slugify already does.

This gate is credential-sensitive and the slug deliberately is not: `originUrlIs` compares `remote.origin.url` to `ref.url` verbatim, while the digest is taken over the credential-free URL precisely "so rotating a token does not orphan the clone". A deployment that rotated its token between the two versions therefore falls through to the removal branch and loses the clone this migration exists to keep. Normalize both sides the way `slugify` already does.
@ -219,0 +311,4 @@
// else rethrows — the self-kill, a network fault or a missing git
// binary says nothing about the tree, and re-cloning into the same wall
// would only hide the real failure.
if (!gitAnswered(e)) throw e;
Owner

The bound is missing. Every non-transient GitExitError condemns the tree, including the ones a fresh clone reproduces exactly — so a pin that does not exist re-clones the whole repo on every boot, forever, thirty-three lines before Deno.serve. Verified: <repo>#v9.9.9, three consecutive prepareExternalModule calls, three full clones and three identical failures.

Two ways to bound it without a third predicate:

  • After the fetch, ask git whether the request is satisfiable at all — rev-parse --verify --quiet <ref>^{commit} / origin/<ref>^{commit}. If the configured ref does not resolve, the checkout failure is the pin's and the tree is innocent. That is the same question the checkout asks, not a guess about what git will accept.
  • Or make it once-per-fault: persist the failure signature beside srcDir when a recovery clone fails the same way, and skip the recovery while it is unchanged. That also covers the cases the ref check misses — a required filter with no binary, a path the filesystem refuses, a remove that keeps failing.
The bound is missing. Every non-transient `GitExitError` condemns the tree, including the ones a fresh clone reproduces exactly — so a pin that does not exist re-clones the whole repo on every boot, forever, thirty-three lines before `Deno.serve`. Verified: `<repo>#v9.9.9`, three consecutive `prepareExternalModule` calls, three full clones and three identical failures. Two ways to bound it without a third predicate: - After the fetch, ask git whether the request is satisfiable at all — `rev-parse --verify --quiet <ref>^{commit}` / `origin/<ref>^{commit}`. If the configured ref does not resolve, the checkout failure is the pin's and the tree is innocent. That is the same question the checkout asks, not a guess about what git will accept. - Or make it once-per-fault: persist the failure signature beside `srcDir` when a recovery clone fails the same way, and skip the recovery while it is unchanged. That also covers the cases the ref check misses — a `required` filter with no binary, a path the filesystem refuses, a `remove` that keeps failing.
@ -219,0 +325,4 @@
try {
await git(["clone", "--quiet", ref.url, fresh]);
const commit = await checkoutConfiguredRef(ref, fresh);
await Deno.remove(dir, { recursive: true });
Owner

Two operations, so the rule the comment above states holds against a failed clone but not against a kill in this window: srcDir is then absent, and the next failure is named by the slug — exactly the outcome swap-only-on-success was written to prevent. Small window and the cost is one clone, but the comment currently claims more than the code guarantees.

Two operations, so the rule the comment above states holds against a failed clone but not against a kill in this window: `srcDir` is then absent, and the next failure is named by the slug — exactly the outcome swap-only-on-success was written to prevent. Small window and the cost is one clone, but the comment currently claims more than the code guarantees.
@ -219,0 +330,4 @@
return commit;
} catch (e2) {
await Deno.remove(fresh, { recursive: true }).catch(() => {});
throw e2;
Owner

throw e2 drops the fault that triggered the recovery, so /system and the bell describe the re-clone and the real event survives only as a warn in container stdout. Your own retargeted test demonstrates it: fetch fails 'origin' does not appear to be a git repository, the record says Could not resolve host: git.example.com. It also decides retryability — non-transient original, transient record — so the failure now schedules a retry chain it would not have before. Chain both errors into the thrown message.

`throw e2` drops the fault that triggered the recovery, so `/system` and the bell describe the re-clone and the real event survives only as a `warn` in container stdout. Your own retargeted test demonstrates it: fetch fails `'origin' does not appear to be a git repository`, the record says `Could not resolve host: git.example.com`. It also decides retryability — non-transient original, transient record — so the failure now schedules a retry chain it would not have before. Chain both errors into the thrown message.
@ -347,0 +750,4 @@
try {
const legacyDir = `${dataDir}/modules/src/${legacyModuleSlug(repo)}`;
await Deno.mkdir(legacyDir, { recursive: true });
await Deno.writeTextFile(`${legacyDir}/stale`, "");
Owner

This makes the test prove the weaker thing. A plain directory with one file is refused by originUrlIs's catch (not a repo at all), so the URL comparison — the gate the test is named after — never runs. Clone a second seeded repo here instead; I checked, and the foreign-origin clone is removed correctly, so it is a one-line change that turns the assertion into the one you meant.

This makes the test prove the weaker thing. A plain directory with one file is refused by `originUrlIs`'s `catch` (not a repo at all), so the URL comparison — the gate the test is named after — never runs. Clone a second seeded repo here instead; I checked, and the foreign-origin clone is removed correctly, so it is a one-line change that turns the assertion into the one you meant.
fix(modules): bound the re-clone recovery and keep the fault it recovers from
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m26s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 47s
140246d570
The recovery-on-failure the last round added condemned the work tree for
every non-transient git answer, including the ones a fresh clone reproduces
exactly — so an ordinary typo'd pin paid a full clone on every boot, on the
startup path where every second is unreachable UI. The recovery is now
once-per-fault: when the recovery clone fails with the same (redacted)
message as the fault that triggered it, that signature is persisted beside
srcDir and the same fault skips the clone on every later boot. Any
successful sync clears it; a fault with a different message is new evidence
and is recovered again. Only git answering may persist — a recovery that
died on a network blip or the self-kill proves nothing about futility, and
persisting it would wedge a genuinely broken tree behind a recovery that
could still succeed.

The failure record also stops discarding the fault that triggered the
recovery: it now carries both halves ("<fault> (re-clone also failed:
<e2>)"), so the bell no longer reports a network blip about a wedged tree,
and retryability is decided over both messages instead of the second one
alone.

Also from the review:

- originUrlIs compares credential-free forms (shared with slugify via
  credentialFreeUrl): a deployment that rotated its token between versions
  keeps its clone through the legacy migration instead of losing it to a
  verbatim comparison.
- The legacy alert-key migration moved out of the alert calls into a boot
  sweep (migrateExternalAlertKeys, called once in main.ts after serve):
  the deployment likeliest to hold a legacy row is the one whose module
  upgrades and then simply works, which raises no alert at all — and the
  keyed UPDATE stops running on every failure alert forever.
- The swap comment states what the code guarantees (remove+rename window)
  and the clone-beside comment names the doubled peak footprint.
- Tests: the unsatisfiable-pin test proves one recovery clone, not one per
  boot, and that the old checkout and its manifest name survive throughout;
  the checkout-kill test asserts the stale index.lock where the kill was
  final; the foreign-origin migration test seeds a real clone of another
  repo so the URL gate actually runs; the manifest-name test asserts both
  halves of the chained record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Rework for the third review is up at 140246d; the description is updated to match. All nine items addressed:

Blocking — the recovery has no bound. Took the once-per-fault option, since it also covers what a ref check cannot (a required filter with no binary, and it degrades gracefully around a failing remove). When the recovery clone fails with the same message as the fault that triggered it — compared and persisted redacted, at <srcDir>.recovery-fault — every later boot skips the re-clone for that fault and rethrows. Any successful sync clears the file; a different message is new evidence and recovers again. Only gitAnswered(e2) with a matching message may persist: a recovery that died on a network blip or the self-kill proves nothing about futility, and persisting it would wedge a genuinely broken tree behind a recovery that could still succeed. Your exact repro is now a test (an unsatisfiable pin costs one recovery clone, not one per boot): boot 1 recovers and says so, boot 2 skips, the old checkout's marker file and manifest name survive both, the corrected pin retires the signature. Honest residue, in the description: a fault that reproduces under a different subcommand name (git fetch failed: vs git clone failed: — deleted remote, revoked credential) never matches its own signature and re-runs its recovery per boot; those clones die at the remote's answer before any transfer.

Blocking — the record names the second attempt. Chained: <fault> (re-clone also failed: <e2>), or <fault> (a fresh clone failed the same way) when the recovery reproduced it. Retryability is now decided over both halves — your retargeted test asserts the chained record.

Non-blocking, all taken:

  • originUrlIs compares credential-free forms via credentialFreeUrl, the same normalization slugify digests — a rotated token no longer loses the clone to the migration. No end-to-end test (local test repos are paths, they cannot carry credentials); the gate and the slug now share the one function, which is the actual guarantee.
  • The rekey moved out of the alert calls into a boot sweep (migrateExternalAlertKeys, main.ts, once per boot after serve, before any write under the new keys) — the upgrade-then-simply-works deployment is now covered, and the UPDATE is off the alert hot path. Not retired outright: that needs a persisted marker, which is more machinery than one keyed UPDATE per configured repo per boot. Said so in the code.
  • The origin-gate test seeds a real clone of a second repo at the legacy name, so the URL comparison itself refuses it.
  • The checkout-kill test asserts the stale index.lock where the kill was final (os !== "windows"); the honesty note stopped being load-bearing.
  • The swap comment now states the remove-then-rename window it actually guarantees; the clone-beside comment and the description name the doubled peak footprint; the "never share a directory" claim is now the 32-bit collision statement; the no-cheap-tier trade (a stale lock costs a full re-transfer) is named as kept.

Gates on this box: deno task check, deno lint packages/server/, deno fmt --check, full packages/server/tests/ 144 passed at 140246d.

Rework for the third review is up at `140246d`; the description is updated to match. All nine items addressed: **Blocking — the recovery has no bound.** Took the once-per-fault option, since it also covers what a ref check cannot (a `required` filter with no binary, and it degrades gracefully around a failing `remove`). When the recovery clone fails with the same message as the fault that triggered it — compared and persisted **redacted**, at `<srcDir>.recovery-fault` — every later boot skips the re-clone for that fault and rethrows. Any successful sync clears the file; a different message is new evidence and recovers again. Only `gitAnswered(e2)` with a matching message may persist: a recovery that died on a network blip or the self-kill proves nothing about futility, and persisting it would wedge a genuinely broken tree behind a recovery that could still succeed. Your exact repro is now a test (*an unsatisfiable pin costs one recovery clone, not one per boot*): boot 1 recovers and says so, boot 2 skips, the old checkout's marker file and manifest name survive both, the corrected pin retires the signature. Honest residue, in the description: a fault that reproduces under a different subcommand name (`git fetch failed:` vs `git clone failed:` — deleted remote, revoked credential) never matches its own signature and re-runs its recovery per boot; those clones die at the remote's answer before any transfer. **Blocking — the record names the second attempt.** Chained: `<fault> (re-clone also failed: <e2>)`, or `<fault> (a fresh clone failed the same way)` when the recovery reproduced it. Retryability is now decided over both halves — your retargeted test asserts the chained record. **Non-blocking, all taken:** - `originUrlIs` compares credential-free forms via `credentialFreeUrl`, the same normalization `slugify` digests — a rotated token no longer loses the clone to the migration. No end-to-end test (local test repos are paths, they cannot carry credentials); the gate and the slug now share the one function, which is the actual guarantee. - The rekey moved out of the alert calls into a boot sweep (`migrateExternalAlertKeys`, `main.ts`, once per boot after serve, before any write under the new keys) — the upgrade-then-simply-works deployment is now covered, and the `UPDATE` is off the alert hot path. Not retired outright: that needs a persisted marker, which is more machinery than one keyed `UPDATE` per configured repo per boot. Said so in the code. - The origin-gate test seeds a real clone of a second repo at the legacy name, so the URL comparison itself refuses it. - The checkout-kill test asserts the stale `index.lock` where the kill was final (`os !== "windows"`); the honesty note stopped being load-bearing. - The swap comment now states the remove-then-rename window it actually guarantees; the clone-beside comment and the description name the doubled peak footprint; the "never share a directory" claim is now the 32-bit collision statement; the no-cheap-tier trade (a stale lock costs a full re-transfer) is named as kept. Gates on this box: `deno task check`, `deno lint packages/server/`, `deno fmt --check`, full `packages/server/tests/` 144 passed at `140246d`.
thisilike requested changes 2026-08-24 14:05:56 +02:00
Dismissed
thisilike left a comment

Re-review at 140246d, checked against a running git rather than against the description. The branch's merge base is current main (9d9c48e), so nothing here is stale. Clone counts below are counted, not inferred — a git shim on PATH logs every invocation.

Verified first

  • Gates reproduce. deno task check, deno lint packages/server/, deno fmt --check green. packages/server/tests/ on this box: 144 passed, 0 failed — the claim holds. Through the pinned toolchain (./.forgejo/deno.sh test -A packages/server/tests/external_modules_test.ts, so denoland/deno:2.9.5 + debian git 2.39.5): 24 passed. The checkout-kill test's new index.lock assertion runs for real in that image, so it can no longer degrade into a second copy of the healthy-tree test.
  • The headline fix holds, on my own lab rather than your test. Real git clone --no-local with a stalling required smudge filter, wait for .git/index.lock, SIGKILL: index.lock seen before kill: true / survives the kill: true, then BOOT 1: ok=true clones=1, BOOT 2: ok=true clones=0. Recovery-on-failure works and the repaired tree is fetched thereafter.
  • The bound works for the case round 3 named. #v9.9.9 against a repo with no such ref, after a healthy boot:
    BOOT 1: ok=false clones=1 fault=SET   record: … 'origin/v9.9.9' (a fresh clone failed the same way)
    BOOT 2: ok=false clones=0 fault=SET   record: … 'origin/v9.9.9'
    BOOT 3: ok=false clones=0 fault=SET
    
    One clone, then none, and a corrected pin retires the signature file. That was the ask and it is met.
  • The record carries both halves. Deleted-remote lab: git fetch failed: fatal: '…' does not appear to be a git repository … (re-clone also failed: git clone failed: fatal: repository '…' does not exist). Retryability is decided over the combined text (isRetryableFailureisTransientGitError(failure.error)), as described.
  • Redaction survives comparison structurally, not just plausibly. redactSecrets is a single replaceAll with a static regex — pure and deterministic, so signature equality across boots is safe.
  • legacyModuleSlug is main's slugify verbatim (9d9c48e:external.ts:315-324), with the floor guards added around it rather than inside it. originUrlIs compares credential-free forms through the shared credentialFreeUrl. The swap comment now claims exactly what remove-then-rename guarantees. The rate-limit change is genuinely gone (6 files). GitExitError/gitAnswered, remoteDefaultBranch, lstat, subcommand naming are all as described.
  • The migration ordering is right. migrateExternalAlertKeys is main.ts:199 — after Deno.serve, before the first alert write at main.ts:223 and before the retry loop starts. Nothing on the startup path writes a new-key row ahead of it.
  • NotificationStore.rekey's two untested branches are correct. Drove both by hand: with both keys active the legacy row is left alone (no second live row on one key), and a dismissed legacy row is not resurrected.
  • The CLAUDE.md correction is accurate. docker/Dockerfile:14 is FROM denoland/deno:2.9.5@sha256:… and .forgejo/deno.sh:31 seds the image out of that line. There is no second Deno pin anywhere in .forgejo/.

Blocking: two configured refs of one repo defeat the bound entirely

…#main and …#dev slugify to one work tree — this repo says so itself at external-retry.ts:222-224, and there is a passing test named two config entries for one repo retry it once. One work tree means one ${srcDir}.recovery-fault, and clearFault() (external.ts:309) fires on any successful sync. So the healthy entry deletes the failing entry's evidence before the failing entry ever reads it.

One good ref plus one bad ref, three boots:

BOOT 1: good ok=true clones=1 | bad ok=false clones=1 fault=SET
BOOT 2: good ok=true clones=0 | bad ok=false clones=1 fault=SET
BOOT 3: good ok=true clones=0 | bad ok=false clones=1 fault=SET

fault=SET the whole way and a full recovery clone every boot regardless. Order-independent — bad entry first gives the same. Two bad refs is worse, because each one's signature is the other's "new evidence":

BOOT 1: v9 clones=1 | v8 clones=1  (total 2)
BOOT 2: v9 clones=1 | v8 clones=1  (total 2)
BOOT 3: v9 clones=1 | v8 clones=1  (total 2)

Two full clones per boot, forever, from main.ts:158 — thirty-three lines before Deno.serve, which is the entire reason the bound exists. The fault's identity is (work tree, configured ref); the file's identity is the work tree alone. Keying the filename on the ref too is a one-line change.

Blocking: the bound misses every checkout fault a fresh clone reproduces — and the comment names one of them as covered

the fault is the request's — a pin no ref satisfies, a filter the container has no binary for — not the tree's — external.ts:334-336

It is not covered. git clone performs its own checkout, so a checkout fault reproduced by the recovery surfaces from the git clone call and never reaches checkoutConfiguredRef: git checkout failed: … on one side, git clone failed: … on the other, plus clone's extra warning: Clone succeeded, but checkout failed. The signature cannot match, ever.

A .gitattributes naming a required filter whose binary is absent, three boots:

BOOT 1: ok=false clones=1 fault=none
BOOT 2: ok=false clones=1 fault=none
BOOT 3: ok=false clones=1 fault=none

fault=none every time — nothing is persisted, so nothing is bounded.

This is not the residual the description already owns. That one is scoped to "a fetch-stage fault the recovery reproduces as a clone-stage one … such clones die at the remote's answer, before any transfer", which I reproduced and which is true. But the pin is the only checkout fault whose recovery keeps the checkout name, precisely because clone checks out the default branch and the pin is applied separately afterwards. Every other checkout fault is renamed to clone, and its recovery clone is a completed transfer — git's own Clone succeeded, but checkout failed is the proof. A disk filling during the fetch has the same shape (git fetch failed: … No space left vs git clone failed: … No space left) and also pays the transfer.

Both holes close by comparing the signature with the leading git <cmd> failed: stripped. Nothing else in those messages is subcommand-specific, and the two texts that genuinely differ between fetch and clone (does not appear to be a git repository vs repository '…' does not exist) stay different, so it does not over-match.

Non-blocking

  • originUrlIs condemns a work tree on a non-answer (external.ts:290). Its catch returns false for "not a repo", "no origin" and "git could not answer" alike — and false means Deno.remove(dir, { recursive: true }). It is the one place in this diff that does not apply the rule the rest of it establishes: only an answer may condemn a tree. Blast radius is the one-time migration window and the cost is a re-clone plus the manifest name that names the first post-upgrade failure — small, for the same reason the probe's guard is small, and that one got a typed error.
  • The bell truncates the half the description calls actionable. briefly() collapses whitespace and cuts at 300 chars; the chained record with a real URL measures 367. With https://git.example.com/opsdeck/mod-teamspeak.git the bell ends … (re-clone also failed: git clone failed: fatal: repository …. The label survives, the reason does not. /system carries the full text so this is a nit — but the fault half is first and the recovery half is what gets eaten.
  • rekey's both-keys-active branch leaves the legacy row orphaned forever and re-attempts it every boot. Correct as written and documented in the comment; reachable only when an earlier boot's own migration failed. Worth knowing it is the one outcome the sweep cannot repair.
  • No test drives the shared-work-tree shape, though the retry loop carries both a comment and a test about that config. The repro above is four lines around prepareInto.
  • The description says eleven new tests in that file; there are twelve.

The mechanism is right and the pin case is genuinely bounded now. What is left is that the fault's identity is not quite the fault: it is shared between refs that do not share a fault, and it carries a subcommand name that changes as soon as the recovery is the thing that reproduces it.

Re-review at `140246d`, checked against a running git rather than against the description. The branch's merge base is current `main` (`9d9c48e`), so nothing here is stale. Clone counts below are counted, not inferred — a `git` shim on `PATH` logs every invocation. ## Verified first - **Gates reproduce.** `deno task check`, `deno lint packages/server/`, `deno fmt --check` green. `packages/server/tests/` on this box: **144 passed, 0 failed** — the claim holds. Through the pinned toolchain (`./.forgejo/deno.sh test -A packages/server/tests/external_modules_test.ts`, so `denoland/deno:2.9.5` + debian git **2.39.5**): **24 passed**. The checkout-kill test's new `index.lock` assertion runs for real in that image, so it can no longer degrade into a second copy of the healthy-tree test. - **The headline fix holds, on my own lab rather than your test.** Real `git clone --no-local` with a stalling `required` smudge filter, wait for `.git/index.lock`, SIGKILL: `index.lock seen before kill: true / survives the kill: true`, then `BOOT 1: ok=true clones=1`, `BOOT 2: ok=true clones=0`. Recovery-on-failure works and the repaired tree is fetched thereafter. - **The bound works for the case round 3 named.** `#v9.9.9` against a repo with no such ref, after a healthy boot: ``` BOOT 1: ok=false clones=1 fault=SET record: … 'origin/v9.9.9' (a fresh clone failed the same way) BOOT 2: ok=false clones=0 fault=SET record: … 'origin/v9.9.9' BOOT 3: ok=false clones=0 fault=SET ``` One clone, then none, and a corrected pin retires the signature file. That was the ask and it is met. - **The record carries both halves.** Deleted-remote lab: `git fetch failed: fatal: '…' does not appear to be a git repository … (re-clone also failed: git clone failed: fatal: repository '…' does not exist)`. Retryability is decided over the combined text (`isRetryableFailure` → `isTransientGitError(failure.error)`), as described. - **Redaction survives comparison structurally, not just plausibly.** `redactSecrets` is a single `replaceAll` with a static regex — pure and deterministic, so signature equality across boots is safe. - **`legacyModuleSlug` is `main`'s `slugify` verbatim** (`9d9c48e:external.ts:315-324`), with the floor guards added around it rather than inside it. **`originUrlIs` compares credential-free forms** through the shared `credentialFreeUrl`. **The swap comment now claims exactly what remove-then-rename guarantees.** **The rate-limit change is genuinely gone** (6 files). **`GitExitError`/`gitAnswered`, `remoteDefaultBranch`, `lstat`, subcommand naming** are all as described. - **The migration ordering is right.** `migrateExternalAlertKeys` is `main.ts:199` — after `Deno.serve`, before the first alert write at `main.ts:223` and before the retry loop starts. Nothing on the startup path writes a new-key row ahead of it. - **`NotificationStore.rekey`'s two untested branches are correct.** Drove both by hand: with both keys active the legacy row is left alone (no second live row on one key), and a dismissed legacy row is not resurrected. - **The `CLAUDE.md` correction is accurate.** `docker/Dockerfile:14` is `FROM denoland/deno:2.9.5@sha256:…` and `.forgejo/deno.sh:31` seds the image out of that line. There is no second Deno pin anywhere in `.forgejo/`. ## Blocking: two configured refs of one repo defeat the bound entirely `…#main` and `…#dev` slugify to one work tree — this repo says so itself at `external-retry.ts:222-224`, and there is a passing test named *two config entries for one repo retry it once*. One work tree means **one `${srcDir}.recovery-fault`**, and `clearFault()` (`external.ts:309`) fires on any successful sync. So the healthy entry deletes the failing entry's evidence before the failing entry ever reads it. One good ref plus one bad ref, three boots: ``` BOOT 1: good ok=true clones=1 | bad ok=false clones=1 fault=SET BOOT 2: good ok=true clones=0 | bad ok=false clones=1 fault=SET BOOT 3: good ok=true clones=0 | bad ok=false clones=1 fault=SET ``` `fault=SET` the whole way and a full recovery clone every boot regardless. Order-independent — bad entry first gives the same. Two *bad* refs is worse, because each one's signature is the other's "new evidence": ``` BOOT 1: v9 clones=1 | v8 clones=1 (total 2) BOOT 2: v9 clones=1 | v8 clones=1 (total 2) BOOT 3: v9 clones=1 | v8 clones=1 (total 2) ``` Two full clones per boot, forever, from `main.ts:158` — thirty-three lines before `Deno.serve`, which is the entire reason the bound exists. The fault's identity is (work tree, configured ref); the file's identity is the work tree alone. Keying the filename on the ref too is a one-line change. ## Blocking: the bound misses every checkout fault a fresh clone reproduces — and the comment names one of them as covered > the fault is the request's — a pin no ref satisfies, **a filter the container has no binary for** — not the tree's — `external.ts:334-336` It is not covered. `git clone` performs its own checkout, so a checkout fault reproduced by the recovery surfaces from the `git clone` call and never reaches `checkoutConfiguredRef`: `git checkout failed: …` on one side, `git clone failed: …` on the other, plus clone's extra `warning: Clone succeeded, but checkout failed.` The signature cannot match, ever. A `.gitattributes` naming a `required` filter whose binary is absent, three boots: ``` BOOT 1: ok=false clones=1 fault=none BOOT 2: ok=false clones=1 fault=none BOOT 3: ok=false clones=1 fault=none ``` `fault=none` every time — nothing is persisted, so nothing is bounded. This is not the residual the description already owns. That one is scoped to "a fetch-stage fault the recovery reproduces as a clone-stage one … such clones die at the remote's answer, before any transfer", which I reproduced and which is true. But **the pin is the only checkout fault whose recovery keeps the `checkout` name**, precisely because clone checks out the default branch and the pin is applied separately afterwards. Every other checkout fault is renamed to `clone`, and its recovery clone is a *completed transfer* — git's own `Clone succeeded, but checkout failed` is the proof. A disk filling during the fetch has the same shape (`git fetch failed: … No space left` vs `git clone failed: … No space left`) and also pays the transfer. Both holes close by comparing the signature with the leading `git <cmd> failed: ` stripped. Nothing else in those messages is subcommand-specific, and the two texts that genuinely differ between fetch and clone (`does not appear to be a git repository` vs `repository '…' does not exist`) stay different, so it does not over-match. ## Non-blocking - **`originUrlIs` condemns a work tree on a non-answer** (`external.ts:290`). Its catch returns false for "not a repo", "no origin" *and* "git could not answer" alike — and false means `Deno.remove(dir, { recursive: true })`. It is the one place in this diff that does not apply the rule the rest of it establishes: only an answer may condemn a tree. Blast radius is the one-time migration window and the cost is a re-clone plus the manifest name that names the first post-upgrade failure — small, for the same reason the probe's guard is small, and that one got a typed error. - **The bell truncates the half the description calls actionable.** `briefly()` collapses whitespace and cuts at 300 chars; the chained record with a real URL measures 367. With `https://git.example.com/opsdeck/mod-teamspeak.git` the bell ends `… (re-clone also failed: git clone failed: fatal: repository …`. The label survives, the reason does not. `/system` carries the full text so this is a nit — but the fault half is first and the recovery half is what gets eaten. - **`rekey`'s both-keys-active branch leaves the legacy row orphaned forever** and re-attempts it every boot. Correct as written and documented in the comment; reachable only when an earlier boot's own migration failed. Worth knowing it is the one outcome the sweep cannot repair. - **No test drives the shared-work-tree shape**, though the retry loop carries both a comment and a test about that config. The repro above is four lines around `prepareInto`. - The description says eleven new tests in that file; there are twelve. The mechanism is right and the pin case is genuinely bounded now. What is left is that the fault's *identity* is not quite the fault: it is shared between refs that do not share a fault, and it carries a subcommand name that changes as soon as the recovery is the thing that reproduces it.
fix(modules): the fault a recovery is bounded by is (work tree, ref), not the tree
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m38s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 54s
192ed1f298
Two configured refs of one repo are one work tree — external-retry.ts says
so and tests it — so a bound keyed on the directory was keyed on half the
fault. The entry that synced fine deleted the failing entry's evidence
before it was ever read, and two failing entries were each other's "new
evidence": either way a full clone on every boot, from thirty-three lines
before Deno.serve, which is the cost the bound exists to stop. The
signature file now carries the digest of the configured ref as well.

The other half the signature could not see was its own subcommand name.
git clone performs a checkout of its own, so a checkout fault reproduced
by the recovery came back worded "git clone failed: …" with clone's advice
appended, never matching the "git checkout failed: …" that condemned the
tree — a filter with no binary, a tree git refuses to write, a disk that
fills, all unbounded, and each recovery a completed transfer. Both clones
now pass --no-checkout and check out through the same call the fetch path
uses, so the wording matches by construction; it also retires the doubled
checkout, since clone's was of the default branch a pinned ref replaced on
the next line. faultSignature() additionally strips the leading
"git <cmd> failed: " for the fetch-vs-clone case, where the two texts that
genuinely differ stay different.

Per-ref keying means a signature outlives a pin corrected by writing a
DIFFERENT ref, or a repo dropped from the config, so sweepFaultSignatures()
removes at boot every signature no configured ref could still read — files
first, because "." survives slugify and a repo URL ending in
".recovery-fault" owns a work tree of that exact shape.

Also from the review: repoOrigin() replaces originUrlIs() with a tri-state,
so git failing to ANSWER no longer condemns the pre-digest clone directory
to removal — the rule the rest of the file already followed. And briefly()
cuts out of the middle: the chained clone record runs past 300 characters
with a real URL in both halves, and trimming the tail dropped the
parenthesised half whole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Pushed as 192ed1f, merged onto current main (4e8defb). Both blocking items are fixed, and the fault's identity is now the pair you said it was. Numbers below are from your own labs, re-run with a counting git shim on PATH.

The bound is keyed on (work tree, configured ref)

faultSignatureFile() appends the FNV-1a of ref.ref to the filename, so …#main and …#dev sharing one srcDir no longer share one file. The digest rather than the ref itself because a branch name is a path (release/1.2) and this is a filename.

Good ref plus bad ref, three boots:

BOOT 1: good ok=true | bad ok=false clones=2   (the seed clone + one recovery)
BOOT 2: good ok=true | bad ok=false clones=0
BOOT 3: good ok=true | bad ok=false clones=0

Two bad refs, after a healthy seed boot:

BOOT 1: clones=2  faults=[…recovery-fault-7feca363, …recovery-fault-ae422604]
BOOT 2: clones=0  faults=[both]
BOOT 3: clones=0  faults=[both]

Test: refs that share a work tree do not share a bound — good, #v9.9.9 and #v8.8.8 over three boots, asserting two signatures stand at once, that boot 1 records a fresh clone failed the same way for each and boots 2–3 record the fault alone whatever order the good entry runs in, and that the shared work tree is never consumed under it.

The recovery no longer renames the fault it reproduces

Two changes, because the name could move in two ways:

  • Both clones pass --no-checkout, and the checkout is checkoutConfiguredRef on both sides. You were right that the pin is the only checkout fault clone does not perform itself — so rather than teach the comparison about clone's advice block, the recovery now runs the same operation the fetch path does, and a checkout fault is worded by the same git checkout either way. It also retires the doubled checkout: clone's was of the default branch, which a pinned ref replaced on the next line.
  • faultSignature() strips the leading git <cmd> failed: before comparing and before persisting, which is the fetch-vs-clone case you named — git fetch failed: … No space left against git clone failed: … No space left, where the recovery IS a completed transfer. The two texts that genuinely differ stay different; there is a unit test for both directions.

Your .gitattributes lab, three boots:

BOOT 1: ok=false clones=1 faults=[]      (the seed clone; its checkout failed, nothing to suspect yet)
BOOT 2: ok=false clones=1 faults=[SET]   record: … (a fresh clone failed the same way)
BOOT 3: ok=false clones=0 faults=[SET]   record: the fault alone

Test: a checkout fault a fresh clone reproduces is bounded too — a commit whose tree holds a blob named .git, which every git refuses to write (error: invalid path '.git') in a clone as much as in a work tree. No filter binary and no process-wide environment, so it is safe under the parallel runner, and it runs for real in the pinned image on git 2.39.5. It asserts the fault is worded by our checkout, that boot 2 reproduces and persists, that boot 3 buys no clone, and that what lands on disk is the message with its subcommand stripped.

The residual paragraph the description used to own is gone with the residual.

Non-blocking, addressed

  • originUrlIs condemning on a non-answer — now repoOrigin() returning "mine" | "other" | "unknown", on gitAnswered. unknown leaves the directory where it is and logs why; the next boot that can ask decides. It is also only asked when adoption is still possible, since once srcDir exists the answer cannot change the outcome.
  • briefly() eating the half the description calls actionable — the cut comes out of the middle now (150 + + 149). Asserted on the chained record: both does not appear to be a git and does not exist) survive it, credentials still do not. The trade for a frontend-build body is named in the comment.
  • No test drove the shared-work-tree shape — there is one now, above.
  • rekey's both-keys-active branch — left as is, for the reason you gave: correct as written, reachable only when an earlier boot's own migration failed, and the comment says so.
  • Sixteen new tests in that file, not eleven or twelve. The description had not counted the previous round's either; it does now.

One thing you did not ask for

Per-ref keying means a signature outlives a pin an operator corrects by writing a different ref, or a repo they drop from the config — a few hundred bytes under /data that nothing would name, which is the smell this whole PR is about. sweepFaultSignatures() runs once at the top of prepareExternalModules, before anything writes a new one, and removes every .recovery-fault-<hex> file no configured ref could still read. isFile first: . survives slugify, so a repo whose URL ends in .recovery-fault owns a work tree of that exact shape, and the sweep must never be what removes a checkout. Test: a signature no configured ref could read is swept at boot.

Gates

deno task check, deno lint packages/server/, deno fmt --check green. packages/server/tests/: 171 passed, 0 failed on this box, and 171 passed through the pinned toolchain (./.forgejo/deno.sh, so denoland/deno:2.9.5 + debian git 2.39.5) at the head commit.

Still not covered

The probe-failure guard (no git on PATH, a hung /data) and a kill inside the recovery clone or the two-op swap window still have no end-to-end test, for the reasons already in the description. repoOrigin's unknown branch joins them — it needs the same unavailable git. The disk-full half of the signature stripping is covered by the unit test rather than by a full disk.

Pushed as `192ed1f`, merged onto current `main` (`4e8defb`). Both blocking items are fixed, and the fault's identity is now the pair you said it was. Numbers below are from your own labs, re-run with a counting `git` shim on `PATH`. ## The bound is keyed on (work tree, configured ref) `faultSignatureFile()` appends the FNV-1a of `ref.ref` to the filename, so `…#main` and `…#dev` sharing one srcDir no longer share one file. The digest rather than the ref itself because a branch name is a path (`release/1.2`) and this is a filename. Good ref plus bad ref, three boots: ``` BOOT 1: good ok=true | bad ok=false clones=2 (the seed clone + one recovery) BOOT 2: good ok=true | bad ok=false clones=0 BOOT 3: good ok=true | bad ok=false clones=0 ``` Two bad refs, after a healthy seed boot: ``` BOOT 1: clones=2 faults=[…recovery-fault-7feca363, …recovery-fault-ae422604] BOOT 2: clones=0 faults=[both] BOOT 3: clones=0 faults=[both] ``` Test: **refs that share a work tree do not share a bound** — good, `#v9.9.9` and `#v8.8.8` over three boots, asserting two signatures stand at once, that boot 1 records `a fresh clone failed the same way` for each and boots 2–3 record the fault alone whatever order the good entry runs in, and that the shared work tree is never consumed under it. ## The recovery no longer renames the fault it reproduces Two changes, because the name could move in two ways: - **Both clones pass `--no-checkout`**, and the checkout is `checkoutConfiguredRef` on both sides. You were right that the pin is the only checkout fault clone does not perform itself — so rather than teach the comparison about clone's advice block, the recovery now runs the same operation the fetch path does, and a checkout fault is worded by the same `git checkout` either way. It also retires the doubled checkout: clone's was of the default branch, which a pinned ref replaced on the next line. - **`faultSignature()` strips the leading `git <cmd> failed: `** before comparing and before persisting, which is the fetch-vs-clone case you named — `git fetch failed: … No space left` against `git clone failed: … No space left`, where the recovery IS a completed transfer. The two texts that genuinely differ stay different; there is a unit test for both directions. Your `.gitattributes` lab, three boots: ``` BOOT 1: ok=false clones=1 faults=[] (the seed clone; its checkout failed, nothing to suspect yet) BOOT 2: ok=false clones=1 faults=[SET] record: … (a fresh clone failed the same way) BOOT 3: ok=false clones=0 faults=[SET] record: the fault alone ``` Test: **a checkout fault a fresh clone reproduces is bounded too** — a commit whose tree holds a blob named `.git`, which every git refuses to write (`error: invalid path '.git'`) in a clone as much as in a work tree. No filter binary and no process-wide environment, so it is safe under the parallel runner, and it runs for real in the pinned image on git 2.39.5. It asserts the fault is worded by *our* checkout, that boot 2 reproduces and persists, that boot 3 buys no clone, and that what lands on disk is the message with its subcommand stripped. The residual paragraph the description used to own is gone with the residual. ## Non-blocking, addressed - **`originUrlIs` condemning on a non-answer** — now `repoOrigin()` returning `"mine" | "other" | "unknown"`, on `gitAnswered`. `unknown` leaves the directory where it is and logs why; the next boot that can ask decides. It is also only asked when adoption is still possible, since once srcDir exists the answer cannot change the outcome. - **`briefly()` eating the half the description calls actionable** — the cut comes out of the middle now (150 + `…` + 149). Asserted on the chained record: both `does not appear to be a git` and `does not exist)` survive it, credentials still do not. The trade for a `frontend-build` body is named in the comment. - **No test drove the shared-work-tree shape** — there is one now, above. - **`rekey`'s both-keys-active branch** — left as is, for the reason you gave: correct as written, reachable only when an earlier boot's own migration failed, and the comment says so. - **Sixteen new tests in that file**, not eleven or twelve. The description had not counted the previous round's either; it does now. ## One thing you did not ask for Per-ref keying means a signature outlives a pin an operator corrects by writing a *different* ref, or a repo they drop from the config — a few hundred bytes under `/data` that nothing would name, which is the smell this whole PR is about. `sweepFaultSignatures()` runs once at the top of `prepareExternalModules`, before anything writes a new one, and removes every `.recovery-fault-<hex>` file no configured ref could still read. `isFile` first: `.` survives `slugify`, so a repo whose URL ends in `.recovery-fault` owns a work tree of that exact shape, and the sweep must never be what removes a checkout. Test: **a signature no configured ref could read is swept at boot**. ## Gates `deno task check`, `deno lint packages/server/`, `deno fmt --check` green. `packages/server/tests/`: **171 passed, 0 failed** on this box, and **171 passed** through the pinned toolchain (`./.forgejo/deno.sh`, so `denoland/deno:2.9.5` + debian git 2.39.5) at the head commit. ## Still not covered The probe-failure guard (no git on PATH, a hung `/data`) and a kill inside the recovery clone or the two-op swap window still have no end-to-end test, for the reasons already in the description. `repoOrigin`'s `unknown` branch joins them — it needs the same unavailable git. The disk-full half of the signature stripping is covered by the unit test rather than by a full disk.
thisilike requested changes 2026-08-24 19:46:50 +02:00
Dismissed
thisilike left a comment

Re-review at 192ed1f, checked against a running git rather than against the description. Merge base is current main, so nothing here is stale. Clone counts below are counted, not inferred — a git shim on PATH logs every invocation.

Verified first

  • Gates reproduce. deno task check, deno lint packages/server/, deno fmt --check green. Through the pinned toolchain (./.forgejo/deno.sh test -A packages/server/tests/, so denoland/deno:2.9.5 + debian git): 171 passed, 0 failed — the claim is exact. On my host the whole-suite run dies with double free or corruption (!prev) after the last test; that reproduces identically on the merge base (exit 139), so it is my box, not this branch.
  • Round 4's first blocker is fixed, and I counted it. One good ref plus two bad pins over three boots, my own lab:
    boot1: good c=0 | v9.9.9 c=1 | v8.8.8 c=1 | signatures=2
    boot2: good c=0 | v9.9.9 c=0 | v8.8.8 c=0 | signatures=2
    boot3: good c=0 | v9.9.9 c=0 | v8.8.8 c=0 | signatures=2
    
    One clone per fault, then none, both signatures standing at once, and the good entry never takes anything away. That was the ask and it is met.
  • Round 4's second blocker is fixed too. A commit whose tree holds a blob named .git, four boots:
    boot1: clones=1 signatures=0   git checkout failed: error: invalid path '.git'
    boot2: clones=1 signatures=1   … (a fresh clone failed the same way)
    boot3: clones=0 signatures=1   … (fault alone)
    boot4: clones=0 signatures=1
    
    --no-checkout on both clones does make the fault come back worded by our git checkout, and what lands on disk is the message with the subcommand stripped. faultSignature does not over-match: the two texts that genuinely differ between fetch and clone stay different.
  • Ordering is right. migrateExternalAlertKeys is main.ts:199 — after Deno.serve (190), before the retry loop (205) and before the first alert write (~218). sweepFaultSignatures runs before the loop and before the early return, so a config emptied of external modules still sweeps.
  • rekey's remaining corner is safe. A dismissed row already sitting at the new key does not block the UPDATE and does not need to: create selects WHERE dismissed_at IS NULL, so the adopted row is still the one every later write finds.
  • The CLAUDE.md correction is accurate. docker/Dockerfile:14 is FROM denoland/deno:2.9.5@sha256:…, .forgejo/deno.sh:31 seds the image out of that line, and there is no second Deno pin under .forgejo/.
  • Both tests I called weak are genuinely fixed — the origin gate now seeds a real clone of a different repo at the legacy name, and the bell-key test drives the boot sweep rather than the alert calls. Sixteen new tests, which is what the description says.

Blocking: --no-checkout costs the manifest name, and the comment above it now says otherwise

Only a first-ever failure with nothing on disk leaves the slug standing. — external.ts, the prepareExternalModule catch

That is no longer true, and this PR is what made it untrue. Before, git clone checked out the default branch, so a repo whose configured checkout then failed still had a manifest on disk and readManifestName recovered the module's name. With --no-checkout, when our checkout is the thing that fails the work tree is empty and there is nothing to read — on that boot and on every boot after it.

Same lab, same repo, first-ever clone with #v9.9.9, external.ts the only difference:

MAIN:  boot1: ok=false name=named-module  nameFromManifest=true
HEAD:  boot1: ok=false name=<slug>        nameFromManifest=false
       boot2: ok=false name=<slug>        nameFromManifest=false

It is not only the first-ever clone. A tree the probe rejects (fetch-phase kill residue) is removed and re-cloned; if the pin is also wrong, that re-clone leaves an empty tree and a module that had been running for months loses its name:

worked once, tree wrecked, then a bad pin:
  ok=false name=<slug> fromManifest=false

The cost is the one that catch block exists to prevent: the bell reads Module "git.example.com-opsdeck-mod-x-74016889" is not running, and /system puts the repo in unidentified, so every row that module stored goes to "not knowable this boot" for as long as the pin is wrong. It does not falsely mark them orphaned — system.ts:144-148 already guards that — so this is diagnostics, not data. But it is diagnostics this file spends a paragraph defending, it is a regression against main, and the description names dropping clone's checkout as pure win ("the doubled checkout goes away").

It is also cheap to keep. The blob is in the object store even with --no-checkoutgit -C <dir> show HEAD:opsdeck.module.json returns the manifest verbatim; I checked on the wrecked tree above. A fallback to that when the file read fails restores the old behaviour without giving back the --no-checkout win. Whichever way it goes, that comment has to stop claiming a first-ever failure is the only case.

Non-blocking

  • The middle cut eats the delimiter, not just the tail. Measured on the exact chained record from your own test log (357 chars collapsed): … Please make sure you have the c…also failed: git clone failed: fatal: unable to access '…': Could not resolve host: git.example.com). Both reasons survive now, which is the half that matters — but (re-clone falls in the hole, so the two errors read as one run-on sentence that closes a parenthesis it never opened. Budgeting the halves where they are composed, or biasing the cut to a word boundary, finishes it.
  • A pinned ref that resolves but cannot be checked out reports the wrong reason. checkoutConfiguredRef's bare catch discards attempt 1 and reports attempt 2. A tag whose tree git refuses to write: git checkout failed: fatal: git checkout: --detach does not take a path argument 'origin/v2.0.0' — the tag exists, and the real answer was error: invalid path '.git'. The bound is unaffected (deterministic across boots; one clone then none, verified). Pre-existing catch, but this PR makes that message the persisted fault identity, the bell body and the /system text, so keeping attempt 1's error when attempt 2 is a ref-resolution failure is worth the two lines.
  • Two boot assertions are weaker than the sentence they defend. assertFalse(error.includes("fresh clone")) passes both for "no recovery ran" and for "a recovery ran and failed differently", since the chained form says re-clone also failed. Asserting on re-clone covers both. Nothing hides today — the pin case is deterministic.
  • The sweep cleans the smallest of the three leftovers. sweepFaultSignatures removes a ~200-byte signature for a repo dropped from the config, arguing that "a leftover under /data that nothing names is the whole subject of this module" — while that repo's entire srcDir, and any <srcDir>.recovering a kill left inside a recovery, stay forever. The srcDir leak is main's; .recovering is new here. Not worth new machinery, but the stated rationale claims more than the sweep does.

The bound is right now, per ref and across the subcommand rename, and I could not construct a fault that wedges a tree recovery could still fix. What is left is that the mechanism which made the bound work quietly took the module's name with it.

Re-review at `192ed1f`, checked against a running git rather than against the description. Merge base is current `main`, so nothing here is stale. Clone counts below are counted, not inferred — a `git` shim on `PATH` logs every invocation. ## Verified first - **Gates reproduce.** `deno task check`, `deno lint packages/server/`, `deno fmt --check` green. Through the pinned toolchain (`./.forgejo/deno.sh test -A packages/server/tests/`, so `denoland/deno:2.9.5` + debian git): **171 passed, 0 failed** — the claim is exact. On my host the whole-suite run dies with `double free or corruption (!prev)` after the last test; that reproduces identically on the merge base (exit 139), so it is my box, not this branch. - **Round 4's first blocker is fixed, and I counted it.** One good ref plus two bad pins over three boots, my own lab: ``` boot1: good c=0 | v9.9.9 c=1 | v8.8.8 c=1 | signatures=2 boot2: good c=0 | v9.9.9 c=0 | v8.8.8 c=0 | signatures=2 boot3: good c=0 | v9.9.9 c=0 | v8.8.8 c=0 | signatures=2 ``` One clone per fault, then none, both signatures standing at once, and the good entry never takes anything away. That was the ask and it is met. - **Round 4's second blocker is fixed too.** A commit whose tree holds a blob named `.git`, four boots: ``` boot1: clones=1 signatures=0 git checkout failed: error: invalid path '.git' boot2: clones=1 signatures=1 … (a fresh clone failed the same way) boot3: clones=0 signatures=1 … (fault alone) boot4: clones=0 signatures=1 ``` `--no-checkout` on both clones does make the fault come back worded by *our* `git checkout`, and what lands on disk is the message with the subcommand stripped. `faultSignature` does not over-match: the two texts that genuinely differ between fetch and clone stay different. - **Ordering is right.** `migrateExternalAlertKeys` is `main.ts:199` — after `Deno.serve` (190), before the retry loop (205) and before the first alert write (~218). `sweepFaultSignatures` runs before the loop and before the early return, so a config emptied of external modules still sweeps. - **`rekey`'s remaining corner is safe.** A *dismissed* row already sitting at the new key does not block the UPDATE and does not need to: `create` selects `WHERE dismissed_at IS NULL`, so the adopted row is still the one every later write finds. - **The `CLAUDE.md` correction is accurate.** `docker/Dockerfile:14` is `FROM denoland/deno:2.9.5@sha256:…`, `.forgejo/deno.sh:31` seds the image out of that line, and there is no second Deno pin under `.forgejo/`. - **Both tests I called weak are genuinely fixed** — the origin gate now seeds a real clone of a *different* repo at the legacy name, and the bell-key test drives the boot sweep rather than the alert calls. **Sixteen new tests**, which is what the description says. ## Blocking: `--no-checkout` costs the manifest name, and the comment above it now says otherwise > Only a first-ever failure with nothing on disk leaves the slug standing. — `external.ts`, the `prepareExternalModule` catch That is no longer true, and this PR is what made it untrue. Before, `git clone` checked out the default branch, so a repo whose *configured* checkout then failed still had a manifest on disk and `readManifestName` recovered the module's name. With `--no-checkout`, when our checkout is the thing that fails the work tree is empty and there is nothing to read — on that boot and on every boot after it. Same lab, same repo, first-ever clone with `#v9.9.9`, `external.ts` the only difference: ``` MAIN: boot1: ok=false name=named-module nameFromManifest=true HEAD: boot1: ok=false name=<slug> nameFromManifest=false boot2: ok=false name=<slug> nameFromManifest=false ``` It is not only the first-ever clone. A tree the probe rejects (fetch-phase kill residue) is removed and re-cloned; if the pin is also wrong, that re-clone leaves an empty tree and a module that had been running for months loses its name: ``` worked once, tree wrecked, then a bad pin: ok=false name=<slug> fromManifest=false ``` The cost is the one that catch block exists to prevent: the bell reads `Module "git.example.com-opsdeck-mod-x-74016889" is not running`, and `/system` puts the repo in `unidentified`, so every row that module stored goes to "not knowable this boot" for as long as the pin is wrong. It does *not* falsely mark them orphaned — `system.ts:144-148` already guards that — so this is diagnostics, not data. But it is diagnostics this file spends a paragraph defending, it is a regression against `main`, and the description names dropping clone's checkout as pure win ("the doubled checkout goes away"). It is also cheap to keep. The blob is in the object store even with `--no-checkout` — `git -C <dir> show HEAD:opsdeck.module.json` returns the manifest verbatim; I checked on the wrecked tree above. A fallback to that when the file read fails restores the old behaviour without giving back the `--no-checkout` win. Whichever way it goes, that comment has to stop claiming a first-ever failure is the only case. ## Non-blocking - **The middle cut eats the delimiter, not just the tail.** Measured on the exact chained record from your own test log (357 chars collapsed): `… Please make sure you have the c…also failed: git clone failed: fatal: unable to access '…': Could not resolve host: git.example.com)`. Both reasons survive now, which is the half that matters — but `(re-clone` falls in the hole, so the two errors read as one run-on sentence that closes a parenthesis it never opened. Budgeting the halves where they are composed, or biasing the cut to a word boundary, finishes it. - **A pinned ref that resolves but cannot be checked out reports the wrong reason.** `checkoutConfiguredRef`'s bare `catch` discards attempt 1 and reports attempt 2. A tag whose tree git refuses to write: `git checkout failed: fatal: git checkout: --detach does not take a path argument 'origin/v2.0.0'` — the tag exists, and the real answer was `error: invalid path '.git'`. The bound is unaffected (deterministic across boots; one clone then none, verified). Pre-existing `catch`, but this PR makes that message the persisted fault identity, the bell body and the `/system` text, so keeping attempt 1's error when attempt 2 is a ref-resolution failure is worth the two lines. - **Two boot assertions are weaker than the sentence they defend.** `assertFalse(error.includes("fresh clone"))` passes both for "no recovery ran" and for "a recovery ran and failed *differently*", since the chained form says `re-clone also failed`. Asserting on `re-clone` covers both. Nothing hides today — the pin case is deterministic. - **The sweep cleans the smallest of the three leftovers.** `sweepFaultSignatures` removes a ~200-byte signature for a repo dropped from the config, arguing that "a leftover under `/data` that nothing names is the whole subject of this module" — while that repo's entire `srcDir`, and any `<srcDir>.recovering` a kill left inside a recovery, stay forever. The `srcDir` leak is `main`'s; `.recovering` is new here. Not worth new machinery, but the stated rationale claims more than the sweep does. The bound is right now, per ref and across the subcommand rename, and I could not construct a fault that wedges a tree recovery could still fix. What is left is that the mechanism which made the bound work quietly took the module's name with it.
fix(modules): a failed configured checkout still names the module
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m12s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m22s
69b7621ae3
`--no-checkout` on both clones made a checkout fault reproducible by the
recovery, and quietly took the module's name with it: clone's checkout of
the default branch was also what left a manifest on disk for a repo whose
configured checkout then failed, and `readManifestName` recovered the name
from it in the failure record. Without it that work tree is empty, on that
boot and every boot after, so the bell named the slug and /system filed the
repo under "unidentified" — a regression against main. The blob is in the
object store regardless: the failure-record fallback now reads
`git show HEAD:opsdeck.module.json` (`manifestNameOf`, same schema) when
the file is not in the work tree, and the comment above it no longer claims
a first-ever failure is the only case that leaves the slug standing.

Also, from the same review:

- `checkoutConfiguredRef` makes its second attempt (`origin/<ref>`) only
  when `rev-parse --verify --quiet origin/<ref>^{commit}` says there is such
  a branch. Otherwise the ref-lookup failure of attempt 2 replaced the
  answer that matters — a tag that exists whose tree git refuses to write
  said "invalid path '.git'" on attempt 1 — and that message is the
  persisted fault identity, the bell body and the /system text. A pin no
  ref satisfies is now worded by attempt 1 too ('v9.9.9', not
  'origin/v9.9.9').
- `briefly()` budgets a chained record half by half around the exported
  `RECOVERY_CHAIN` delimiter: one middle cut kept both reasons and ate the
  "(re-clone also failed:" between them.
- The boot assertions that mean "no recovery ran" assert neither
  "fresh clone" nor "re-clone" (`assertNoRecovery`); the old form also
  passed for a recovery that ran and failed differently.
- `sweepFaultSignatures` says what it sweeps and what it leaves: the
  dropped repo's srcDir (main's) and a `.recovering` a kill left (ours).

Two tests: a pin that cannot be checked out still names the module (first
clone, recovery boot, bounded boot, and a wrecked-then-re-cloned tree —
with no manifest in the work tree, asserted), and a pinned ref that
resolves but cannot be checked out reports the checkout's answer. The bell
test asserts the seam survives the budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

Rework for the fifth review is up at 69b7621; description updated to match. All five items, in review order.

Blocking — --no-checkout costs the manifest name

Taken the way you suggested: the failure-record fallback in prepareExternalModule's catch now reads git -C <srcDir> show HEAD:opsdeck.module.json when readManifestName finds no file in the work tree, parsed by a new manifestNameOf in manifest.ts (same schema, same "readable, not acceptable" bar, shared with readManifestName). HEAD is the default branch clone pointed it at, so it is the same manifest clone's checkout used to land — behaviour matches main again without giving back the --no-checkout win. The comment above it no longer claims a first-ever failure is the only case; it now says what leaves the slug standing: nothing on disk at all, or a HEAD that never resolved.

Your lab is now a test, both halves — a pin that cannot be checked out still names the module: a first-ever clone under #v9.9.9 reports name=named nameFromManifest=true with opsdeck.module.json asserted absent from the work tree (so the name provably came from the object store), holds through the recovery boot and the bounded boot after it, and holds again for a tree that ran once, was wrecked (.git removed), and was re-cloned under the bad pin.

Non-blocking, all taken

  • The middle cut eats the delimiter. briefly() now splits its budget on the chain: the delimiter is one exported constant (RECOVERY_CHAIN, written by syncRepo, read by the bell), each half gets its own middle cut, the delimiter and the closing paren are kept, and a short half hands its unused share to the other — still 300 characters total. The bell test asserts (re-clone also failed: git clone failed: survives, along with both reasons and no credentials.
  • A pinned ref that resolves but cannot be checked out reports the wrong reason. checkoutConfiguredRef makes its second attempt only when rev-parse --verify --quiet origin/<ref>^{commit} says there is such a branch; otherwise the first attempt's error stands. Your tag case is a test — a pinned ref that resolves but cannot be checked out reports the checkout's answer: the uncheckoutable commit tagged v2.0.0 records invalid path '.git', not does not take a path argument, and a ref that is a remote branch (#release, no local branch of that name) still gets its second attempt and syncs. One visible consequence, in the description: a pin no ref satisfies is now worded by the first attempt — … 'v9.9.9' rather than … 'origin/v9.9.9' — which is the name the operator typed. Deterministic across boots, so the bound is unaffected.
  • Two boot assertions weaker than the sentence. assertNoRecovery() asserts neither fresh clone nor re-clone, at all four sites.
  • The sweep's rationale claims more than it does. Reworded in the doc comment, the test comment and the description: the signature is the smallest of the three leftovers and the only one swept; a dropped repo's srcDir stays (as on main) and so does a <srcDir>.recovering a kill left, which only the next sync of that same repo removes. No new machinery, per your read.

Verification

deno task check, deno lint packages/server/, deno fmt --check green. packages/server/tests/: 173 passed, 0 failed on this box (git 2.55, Windows). Same suite in the derived image .forgejo/deno.sh builds (denoland/deno:2.9.5 + debian git): 173 passed, 0 failed — with the honest caveat that the script itself could not complete here (podman cp refuses a Windows junction under node_modules), so the same image was run with the tree streamed in minus node_modules and deno install inside. CI runs the script proper.

Found on the way, not fixed here

While checking the rev-parse guard against a running git: a pin on the default branch's name (…#main when main is the remote's default) never advances past the clone-time commit. git clone creates a local main, checkout --detach main resolves to that local branch, and fetch --tags origin only moves origin/main — so every boot re-checks-out the same commit:

boot1 HEAD: 6168d0d  src: 6168d0d
boot2 via 'main':        HEAD=6168d0d  src=790e996   # stale
boot2 via 'origin/main': HEAD=790e996

Pre-existing on main (clone created the local branch there too), and --no-checkout neither causes nor changes it. Not touched in this PR because the fix has a semantics question of its own — prefer origin/<ref> first and a branch shadows a tag of the same name; prefer the tag and it needs an explicit refs/tags/ probe — and this PR is already five rounds deep. Worth its own issue.

Rework for the fifth review is up at `69b7621`; description updated to match. All five items, in review order. ## Blocking — `--no-checkout` costs the manifest name Taken the way you suggested: the failure-record fallback in `prepareExternalModule`'s catch now reads `git -C <srcDir> show HEAD:opsdeck.module.json` when `readManifestName` finds no file in the work tree, parsed by a new `manifestNameOf` in `manifest.ts` (same schema, same "readable, not acceptable" bar, shared with `readManifestName`). HEAD is the default branch clone pointed it at, so it is the same manifest clone's checkout used to land — behaviour matches `main` again without giving back the `--no-checkout` win. The comment above it no longer claims a first-ever failure is the only case; it now says what leaves the slug standing: nothing on disk at all, or a HEAD that never resolved. Your lab is now a test, both halves — **a pin that cannot be checked out still names the module**: a first-ever clone under `#v9.9.9` reports `name=named nameFromManifest=true` with `opsdeck.module.json` asserted *absent* from the work tree (so the name provably came from the object store), holds through the recovery boot and the bounded boot after it, and holds again for a tree that ran once, was wrecked (`.git` removed), and was re-cloned under the bad pin. ## Non-blocking, all taken - **The middle cut eats the delimiter.** `briefly()` now splits its budget on the chain: the delimiter is one exported constant (`RECOVERY_CHAIN`, written by `syncRepo`, read by the bell), each half gets its own middle cut, the delimiter and the closing paren are kept, and a short half hands its unused share to the other — still 300 characters total. The bell test asserts `(re-clone also failed: git clone failed:` survives, along with both reasons and no credentials. - **A pinned ref that resolves but cannot be checked out reports the wrong reason.** `checkoutConfiguredRef` makes its second attempt only when `rev-parse --verify --quiet origin/<ref>^{commit}` says there is such a branch; otherwise the first attempt's error stands. Your tag case is a test — **a pinned ref that resolves but cannot be checked out reports the checkout's answer**: the uncheckoutable commit tagged `v2.0.0` records `invalid path '.git'`, not `does not take a path argument`, and a ref that *is* a remote branch (`#release`, no local branch of that name) still gets its second attempt and syncs. One visible consequence, in the description: a pin no ref satisfies is now worded by the first attempt — `… 'v9.9.9'` rather than `… 'origin/v9.9.9'` — which is the name the operator typed. Deterministic across boots, so the bound is unaffected. - **Two boot assertions weaker than the sentence.** `assertNoRecovery()` asserts neither `fresh clone` nor `re-clone`, at all four sites. - **The sweep's rationale claims more than it does.** Reworded in the doc comment, the test comment and the description: the signature is the smallest of the three leftovers and the only one swept; a dropped repo's `srcDir` stays (as on `main`) and so does a `<srcDir>.recovering` a kill left, which only the next sync of that same repo removes. No new machinery, per your read. ## Verification `deno task check`, `deno lint packages/server/`, `deno fmt --check` green. `packages/server/tests/`: **173 passed, 0 failed** on this box (git 2.55, Windows). Same suite in the derived image `.forgejo/deno.sh` builds (`denoland/deno:2.9.5` + debian git): **173 passed, 0 failed** — with the honest caveat that the script itself could not complete here (`podman cp` refuses a Windows junction under `node_modules`), so the same image was run with the tree streamed in minus `node_modules` and `deno install` inside. CI runs the script proper. ## Found on the way, not fixed here While checking the `rev-parse` guard against a running git: a pin on the *default* branch's name (`…#main` when `main` is the remote's default) never advances past the clone-time commit. `git clone` creates a local `main`, `checkout --detach main` resolves to that local branch, and `fetch --tags origin` only moves `origin/main` — so every boot re-checks-out the same commit: ``` boot1 HEAD: 6168d0d src: 6168d0d boot2 via 'main': HEAD=6168d0d src=790e996 # stale boot2 via 'origin/main': HEAD=790e996 ``` Pre-existing on `main` (clone created the local branch there too), and `--no-checkout` neither causes nor changes it. Not touched in this PR because the fix has a semantics question of its own — prefer `origin/<ref>` first and a branch shadows a tag of the same name; prefer the tag and it needs an explicit `refs/tags/` probe — and this PR is already five rounds deep. Worth its own issue.
thisilike requested changes 2026-09-02 08:30:48 +02:00
Dismissed
thisilike left a comment

Re-review at 69b7621, checked against a running git (2.55) and Deno 2.9.6, and against mutations of your own source rather than against the description.

Verified first

  • Gates reproduce exactly. deno task check, deno lint packages/server/ (60 files), deno fmt --check (326 files) green; packages/server/tests/ 173 passed, 0 failed. Test count 12 → 30, so "eighteen" is exact.
  • Round 5's blocker is fixed. git show HEAD:opsdeck.module.json restores the name; dropping it fails a pin that cannot be checked out still names the module.
  • Every previous round's blocking fix is genuinely defended by a test. I reverted each one in turn: recovery-on-failure fails 6 tests, the bound 3, per-ref keying 1, subcommand stripping 2, --no-checkout 2, clone-beside/swap-on-success 3, the isBranch guard 1, the sweep 1, lstat 1. That is the part that matters and it holds.
  • The #main staleness you were told about is genuinely pre-existing. Same result on 4e8defb and on this head; --no-checkout neither causes nor changes it. Worth its own issue, as you said.
  • The CLAUDE.md correction is accurate — docker/Dockerfile:14 is FROM denoland/deno:2.9.5@sha256:…, .forgejo/deno.sh:31 seds the image out of that line, no 2.5.6 anywhere. Redaction covers every log line that carries a URL, and the slug is credential-free. migrateExternalAlertKeys ordering is right (158 → 191 → 199 → 208 → 223).

Blocking: a merely dirty work tree is condemned, and the bound cannot see it

checkout --detach refuses to run when a locally-modified tracked file would be overwritten. That is a non-transient GitExitError, so gitAnswered is true, so the tree is the suspect, so it is removed and re-cloned. But a dirty tree is not an unusable tree — git said no about the checkout, not about the repository.

The reason this is blocking rather than cosmetic is that the recovery succeeds, so clearFault() runs and no signature is ever written. Four boots with something writing a tracked file into srcDir between them:

boot1 ok=true
boot2 ok=true signatures=0 untracked-file-survived=false
boot3 ok=true signatures=0 untracked-file-survived=false
boot4 ok=true signatures=0 untracked-file-survived=false

A full clone every boot, from thirty-three lines before Deno.serve — the exact cost round 3 blocked on — plus every untracked file under srcDir destroyed each time, with one warn line naming it. The bound structurally cannot catch this class, because it only engages when the recovery reproduces the fault.

The fix is one word, and this file already argues for it: the comment at the leftover branch says everything under srcDir came from the clone. If that is the invariant, then checkout --force --detach is the checkout you wanted — I verified it takes the new ref with no re-clone and leaves untracked files alone. On main this shape merely disabled the module; here it deletes data and pays a clone forever, so it is a regression this PR introduces.

Blocking: the two guards that keep rm -rf off an innocent tree have no test

gitAnswered is the single predicate deciding whether a work tree gets removed. I replaced its body with return true — a network blip, the 120 s self-kill and a missing git all now condemn the tree — and all 30 tests pass. Same for the probe's rethrow guard.

The description says simulating this "means mutating PATH for the whole test process, which the parallel runner makes unsafe". That is true of the missing-git half and not of the half that matters more: a healthy tree whose origin is unreachable needs no PATH mutation, and this file already points tests at git://127.0.0.1:1/nope.git. One test — healthy tree, unreachable origin, assert the tree survives and nothing was re-cloned — closes the gap on the most destructive decision in the diff. Without it, the next edit that widens gitAnswered deletes a good checkout on a DNS hiccup and the suite says nothing.

Non-blocking

  • The briefly() rework is entirely uncovered, and the description says otherwise. alerts.ts:145 redacts before briefly, and the bell test's chained record is 268 characters after redaction — under the 300 budget, so it never enters the cutting code. All three chained assertions pass trivially, and reverting briefly to main's tail-cut passes 30/30. The mechanism is correct — I brute-forced every fault/recovery length pair: it never exceeds 300 and the seam always survives for a non-empty fault — but nothing verifies it. The fixture needs an uncredentialed URL (redaction is what shrinks the credentialed one below the threshold), or briefly needs a direct unit test.
  • The probe is now a pure optimisation with no coverage. Reverting isUsableWorkTree to the literal #28 predicate — Deno.stat(dir + "/.git") — passes 30/30, because recovery-on-failure catches the killed clone anyway. That is the right architecture and you say so in the body; the consequence is that a clone the kernel killed is re-cloned no longer proves the probe, and the earlier "verified these fail with the source change stashed" is no longer true of it. Worth moving to "Not covered", which currently lists only the probe-failure guard.
  • remoteDefaultBranch likewise. Reverting it to the old literal still passes a work tree that lost origin/HEAD re-asks the remote — the recovery clone rescues it.
  • The disk-full case the body names as covered isn't, on the persistence side. Deno.writeTextFile(sigFile, faultSig).catch(() => {}) fails on the very full disk that produced the fault, so the bound never engages for it. Logging the write failure would at least make it visible.
  • The swap can delete both copies without a kill. If Deno.remove(dir) succeeds and Deno.rename(fresh, dir) throws, catch (e2) then removes fresh — the old checkout and the fresh one are both gone and the record falls back to the slug. The comment covers a kill in that window, not a rename failure. Removing fresh only when dir still exists is a one-line guard.
  • migrateLegacyClone removes the legacy directory with no origin check when srcDir already exists. With a legacy fold collision, repo A's boot deletes repo B's adoptable clone before B ever migrates. One wasted clone, and the log.info names it — but the "only git answering may condemn a directory" rule the rest of the file follows does not hold on this path.
  • The name fallback can cost another 120 s on the path that is already slow. git show HEAD:… runs with the full GIT_TIMEOUT_MS, after syncRepo may already have spent it, before prepareExternalModule returns — on the pre-serve path.
  • Pre-existing, but the per-ref bound newly depends on it: the retry chain resolves find(r => r.url === configuredAs), so a #dev failure is retried with the #main entry and writes or clears the wrong ref's signature.
  • readManifestName no longer goes through parseManifestFile, duplicating its two lines.

The bound is right, per ref and across the subcommand rename, and I could not construct a fault that wedges a tree a recovery could still fix. What is left is that the recovery is now willing to destroy a tree git only declined to check out, and that the guards holding it back are the only untested part of the mechanism.

Re-review at `69b7621`, checked against a running git (2.55) and Deno 2.9.6, and against mutations of your own source rather than against the description. ### Verified first - **Gates reproduce exactly.** `deno task check`, `deno lint packages/server/` (60 files), `deno fmt --check` (326 files) green; `packages/server/tests/` **173 passed, 0 failed**. Test count 12 → 30, so "eighteen" is exact. - **Round 5's blocker is fixed.** `git show HEAD:opsdeck.module.json` restores the name; dropping it fails *a pin that cannot be checked out still names the module*. - **Every previous round's blocking fix is genuinely defended by a test.** I reverted each one in turn: recovery-on-failure fails 6 tests, the bound 3, per-ref keying 1, subcommand stripping 2, `--no-checkout` 2, clone-beside/swap-on-success 3, the `isBranch` guard 1, the sweep 1, `lstat` 1. That is the part that matters and it holds. - **The `#main` staleness you were told about is genuinely pre-existing.** Same result on `4e8defb` and on this head; `--no-checkout` neither causes nor changes it. Worth its own issue, as you said. - The `CLAUDE.md` correction is accurate — `docker/Dockerfile:14` is `FROM denoland/deno:2.9.5@sha256:…`, `.forgejo/deno.sh:31` seds the image out of that line, no 2.5.6 anywhere. Redaction covers every log line that carries a URL, and the slug is credential-free. `migrateExternalAlertKeys` ordering is right (158 → 191 → 199 → 208 → 223). ### Blocking: a merely dirty work tree is condemned, and the bound cannot see it `checkout --detach` refuses to run when a locally-modified tracked file would be overwritten. That is a non-transient `GitExitError`, so `gitAnswered` is true, so the tree is the suspect, so it is removed and re-cloned. But a dirty tree is not an unusable tree — git said no about the *checkout*, not about the repository. The reason this is blocking rather than cosmetic is that the recovery **succeeds**, so `clearFault()` runs and no signature is ever written. Four boots with something writing a tracked file into `srcDir` between them: ``` boot1 ok=true boot2 ok=true signatures=0 untracked-file-survived=false boot3 ok=true signatures=0 untracked-file-survived=false boot4 ok=true signatures=0 untracked-file-survived=false ``` A full clone every boot, from thirty-three lines before `Deno.serve` — the exact cost round 3 blocked on — plus every untracked file under `srcDir` destroyed each time, with one `warn` line naming it. The bound structurally cannot catch this class, because it only engages when the recovery *reproduces* the fault. The fix is one word, and this file already argues for it: the comment at the leftover branch says everything under `srcDir` came from the clone. If that is the invariant, then `checkout --force --detach` is the checkout you wanted — I verified it takes the new ref with no re-clone and leaves untracked files alone. On `main` this shape merely disabled the module; here it deletes data and pays a clone forever, so it is a regression this PR introduces. ### Blocking: the two guards that keep `rm -rf` off an innocent tree have no test `gitAnswered` is the single predicate deciding whether a work tree gets removed. I replaced its body with `return true` — a network blip, the 120 s self-kill and a missing git all now condemn the tree — and **all 30 tests pass**. Same for the probe's rethrow guard. The description says simulating this "means mutating `PATH` for the whole test process, which the parallel runner makes unsafe". That is true of the *missing-git* half and not of the half that matters more: a healthy tree whose origin is unreachable needs no `PATH` mutation, and this file already points tests at `git://127.0.0.1:1/nope.git`. One test — healthy tree, unreachable origin, assert the tree survives and nothing was re-cloned — closes the gap on the most destructive decision in the diff. Without it, the next edit that widens `gitAnswered` deletes a good checkout on a DNS hiccup and the suite says nothing. ### Non-blocking - **The `briefly()` rework is entirely uncovered, and the description says otherwise.** `alerts.ts:145` redacts *before* `briefly`, and the bell test's chained record is **268 characters** after redaction — under the 300 budget, so it never enters the cutting code. All three chained assertions pass trivially, and reverting `briefly` to `main`'s tail-cut passes 30/30. The mechanism is correct — I brute-forced every fault/recovery length pair: it never exceeds 300 and the seam always survives for a non-empty fault — but nothing verifies it. The fixture needs an *uncredentialed* URL (redaction is what shrinks the credentialed one below the threshold), or `briefly` needs a direct unit test. - **The probe is now a pure optimisation with no coverage.** Reverting `isUsableWorkTree` to the literal #28 predicate — `Deno.stat(dir + "/.git")` — passes 30/30, because recovery-on-failure catches the killed clone anyway. That is the right architecture and you say so in the body; the consequence is that *a clone the kernel killed is re-cloned* no longer proves the probe, and the earlier "verified these fail with the source change stashed" is no longer true of it. Worth moving to "Not covered", which currently lists only the probe-failure guard. - **`remoteDefaultBranch` likewise.** Reverting it to the old literal still passes *a work tree that lost origin/HEAD re-asks the remote* — the recovery clone rescues it. - **The disk-full case the body names as covered isn't, on the persistence side.** `Deno.writeTextFile(sigFile, faultSig).catch(() => {})` fails on the very full disk that produced the fault, so the bound never engages for it. Logging the write failure would at least make it visible. - **The swap can delete both copies without a kill.** If `Deno.remove(dir)` succeeds and `Deno.rename(fresh, dir)` throws, `catch (e2)` then removes `fresh` — the old checkout and the fresh one are both gone and the record falls back to the slug. The comment covers a kill in that window, not a rename failure. Removing `fresh` only when `dir` still exists is a one-line guard. - **`migrateLegacyClone` removes the legacy directory with no origin check when `srcDir` already exists.** With a legacy fold collision, repo A's boot deletes repo B's adoptable clone before B ever migrates. One wasted clone, and the `log.info` names it — but the "only git answering may condemn a directory" rule the rest of the file follows does not hold on this path. - **The name fallback can cost another 120 s on the path that is already slow.** `git show HEAD:…` runs with the full `GIT_TIMEOUT_MS`, after `syncRepo` may already have spent it, before `prepareExternalModule` returns — on the pre-serve path. - **Pre-existing, but the per-ref bound newly depends on it:** the retry chain resolves `find(r => r.url === configuredAs)`, so a `#dev` failure is retried with the `#main` entry and writes or clears the wrong ref's signature. - `readManifestName` no longer goes through `parseManifestFile`, duplicating its two lines. The bound is right, per ref and across the subcommand rename, and I could not construct a fault that wedges a tree a recovery could still fix. What is left is that the recovery is now willing to destroy a tree git only declined to *check out*, and that the guards holding it back are the only untested part of the mechanism.
fix(modules): a tree git declined to check out is not a tree to re-clone
All checks were successful
Build and Deploy / verify (pull_request) Successful in 13m49s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 56s
ff1640fdcf
`checkout --detach` refuses to run when a locally-modified tracked file
would be overwritten, and that refusal is git answering non-transiently —
so `syncRepo` read it as "this work tree is unusable", removed the
checkout and re-cloned. The recovery then SUCCEEDED, which is why the
bound could never see the class: `clearFault()` ran, no signature was
written, and the next boot did it again. Four boots with something
writing a tracked file into srcDir between them bought four full clones
from thirty-three lines before `Deno.serve`, and destroyed every
untracked file under srcDir each time. git said no about the checkout,
not about the repository.

Every checkout now passes `--force`, which is the checkout this file
already argues for: the leftover branch states that everything under
srcDir came from the clone, so there are no local changes worth keeping,
and `--force` leaves untracked files alone.

Also in this round:

- `gitAnswered` — the single predicate deciding whether a work tree gets
  removed — now has a test. A healthy tree whose own origin points at an
  unreachable port, while the config still names a reachable path, must
  survive the boot; with `gitAnswered` widened to `return true` the
  recovery clone succeeds and takes the tree with it.
- `briefly()`'s chained-record rework was entirely unexecuted by the
  suite: the bell fixture is 268 characters after redaction, under the
  budget. It is exported and has a direct unit test over a record that
  actually exceeds it.
- The recovery swap is two renames rather than remove-then-rename. A
  rename that threw after the remove left neither copy standing, and the
  catch removed the fresh one too. The suspect moves to `<srcDir>.replaced`,
  the fresh clone takes its name, and a failed swap puts the old tree
  back; both leftovers are swept by the next sync.
- `migrateLegacyClone` asks `repoOrigin` whether or not srcDir is
  occupied. Adoption needs it free, but removal is the same `rm -rf`
  either way, and skipping the question made the steady-state path the
  one path that removed on no evidence.
- A fault-signature write that fails — on exactly the full disk that
  produced the fault — is logged rather than swallowed.
- The manifest-name fallback gets its own 10 s budget instead of
  `GIT_TIMEOUT_MS`: it is a local `git show` on the pre-`Deno.serve` path.
- `ModuleFailure` carries `configuredRef`, and the retry loop resolves
  the config entry by (url, ref). A `#dev` failure retried with the
  `#main` entry checked out the wrong ref and wrote or cleared the wrong
  ref's fault signature.
- `manifest.ts` reads the manifest path in one place.

deno task check, deno lint, deno fmt --check and packages/server/tests/
(176 passed, 0 failed) are green, on this box and in the pinned image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

Rework for the sixth review is up at ff1640f; description updated to match. Both blocking items are fixed, and every non-blocking one is taken except the two I argue below are already where they belong.

Blocking — a merely dirty work tree is condemned

You were right about the shape and right that it is a regression this PR introduced: on main a refused checkout disabled the module, here it deletes data and pays a clone forever. Fix is the one word you named — --force on all three checkout --detach sites.

I reproduced the refusal before changing anything, because the naive repro does not show it:

# same commit at HEAD, tracked file dirty
git checkout --quiet --detach origin/main; exit=0
# after the remote advances that same file
error: Your local changes to the following files would be overwritten by checkout: f.txt
exit=1
# --force
exit=0   ->  f.txt = "two", untracked u.txt still there

So the class needs both halves — a dirty tracked file and a target that moves it — which is what the test now seeds: boot 1, edit mod.ts in srcDir, commit a different mod.ts on the remote, boot 2. a work tree git only declined to check out is kept, not condemned asserts the untracked marker survives (a re-clone removes the tree and it with it), that the new commit actually landed over the local edit, and that no signature was written. Reverting --force fails it.

The justification is the invariant the file already states at the leftover branch — everything under srcDir came from the clone — so there are no local changes worth keeping, and --force leaves untracked files alone, which is what the marker asserts through.

Blocking — the guards that keep rm -rf off an innocent tree have no test

Confirmed: with gitAnswered replaced by return true the whole file still passed. Took your suggestion exactly, since it needs no PATH mutation — a healthy tree whose origin went unreachable survives the boot: prepare once, drop an untracked marker, then point the tree's remote.origin.url at git://127.0.0.1:1/nope.git while the config still names the reachable path. That asymmetry is the point — a re-clone would succeed and take the tree with it, so the test fails loudly rather than silently.

It asserts the outcome is a failure, that the failure is transient, that the record is the fault alone (assertNoRecovery), that the marker survives, and that neither a signature nor a .recovering is left. With return true:

a healthy tree whose origin went unreachable survives the boot => FAILED
  at assertFalse(second.outcome.ok)   # the recovery succeeded and consumed the tree

The probe's rethrow guard is still uncovered and is now named in Not covered — that half genuinely does need a missing git.

Non-blocking, taken

  • briefly() is uncovered and the description said otherwise. Correct, and worse than uncovered: reverting it to main's tail cut passed the file. Went with the direct unit test rather than an uncredentialed fixture, so the length pairs are exercised on purpose rather than incidentally. the bell keeps both halves of a chained record, and the seam builds a record past 300 from uncredentialed halves and asserts the delimiter and the closing paren survive, that each half is cut out of its middle (label at the front, reason at the end), that a short half hands its budget to the other, and that an unchained body is one middle cut. main's tail cut fails it. The description no longer claims the bell test covers this.
  • The probe and remoteDefaultBranch are optimisations with no coverage. Both moved to Not covered, worded as you put it: recovery-on-failure rescues each, so a clone the kernel killed no longer proves the probe and a work tree that lost origin/HEAD no longer proves remoteDefaultBranch. That is the architecture and the claim was the thing that was wrong, not the code.
  • The disk-full signature write is silent. Now logged (could not record the fault: it will be recovered again), redacted. Still not driven by a full disk — said so.
  • The swap can delete both copies without a kill. Took more than the one-line guard, because the guard alone does not fix the harm you named: with dir already removed the record still falls back to the slug. The swap is now two renames — dir<srcDir>.replaced, freshdir, then drop the aside — and a rename that throws puts the old tree back. .replaced joins .recovering in the sweep at the top of syncRepo. A kill inside the window still costs the checkout, which is what the comment claims and no more.
  • migrateLegacyClone removes with no origin check when srcDir exists. repoOrigin is now asked on both paths. Adoption still needs srcDir free; removal is the same rm -rf either way, and skipping the question there made the steady-state path the one path that condemned a directory on no evidence.
  • The name fallback costs another 120 s on the slow path. git() takes a per-call timeout and the git show HEAD:… lookup gets 10 s. Losing the name costs the slug in one record; losing two more minutes costs the UI.
  • The retry chain resolves the wrong ref. ModuleFailure carries configuredRef and the chain looks the entry up by (url, ref), falling back to url-only for a failure load() raised (it never sees a ref). One chain per URL is unchanged — two would fight over index.lock in the shared tree — so what this fixes is which ref that one chain uses, and therefore which signature it writes or clears.
  • readManifestName duplicates parseManifestFile. Both go through one readManifestText/manifestPath now.

Filed rather than fixed

The #main staleness is #60, with the semantics question (branch-first shadows a tag of the same name; tag-first needs an explicit refs/tags/ probe) written down as the reason it is not a one-liner.

Verification

deno task check, deno lint packages/server/ (60 files), deno fmt --check (326 files) green. packages/server/tests/: 176 passed, 0 failed at ff1640f, and 176 passed, 0 failed through ./.forgejo/deno.sh proper — the derived image from the Dockerfile's FROM, denoland/deno:2.9.5 + debian git. Three new tests, and each was checked the way you check them: --force reverted fails the first, gitAnsweredreturn true fails the second, main's briefly fails the third.

Rework for the sixth review is up at `ff1640f`; description updated to match. Both blocking items are fixed, and every non-blocking one is taken except the two I argue below are already where they belong. ## Blocking — a merely dirty work tree is condemned You were right about the shape and right that it is a regression this PR introduced: on `main` a refused checkout disabled the module, here it deletes data and pays a clone forever. Fix is the one word you named — `--force` on all three `checkout --detach` sites. I reproduced the refusal before changing anything, because the naive repro does *not* show it: ``` # same commit at HEAD, tracked file dirty git checkout --quiet --detach origin/main; exit=0 # after the remote advances that same file error: Your local changes to the following files would be overwritten by checkout: f.txt exit=1 # --force exit=0 -> f.txt = "two", untracked u.txt still there ``` So the class needs *both* halves — a dirty tracked file **and** a target that moves it — which is what the test now seeds: boot 1, edit `mod.ts` in `srcDir`, commit a different `mod.ts` on the remote, boot 2. **a work tree git only declined to check out is kept, not condemned** asserts the untracked marker survives (a re-clone removes the tree and it with it), that the new commit actually landed over the local edit, and that no signature was written. Reverting `--force` fails it. The justification is the invariant the file already states at the leftover branch — everything under `srcDir` came from the clone — so there are no local changes worth keeping, and `--force` leaves untracked files alone, which is what the marker asserts through. ## Blocking — the guards that keep `rm -rf` off an innocent tree have no test Confirmed: with `gitAnswered` replaced by `return true` the whole file still passed. Took your suggestion exactly, since it needs no `PATH` mutation — **a healthy tree whose origin went unreachable survives the boot**: prepare once, drop an untracked marker, then point the *tree's* `remote.origin.url` at `git://127.0.0.1:1/nope.git` while the config still names the reachable path. That asymmetry is the point — a re-clone would *succeed* and take the tree with it, so the test fails loudly rather than silently. It asserts the outcome is a failure, that the failure is transient, that the record is the fault alone (`assertNoRecovery`), that the marker survives, and that neither a signature nor a `.recovering` is left. With `return true`: ``` a healthy tree whose origin went unreachable survives the boot => FAILED at assertFalse(second.outcome.ok) # the recovery succeeded and consumed the tree ``` The probe's rethrow guard is still uncovered and is now named in **Not covered** — that half genuinely does need a missing git. ## Non-blocking, taken - **`briefly()` is uncovered and the description said otherwise.** Correct, and worse than uncovered: reverting it to `main`'s tail cut passed the file. Went with the direct unit test rather than an uncredentialed fixture, so the length pairs are exercised on purpose rather than incidentally. **the bell keeps both halves of a chained record, and the seam** builds a record past 300 from uncredentialed halves and asserts the delimiter and the closing paren survive, that each half is cut out of its middle (label at the front, reason at the end), that a short half hands its budget to the other, and that an unchained body is one middle cut. `main`'s tail cut fails it. The description no longer claims the bell test covers this. - **The probe and `remoteDefaultBranch` are optimisations with no coverage.** Both moved to **Not covered**, worded as you put it: recovery-on-failure rescues each, so *a clone the kernel killed* no longer proves the probe and *a work tree that lost origin/HEAD* no longer proves `remoteDefaultBranch`. That is the architecture and the claim was the thing that was wrong, not the code. - **The disk-full signature write is silent.** Now logged (`could not record the fault: it will be recovered again`), redacted. Still not driven by a full disk — said so. - **The swap can delete both copies without a kill.** Took more than the one-line guard, because the guard alone does not fix the harm you named: with `dir` already removed the record still falls back to the slug. The swap is now two renames — `dir` → `<srcDir>.replaced`, `fresh` → `dir`, then drop the aside — and a rename that throws puts the old tree back. `.replaced` joins `.recovering` in the sweep at the top of `syncRepo`. A kill inside the window still costs the checkout, which is what the comment claims and no more. - **`migrateLegacyClone` removes with no origin check when `srcDir` exists.** `repoOrigin` is now asked on both paths. Adoption still needs `srcDir` free; removal is the same `rm -rf` either way, and skipping the question there made the steady-state path the one path that condemned a directory on no evidence. - **The name fallback costs another 120 s on the slow path.** `git()` takes a per-call timeout and the `git show HEAD:…` lookup gets 10 s. Losing the name costs the slug in one record; losing two more minutes costs the UI. - **The retry chain resolves the wrong ref.** `ModuleFailure` carries `configuredRef` and the chain looks the entry up by (url, ref), falling back to url-only for a failure `load()` raised (it never sees a ref). One chain per URL is unchanged — two would fight over `index.lock` in the shared tree — so what this fixes is which ref that one chain uses, and therefore which signature it writes or clears. - **`readManifestName` duplicates `parseManifestFile`.** Both go through one `readManifestText`/`manifestPath` now. ## Filed rather than fixed The `#main` staleness is #60, with the semantics question (branch-first shadows a tag of the same name; tag-first needs an explicit `refs/tags/` probe) written down as the reason it is not a one-liner. ## Verification `deno task check`, `deno lint packages/server/` (60 files), `deno fmt --check` (326 files) green. `packages/server/tests/`: **176 passed, 0 failed** at `ff1640f`, and **176 passed, 0 failed** through `./.forgejo/deno.sh` proper — the derived image from the Dockerfile's `FROM`, `denoland/deno:2.9.5` + debian git. Three new tests, and each was checked the way you check them: `--force` reverted fails the first, `gitAnswered` → `return true` fails the second, `main`'s `briefly` fails the third.
thisilike approved these changes 2026-09-02 12:13:22 +02:00
thisilike left a comment

Re-review at ff1640f, checked against a running git (2.55) and Deno 2.9.6, and against mutations of your own source rather than against the description.

Verified first

  • Gates reproduce. deno task check, deno lint packages/server/ (60 files), deno fmt --check (326 files) green; packages/server/tests/ 176 passed, 0 failed; the external file 30 → 33. "Twenty-one around this" is exact — main has 12 in that file.
  • Both blockers are fixed, and each is pinned by the mutation it needed to be pinned by. Removing --force from all three sites fails a work tree git only declined to check out is kept, not condemned; gitAnsweredreturn true fails a healthy tree whose origin went unreachable survives the boot; main's tail cut fails the bell keeps both halves of a chained record, and the seam. Your three claims about your three tests are all true.
  • The dirty-tree class is closed end-to-end. Four boots with a tracked file edited in srcDir and moved on the remote between each: ok=true marker=true sigs=0 every boot, against last round's marker=false every boot. --force takes the new commit and leaves the untracked marker.
  • The unreachable-origin test is not theatre. The narrower mutation — gitAnswered reduced to e instanceof GitExitError, dropping only the transient check — fails it too. The other half (!isTransientGitError alone) still passes 176/0, which is exactly what you moved to "Not covered".
  • briefly's seam is genuinely covered now. Head-only middleCut, tail-only middleCut and a dropped closing paren each fail the new test. That is three of its four real properties.
  • No regression in any earlier round's fix. I reverted each in turn again and the counts are identical to last time: recovery-on-failure 6, the bound 3, per-ref keying 1, subcommand stripping 2, --no-checkout 2, clone-beside/swap 3, the isBranch guard 1, the sweep 1, lstat 1.
  • The two rebuttals are correct. Reverting isUsableWorkTree to the literal #28 predicate, and remoteDefaultBranch to the old literal, each still pass 176/0. They belong in "Not covered" and that is where they are.
  • "Not covered" is accurate on every item I could drive. The swap's remove-then-rename shape, the dropped restore, the re-swallowed signature write, and the GitExitError half of gitAnswered all pass 176/0 — as the section says.
  • No new path can delete a directory. aside/fresh are dir plus a literal suffix and every slug ends in -<8 hex>, so neither can ever name another repo's srcDir; prepareExternalModules is sequential, so two refs sharing a work tree cannot race the swap; a throwing rename(dir, aside) reaches catch (e2) with the old tree intact.

Non-blocking

  • The budget handoff is the one property of the new bell test that is still theatre — and for the reason the test's own comment names. The lopsided fixture is 203 characters, so briefly returns at if (one.length <= MAX_ERROR_CHARS) return one and the split never runs. Both handoff mutations pass 176/0: dropping Math.min(fault.length, …), and dropping Math.max(…, budget − recovery.length) for a plain even split. The arithmetic is right — I checked it — but nothing holds it. It needs two fixtures that actually exceed 300 with one short half (a recovery over 253 characters against a short fault, and a fault over 245 against a short recovery) and assertEquals(briefly(record).length, 300) on each: the handoff is precisely what keeps the total at the full budget, and those two mutations come out at 184 and 185.
  • Nothing asserts the recovery cleans up after itself. Dropping Deno.remove(aside, …) after a successful swap passes 176/0 — and that leaves a full duplicate of the repo under /data for the whole process lifetime, swept only by that repo's next sync. Dropping Deno.remove(fresh, …) on the failure path passes 176/0 too. "Briefly doubling the repo's footprint" is the word nothing checks, and an unnamed second copy under /data is the smell this PR exists to end. One readDir(srcRoot) assertion after the recovery boot in an unsatisfiable pin costs one recovery clone pins both.
  • The (url, ref) retry resolution has no coverage and is not in "Not covered". Dropping configuredRef: ref.ref from the failure record silently reverts the whole fix — the paired find then never matches and every failure falls through to the url-only lookup — and that passes 176/0, as does reverting the lookup itself. Worth knowing too: it is a no-op in the common ordering, since with [U#main, U#dev] both failing, host.failed order equals config order and both lookups pick #main; it bites when the failing entry is not the first for its URL. Either a unit test over the resolution, or name it alongside the others.
  • Two local git calls did not get the treatment you just gave the third. repoOrigin's config --get and isUsableWorkTree's rev-parse are local-only, on the pre-Deno.serve path, still carrying the full 120 s GIT_TIMEOUT_MS — which is the argument NAME_LOOKUP_TIMEOUT_MS makes one function away. The 10 s budget applies to them for the same reason.
  • --force does touch untracked files, in one case. On git 2.55: an untracked file at a path the target commit tracks makes plain checkout --detach refuse ("The following untracked working tree files would be overwritten by checkout"), and --force overwrites it. So the comment at checkoutConfiguredRef claims slightly more than --force gives — worth rewording, not changing, since it is still strictly better than the re-clone it replaces and it quietly closes that refusal class too.
  • middleCut has a latent trap now that briefly is exported. For max <= 2 the tail goes to zero or below, and text.slice(-0) is text.slice(0) — it returns the whole string plus an ellipsis, longer than its own budget. Unreachable from briefly today (when a cut is needed neither share drops below 137, which I checked over the whole space), but three Math calls are what stand between them. Math.max(0, max - 1 - head) and text.slice(text.length - tail) retire it.
  • The bell row is still keyed on the URL alone. U#main and U#dev share one notification row while the fault signature, the failure record and the retry chain are all per-ref now. Pre-existing, but this PR is what made the pair first-class everywhere else.

Both blockers are fixed in the source and each is held by a test that fails under exactly the mutation it needed to fail under, every earlier round's fix is still defended with identical counts, and there is no new way for this code to remove a directory. What is left is test coverage and two comments claiming a little more than the code does, none of it on a destructive path. Approving with those.

Re-review at `ff1640f`, checked against a running git (2.55) and Deno 2.9.6, and against mutations of your own source rather than against the description. ## Verified first - **Gates reproduce.** `deno task check`, `deno lint packages/server/` (60 files), `deno fmt --check` (326 files) green; `packages/server/tests/` **176 passed, 0 failed**; the external file 30 → 33. "Twenty-one around this" is exact — `main` has 12 in that file. - **Both blockers are fixed, and each is pinned by the mutation it needed to be pinned by.** Removing `--force` from all three sites fails *a work tree git only declined to check out is kept, not condemned*; `gitAnswered` → `return true` fails *a healthy tree whose origin went unreachable survives the boot*; `main`'s tail cut fails *the bell keeps both halves of a chained record, and the seam*. Your three claims about your three tests are all true. - **The dirty-tree class is closed end-to-end.** Four boots with a tracked file edited in `srcDir` and moved on the remote between each: `ok=true marker=true sigs=0` every boot, against last round's `marker=false` every boot. `--force` takes the new commit and leaves the untracked marker. - **The unreachable-origin test is not theatre.** The narrower mutation — `gitAnswered` reduced to `e instanceof GitExitError`, dropping only the transient check — fails it too. The other half (`!isTransientGitError` alone) still passes 176/0, which is exactly what you moved to "Not covered". - **`briefly`'s seam is genuinely covered now.** Head-only `middleCut`, tail-only `middleCut` and a dropped closing paren each fail the new test. That is three of its four real properties. - **No regression in any earlier round's fix.** I reverted each in turn again and the counts are identical to last time: recovery-on-failure 6, the bound 3, per-ref keying 1, subcommand stripping 2, `--no-checkout` 2, clone-beside/swap 3, the `isBranch` guard 1, the sweep 1, `lstat` 1. - **The two rebuttals are correct.** Reverting `isUsableWorkTree` to the literal `#28` predicate, and `remoteDefaultBranch` to the old literal, each still pass 176/0. They belong in "Not covered" and that is where they are. - **"Not covered" is accurate on every item I could drive.** The swap's remove-then-rename shape, the dropped restore, the re-swallowed signature write, and the `GitExitError` half of `gitAnswered` all pass 176/0 — as the section says. - **No new path can delete a directory.** `aside`/`fresh` are `dir` plus a literal suffix and every slug ends in `-<8 hex>`, so neither can ever name another repo's `srcDir`; `prepareExternalModules` is sequential, so two refs sharing a work tree cannot race the swap; a throwing `rename(dir, aside)` reaches `catch (e2)` with the old tree intact. ## Non-blocking - **The budget handoff is the one property of the new bell test that is still theatre — and for the reason the test's own comment names.** The lopsided fixture is **203 characters**, so `briefly` returns at `if (one.length <= MAX_ERROR_CHARS) return one` and the split never runs. Both handoff mutations pass 176/0: dropping `Math.min(fault.length, …)`, and dropping `Math.max(…, budget − recovery.length)` for a plain even split. The arithmetic is right — I checked it — but nothing holds it. It needs two fixtures that actually exceed 300 with one short half (a recovery over 253 characters against a short fault, and a fault over 245 against a short recovery) and `assertEquals(briefly(record).length, 300)` on each: the handoff is precisely what keeps the total at the full budget, and those two mutations come out at 184 and 185. - **Nothing asserts the recovery cleans up after itself.** Dropping `Deno.remove(aside, …)` after a successful swap passes 176/0 — and that leaves a full duplicate of the repo under `/data` for the whole process lifetime, swept only by that repo's next sync. Dropping `Deno.remove(fresh, …)` on the failure path passes 176/0 too. "Briefly doubling the repo's footprint" is the word nothing checks, and an unnamed second copy under `/data` is the smell this PR exists to end. One `readDir(srcRoot)` assertion after the recovery boot in *an unsatisfiable pin costs one recovery clone* pins both. - **The (url, ref) retry resolution has no coverage and is not in "Not covered".** Dropping `configuredRef: ref.ref` from the failure record silently reverts the whole fix — the paired `find` then never matches and every failure falls through to the url-only lookup — and that passes 176/0, as does reverting the lookup itself. Worth knowing too: it is a no-op in the common ordering, since with `[U#main, U#dev]` both failing, `host.failed` order equals config order and both lookups pick `#main`; it bites when the failing entry is not the first for its URL. Either a unit test over the resolution, or name it alongside the others. - **Two local git calls did not get the treatment you just gave the third.** `repoOrigin`'s `config --get` and `isUsableWorkTree`'s `rev-parse` are local-only, on the pre-`Deno.serve` path, still carrying the full 120 s `GIT_TIMEOUT_MS` — which is the argument `NAME_LOOKUP_TIMEOUT_MS` makes one function away. The 10 s budget applies to them for the same reason. - **`--force` does touch untracked files, in one case.** On git 2.55: an untracked file at a path the target commit tracks makes plain `checkout --detach` refuse ("The following untracked working tree files would be overwritten by checkout"), and `--force` overwrites it. So the comment at `checkoutConfiguredRef` claims slightly more than `--force` gives — worth rewording, not changing, since it is still strictly better than the re-clone it replaces and it quietly closes that refusal class too. - **`middleCut` has a latent trap now that `briefly` is exported.** For `max <= 2` the tail goes to zero or below, and `text.slice(-0)` is `text.slice(0)` — it returns the whole string plus an ellipsis, longer than its own budget. Unreachable from `briefly` today (when a cut is needed neither share drops below 137, which I checked over the whole space), but three `Math` calls are what stand between them. `Math.max(0, max - 1 - head)` and `text.slice(text.length - tail)` retire it. - **The bell row is still keyed on the URL alone.** `U#main` and `U#dev` share one notification row while the fault signature, the failure record and the retry chain are all per-ref now. Pre-existing, but this PR is what made the pair first-class everywhere else. Both blockers are fixed in the source and each is held by a test that fails under exactly the mutation it needed to fail under, every earlier round's fix is still defended with identical counts, and there is no new way for this code to remove a directory. What is left is test coverage and two comments claiming a little more than the code does, none of it on a destructive path. Approving with those.
fix(modules): pin the budget handoff, the recovery's cleanup, and the (url, ref) retry
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m12s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m0s
c0925936b5
The approving review named five things the code claims and nothing holds,
and two comments claiming more than the code gives. All seven, in order.

Three mutants that passed the whole suite now die:

- `briefly`'s budget handoff. The lopsided fixture was 203 characters, so
  the length guard returned it whole and the split never ran — both handoff
  terms could be deleted with 176/0 green. Two fixtures that actually exceed
  300 with one half short replace it, asserted on LENGTH: the handoff is
  precisely what keeps the total at the full budget, so dropping
  `Math.min(fault.length, …)` comes out at 184 and dropping
  `Math.max(…, budget - recovery.length)` at 185.
- The recovery's own cleanup. Dropping `Deno.remove(aside)` after a
  successful swap, or `Deno.remove(fresh)` on the failure path, left a full
  second copy of the repo under /data for the process lifetime and passed
  176/0. One `readDir` helper, asserted in the test that takes each path:
  the killed-checkout recovery swaps successfully, the unsatisfiable pin's
  fails.
- The (url, ref) retry resolution. Dropping `configuredRef: ref.ref` from
  the failure record silently reverted the fix — every failure then fell
  through to the url-only lookup — and so did reverting the lookup itself.
  The record half is asserted where a pinned failure is already produced;
  the lookup half needs the failing entry to be the SECOND for its url,
  which is the only ordering where the two lookups disagree.

And the two comments:

- `--force` does touch one untracked file: one at a path the target commit
  tracks. Plain `checkout` refuses outright there, so it is a refusal class
  `--force` closes rather than a file it costs — the alternative was the
  re-clone, which removes the whole tree. Reworded to say that.
- `middleCut` is exported-adjacent now and its `max <= 2` tail reaches zero,
  where `slice(-0)` returns the whole string plus an ellipsis. Clamped and
  written as an absolute index. Unreachable from `briefly` today; three
  `Math` calls were all that stood between them.

`NAME_LOOKUP_TIMEOUT_MS` becomes `LOCAL_GIT_TIMEOUT_MS` and covers the two
calls that made the same argument and did not get it: `repoOrigin`'s
`config --get` and `isUsableWorkTree`'s two `rev-parse`s. Local-only, on the
pre-`Deno.serve` path, previously able to spend two minutes each on a wedged
/data.

`deno task check`, `deno lint packages/server/`, `deno fmt --check` and the
full suite (177 passed, 0 failed) are green, and 177/0 through
`./.forgejo/deno.sh` on the image the Dockerfile's FROM names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
julian merged commit 19ef2636da into main 2026-09-02 13:05:14 +02:00
julian deleted branch fix/issue-28-wedged-clone 2026-09-02 13:05:14 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OpsDeck/core!40
No description provided.