From 7d199195d8f2a6007b1cdcbc503ff3e8be014daa Mon Sep 17 00:00:00 2001 From: psmyrdek Date: Thu, 13 Aug 2026 20:50:10 +0200 Subject: [PATCH] feat: auto-register the surrounding product repo as first base repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running 'bench-kit init' from inside a product repo is the common flow, so init now detects the git repo containing the invocation cwd (git rev-parse --show-toplevel + origin remote + HEAD) and replaces the template's demo-app placeholder in bench.config.yaml with that repo, editing the YAML document in place so company-zone comments survive. The detection also lands in instance.json (incl. HEAD as a candidate pin for the first task). No detection, no origin, or detecting the instance itself → the placeholder stays. Adds the 'yaml' dependency. Co-Authored-By: Claude Fable 5 --- bun.lock | 3 + package.json | 3 +- src/commands/bench-kit.ts | 97 +++++++++++++++++++++++++++++++-- tests/bench-kit-command.test.ts | 72 +++++++++++++++++++++++- 4 files changed, 168 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 606fbfb..5a82fa6 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@clack/prompts": "0.9.1", "cac": "7.0.0", "proper-lockfile": "4.1.2", + "yaml": "^2.9.0", }, "devDependencies": { "@types/bun": "1.3.12", @@ -159,6 +160,8 @@ "uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], diff --git a/package.json b/package.json index ff4dd3f..3fc9117 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "dependencies": { "@clack/prompts": "0.9.1", "cac": "7.0.0", - "proper-lockfile": "4.1.2" + "proper-lockfile": "4.1.2", + "yaml": "^2.9.0" }, "devDependencies": { "@types/bun": "1.3.12", diff --git a/src/commands/bench-kit.ts b/src/commands/bench-kit.ts index 377bac3..b06713e 100644 --- a/src/commands/bench-kit.ts +++ b/src/commands/bench-kit.ts @@ -22,8 +22,9 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; import type { CAC } from "cac"; +import { parseDocument } from "yaml"; import { experimentalEnabled, requireExperimental } from "../lib/experimental"; import { ExitCodes, @@ -38,12 +39,28 @@ import { export const TEMPLATE_REPO_URL = "https://github.com/przeprogramowani/10x-bench-kit"; +/** The template's placeholder base-repo entry that init may replace. */ +export const PLACEHOLDER_BASE_REPO = "demo-app"; + +/** A git repo detected around the directory init was invoked from. */ +export interface DetectedBaseRepo { + /** Repo root directory (used to avoid registering the instance itself). */ + rootDir: string; + /** Base-repo name for bench.config.yaml (basename of the repo root). */ + name: string; + /** The `origin` remote URL. */ + url: string; + /** Current HEAD — a candidate pin for the first task. */ + headCommit: string; +} + /** Instance manifest written next to the template's VERSION file. */ export interface InstanceManifest { templateVersion: string; templateRef: string; templateSource: string; initializedAt: string; + detectedBaseRepo?: DetectedBaseRepo; } interface BenchKitFlags extends GlobalFlags { @@ -63,6 +80,8 @@ export interface BenchKitDeps { cloneTemplate(ref: string | null, destDir: string): Promise<{ ok: boolean; error: string }>; /** Runs git with `args` inside `cwd` (fresh `git init` + first commit). */ runGit(args: string[], cwd: string): Promise<{ ok: boolean; error: string }>; + /** Detects the git repo containing `cwd` (null when absent or origin-less). */ + detectBaseRepo(cwd: string): Promise; now(): Date; } @@ -158,11 +177,25 @@ export async function runBenchKitInit( mkdirSync(targetDir, { recursive: true }); const copied = materialize(scratch, targetDir, { skipExisting: repair }); + // Running init from inside a product repo is the common flow — register + // that repo as the first base repo instead of leaving the placeholder. + let baseRepo: DetectedBaseRepo | null = null; + if (!repair) { + const detected = await deps.detectBaseRepo(process.cwd()); + if (detected !== null && resolve(detected.rootDir) !== targetDir) { + if (registerBaseRepo(join(targetDir, "bench.config.yaml"), detected)) { + baseRepo = detected; + verbose(ctx, `registered base repo ${detected.name} (${detected.url})`); + } + } + } + const manifest: InstanceManifest = { templateVersion, templateRef: requestedRef ?? "latest", templateSource: TEMPLATE_REPO_URL, initializedAt: deps.now().toISOString(), + ...(baseRepo === null ? {} : { detectedBaseRepo: baseRepo }), }; writeFileSync( join(targetDir, ".bench-kit", "instance.json"), @@ -181,6 +214,9 @@ export async function runBenchKitInit( ] : [ `Created a benchmark instance in '${targetDir}' from template ${templateVersion}.`, + baseRepo === null + ? "No product repo detected here — add your base repos to bench.config.yaml." + : `Registered '${baseRepo.name}' (${baseRepo.url}) as the first base repo in bench.config.yaml.`, committed ? "Initialized a fresh git repository with an initial commit." : "Initialized a fresh git repository (initial commit skipped — commit the files yourself).", @@ -192,6 +228,7 @@ export async function runBenchKitInit( templateVersion, templateRef: manifest.templateRef, filesCopied: copied, + baseRepo, gitInitialized: !repair, committed, }); @@ -286,6 +323,31 @@ function materialize( return copied; } +/** + * Replaces the template's placeholder base-repo entry with the detected + * repo, editing bench.config.yaml in place (comments preserved via yaml + * document editing). Returns false when the config has no placeholder to + * replace — company content is never overwritten on a guess. + */ +export function registerBaseRepo(configPath: string, repo: DetectedBaseRepo): boolean { + if (!existsSync(configPath)) return false; + const doc = parseDocument(readFileSync(configPath, "utf8")); + const firstName = doc.getIn(["base_repos", 0, "name"]); + if (firstName !== PLACEHOLDER_BASE_REPO) return false; + doc.setIn(["base_repos", 0, "name"], repo.name); + doc.setIn(["base_repos", 0, "url"], repo.url); + // The entry is real now — drop the template's per-field placeholder + // comments (file-level comments stay). + const entry = doc.getIn(["base_repos", 0], true); + if (entry && typeof entry === "object" && "items" in entry) { + for (const pair of (entry as { items: { key?: { commentBefore?: string | null } }[] }).items) { + if (pair.key) pair.key.commentBefore = null; + } + } + writeFileSync(configPath, doc.toString()); + return true; +} + /** Fresh `git init` + first commit. A failed commit degrades to a warning. */ async function freshGitInit( ctx: OutputContext, @@ -321,18 +383,34 @@ async function freshGitInit( // Default (real) side effects // --------------------------------------------------------------------------- -function run(cmd: string, args: string[], cwd?: string): Promise<{ ok: boolean; error: string }> { +function run( + cmd: string, + args: string[], + cwd?: string, +): Promise<{ ok: boolean; stdout: string; error: string }> { return new Promise((resolvePromise) => { - const child = spawn(cmd, args, { cwd, stdio: ["ignore", "ignore", "pipe"] }); + const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); - child.on("error", (err) => resolvePromise({ ok: false, error: err.message })); - child.on("close", (code) => resolvePromise({ ok: code === 0, error: stderr })); + child.on("error", (err) => resolvePromise({ ok: false, stdout, error: err.message })); + child.on("close", (code) => resolvePromise({ ok: code === 0, stdout, error: stderr })); }); } +/** `git -C …` returning trimmed stdout, or null on failure. */ +async function gitQuery(cwd: string, args: string[]): Promise { + const result = await run("git", ["-C", cwd, ...args]); + if (!result.ok) return null; + const value = result.stdout.trim(); + return value === "" ? null : value; +} + const defaultDeps: BenchKitDeps = { async toolAvailable(cmd) { const result = await run(cmd, ["--version"]); @@ -347,5 +425,14 @@ const defaultDeps: BenchKitDeps = { runGit(args, cwd) { return run("git", args, cwd); }, + async detectBaseRepo(cwd) { + const rootDir = await gitQuery(cwd, ["rev-parse", "--show-toplevel"]); + if (rootDir === null) return null; + const url = await gitQuery(cwd, ["remote", "get-url", "origin"]); + if (url === null) return null; + const headCommit = await gitQuery(cwd, ["rev-parse", "HEAD"]); + if (headCommit === null) return null; + return { rootDir, name: basename(rootDir), url, headCommit }; + }, now: () => new Date(), }; diff --git a/tests/bench-kit-command.test.ts b/tests/bench-kit-command.test.ts index 48103c0..f65d2ca 100644 --- a/tests/bench-kit-command.test.ts +++ b/tests/bench-kit-command.test.ts @@ -93,7 +93,19 @@ function buildTemplateFixture(version = "0.1.0"): string { writeFileSync(join(dir, ".bench-kit", "VERSION"), `${version}\n`); mkdirSync(join(dir, "tasks", "demo"), { recursive: true }); writeFileSync(join(dir, "tasks", "demo", "prompt.md"), "demo prompt\n"); - writeFileSync(join(dir, "bench.config.yaml"), "base_repos: []\n"); + writeFileSync( + join(dir, "bench.config.yaml"), + [ + "# Konfiguracja instancji benchmarku.", + "base_repos:", + " - name: demo-app", + " # (placeholder)", + " url: git@github.com:example-org/demo-app.git", + "judge:", + " model: anthropic/claude-fable-5", + "", + ].join("\n"), + ); return dir; } @@ -114,6 +126,7 @@ function fakeDeps(templateDir: string, overrides: Partial = {}): F gitCalls.push(args); return Promise.resolve({ ok: true, error: "" }); }, + detectBaseRepo: () => Promise.resolve(null), now: () => new Date("2026-08-13T12:00:00.000Z"), ...overrides, }; @@ -154,6 +167,63 @@ describe("10x bench-kit init", () => { expect(envelope.data.committed).toBe(true); }); + it("registers the surrounding product repo as the first base repo", 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 { deps } = fakeDeps(template, { + detectBaseRepo: () => Promise.resolve(detected), + }); + + const result = await captureStreams(() => + runBenchKitInit(JSON_CTX, target, {}, deps), + ); + + expect(result.exitCode).toBeUndefined(); + const config = readFileSync(join(target, "bench.config.yaml"), "utf8"); + expect(config).toContain("name: shop-app"); + expect(config).toContain("url: git@github.com:acme/shop-app.git"); + expect(config).not.toContain("demo-app"); + // Comments survive the in-place edit. + expect(config).toContain("# Konfiguracja instancji benchmarku."); + + const manifest = JSON.parse(readFileSync(join(target, ".bench-kit", "instance.json"), "utf8")); + expect(manifest.detectedBaseRepo.name).toBe("shop-app"); + expect(manifest.detectedBaseRepo.headCommit).toBe("a".repeat(40)); + + const envelope = parseEnvelope(result.stdout); + expect(envelope.data.baseRepo.name).toBe("shop-app"); + }); + + it("keeps the placeholder when init runs inside the instance itself", async () => { + const template = buildTemplateFixture(); + const target = join(tempDir("bench-kit-target-"), "instance"); + const { deps } = fakeDeps(template, { + detectBaseRepo: () => + Promise.resolve({ + rootDir: target, + name: "instance", + url: "git@github.com:acme/instance.git", + headCommit: "b".repeat(40), + }), + }); + + const result = await captureStreams(() => + runBenchKitInit(JSON_CTX, target, {}, deps), + ); + + expect(result.exitCode).toBeUndefined(); + const config = readFileSync(join(target, "bench.config.yaml"), "utf8"); + expect(config).toContain("name: demo-app"); + const envelope = parseEnvelope(result.stdout); + expect(envelope.data.baseRepo).toBeNull(); + }); + it("refuses a non-empty directory that is not an instance", async () => { const template = buildTemplateFixture(); const target = tempDir("bench-kit-target-");