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
60 changes: 60 additions & 0 deletions src/commands/bench-kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export const SHARED_ROOT_FILES = ["AGENTS.md"];
/** The template's placeholder base-repo entry that init may replace. */
export const PLACEHOLDER_BASE_REPO = "demo-app";

/**
* Instance-local base-repo clones live under `.repos/<name>` (gitignored).
* The authoring skills (bench-task, bench-refresh, bench-wiring) read this
* convention from AGENTS.md — init pre-clones the detected repo there so
* the first `/bench-task` never starts with a cold network clone.
*/
export const BASE_REPOS_DIR = ".repos";

/** A git repo detected around the directory init was invoked from. */
export interface DetectedBaseRepo {
/** Repo root directory (used to avoid registering the instance itself). */
Expand Down Expand Up @@ -105,6 +113,8 @@ export interface BenchKitDeps {
runGit(args: string[], cwd: string): Promise<{ ok: boolean; stdout: string; error: string }>;
/** Detects the git repo containing `cwd` (null when absent or origin-less). */
detectBaseRepo(cwd: string): Promise<DetectedBaseRepo | null>;
/** Clones the detected repo into `destDir` (local source, remote origin). */
cloneBaseRepo(repo: DetectedBaseRepo, destDir: string): Promise<{ ok: boolean; error: string }>;
/** True when `git ls-remote` succeeds against `url` (https preference probe). */
remoteReachable(url: string): Promise<boolean>;
/** Installs runner dependencies (`npm ci`) inside `runnerDir`. */
Expand Down Expand Up @@ -248,6 +258,25 @@ export async function runBenchKitInit(
}
}

// Working copy for the authoring skills: clone the base repo into
// .repos/<name> (gitignored) so /bench-task explores locally instead of
// re-cloning into a scratchpad. Failure degrades to a hint — the clone
// is a convenience, not a prerequisite of a valid instance.
let baseRepoClone: "cloned" | "failed" | "skipped" = "skipped";
if (baseRepo !== null) {
ensureIgnored(targetDir, `${BASE_REPOS_DIR}/`);
const cloneDest = join(targetDir, BASE_REPOS_DIR, baseRepo.name);
verbose(ctx, `cloning base repo into ${BASE_REPOS_DIR}/${baseRepo.name}`);
const cloned = await deps.cloneBaseRepo(baseRepo, cloneDest);
if (cloned.ok) {
baseRepoClone = "cloned";
} else {
baseRepoClone = "failed";
rmSync(cloneDest, { recursive: true, force: true });
verbose(ctx, `base repo clone failed (${cloned.error.trim().split("\n").pop() ?? ""})`);
}
}

const runnerDeps = await installRunnerDependencies(ctx, deps, targetDir);

const manifest: InstanceManifest = {
Expand Down Expand Up @@ -285,6 +314,15 @@ export async function runBenchKitInit(
...(demoTasksPinned > 0
? [`Pinned the demo task to ${baseRepo?.headCommit.slice(0, 12)} (current HEAD of the base repo).`]
: []),
...(baseRepoClone === "cloned"
? [
`Cloned '${baseRepo?.name}' into ${BASE_REPOS_DIR}/${baseRepo?.name}/ — local working copy for the authoring skills (gitignored).`,
]
: baseRepoClone === "failed"
? [
`Base repo clone failed — run 'git clone ${baseRepo?.url} ${BASE_REPOS_DIR}/${baseRepo?.name}' yourself.`,
]
: []),
...(runnerDeps === "failed"
? ["Runner dependencies did not install — run 'npm ci --prefix .bench-kit/runner' yourself."]
: runnerDeps === "installed"
Expand All @@ -304,6 +342,7 @@ export async function runBenchKitInit(
skillRoot: skillRootFor(toolId),
filesCopied: copied + skills.added,
baseRepo,
baseRepoClone,
demoTasksPinned,
runnerDeps,
gitInitialized: !repair,
Expand Down Expand Up @@ -769,6 +808,19 @@ export function toHttpsUrl(url: string): string | null {
return null;
}

/**
* Guarantees `.gitignore` covers `entry` before the local clone lands —
* the template ships the rule, but an older template tag must not end up
* committing a whole product repo into the instance's initial commit.
*/
function ensureIgnored(dir: string, entry: string): void {
const file = join(dir, ".gitignore");
const current = existsSync(file) ? readFileSync(file, "utf8") : "";
if (current.split("\n").some((line) => line.trim() === entry)) return;
const prefix = current === "" || current.endsWith("\n") ? current : `${current}\n`;
writeFileSync(file, `${prefix}${entry}\n`);
}

/** All-zeros commit the template ships in the demo task. */
const PLACEHOLDER_COMMIT = /^0{40}$/;

Expand Down Expand Up @@ -915,6 +967,14 @@ const defaultDeps: BenchKitDeps = {
if (headCommit === null) return null;
return { rootDir, name: basename(rootDir), url, headCommit };
},
async cloneBaseRepo(repo, destDir) {
// Clone from the local working copy (instant, offline, full history),
// then point origin at the registered remote so `git fetch` behaves
// like in a network clone.
const clone = await run("git", ["clone", "--quiet", repo.rootDir, destDir]);
if (!clone.ok) return { ok: false, error: clone.error };
return run("git", ["-C", destDir, "remote", "set-url", "origin", repo.url]);
},
async remoteReachable(url) {
// No terminal prompt: an auth-gated remote must fail fast, not hang.
const result = await run("git", ["ls-remote", "--heads", url], undefined, {
Expand Down
71 changes: 71 additions & 0 deletions tests/bench-kit-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ function fakeDeps(templateDir: string, overrides: Partial<BenchKitDeps> = {}): F
return Promise.resolve({ ok: true, stdout: "", error: "" });
},
detectBaseRepo: () => Promise.resolve(null),
cloneBaseRepo: (_repo, destDir) => {
mkdirSync(destDir, { recursive: true });
return Promise.resolve({ ok: true, error: "" });
},
remoteReachable: () => Promise.resolve(false),
installRunnerDeps: () => Promise.resolve({ ok: true, error: "" }),
detectToolSignals: () => [],
Expand Down Expand Up @@ -317,6 +321,73 @@ describe("10x bench-kit init", () => {
expect(envelope.data.demoTasksPinned).toBe(1);
});

it("clones the detected repo into .repos/<name> and gitignores it", async () => {
const template = buildTemplateFixture();
const target = join(tempDir("bench-kit-target-"), "instance");
const detected = {
rootDir: "/somewhere/shop-app",
name: "shop-app",
url: "git@github.com:acme/shop-app.git",
headCommit: "a".repeat(40),
};
const cloneCalls: { rootDir: string; destDir: string }[] = [];
const { deps } = fakeDeps(template, {
detectBaseRepo: () => Promise.resolve(detected),
cloneBaseRepo: (repo, destDir) => {
cloneCalls.push({ rootDir: repo.rootDir, destDir });
mkdirSync(destDir, { recursive: true });
return Promise.resolve({ ok: true, error: "" });
},
});

const result = await captureStreams(() => runBenchKitInit(JSON_CTX, target, {}, deps));

expect(result.exitCode).toBeUndefined();
expect(cloneCalls).toEqual([
{ rootDir: "/somewhere/shop-app", destDir: join(target, ".repos", "shop-app") },
]);
// The clone never enters the instance's git history.
expect(readFileSync(join(target, ".gitignore"), "utf8")).toContain(".repos/");
expect(parseEnvelope(result.stdout).data.baseRepoClone).toBe("cloned");
});

it("degrades to a hint when the base repo clone fails (init still succeeds)", async () => {
const template = buildTemplateFixture();
const target = join(tempDir("bench-kit-target-"), "instance");
const { deps } = fakeDeps(template, {
detectBaseRepo: () =>
Promise.resolve({
rootDir: "/somewhere/shop-app",
name: "shop-app",
url: "git@github.com:acme/shop-app.git",
headCommit: "a".repeat(40),
}),
cloneBaseRepo: () => Promise.resolve({ ok: false, error: "disk full" }),
});

const result = await captureStreams(() => runBenchKitInit(JSON_CTX, target, {}, deps));

expect(result.exitCode).toBeUndefined();
expect(existsSync(join(target, ".repos", "shop-app"))).toBe(false);
expect(parseEnvelope(result.stdout).data.baseRepoClone).toBe("failed");
});

it("skips the base repo clone when no product repo was detected", async () => {
const template = buildTemplateFixture();
const target = join(tempDir("bench-kit-target-"), "instance");
const { deps } = fakeDeps(template, {
cloneBaseRepo: () => {
throw new Error("must not be called");
},
});

const result = await captureStreams(() => runBenchKitInit(JSON_CTX, target, {}, deps));

expect(result.exitCode).toBeUndefined();
expect(existsSync(join(target, ".repos"))).toBe(false);
expect(parseEnvelope(result.stdout).data.baseRepoClone).toBe("skipped");
});

it("prefers https over SSH when the repo answers publicly", async () => {
const template = buildTemplateFixture();
const target = join(tempDir("bench-kit-target-"), "instance");
Expand Down