diff --git a/aether-agents-0.2.0.tgz b/aether-agents-0.2.0.tgz new file mode 100644 index 0000000..8cbb596 Binary files /dev/null and b/aether-agents-0.2.0.tgz differ diff --git a/src/commands/code.ts b/src/commands/code.ts index 5be3d45..4852c10 100644 --- a/src/commands/code.ts +++ b/src/commands/code.ts @@ -148,6 +148,9 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr // A non-TTY run without --yes proceeds in place with zero prompts/side // effects, so pipes, CI, and tests never hang. let repoSpec: RepoSpec | null = null; + // The exact revision a --repo worktree must start from. Null for a plain + // --worktree run, where the user's own checkout is the intended base. + let repoBase: string | null = null; let worktree: Worktree | null = null; let cwd: string; if (opts.repo || opts.worktree) { @@ -167,19 +170,30 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr ? "(fetched)" : `(NOT REFRESHED — ${co.freshness.reason ?? "reason unknown"})`; process.stderr.write(`⎇ repo ${repoSpec.full} ${how}${tip}\n ${co.dir}\n`); - if (co.freshness.state !== "fresh") { + // Refuse rather than branch off a base nobody can name. git fetch moves + // remote refs, not the mirror's HEAD, so without a known tip the run + // would silently start from whatever was on disk while having just + // printed a reassuring fetch line. + if (co.freshness.state !== "fresh" || !co.freshness.remoteTip) { process.stderr.write( - " ! this worktree will branch off whatever the mirror already had;\n" + - " its base is not known to match the remote.\n", + `✗ refusing to start: the base for ${repoSpec.full} is not known to match the remote.\n` + + ` ${co.freshness.reason ?? "no revision was resolved"}\n` + + " a worktree cut now would branch off whatever the mirror already had.\n" + + " reconnect and retry, or work in a local checkout you control.\n", ); + return 1; } + repoBase = co.freshness.remoteTip; } catch (err) { process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); return 1; } } try { - worktree = createWorktree(repoRoot, label); + // Pin the worktree to the revision the mirror actually fetched. git fetch + // moves remote refs, not the mirror's HEAD, so an unpinned `worktree add` + // branches off a base that can be well behind the tip just reported. + worktree = createWorktree(repoRoot, label, undefined, repoBase ?? undefined); process.stderr.write(`⌥ worktree ${worktree.branch}\n ${worktree.dir}\n`); } catch (err) { process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); diff --git a/src/core/worktree.ts b/src/core/worktree.ts index aa83374..703af7f 100644 --- a/src/core/worktree.ts +++ b/src/core/worktree.ts @@ -124,8 +124,19 @@ export function worktreeBranch(task: string, id: string): string { } /** git args to add a worktree on a new branch off current HEAD. Pure. */ -export function worktreeAddArgs(repoRoot: string, branch: string, dir: string): string[] { - return ["-C", repoRoot, "worktree", "add", "-b", branch, dir]; +export function worktreeAddArgs( + repoRoot: string, + branch: string, + dir: string, + startRevision?: string, +): string[] { + // git fetch updates remote refs and FETCH_HEAD; it does NOT move the mirror's + // checked-out HEAD. Without an explicit start point, `worktree add` branches + // off whatever the mirror already had — which can be days behind the tip the + // run just reported fetching. The revision goes last: git reads the final + // positional as the start point. + const argv = ["-C", repoRoot, "worktree", "add", "-b", branch, dir]; + return startRevision ? [...argv, startRevision] : argv; } /** Resolve the repo root for `cwd`, or null if not inside a git repo. @@ -140,7 +151,7 @@ export function repoRootOf(cwd: string): string | null { * Throws with an actionable message if `cwd` isn't a git repo or git fails. * `id` is injected (default: short base-36 timestamp) so tests are deterministic. */ -export function createWorktree(cwd: string, task: string, id?: string): Worktree { +export function createWorktree(cwd: string, task: string, id?: string, startRevision?: string): Worktree { const repoRoot = repoRootOf(cwd); if (!repoRoot) { throw new Error("--worktree needs a git repo (run `git init` first, or drop the flag)"); @@ -148,7 +159,7 @@ export function createWorktree(cwd: string, task: string, id?: string): Worktree const safeId = id ?? Date.now().toString(36); const branch = worktreeBranch(task, safeId); const dir = join(worktreesRoot(), branch.replace(/\//g, "-")); - const r = spawnSync("git", worktreeAddArgs(repoRoot, branch, dir), { encoding: "utf8" }); + const r = spawnSync("git", worktreeAddArgs(repoRoot, branch, dir, startRevision), { encoding: "utf8" }); if (r.status !== 0) { const why = ((r.stderr ?? "") + (r.stdout ?? "")).trim() || "git worktree add failed"; throw new Error(`could not create worktree: ${why}`); diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 0f31200..dd133a5 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -182,3 +183,94 @@ test("mergeHint names the branch, merge, and discard paths", () => { assert.match(hint, /merge aether\/x-1/); assert.match(hint, /worktree remove \/wt\/x/); }); + +// ── exact remote base (SC-A7) ─────────────────────────────────────────────── +// `git fetch` moves remote refs and FETCH_HEAD; it does NOT move the mirror's +// checked-out HEAD. So `worktree add -b ` with no start-point +// branches off whatever the mirror already had, even though the run just +// reported a freshly fetched tip. The base must be pinned explicitly. + +test("worktreeAddArgs pins the start revision when one is given", () => { + assert.deepEqual(worktreeAddArgs("/repo", "aether/x-1", "/wt", "c".repeat(40)), [ + "-C", + "/repo", + "worktree", + "add", + "-b", + "aether/x-1", + "/wt", + "c".repeat(40), + ]); +}); + +test("worktreeAddArgs without a start revision is unchanged", () => { + assert.deepEqual(worktreeAddArgs("/repo", "aether/x-1", "/wt"), [ + "-C", + "/repo", + "worktree", + "add", + "-b", + "aether/x-1", + "/wt", + ]); +}); + +test("the pinned revision is the last argv element, so git reads it as the start point", () => { + const argv = worktreeAddArgs("/repo", "aether/x-1", "/wt", "deadbee"); + assert.equal(argv[argv.length - 1], "deadbee"); + assert.equal(argv[argv.length - 2], "/wt", "the directory must still precede the start point"); +}); + +// Real-git canary for the stale-base defect. Everything above asserts argv; +// this asserts the resulting checkout. The failure it guards is specific: +// `git fetch` advances remote refs and FETCH_HEAD but leaves the mirror's own +// HEAD where it was, so an unpinned `worktree add` starts from the OLD commit +// while the run has just printed the NEW one. +{ + const git = (cwd: string, ...args: string[]): string => + spawnSync("git", ["-c", "core.literalPathspecs=true", "-C", cwd, ...args], { encoding: "utf8" }).stdout.trim(); + const canSpawnGit = !spawnSync("git", ["--version"], { encoding: "utf8" }).error; + + test("a pinned worktree starts at the fetched revision, not the mirror's stale HEAD", (t) => { + if (!canSpawnGit) return t.skip("git not available"); + const root = mkdtempSync(join(tmpdir(), "aether-base-")); + const remote = join(root, "remote"); + const mirror = join(root, "mirror"); + + // A remote with one commit; clone it, so mirror HEAD == remote tip == A. + mkdirSync(remote, { recursive: true }); + git(remote, "init", "-q", "-b", "main"); + git(remote, "config", "user.email", "t@t.t"); + git(remote, "config", "user.name", "t"); + writeFileSync(join(remote, "a.txt"), "A\n"); + git(remote, "add", "-A"); + git(remote, "commit", "-q", "-m", "A"); + spawnSync("git", ["clone", "-q", remote, mirror], { encoding: "utf8" }); + const staleHead = git(mirror, "rev-parse", "HEAD"); + + // The remote moves on. The mirror knows nothing about it yet. + writeFileSync(join(remote, "a.txt"), "C\n"); + git(remote, "add", "-A"); + git(remote, "commit", "-q", "-m", "C"); + const remoteTip = git(remote, "rev-parse", "HEAD"); + assert.notEqual(staleHead, remoteTip, "the fixture must actually diverge"); + + // Fetch, exactly as refreshMirror does — refs move, HEAD does not. + git(mirror, "fetch", "--prune", "origin"); + const fetched = git(mirror, "rev-parse", "FETCH_HEAD"); + assert.equal(fetched, remoteTip, "fetch should resolve the remote tip"); + assert.equal( + git(mirror, "rev-parse", "HEAD"), + staleHead, + "fetch must NOT move the mirror's HEAD — this is the whole defect", + ); + + // Cut the worktree the way the product now does: pinned to the fetched tip. + const dir = join(root, "wt"); + const r = spawnSync("git", worktreeAddArgs(mirror, "aether/pinned-1", dir, fetched), { encoding: "utf8" }); + assert.equal(r.status, 0, `worktree add failed: ${r.stderr}`); + + assert.equal(git(dir, "rev-parse", "HEAD"), remoteTip, "the worktree must start at the fetched revision"); + assert.notEqual(git(dir, "rev-parse", "HEAD"), staleHead, "it must not start at the mirror's stale HEAD"); + }); +}