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
84 changes: 83 additions & 1 deletion scripts/agent-worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readlinkSync, realpathSy
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";

import { assetLinks, canLinkDeps, linkAssets, linkDeps, worktreeTarget } from "./agent-worktree";
import { assetLinks, canLinkDeps, ensureWorktreeReady, linkAssets, linkDeps, mainCheckoutRoot, worktreeTarget } from "./agent-worktree";

describe("worktreeTarget", () => {
test("places worktrees under the main checkout's .claude/worktrees", () => {
Expand Down Expand Up @@ -71,4 +71,86 @@ describe("linking a worktree to the main checkout", () => {
expect(realpathSync(join(target, "apps/dev/public/models/pack"))).toBe(realpathSync(join(main, "apps/dev/public/models/pack")));
expect(linkAssets(main, target, links)).toEqual([]);
});

test("relinks a dangling Games junction instead of leaving the tree unusable", () => {
mkdirSync(join(main, "Games", "beacon"), { recursive: true });
mkdirSync(join(target, "Games"), { recursive: true });
rmSync(join(target, "Games"), { recursive: true, force: true });
symlinkSync(join(main, "gone-checkout"), join(target, "Games"), "junction");
expect(linkAssets(main, target, ["Games"])).toEqual(["Games"]);
expect(realpathSync(join(target, "Games"))).toBe(realpathSync(join(main, "Games")));
});
});

describe("readiness for a worktree nobody linked", () => {
let base: string;
let main: string;
let target: string;
const write = (path: string, body = ""): void => {
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, body);
};

beforeEach(() => {
base = mkdtempSync(join(tmpdir(), "agent-ready-"));
main = join(base, "main");
target = join(base, "wt");
for (const root of [main, target]) {
write(join(root, "bun.lock"), "lock-v1");
write(join(root, "packages", "core", "package.json"), "{}");
}
write(join(main, "node_modules", ".bin", "tsgo"));
mkdirSync(join(main, ".git", "worktrees", "wt"), { recursive: true });
mkdirSync(join(main, "Games", "beacon", "src"), { recursive: true });
mkdirSync(join(main, "apps", "dev", "public", "models", "pack"), { recursive: true });
// `git worktree add` writes `.git` as a file; that is the only marker this needs.
write(join(target, ".git"), `gitdir: ${join(main, ".git", "worktrees", "wt")}\n`);
});

afterEach(() => {
rmSync(base, { recursive: true, force: true });
});

const ignored = (): string[] => ["apps/dev/public/models/pack/"];

test("finds the main checkout from the .git file and only for linked worktrees", () => {
expect(mainCheckoutRoot(target)).toBe(main);
expect(mainCheckoutRoot(main)).toBeNull();
write(join(base, "plain", "package.json"), "{}");
expect(mainCheckoutRoot(join(base, "plain"))).toBeNull();
});

test("a bare worktree gets dependencies and the Games link, and a second call changes nothing", () => {
const first = ensureWorktreeReady(target, { ignored });
expect(first).toMatchObject({ installed: false, unchanged: false, assets: ["Games", "apps/dev/public/models/pack"] });
expect(existsSync(join(target, "node_modules", ".bin", "tsgo"))).toBe(true);
expect(realpathSync(join(target, "Games"))).toBe(realpathSync(join(main, "Games")));
expect(realpathSync(join(target, "apps/dev/public/models/pack"))).toBe(realpathSync(join(main, "apps/dev/public/models/pack")));

const second = ensureWorktreeReady(target, { ignored: () => { throw new Error("second call must not list ignored files"); } });
expect(second).toMatchObject({ unchanged: true, deps: [], assets: [] });
});

test("installs only when the locks differ, and links nothing from the stale store", () => {
writeFileSync(join(target, "bun.lock"), "lock-v2");
mkdirSync(join(target, "node_modules", ".bin"), { recursive: true });
writeFileSync(join(target, "node_modules", ".bin", "tsgo"), "");
rmSync(join(target, "node_modules"), { recursive: true, force: true });
let installed = 0;
const result = ensureWorktreeReady(target, {
ignored,
install: (dir) => {
installed += 1;
expect(dir).toBe(target);
write(join(target, "node_modules", ".bin", "tsgo"));
},
});
expect(installed).toBe(1);
expect(result).toMatchObject({ installed: true, deps: [] });
expect(realpathSync(join(target, "Games"))).toBe(realpathSync(join(main, "Games")));
});

test("the main checkout itself is left alone", () => {
expect(ensureWorktreeReady(main)).toBeNull();
});
});
150 changes: 118 additions & 32 deletions scripts/agent-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@
* (otherwise `bun install --frozen-lockfile`), `Games` and the dev runner's
* gitignored public assets linked, and a clean `git status`. No build is needed:
* tests and type-checks resolve `@jgengine/*` to source (scripts/sourcePaths.ts).
*
* `claude --worktree` and a bare `git worktree add` skip all of that, so
* `ensureWorktreeReady` is the same repair path exposed to any tree through
* `bun run ensure-ready` (which every test/build/smoke script runs).
*/
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, symlinkSync } from "node:fs";
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, symlinkSync, unlinkSync } from "node:fs";
import { dirname, join, resolve, sep } from "node:path";

export const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
Expand Down Expand Up @@ -108,14 +112,111 @@ export function linkAssets(mainRoot: string, target: string, links: string[]): s
const made: string[] = [];
for (const rel of links) {
const to = join(target, rel);
if (exists(to)) continue;
if (existsSync(to)) continue;
if (exists(to)) unlinkSync(to); // dangling junction from a moved main checkout
mkdirSync(dirname(to), { recursive: true });
symlinkSync(realpathSync(join(mainRoot, rel)), to, "junction");
made.push(rel);
}
return made;
}

/**
* Main checkout that owns `cwd`, or null when `cwd` *is* the main checkout.
* A linked worktree has `.git` as a file pointing at `<main>/.git/worktrees/<name>`,
* so this is one read instead of a git process on every test and build.
*/
export function mainCheckoutRoot(cwd: string): string | null {
const dotGit = join(cwd, ".git");
let content: string;
try {
if (!lstatSync(dotGit).isFile()) return null;
content = readFileSync(dotGit, "utf8").trim();
} catch {
return null;
}
const match = /^gitdir:\s*(.+)$/m.exec(content);
if (!match) return null;
const gitdir = resolve(cwd, match[1]!.trim());
const root = dirname(dirname(dirname(gitdir)));
return existsSync(join(root, ".git")) ? root : null;
}

export interface WorktreeReadiness {
mainRoot: string;
/** Workspace-relative node_modules dirs linked by this call. */
deps: string[];
/** Workspace-relative asset links created by this call. */
assets: string[];
/** True when this call ran `bun install` because the locks differ. */
installed: boolean;
/** True when the tree already had everything: nothing was touched. */
unchanged: boolean;
}

export type WorktreeReadyOptions = {
/** Called when dependencies cannot be linked; must install or throw. */
install?: (target: string) => void;
/** Ignored-file listing used to find dev-runner assets. Defaults to `git ls-files`. */
ignored?: (mainRoot: string) => string[];
};

function gitIgnored(mainRoot: string): string[] {
const result = Bun.spawnSync(
["git", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "apps"],
{ cwd: mainRoot, stdout: "pipe", stderr: "pipe", timeout: 60_000 },
);
if (result.exitCode !== 0) return [];
return (result.stdout?.toString() ?? "").split("\n").map((line) => line.trim()).filter(Boolean);
}

function installed(target: string): boolean {
return (
existsSync(join(target, "node_modules", ".bin", "tsgo")) ||
existsSync(join(target, "node_modules", ".bin", "tsgo.exe"))
);
}

/**
* Make any tree usable: dependencies (linked from the main checkout when the
* locks match, else installed) plus the `Games` and dev-runner asset links.
*
* Idempotent and cheap when repeated: the already-ready path costs a few
* `existsSync` calls and no process spawn, so `ensure-ready` can call this on
* every test run. Returns null when `cwd` is the main checkout itself.
*/
export function ensureWorktreeReady(cwd: string, options: WorktreeReadyOptions = {}): WorktreeReadiness | null {
const mainRoot = mainCheckoutRoot(cwd);
if (mainRoot === null || realpathSync(mainRoot) === realpathSync(cwd)) return null;

const needsDeps = !installed(cwd);
const needsGames = existsSync(join(mainRoot, "Games")) && !existsSync(join(cwd, "Games"));
if (!needsDeps && !needsGames) return { mainRoot, deps: [], assets: [], installed: false, unchanged: true };

const result: WorktreeReadiness = { mainRoot, deps: [], assets: [], installed: false, unchanged: false };
if (needsDeps) {
if (canLinkDeps(mainRoot, cwd)) {
result.deps = linkDeps(mainRoot, cwd);
} else {
(options.install ?? ((target: string) => {
const install = Bun.spawnSync(["bun", "install", "--frozen-lockfile"], {
cwd: target,
stdout: "inherit",
stderr: "inherit",
timeout: 600_000,
});
if (install.exitCode !== 0) {
throw new Error(`bun install --frozen-lockfile failed (exit ${install.exitCode ?? 1})`);
}
}))(cwd);
result.installed = true;
}
}
const ignored = (options.ignored ?? gitIgnored)(mainRoot);
result.assets = linkAssets(mainRoot, cwd, assetLinks(mainRoot, ignored));
return result;
}

function git(cwd: string, args: string[], timeoutMs = 120_000): string {
const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe", timeout: timeoutMs });
const out = (result.stdout?.toString() ?? "") + (result.stderr?.toString() ?? "");
Expand All @@ -127,6 +228,7 @@ function git(cwd: string, args: string[], timeoutMs = 120_000): string {
}

if (import.meta.main) {
const startedAt = Date.now();
const args = process.argv.slice(2).filter((a) => a !== "--");
const name = args[0] ?? "";
const branch = args[1] ?? `agent/${name}`;
Expand All @@ -145,49 +247,33 @@ if (import.meta.main) {
mkdirSync(dirname(target), { recursive: true });

if (existsSync(target)) {
console.log(`agent-worktree: already exists at ${target} — relinking`);
console.log(`agent-worktree: already exists at ${target} — verifying`);
} else {
const branchExists =
Bun.spawnSync(["git", "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: mainRoot }).exitCode === 0;
console.log(`agent-worktree: adding ${target} on ${branch}${branchExists ? "" : ` from origin/beta (${base.slice(0, 8)})`}`);
git(mainRoot, branchExists ? ["worktree", "add", target, branch] : ["worktree", "add", "-b", branch, target, "origin/beta"], 180_000);
}

if (canLinkDeps(mainRoot, target)) {
const linked = linkDeps(mainRoot, target);
console.log(`agent-worktree: linked ${linked.length} node_modules from ${mainRoot}`);
} else {
console.log("agent-worktree: bun.lock differs from the main checkout (or it has no install) — installing");
const install = Bun.spawnSync(["bun", "install", "--frozen-lockfile"], {
cwd: target,
stdout: "inherit",
stderr: "inherit",
timeout: 600_000,
});
if (install.exitCode !== 0) {
console.error("agent-worktree: bun install failed — fix, then `cd` there and re-run bun run agent:bootstrap");
process.exit(install.exitCode ?? 1);
}
const readiness = ensureWorktreeReady(target);
if (readiness === null) {
console.error("agent-worktree: could not resolve the main checkout for this worktree");
process.exit(1);
}

const ignored = git(mainRoot, ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "apps"])
.split("\n")
.filter(Boolean);
const assets = linkAssets(mainRoot, target, assetLinks(mainRoot, ignored));
if (assets.length > 0) console.log(`agent-worktree: linked ${assets.join(", ")}`);

const dirty = git(target, ["status", "--porcelain"]);
if (dirty !== "") {
console.error(`agent-worktree: new worktree is not clean — a link is not gitignored:\n${dirty}`);
process.exit(1);
}

console.log(`
agent-worktree: ready
path: ${target}
branch: ${branch}

Tests and type-checks run from source with no build. Work with cwd = that path;
do not create another worktree from inside it.
`);
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
const did: string[] = [];
if (readiness.installed) did.push("installed dependencies (bun install --frozen-lockfile)");
if (readiness.deps.length > 0) did.push(`linked ${readiness.deps.length} node_modules from the main checkout`);
if (readiness.assets.length > 0) did.push(`linked ${readiness.assets.join(", ")}`);
console.log(
`agent-worktree: ready in ${seconds}s — ${did.length > 0 ? did.join("; ") : "already set up (deps, Games, assets present; nothing changed)"}`,
);
console.log(`agent-worktree: work in ${target} on ${branch}; tests run from source, no build needed`);
}
74 changes: 74 additions & 0 deletions scripts/ensure-ready.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";

const script = join(import.meta.dir, "ensure-ready.ts");
const scratch = join(import.meta.dir, "..", ".scratch");

function write(path: string, body = ""): void {
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, body);
}

describe("ensure-ready in a worktree nobody linked", () => {
test("links dependencies and Games the way agent:worktree would", () => {
const base = join(scratch, "ensure-ready-fixture");
rmSync(base, { recursive: true, force: true });
const main = join(base, "main");
const worktree = join(base, "wt");
try {
for (const root of [main, worktree]) {
write(join(root, "bun.lock"), "lock-v1");
write(join(root, "package.json"), JSON.stringify({ workspaces: ["packages/*"] }));
}
write(join(main, "node_modules", ".bin", "tsgo")); write(join(main, "node_modules", ".bin", "tsgo.exe"));
mkdirSync(join(main, ".git", "worktrees", "wt"), { recursive: true });
mkdirSync(join(main, "Games", "beacon", "src"), { recursive: true });
write(join(worktree, ".git"), `gitdir: ${join(main, ".git", "worktrees", "wt")}\n`);

const run = Bun.spawnSync([process.execPath, script, "--install-only"], {
cwd: worktree,
stdout: "pipe",
stderr: "pipe",
timeout: 120_000,
});
expect(run.stderr.toString()).toBe("");
expect(run.exitCode).toBe(0);
expect(run.stdout.toString()).toContain("Games");
expect(existsSync(join(worktree, "node_modules", ".bin", "tsgo"))).toBe(true);
expect(realpathSync(join(worktree, "Games"))).toBe(realpathSync(join(main, "Games")));

const again = Bun.spawnSync([process.execPath, script, "--install-only"], {
cwd: worktree,
stdout: "pipe",
stderr: "pipe",
timeout: 120_000,
});
expect(again.exitCode).toBe(0);
expect(again.stdout.toString()).toBe("");
} finally {
rmSync(base, { recursive: true, force: true });
}
});

test("stays quiet in the main checkout, which needs no links", () => {
const base = join(scratch, "ensure-ready-main-fixture");
rmSync(base, { recursive: true, force: true });
try {
write(join(base, "bun.lock"), "lock-v1");
write(join(base, "package.json"), JSON.stringify({ workspaces: ["packages/*"] }));
mkdirSync(join(base, "node_modules", ".bin"), { recursive: true });
write(join(base, "node_modules", ".bin", "tsgo")); write(join(base, "node_modules", ".bin", "tsgo.exe"));
const run = Bun.spawnSync([process.execPath, script, "--install-only"], {
cwd: base,
stdout: "pipe",
stderr: "pipe",
timeout: 120_000,
});
expect(run.exitCode).toBe(0);
expect(run.stdout.toString()).toBe("");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});
16 changes: 16 additions & 0 deletions scripts/ensure-ready.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { join } from "node:path";
import { ensureWorktreeReady } from "./agent-worktree";
import { scanStalePackages } from "./distStaleness";

function run(label: string, cmd: string[], timeoutMs: number): void {
Expand All @@ -19,6 +20,21 @@ function run(label: string, cmd: string[], timeoutMs: number): void {
}
}

// A tree created by `claude --worktree` or a bare `git worktree add` has no
// node_modules and no Games link; agent:worktree is the only thing that made
// them, so every test/build/smoke run in such a tree failed on missing deps or
// an empty game list. Link from the main checkout (instant) before installing.
const readiness = ensureWorktreeReady(process.cwd(), {
install: () => run("dependencies cannot be linked (bun.lock differs from the main checkout) — running bun install", ["bun", "install"], 300_000),
});
if (readiness !== null && !readiness.unchanged) {
const did: string[] = [];
if (readiness.installed) did.push("installed dependencies");
if (readiness.deps.length > 0) did.push(`linked ${readiness.deps.length} node_modules`);
if (readiness.assets.length > 0) did.push(`linked ${readiness.assets.join(", ")}`);
console.log(`ensure-ready: ${did.join("; ")}`);
}

const tsgo = join(process.cwd(), "node_modules", ".bin", process.platform === "win32" ? "tsgo.exe" : "tsgo");
if (!existsSync(tsgo)) {
run("node_modules incomplete (tsgo missing) — running bun install", ["bun", "install"], 300_000);
Expand Down
Loading
Loading