Skip to content

fix(tooling): isolate the throwaway git repos in the merge driver and the pre-commit/pre-push gate - #16811

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-16753-git-free-env-hook-invoked
Sep 8, 2026
Merged

baozhoutao merged 1 commit into
mainfrom
claude/issue-16753-git-free-env-hook-invoked

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #16753

Tier A of the #16644 class: the two files git itself invokes built throwaway git repositories with no environment of their own. scripts/git-merge-regen.mjs is the merge=os-regen driver, so git exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE into it by construction; scripts/check-regen-pending.mjs is what .githooks/pre-commit and .githooks/pre-push invoke — the exact path #16624 travelled. Those variables outrank cwd, so a git init / git config / git add aimed at a temp directory lands on the real repository.

Tiers B and C stay on #16644. No repo-wide gate here — that belongs after Tier C.

Per spawn site, classified rather than blanket-stripped

Acceptance item 4 asks for a local-only / network-touching call per site. Neither file spawns a network child — measured with a control: zero fetch / clone / push / ls-remote sites in these two files, non-zero in eight other files under scripts/. So "apply to every local-only site" would mean all of them, and that is wrong: three local-only sites read the ambient repository on purpose, and stripping them is a behaviour change in the dangerous direction. The table below is the confirmation the card asked the reviewer to produce.

scripts/git-merge-regen.mjs

spawn site git command subject class gitFreeEnv()
gitDir() from markPending (driver path) rev-parse --absolute-git-dir the repository git is merging ambient by design no
isProbeInvocation() rev-parse --git-path index the index git holds right now ambient by design no
textMergeInPlace() merge-file on the three %O %A %B temp paths no repository at all local-only, driver path no
reconcileUntrackedDispositions() ls-files -- SPEC this checkout local-only yes
reconcileAttributeSemantics() ls-files -z this checkout local-only yes
reconcileAttributeSemantics() check-attr -z merge --stdin this checkout local-only yes
hookIsExecutable() ls-files -s .githooks/pre-commit this checkout's index local-only yes
registeredDriverResolves() config --get merge.os-regen.driver this clone's config local-only yes
expandDriverScript() sh -c expanding git rev-parse --show-toplevel — indirect this checkout local-only yes
endToEnd() init config add commit checkout merge status throwaway repo local-only yes
endToEndMixed() the same set throwaway repo local-only yes
probeLeavesNoMarker() the same set, plus gitDir(dir, gitFreeEnv()) throwaway repo local-only yes
probeLeavesNoMarker() spawnSync merge-tree --write-tree throwaway repo local-only yes

scripts/check-regen-pending.mjs

spawn site git command subject class gitFreeEnv()
gitDirPath() rev-parse --absolute-git-dir the repository the hook is running in ambient by design no
mergeInProgress() rev-parse HEAD the merge being completed right now ambient by design no
fixtureSelfTest() init config add commit merge checkout ls-files throwaway fixture local-only yes
fixtureSelfTest() / runHook() a node child that re-enters this file inside the fixture and spawns git children of its own throwaway fixture local-only yes
prePushIsArmedSelfTest() ls-files -s .githooks/pre-push this checkout's index local-only yes
runCheck() execSync('pnpm -s GATE') — not a git child; the production gate run against this checkout ambient by design no (unchanged)

Why the three ambient sites must stay ambient, in one line each: isProbeInvocation() reads the lock on the index GIT_INDEX_FILE names — strip it and a real merge reads as a probe and the deferral's own record is dropped, which the file's own docblock already calls the asymmetric direction; gitDir() on the driver path would otherwise write the marker for whatever repository cwd happens to sit in; merge-file is a child of the merge git is already performing and reading no index, so gitFreeEnv() would only take that merge's configuration away from it.

A thirteenth spawn site the card's probe could not see

#16753 counts 12 git spawn sites in git-merge-regen.mjs. There are 13. expandDriverScript() spawns sh -c on a command string containing $(git rev-parse --show-toplevel), so the 'git' literal probe does not find it — the same false-negative species the card records for git init, one level further out. It was found by the new behavioural battery, not by reading: with the leak live, registeredDriverResolves() expanded the registered driver against the stand-in repository and reported this clone's driver as missing.

Red before green, on a disposable stand-in

⛔ Run literally against a shared checkout, this card's own acceptance test reproduces #16624. Both legs therefore use a scratch repository created for the test and thrown away; what is under test is that an ambient GIT_DIR outranks cwd, and any real repository shows that.

Each file grows one self-test battery, ambientGitEnvIsolation, which builds the stand-in, points all three variables at it, and re-enters the file's whole --self-test under that environment. The stand-in must come back byte-identical in config, index, refs and HEAD. It carries a firing control first — a plain git child in an unrelated directory must answer with the stand-in's git dir — because "untouched" is also what an environment that never reached the child produces.

RED, with the batteries in place and the spawn sites unfixed:

git-merge-regen.mjs   -> exit 1
  stand-in .git/config BEFORE:  [user] email = stand-in@objectstack.invalid / name = os-regen stand-in
  stand-in .git/config AFTER:   [user] email = selftest@objectstack.ai / name = self-test
                                [merge "os-regen"] name = regenerate instead of text-merging
                                                   driver = node ".../scripts/git-merge-regen.mjs" %O %A %B %P

check-regen-pending.mjs -> exit 1
  stand-in .git/config AFTER:   [core] hooksPath = /dev/null
                                [user] email = fixture@objectstack.test / name = os-regen fixture

The second one is worth naming: the fixture writes core.hooksPath = /dev/null into the config, and on a shared clone that disarms every git hook for every worktree — a damage shape beside the core.bare flip #16624 measured.

GREEN, after the fix: both exit 0, pnpm check:merge-driver exit 0.

Measured asymmetry, recorded because it decides the fingerprint: with all three variables set, git resolves the work tree to the stand-in, so git add -A re-adds the stand-in's own files and the index comes back unchanged — scripts/git-env.mjs records the same thing. Under GIT_DIR alone the index moves too (measured here: the stand-in's tracked file dropped, the corpus file staged). The fingerprint is therefore config + index + refs + HEAD, so it reddens under either shape.

Negative control

With the variables absent — ordinary CI and hand-run — both files behave exactly as they do today. Diffed against output captured before any edit, the only difference is the new battery's own line; every pre-existing case prints byte-identical text. The production hook paths (node scripts/check-regen-pending.mjs and --pre-push) still exit 0 with no marker, and this PR's own commit and push ran through .githooks/pre-commit and .githooks/pre-push executing the changed code.

Gates

Derived from the actual changed set with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack; every derived command run, exit codes recorded. All green, including check:merge-driver, check:scripts-symbol-anchors, check:self-test-wired, check:nul-bytes and check:entry-guard. Three packages/spec gates first exited 1 naming their own unmet prerequisite (packages/spec/dist not built, json-schema/ absent) — NOT MEASURED, not a verdict; after pnpm --filter @objectstack/spec build all three exit 0.

No changeset: nothing published moves. Measured — 70 workspace packages declare files[] and none can ship repo-root scripts/; the root manifest is private with no files[].


Generated by Claude Code

…invoked scripts

Git exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE into every child it runs
and those outrank `cwd`, so a `git init` / `git config` / `git add` aimed at a
temp directory lands on the REAL repository whenever these two files run under
a hook or as the merge driver. Both are exactly that: git-merge-regen.mjs IS
the `merge=os-regen` driver, and check-regen-pending.mjs is what
.githooks/pre-commit and .githooks/pre-push invoke.

Every spawn site is classified rather than blanket-stripped. Children whose
subject is a repository their own `cwd` and arguments name take `gitFreeEnv()`
from scripts/git-env.mjs; the three whose subject is the merge git is currently
performing keep the ambient environment and carry a note saying why stripping
them would be a behaviour change in the dangerous direction. Neither file
spawns a network child, measured with a control.

A new self-test battery in each file proves it behaviourally: a disposable
stand-in repository is created, the three variables are pointed at it, and the
file's whole --self-test is re-entered under that environment. The stand-in
must come back byte-identical in config, index, refs and HEAD, and a firing
control asserts the leaked environment actually reached the child first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 8, 2026
@github-actions github-actions Bot added the size/m label Sep 8, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 8, 2026 07:49
@baozhoutao
baozhoutao enabled auto-merge September 8, 2026 07:49
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit d4401f7 Sep 8, 2026
36 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-16753-git-free-env-hook-invoked branch September 8, 2026 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants