Land gate checks the merged tree's own dependencies
Status: design frozen, no code written yet. Remove this specification once tools/scripts/land.ts,
devenv.nix, and tools/scripts/tests/land.test.ts implement it and AGENTS.md reflects it.
Goal
just land <branch> must check the merged tree against dependencies installed from the merged
tree's own bun.lock/package.json, and must leave main's checkout and its node_modules
consistent with main's own bun.lock no matter how the land ends (clean landing, a failed check,
a conflict, or an interrupted process). A frozen install (bun install --frozen-lockfile) must
fail loudly when bun.lock does not satisfy package.json.
Bug
merge-full (devenv.nix) runs just check --concurrency=1 && just test --concurrency=1 on the
pre-merge-commit worktree but never installs. A branch that changes package.json/bun.lock is
gated against main's stale node_modules. Observed: landing docs/fumadocs-integration-0927
(adds yaml 2.9.1, rewrites bun.lock) aborted with Cannot find package 'yaml' in just layout.
Decision: (b), a temporary detached worktree, not (a) a same-checkout reinstall
Chosen: land.ts builds and verifies the merge in a temporary detached worktree created from
main's current tip, then lands into main only as a fast-forward of the exact commit it already
verified there. Rejected: (a) running bun install --frozen-lockfile directly in main's checkout
before check/test, with a reinstall in land.ts after an aborted merge to restore main.
Why (b) has fewer ways to leave main or its node_modules inconsistent:
- Under (a), main sits in a "merge in progress" state for the entire duration of
check/test(potentially many minutes: full test suite, lint, type check). Any interruption in that long window (SIGKILL, an unrelated crash) leaves main mid-merge withnode_modulesalready mutated to match the merged tree, whilegit merge --abortonly restores tracked files (bun.lock,package.json) to main's old state —node_modulesis untracked, so it is not restored. (a)'s own proposal papers over exactly this with a second explicit reinstall after abort, i.e. it needs a remedy because the defect is representable. - Under (b), main is never modified until after the merge, the install, and the full
check/testhave already succeeded once, in a disposable worktree. The only operation that ever touches main isgit merge --ff-only <verified-sha>(a ref update to a commit that is known to be a genuine fast-forward from main's tip — no working-tree merge algorithm runs there, and pergithooks(5), a fast-forward never invokespre-merge-commit). If the verification fails or is interrupted, only the disposable worktree is damaged; it is discarded and main was untouched the whole time — the "restore main's dependencies after an aborted merge" step (a) needs is unnecessary by construction, not by a second remedy. - Because hooks are shared across all linked worktrees of one repository (they live in the common
.gitdir, not per-worktree), merging in the temporary worktree still runs the exact samepre-merge-commithooks (hook-config-current-merge,check-merge-conflicts,merge-full) as merging in main would. Fixingmerge-fullitself (adding the install before check/test) is still required and benefits any merge commit made directly on main by someone bypassingland.ts— belt and suspenders, not a duplicate mechanism. - The only step (b) still performs directly in main after landing is a sync install
(
bun install --frozen-lockfile, gated onbun.lockexisting) so main's own checkout stays usable, since a fast-forward does not invoke any hook that would do it. If that sync install is interrupted, main's ref has already correctly landed (git-level success), and the state left behind is "needsbun install" — the same idempotent, always-safe-to-rerun state as a fresh clone before its first install — not a torn state betweenbun.lockandpackage.json.
The merge-full hook does not re-run just check/just test a second time on the same
tree during a normal just land: land.ts's own fast-forward of the already-verified commit into
main cannot invoke pre-merge-commit at all (fast-forwards don't fire it), so there is nothing to
skip by special-casing — the non-repetition falls out of using --ff-only, not an env flag the
hook has to trust.
Concrete plan (not yet implemented)
devenv.nix,merge-fullhook: prependbun install --frozen-lockfile &&to its entry, sobash -c 'bun install --frozen-lockfile && just check --concurrency=1 && just test --concurrency=1'. This is required regardless of (a)/(b): it is what actually installs the merged tree's own dependencies wherever the merge happens (main directly, or the temporary worktree).tools/scripts/land.ts, in theelsebranch that currently doesgit merge --no-ff --no-edit branchdirectly in main:beforeFull = read("rev-parse", "main").- Create
tempDir = mkdtempSync(join(tmpdir(), "land-verify-")). git("worktree", "add", "--detach", tempDir, beforeFull).- Attempt
spawnSync("git", ["-C", tempDir, "merge", "--no-ff", "--no-edit", branch], { stdio: "inherit" }).- On failure: if
git -C tempDir rev-parse --verify --quiet MERGE_HEADsucceeds, rungit -C tempDir merge --abort; alwaysgit worktree remove --force tempDirafter;failwith the existing message ("The merge of ... stopped and was aborted. main is unchanged at ..."). Main is untouched the entire time — no separate abort path in main needed.
- On failure: if
- On success:
verifiedSha = read("-C", tempDir, "rev-parse", "HEAD"). - Land:
spawnSync("git", ["merge", "--ff-only", verifiedSha], { stdio: "inherit" })in main (default cwd). If this fails (main changed concurrently — should not happen under the single-writer-per-worktree convention),failwith a clear message namingverifiedSha. git("worktree", "remove", "--force", tempDir);failif removal fails, same style as the existing branch-worktree-removal failure (land already succeeded at this point, so this is reported, not treated as land having failed).- If
existsSync(join(process.cwd(), "bun.lock")), runspawnSync("bun", ["install", "--frozen-lockfile"], { stdio: "inherit" })in main to syncnode_modulesto the now-landedbun.lock; on failure, print a clear remediation message (main landed at <sha>; node_modules needs bun install --frozen-lockfile) and exit 1 — this does not undo the already-successful git-level land. how = "merge commit"unchanged.
tools/scripts/tests/land.test.ts: add, following the existing fixture style (a fabricatedmain+featurerepo pair, a hand-written.git/hooks/pre-merge-commitscript — hooks are shared across worktrees so a script installed once covers both main and the temporary worktree):- A fixture repo whose base commit carries a
package.json, abun.lockwith no dependencies, and a tracked local packagelocal-dep/(its own minimalpackage.json+index.js) so afile:./local-depdependency installs fully offline, no network. - Red (proves the hook itself must gain the install step, not just land.ts's worktree
restructuring): feature branch adds the
local-depdependency topackage.json, regeneratesbun.lockto match, and addsindex.tsimporting and using it.pre-merge-commitis today's exact hook shape,bun run index.tswith no install first. Landing fails (Cannot find package), main unchanged,existsSync(join(main, "node_modules"))isfalse. - Green (the fix): identical branch,
pre-merge-commitnow doesbun install --frozen-lockfile && bun run index.ts(mirrors the fixedmerge-full). Landing succeeds; assertgit rev-list --parentson main same as the existing fast-forward test. - Negative control: feature branch's
bun.lockis inconsistent with itspackage.json(e.g.package.jsongains a dependencybun.lockdoes not list). With the fixed hook, landing fails specifically at the frozen install (assert on the frozen-lockfile failure, not a generic non-zero), main unchanged. - Consistency after abort: reusing the red or negative-control scenario, assert
existsSync(join(main, "node_modules"))isfalseafter the aborted land — main'snode_moduleswas never touched becauseland.tsnever runs any install in main until after a verified fast-forward.
- A fixture repo whose base commit carries a
AGENTS.md, the landing paragraph (one sentence): note thatjust landverifies the merge, including installing its own dependencies, in a temporary worktree before fast-forwarding main, so a failed or interrupted land never leaves main's checkout ornode_modulesinconsistent.- Run
bun test tools/scripts/tests/land.test.tsdirectly (fast, no heavy lock needed — these are plain git/bun operations, notjust check/just test), thenjust measure --class ... -- just check --concurrency=1per the assignment before the final commit.
Not yet done
No code has been written. devenv shell -- true has been run once in this worktree
(../mono-web-land-installs), so hooks are installed and current. Nothing else is committed.