harden(guard-push): never force-delete a scratch checkout that still holds a borrowed node_modules link - #2244
Conversation
…deleting its container The format guard checks the pushed commit in a scratch worktree and links a node_modules tree in so a dynamic prettier config can resolve its plugins. When the pushing worktree has no dependencies of its own, findPrettierBin borrows ANOTHER worktree's real tree, and on Windows that borrow is a junction. The scratch directory is then torn down with `git worktree remove --force` plus a recursive rmSync, neither of which respects `git worktree lock`. PR #2186 added a cleanup that removed the link first, but it had two gaps: - it gated on existsSync, which FOLLOWS the link, so a link whose target had gone away read as absent and was left in place for the force-deletes; - it swallowed an unlink failure and continued, so the one case where the link is still live is the case where both force-deletes still ran over it. Extract the teardown into unlinkDependencyLink() and cleanupFormatCheckout(). The first uses lstat, operates on the link itself (unlink, then rmdir for a Windows directory reparse point), refuses a real directory outright, and never uses a recursive delete on that path. The second runs it before either force-delete and skips both when it did not succeed — a leftover scratch directory is cheap, and `git worktree prune` clears its registration next push. The format guard is not weakened; SKIP_FORMAT_GUARD=1 remains the only escape hatch. The borrowing fallback is deliberately left ungated on donor idleness, with the reasoning recorded at findPrettierBin. This fixes a demonstrable hazard. It makes no claim about the cause of any past worktree loss. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The injected removeWorktree/removeDir returned Array.prototype.push's number where tryGit's signature is string | undefined, and the reason loop widened the literal union. Braces and `as const` instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 14 minutes Limit details: You’ve used the included review currently available. Your 87 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab. How can I continue?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
CI triageCI failed on this PR. Automated classification of the 2 failed job(s):
Compared with main CI run #12949 (success). Classification is evidence routing, not permission to ignore a failure. Exact quarantined Playwright identities remain governed by the flake ledger. |
…calls
tests/test-runner-safety.test.ts's repo-wide static contract requires every
recursive rmSync in a test file to carry a positive maxRetries so Windows
transient handle errors (EBUSY/EPERM/ENOTEMPTY) never masquerade as a real
assertion failure. The two new rmSync calls added by this PR's
guard-push.test.ts fixtures ("removes the link itself and leaves the borrowed
tree intact" and "removes a DANGLING link, which existsSync reports as
absent") omitted maxRetries, tripping "requires bounded retries for every
recursive test-fixture cleanup" in CI's Unit coverage job. Add
maxRetries: 5, retryDelay: 100 to both, matching this file's existing
afterEach cleanup convention.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015StJgDC2dfef8PXN9dfriw
… fixed The three handover documents said the cause was not conclusively identified. That was wrong, and the answer was in front of me the whole time - both fixes are already on main and this branch predates them: a04330e harden(guard-push): never force-delete a scratch checkout that still holds a borrowed node_modules link (#2244) cdfcbac fix(worktrees): stop silent worktree wipes and misdirected commands (#2240) The old scripts/guard-push.mjs linked a borrowed worktree's real node_modules into a scratch checkout as a Windows junction, then force-deleted that checkout recursively. A git worktree lock cannot stop it because it is a filesystem delete, not a git worktree operation - which is exactly why the third destruction went through a lock. Any concurrent session pushing from a stale base ran it against whichever worktree it had borrowed from. This branch is 122 commits behind origin/main and has neither fix, so the tooling in this worktree - and in the other stale worktrees running alongside it - predated its own fix. scripts/clean-worktree.mjs was investigated and cleared: it contains no filesystem deletion at all. The remedy is to merge origin/main before any further build work, and the documents now say so. Also corrects two claims that went stale: the branch is no longer unpushed, and the authorisation boundary now records that the push happened with the user's explicit agreement after they were told the repository is public. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
scripts/guard-push.mjs— the format guard's scratch-checkout teardown no longer has any paththat can force-delete a directory still holding a live link into another worktree's
node_modules.The link is removed by
lstat+unlink/rmdir(never followed, never recursively deleted), andwhen that does not succeed,
git worktree remove --forceand the recursivermSyncare bothskipped rather than run anyway.
tests/guard-push.test.ts— seven tests pinning that behaviour, four of which fail against thecode as it stands on
main.No clinical, RAG, retrieval, ingestion, auth, or privacy surface is touched, so no Clinical
Governance Preflight applies. No provider-backed gate was run.
The hazard
checkPushedCommitchecks out the pushed commit into a scratch worktree under%TEMP%and links anode_modulestree in so a dynamicprettier.config.*can resolve its plugins. When the pushingworktree has no dependencies of its own,
findPrettierBindeliberately borrows another worktree'sreal
node_modules— gated on a byte-identicalpackage-lock.jsonand a matching installedPrettier version. On Windows that borrow is linked in as a junction. The scratch directory is
then torn down with
git worktree remove --forcefollowed byrmSync(recursive: true, force: true),neither of which respects
git worktree lock.PR #2186 added a cleanup that removed the link before the worktree removal. Two gaps remained:
existsSyncfollows the link. Once the borrowed tree is gone, the link reads as absent, sothe cleanup skips it — and the two force-deletes then run over it anyway. This is not theoretical:
a live dangling junction of exactly this kind was found on the development machine while preparing
this change (details below).
// Continue cleanup even if unlinking junction throws). The one case where the link is still live is therefore the one casewhere both force-deletes still ran over it.
What this changes
unlinkDependencyLink(linkPath)— useslstat, so a dangling link is still seen;unlink, fallingback to
rmdirfor a Windows directory reparse point, both of which remove the link and never itstarget; refuses a real directory outright; and never uses a recursive delete on that path under any
branch.
cleanupFormatCheckout(dir, deps)— runs the unlink before either force-delete, and returnswithout running either when the link is still there, logging the path and reason. A leftover scratch
directory is cheap, and the
git worktree pruneat the top ofcheckPushedCommitclears itsregistration on the next push. Dependencies are injectable so ordering and the skip are unit-testable
without creating and destroying real worktrees.
The format guard is not weakened. It still fails closed — being unable to check is not evidence a
push is clean — and
SKIP_FORMAT_GUARD=1remains the only escape hatch.Considered and deliberately not done: gating the borrow on whether the donor worktree is idle.
"Actively in use" is not cheaply detectable — an in-flight
npm ciin another process leaves nothingguard-pushcan read — and a heuristic that misfires would silently disable the format guard. Theborrow is read-only; the deletion risk lived entirely in the teardown. Reasoning is recorded at
findPrettierBinrather than half-built.What the tests do and do not prove
Not all seven are regression guards, and it matters which are which:
main?The sentinel test (link a directory in, delete the container, assert the target survived) is a
guarantee test, not a regression test: Node's recursive
rmSynclstats children and unlinkslinks rather than descending them, so it passes with or without this change. It is kept because it
pins the invariant in the place a future reader will look for it, but the four marked yes above
are what actually prove the fix.
Platform coverage — stated precisely
The link is a junction on win32 and a directory symlink everywhere else; the same assertions
cover both.
run on Windows 11 with Node 24.19.0, creating real junctions via
symlinkSync(..., "junction").junction case — that evidence is local only.
Evidence
Before the fix (the
mainteardown logic, extracted verbatim behind the new seams so the failuresare real assertions rather than an import error):
After:
npm run typecheck— exit 0,[gate-receipts] recorded a pass for "typecheck:internal" (3916 input files).npm run lint— exit 0,[gate-receipts] recorded a pass for "lint:internal" (3916 input files).prettier --check .(whole tree, not per-file) — exit 0,All matched files use Prettier code style!Live corroboration found while preparing this
On the development machine,
%TEMP%held several abandonedguard-push-format-*scratchdirectories, three of which still carried a live
Directory, ReparsePointjunction into a differentworktree's real
node_modules:The first target no longer exists — a dangling junction, i.e. gap (1) above occurring in the wild.
All three were cleaned up using the new
unlinkDependencyLink, and both surviving targets wereverified byte-count-stable at 528 entries before and after.
What this does not claim
This fixes a demonstrable hazard: the code links a foreign worktree's real
node_modulesinto adirectory it then force-deletes. It makes no claim that this caused any past worktree loss. That
was not established and is not asserted here, in the commits, or in the code comments.