diff --git a/packages/gittensory-miner/lib/attempt-worktree.d.ts b/packages/gittensory-miner/lib/attempt-worktree.d.ts new file mode 100644 index 0000000000..b60d181602 --- /dev/null +++ b/packages/gittensory-miner/lib/attempt-worktree.d.ts @@ -0,0 +1,31 @@ +import type { WorktreeExecFn } from "@jsonbored/gittensory-engine"; +import type { RunGitFn } from "./repo-clone.js"; + +export function createRealWorktreeExec(timeoutMs?: number): WorktreeExecFn; + +export type PrepareAttemptWorktreeOptions = { + baseBranch?: string; + cloneBaseDir?: string; + env?: Record; + exec?: WorktreeExecFn; + timeoutMs?: number; + remoteUrl?: string; + runGit?: RunGitFn; +}; + +export type PrepareAttemptWorktreeResult = + | { ok: true; worktreePath: string; branchName: string; repoPath: string } + | { ok: false; repoPath?: string; error: string }; + +export function prepareAttemptWorktree( + repoFullName: string, + attemptId: string, + options?: PrepareAttemptWorktreeOptions, +): Promise; + +export function cleanupAttemptWorktree( + repoPath: string, + worktreePath: string, + attemptOk: boolean, + options?: { exec?: WorktreeExecFn; timeoutMs?: number }, +): Promise<{ ok: boolean; removed: boolean; error?: string }>; diff --git a/packages/gittensory-miner/lib/attempt-worktree.js b/packages/gittensory-miner/lib/attempt-worktree.js new file mode 100644 index 0000000000..a0c136ccce --- /dev/null +++ b/packages/gittensory-miner/lib/attempt-worktree.js @@ -0,0 +1,94 @@ +import { spawn } from "node:child_process"; +import { addWorktree, removeWorktree, shouldRetainWorktree } from "@jsonbored/gittensory-engine"; +import { ensureRepoCloned } from "./repo-clone.js"; + +// Real attempt-worktree preparation (#5132, Wave 3.5 follow-up). Composes ensureRepoCloned (repo-clone.js, +// the missing base-clone-management step) with @jsonbored/gittensory-engine's already-built, already-tested +// addWorktree/removeWorktree primitives -- which existed but were never called from this package, so +// `workingDirectory` handed to runIterateLoop was always just an empty directory with no real git repo in +// it. This is the caller that finally exercises them for real. + +const DEFAULT_TIMEOUT_MS = 120_000; + +/** + * Real child_process-backed implementation of the engine's WorktreeExecFn contract. Resolves (never + * rejects) on error/timeout, mirroring coding-agent-construction.js's createRealCliSubprocessSpawn -- a + * failed `git worktree add`'s stderr is the diagnosable signal, not something to lose to an unhandled + * rejection. + * + * @returns {import("@jsonbored/gittensory-engine").WorktreeExecFn} + */ +export function createRealWorktreeExec(timeoutMs = DEFAULT_TIMEOUT_MS) { + return (cmd, args, opts) => + new Promise((resolve) => { + const child = spawn(cmd, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve({ code: null, stdout, stderr: `${stderr}\ntimed_out_after_${timeoutMs}ms`.trim() }); + }, timeoutMs); + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (err) => { + clearTimeout(timer); + resolve({ code: null, stdout, stderr: err.message }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }); + }); +} + +/** + * Prepare a real, isolated git worktree for one attempt: ensure the target repo's base clone exists and is + * current, then create a fresh `git worktree` off it on a deterministically-named branch. Fails closed + * (`ok: false`) on any step's failure rather than handing back a half-prepared directory. + * + * @param {string} repoFullName + * @param {string} attemptId + * @param {{ + * baseBranch?: string, cloneBaseDir?: string, env?: Record, + * exec?: import("@jsonbored/gittensory-engine").WorktreeExecFn, timeoutMs?: number, + * remoteUrl?: string, runGit?: import("./repo-clone.js").RunGitFn, + * }} [options] + * @returns {Promise<{ ok: boolean, worktreePath?: string, branchName?: string, repoPath?: string, error?: string }>} + */ +export async function prepareAttemptWorktree(repoFullName, attemptId, options = {}) { + const cloneResult = await ensureRepoCloned(repoFullName, { + baseBranch: options.baseBranch, + cloneBaseDir: options.cloneBaseDir, + env: options.env, + timeoutMs: options.timeoutMs, + remoteUrl: options.remoteUrl, + runGit: options.runGit, + }); + if (!cloneResult.ok) return { ok: false, error: cloneResult.error ?? "ensure_repo_cloned_failed" }; + + const exec = options.exec ?? createRealWorktreeExec(options.timeoutMs); + const baseBranch = typeof options.baseBranch === "string" && options.baseBranch.trim() ? options.baseBranch.trim() : "main"; + const added = await addWorktree({ exec, repoPath: cloneResult.repoPath, baseBranch, attemptId }); + if (!added.ok) return { ok: false, repoPath: cloneResult.repoPath, error: added.error ?? "git_worktree_add_failed" }; + + return { ok: true, worktreePath: added.plan.worktreePath, branchName: added.plan.branchName, repoPath: cloneResult.repoPath }; +} + +/** + * Tear down an attempt's worktree once the attempt concludes, per the engine's own retention policy: a + * failed attempt's worktree is RETAINED for post-mortem inspection, a succeeded one is removed. + * + * @param {string} repoPath + * @param {string} worktreePath + * @param {boolean} attemptOk + * @param {{ exec?: import("@jsonbored/gittensory-engine").WorktreeExecFn, timeoutMs?: number }} [options] + * @returns {Promise<{ ok: boolean, removed: boolean, error?: string }>} + */ +export async function cleanupAttemptWorktree(repoPath, worktreePath, attemptOk, options = {}) { + const exec = options.exec ?? createRealWorktreeExec(options.timeoutMs); + return removeWorktree({ exec, repoPath, worktreePath, retain: shouldRetainWorktree(attemptOk) }); +} diff --git a/packages/gittensory-miner/lib/repo-clone.d.ts b/packages/gittensory-miner/lib/repo-clone.d.ts new file mode 100644 index 0000000000..744f985372 --- /dev/null +++ b/packages/gittensory-miner/lib/repo-clone.d.ts @@ -0,0 +1,19 @@ +export function resolveRepoCloneBaseDir(env?: Record): string; + +export function resolveRepoCloneDir(repoFullName: string, env?: Record): string; + +export type EnsureRepoClonedResult = { ok: boolean; repoPath: string; error?: string }; + +export type RunGitFn = (args: string[], cwd: string, timeoutMs: number) => Promise<{ ok: boolean; stdout: string; stderr: string }>; + +export function ensureRepoCloned( + repoFullName: string, + options?: { + baseBranch?: string; + cloneBaseDir?: string; + env?: Record; + timeoutMs?: number; + remoteUrl?: string; + runGit?: RunGitFn; + }, +): Promise; diff --git a/packages/gittensory-miner/lib/repo-clone.js b/packages/gittensory-miner/lib/repo-clone.js new file mode 100644 index 0000000000..150395e586 --- /dev/null +++ b/packages/gittensory-miner/lib/repo-clone.js @@ -0,0 +1,102 @@ +import { execFile } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +// Per-repo base-clone cache (#5132, Wave 3.5 follow-up). packages/gittensory-engine/src/miner/ +// worktree-allocator.ts's real `addWorktree` primitive (git worktree add -b ) +// requires an EXISTING git clone to branch off -- it has never been wired into this package because that +// clone-management step didn't exist yet. This module is that step: clone a target repo once, then keep it +// current (fetch + hard-reset to the base branch) on every subsequent attempt, so `addWorktree` always +// branches off real, fresh content. Relies entirely on whatever git/gh credentials are already configured +// on this machine -- same assumption execute-local-write.js's `gh pr create` already makes; this module +// never embeds a token in a clone URL. + +const execFileAsync = promisify(execFile); +const DEFAULT_CLONE_DIR_NAME = "repos"; +const DEFAULT_BASE_BRANCH = "main"; + +export function resolveRepoCloneBaseDir(env = process.env) { + const explicitPath = typeof env.GITTENSORY_MINER_REPO_CLONE_DIR === "string" ? env.GITTENSORY_MINER_REPO_CLONE_DIR.trim() : ""; + if (explicitPath) return explicitPath; + + const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string" ? env.GITTENSORY_MINER_CONFIG_DIR.trim() : ""; + if (explicitConfigDir) return join(explicitConfigDir, DEFAULT_CLONE_DIR_NAME); + + const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim() ? env.XDG_CONFIG_HOME.trim() : join(homedir(), ".config"); + return join(configHome, "gittensory-miner", DEFAULT_CLONE_DIR_NAME); +} + +// GitHub owner/repo names are restricted to alphanumerics, hyphens, underscores, and periods, and are never +// exactly "." or ".." -- both are rejected here so a value like "../foo" can't make resolveRepoCloneDir's +// join(cloneBaseDir, owner, repo) escape the intended clone directory (a real path-traversal finding). +const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/; + +function isPathTraversalSegment(segment) { + return segment === "." || segment === ".."; +} + +function normalizeRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.trim().split("/"); + if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); + if (!REPO_SEGMENT_PATTERN.test(owner) || !REPO_SEGMENT_PATTERN.test(repo)) throw new Error("invalid_repo_full_name"); + if (isPathTraversalSegment(owner) || isPathTraversalSegment(repo)) throw new Error("invalid_repo_full_name"); + return { owner, repo, repoFullName: `${owner}/${repo}` }; +} + +export function resolveRepoCloneDir(repoFullName, env = process.env) { + const target = normalizeRepoFullName(repoFullName); + return join(resolveRepoCloneBaseDir(env), target.owner, target.repo); +} + +async function defaultRunGit(args, cwd, timeoutMs) { + try { + const { stdout, stderr } = await execFileAsync("git", args, { cwd, timeout: timeoutMs }); + return { ok: true, stdout, stderr }; + } catch (error) { + const stderr = typeof error?.stderr === "string" ? error.stderr : ""; + return { ok: false, stdout: "", stderr: stderr || (error instanceof Error ? error.message : String(error)) }; + } +} + +/** + * Ensure a real, current local clone of `repoFullName` exists at the deterministic per-repo cache path. + * First use: `git clone`. Subsequent use: `git fetch origin` + hard-reset the base branch to + * `origin/`, so every attempt branches off fresh content, not a stale prior checkout. + * + * @param {string} repoFullName + * @param {{ + * baseBranch?: string, cloneBaseDir?: string, env?: Record, timeoutMs?: number, + * remoteUrl?: string, runGit?: (args: string[], cwd: string, timeoutMs: number) => Promise<{ ok: boolean, stdout: string, stderr: string }>, + * }} [options] + * @returns {Promise<{ ok: boolean, repoPath: string, error?: string }>} + */ +export async function ensureRepoCloned(repoFullName, options = {}) { + const target = normalizeRepoFullName(repoFullName); + const baseBranch = typeof options.baseBranch === "string" && options.baseBranch.trim() ? options.baseBranch.trim() : DEFAULT_BASE_BRANCH; + const cloneBaseDir = typeof options.cloneBaseDir === "string" && options.cloneBaseDir.trim() ? options.cloneBaseDir.trim() : resolveRepoCloneBaseDir(options.env); + const repoPath = join(cloneBaseDir, target.owner, target.repo); + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 120_000; + const runGit = options.runGit ?? defaultRunGit; + + if (!existsSync(repoPath)) { + mkdirSync(join(cloneBaseDir, target.owner), { recursive: true, mode: 0o700 }); + const cloneUrl = typeof options.remoteUrl === "string" && options.remoteUrl.trim() ? options.remoteUrl.trim() : `https://github.com/${target.owner}/${target.repo}.git`; + const cloned = await runGit(["clone", cloneUrl, repoPath], cloneBaseDir, timeoutMs); + if (!cloned.ok) return { ok: false, repoPath, error: cloned.stderr || "git_clone_failed" }; + return { ok: true, repoPath }; + } + + const fetched = await runGit(["fetch", "origin"], repoPath, timeoutMs); + if (!fetched.ok) return { ok: false, repoPath, error: fetched.stderr || "git_fetch_failed" }; + + const checkedOut = await runGit(["checkout", baseBranch], repoPath, timeoutMs); + if (!checkedOut.ok) return { ok: false, repoPath, error: checkedOut.stderr || "git_checkout_failed" }; + + const reset = await runGit(["reset", "--hard", `origin/${baseBranch}`], repoPath, timeoutMs); + if (!reset.ok) return { ok: false, repoPath, error: reset.stderr || "git_reset_failed" }; + + return { ok: true, repoPath }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 79a9db3434..db576aa86f 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-cli.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-cli.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/miner-attempt-worktree.test.ts b/test/unit/miner-attempt-worktree.test.ts new file mode 100644 index 0000000000..253b1a6684 --- /dev/null +++ b/test/unit/miner-attempt-worktree.test.ts @@ -0,0 +1,125 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { cleanupAttemptWorktree, createRealWorktreeExec, prepareAttemptWorktree } from "../../packages/gittensory-miner/lib/attempt-worktree.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(prefix: string) { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +const GIT_ENV = { GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com" }; + +function initOriginRepo(root: string) { + const originPath = join(root, "origin"); + execFileSync("git", ["init", "--initial-branch=main", originPath], { stdio: "ignore" }); + writeFileSync(join(originPath, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: originPath, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "initial commit"], { cwd: originPath, env: { ...process.env, ...GIT_ENV }, stdio: "ignore" }); + return originPath; +} + +describe("createRealWorktreeExec (#5132)", () => { + it("captures stdout and a zero exit code from a real short-lived command", async () => { + const exec = createRealWorktreeExec(); + const result = await exec(process.execPath, ["-e", "process.stdout.write('hello')"], { cwd: process.cwd() }); + expect(result).toEqual({ code: 0, stdout: "hello", stderr: "" }); + }); + + it("resolves (never rejects) with code:null and the error message when the command doesn't exist", async () => { + const exec = createRealWorktreeExec(); + const result = await exec("this-command-definitely-does-not-exist-xyz", [], { cwd: process.cwd() }); + expect(result.code).toBeNull(); + expect(result.stderr).toContain("this-command-definitely-does-not-exist-xyz"); + }); + + it("kills a long-lived process and resolves with a timeout marker when it elapses", async () => { + const exec = createRealWorktreeExec(100); + const result = await exec(process.execPath, ["-e", "setInterval(() => {}, 50)"], { cwd: process.cwd() }); + expect(result.code).toBeNull(); + expect(result.stderr).toContain("timed_out_after_100ms"); + }); +}); + +describe("prepareAttemptWorktree / cleanupAttemptWorktree (#5132)", () => { + it("REGRESSION: worktreePath is a real, checked-out git repo on a real branch, not an empty directory", async () => { + const root = tempRoot("gittensory-miner-attempt-worktree-"); + const originPath = initOriginRepo(root); + const cloneBaseDir = join(root, "cache"); + + const result = await prepareAttemptWorktree("acme/widgets", "attempt-1", { cloneBaseDir, remoteUrl: originPath }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.branchName).toBe("gittensory/attempt/attempt-1"); + expect(existsSync(result.worktreePath)).toBe(true); + // The critical assertion: real repo content is actually present, not an empty directory. + expect(readFileSync(join(result.worktreePath, "README.md"), "utf8")).toBe("hello\n"); + // And it's a real, distinct branch -- not just a copy of main. + const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: result.worktreePath, encoding: "utf8" }).trim(); + expect(branch).toBe("gittensory/attempt/attempt-1"); + }); + + it("removes a succeeded attempt's worktree but retains a failed one's, per the engine's own retention policy", async () => { + const root = tempRoot("gittensory-miner-attempt-worktree-cleanup-"); + const originPath = initOriginRepo(root); + const cloneBaseDir = join(root, "cache"); + + const succeeded = await prepareAttemptWorktree("acme/widgets", "attempt-ok", { cloneBaseDir, remoteUrl: originPath }); + if (!succeeded.ok) throw new Error("expected ok"); + const removedResult = await cleanupAttemptWorktree(succeeded.repoPath, succeeded.worktreePath, true); + expect(removedResult).toEqual({ ok: true, removed: true }); + expect(existsSync(succeeded.worktreePath)).toBe(false); + + const failed = await prepareAttemptWorktree("acme/widgets", "attempt-fail", { cloneBaseDir, remoteUrl: originPath }); + if (!failed.ok) throw new Error("expected ok"); + const retainedResult = await cleanupAttemptWorktree(failed.repoPath, failed.worktreePath, false); + expect(retainedResult).toEqual({ ok: true, removed: false }); + expect(existsSync(failed.worktreePath)).toBe(true); + }); + + it("returns ok:false when the base clone cannot be prepared, without attempting git worktree add", async () => { + const root = tempRoot("gittensory-miner-attempt-worktree-clonefail-"); + const cloneBaseDir = join(root, "cache"); + const execSpy = vi.fn(); + + const result = await prepareAttemptWorktree("acme/does-not-exist", "attempt-1", { + cloneBaseDir, + remoteUrl: join(root, "nonexistent-origin"), + exec: execSpy, + timeoutMs: 5000, + }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected failure"); + expect(result.error).toBeTruthy(); + expect(execSpy).not.toHaveBeenCalled(); + }); + + it("returns ok:false with git's real stderr when git worktree add fails (e.g. an unknown base branch)", async () => { + const root = tempRoot("gittensory-miner-attempt-worktree-addfail-"); + const originPath = initOriginRepo(root); + const cloneBaseDir = join(root, "cache"); + + const result = await prepareAttemptWorktree("acme/widgets", "attempt-1", { cloneBaseDir, remoteUrl: originPath, baseBranch: "does-not-exist" }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected failure"); + expect(result.repoPath).toBe(join(cloneBaseDir, "acme", "widgets")); + expect(result.error).toBeTruthy(); + }); +}); diff --git a/test/unit/miner-repo-clone.test.ts b/test/unit/miner-repo-clone.test.ts new file mode 100644 index 0000000000..c6a9e0eb26 --- /dev/null +++ b/test/unit/miner-repo-clone.test.ts @@ -0,0 +1,150 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ensureRepoCloned, resolveRepoCloneBaseDir, resolveRepoCloneDir } from "../../packages/gittensory-miner/lib/repo-clone.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(prefix: string) { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +const GIT_ENV = { GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com" }; + +/** A real local git repo (main branch, one commit) to act as a clone "origin" without touching the network. */ +function initOriginRepo(root: string) { + const originPath = join(root, "origin"); + execFileSync("git", ["init", "--initial-branch=main", originPath], { stdio: "ignore" }); + writeFileSync(join(originPath, "README.md"), "hello\n"); + execFileSync("git", ["add", "README.md"], { cwd: originPath, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "initial commit"], { cwd: originPath, env: { ...process.env, ...GIT_ENV }, stdio: "ignore" }); + return originPath; +} + +function commitFile(originPath: string, fileName: string, content: string) { + writeFileSync(join(originPath, fileName), content); + execFileSync("git", ["add", fileName], { cwd: originPath, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", `add ${fileName}`], { cwd: originPath, env: { ...process.env, ...GIT_ENV }, stdio: "ignore" }); +} + +describe("resolveRepoCloneBaseDir / resolveRepoCloneDir (#5132)", () => { + it("resolves from explicit env, config dir, and XDG default, in precedence order", () => { + expect(resolveRepoCloneBaseDir({ GITTENSORY_MINER_REPO_CLONE_DIR: "/custom/repos" })).toBe("/custom/repos"); + expect(resolveRepoCloneBaseDir({ GITTENSORY_MINER_CONFIG_DIR: "/cfg" })).toBe("/cfg/repos"); + expect(resolveRepoCloneDir("acme/widgets", { GITTENSORY_MINER_CONFIG_DIR: "/cfg" })).toBe("/cfg/repos/acme/widgets"); + }); + + it("rejects a malformed repoFullName", () => { + expect(() => resolveRepoCloneDir("not-a-repo")).toThrow("invalid_repo_full_name"); + }); + + it("REGRESSION: rejects '.'/'..' path-traversal segments in owner or repo, in either position", () => { + expect(() => resolveRepoCloneDir("../foo")).toThrow("invalid_repo_full_name"); + expect(() => resolveRepoCloneDir("foo/..")).toThrow("invalid_repo_full_name"); + expect(() => resolveRepoCloneDir("./foo")).toThrow("invalid_repo_full_name"); + expect(() => resolveRepoCloneDir("foo/.")).toThrow("invalid_repo_full_name"); + expect(() => resolveRepoCloneDir("../..")).toThrow("invalid_repo_full_name"); + }); + + it("rejects an owner or repo segment with characters outside GitHub's allowed set", () => { + expect(() => resolveRepoCloneDir("acme/wid gets")).toThrow("invalid_repo_full_name"); + expect(() => resolveRepoCloneDir("ac me/widgets")).toThrow("invalid_repo_full_name"); + }); +}); + +describe("ensureRepoCloned (#5132)", () => { + it("clones a real repo on first use, and fetches + hard-resets an existing clone to pick up new commits", async () => { + const root = tempRoot("gittensory-miner-repo-clone-"); + const originPath = initOriginRepo(root); + const cloneBaseDir = join(root, "cache"); + + const first = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: originPath }); + expect(first.ok).toBe(true); + expect(first.repoPath).toBe(join(cloneBaseDir, "acme", "widgets")); + expect(readFileSync(join(first.repoPath, "README.md"), "utf8")).toBe("hello\n"); + + // A local edit that was never committed -- the second call's hard-reset must discard it, not preserve it. + writeFileSync(join(first.repoPath, "README.md"), "locally modified, should be discarded\n"); + + commitFile(originPath, "second.txt", "second file\n"); + + const second = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: originPath }); + expect(second.ok).toBe(true); + expect(readFileSync(join(second.repoPath, "README.md"), "utf8")).toBe("hello\n"); + expect(readFileSync(join(second.repoPath, "second.txt"), "utf8")).toBe("second file\n"); + }); + + it("respects a non-default baseBranch on the fetch+reset path", async () => { + const root = tempRoot("gittensory-miner-repo-clone-branch-"); + const originPath = initOriginRepo(root); + execFileSync("git", ["checkout", "-b", "develop"], { cwd: originPath, stdio: "ignore" }); + const cloneBaseDir = join(root, "cache"); + + const first = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: originPath, baseBranch: "develop" }); + expect(first.ok).toBe(true); + + commitFile(originPath, "develop-only.txt", "develop content\n"); + const second = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: originPath, baseBranch: "develop" }); + expect(second.ok).toBe(true); + expect(readFileSync(join(second.repoPath, "develop-only.txt"), "utf8")).toBe("develop content\n"); + }); + + it("rejects a malformed repoFullName", async () => { + await expect(ensureRepoCloned("not-a-repo")).rejects.toThrow("invalid_repo_full_name"); + }); + + it("returns ok:false with the real git stderr when the clone URL doesn't resolve", async () => { + const root = tempRoot("gittensory-miner-repo-clone-fail-"); + const cloneBaseDir = join(root, "cache"); + const result = await ensureRepoCloned("acme/does-not-exist", { cloneBaseDir, remoteUrl: join(root, "nonexistent-origin"), timeoutMs: 5000 }); + expect(result.ok).toBe(false); + expect(result.error).toBeTruthy(); + }); + + it("returns ok:false on a fetch failure without touching the existing clone (injected runGit)", async () => { + const root = tempRoot("gittensory-miner-repo-clone-fetchfail-"); + const originPath = initOriginRepo(root); + const cloneBaseDir = join(root, "cache"); + const first = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: originPath }); + expect(first.ok).toBe(true); + + const runGit = async (args: string[]) => (args[0] === "fetch" ? { ok: false, stdout: "", stderr: "network unreachable" } : { ok: true, stdout: "", stderr: "" }); + const second = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: originPath, runGit }); + expect(second.ok).toBe(false); + expect(second.error).toBe("network unreachable"); + }); + + it("returns ok:false on a checkout failure and a reset failure (injected runGit)", async () => { + const root = tempRoot("gittensory-miner-repo-clone-checkoutfail-"); + const originPath = initOriginRepo(root); + const cloneBaseDir = join(root, "cache"); + await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: originPath }); + + const checkoutFails = async (args: string[]) => (args[0] === "checkout" ? { ok: false, stdout: "", stderr: "" } : { ok: true, stdout: "", stderr: "" }); + const checkoutResult = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: originPath, runGit: checkoutFails }); + expect(checkoutResult.ok).toBe(false); + expect(checkoutResult.error).toBe("git_checkout_failed"); + + const resetFails = async (args: string[]) => (args[0] === "reset" ? { ok: false, stdout: "", stderr: "" } : { ok: true, stdout: "", stderr: "" }); + const resetResult = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: originPath, runGit: resetFails }); + expect(resetResult.ok).toBe(false); + expect(resetResult.error).toBe("git_reset_failed"); + }); + + it("returns ok:false with a fallback error message on a clone failure with no stderr (injected runGit)", async () => { + const root = tempRoot("gittensory-miner-repo-clone-nostderr-"); + const cloneBaseDir = join(root, "cache"); + const runGit = async () => ({ ok: false, stdout: "", stderr: "" }); + const result = await ensureRepoCloned("acme/widgets", { cloneBaseDir, remoteUrl: "unused", runGit }); + expect(result.ok).toBe(false); + expect(result.error).toBe("git_clone_failed"); + }); +});