Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added aether-agents-0.2.0.tgz
Binary file not shown.
22 changes: 18 additions & 4 deletions src/commands/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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`);
Expand Down
19 changes: 15 additions & 4 deletions src/core/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -140,15 +151,15 @@ 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)");
}
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}`);
Expand Down
94 changes: 93 additions & 1 deletion test/worktree.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 <branch> <dir>` 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");
});
}