diff --git a/docs/reference.md b/docs/reference.md index 6889e01..83ae2f6 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -500,7 +500,22 @@ trust a generated checkout or bypass the runtime manager. Preflight creates missing state/review directories and removes temporary write probes. It does not invoke reconciliation, acquire the semantic loop lock, or create queue state. Its `ready`/`blocked` result reports each required directory -and the recovery action. A successful probe is point-in-time evidence, not a +(`checks`), a machine-readable `blocker` code, `primaryCheckout`, and the +recovery action. Blocker codes: `null` (ready), `state-write-denied`, +`loop-config-invalid`, `loop-disabled`, `linked-worktree-without-loop-config`, +and `worktree-unresolved`. + +Generated or linked Git worktrees do not carry the ignored `.ai/` review +configuration. When preflight runs in one (including from a subdirectory) and +the configuration is absent, it returns `linked-worktree-without-loop-config` +with `primaryCheckout` set when it can be resolved (including worktrees of +submodules), probes no directories, and creates no per-worktree state. Run the +scheduled review from the primary checkout instead; do not run +`fclt ai review init` in the worktree. If Git is unavailable, times out, is +older than 2.31, or cannot read existing metadata while the review configuration +is also absent, preflight returns `worktree-unresolved` instead of guessing. +`primaryCheckout` names the matching subdirectory when `.ai` is not at the +repository root. A successful probe is point-in-time evidence, not a reservation or a guarantee against a later permission change. Configure narrowly scoped host write allowances; fclt does not alter the host sandbox policy. diff --git a/src/evolution-preflight.test.ts b/src/evolution-preflight.test.ts index 3d0422d..10ebcdc 100644 --- a/src/evolution-preflight.test.ts +++ b/src/evolution-preflight.test.ts @@ -1,7 +1,15 @@ import { afterEach, expect, it } from "bun:test"; -import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readdir, + realpath, + rm, + symlink, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { runFixtureGit } from "../test/git-fixture"; import { enableEvolutionLoop } from "./evolution-loop"; import { preflightEvolutionLoop } from "./evolution-preflight"; import { @@ -146,3 +154,362 @@ for (const scenario of [ ).toBe(false); }); } + +it("blocks a generated worktree without loop config and names the primary checkout", async () => { + const homeDir = await realpath( + await mkdtemp(join(tmpdir(), "fclt-preflight-wt-")) + ); + roots.push(homeDir); + const primary = join(homeDir, "repo"); + await mkdir(join(primary, ".ai"), { recursive: true }); + const git = (cwd: string, ...argv: string[]) => + runFixtureGit({ + argv: ["-C", cwd, ...argv], + repoDir: primary, + homeDir: join(homeDir, ".git-home"), + }); + await git(primary, "init", "-q"); + await Bun.write(join(primary, ".gitignore"), ".ai/\n"); + await git(primary, "add", ".gitignore"); + await git( + primary, + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-qm", + "init" + ); + await Bun.write( + join(primary, ".ai", "reconciliation.json"), + JSON.stringify({ + version: 1, + sources: [{ id: "writebacks", type: "writebacks" }], + }) + ); + await enableEvolutionLoop({ + homeDir, + rootDir: join(primary, ".ai"), + scope: "project", + }); + const worktree = join(homeDir, "wt"); + await git(primary, "worktree", "add", "-q", "--detach", worktree); + const rootDir = join(worktree, ".ai"); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.status).toBe("blocked"); + expect(result.blocker).toBe("linked-worktree-without-loop-config"); + expect(result.primaryCheckout).toBe(primary); + expect(result.loopInvoked).toBe(false); + expect(result.checks).toEqual([]); + expect(result.recovery).toContain(primary); + const primaryResult = await preflightEvolutionLoop({ + homeDir, + rootDir: join(primary, ".ai"), + scope: "project", + }); + expect(primaryResult.status).toBe("ready"); + expect(primaryResult.blocker).toBeNull(); + const previous = { + dir: process.env.GIT_DIR, + common: process.env.GIT_COMMON_DIR, + }; + process.env.GIT_DIR = join(homeDir, "missing-git-dir"); + process.env.GIT_COMMON_DIR = join(homeDir, "missing-common-dir"); + try { + const inherited = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(inherited.blocker).toBe("linked-worktree-without-loop-config"); + expect(inherited.primaryCheckout).toBe(primary); + expect(inherited.checks).toEqual([]); + } finally { + for (const [name, value] of [ + ["GIT_DIR", previous.dir], + ["GIT_COMMON_DIR", previous.common], + ] as const) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + } +}); + +it("does not report a configured but disabled linked worktree as missing config", async () => { + const homeDir = await realpath( + await mkdtemp(join(tmpdir(), "fclt-preflight-wt-")) + ); + roots.push(homeDir); + const primary = join(homeDir, "repo"); + await mkdir(primary, { recursive: true }); + const git = (cwd: string, ...argv: string[]) => + runFixtureGit({ + argv: ["-C", cwd, ...argv], + repoDir: primary, + homeDir: join(homeDir, ".git-home"), + }); + await git(primary, "init", "-q"); + await git( + primary, + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-q", + "--allow-empty", + "-m", + "init" + ); + const worktree = join(homeDir, "wt"); + await git(primary, "worktree", "add", "-q", "--detach", worktree); + const rootDir = join(worktree, ".ai"); + await mkdir(rootDir, { recursive: true }); + await Bun.write( + join(rootDir, "reconciliation.json"), + JSON.stringify({ + version: 1, + sources: [{ id: "writebacks", type: "writebacks" }], + }) + ); + await enableEvolutionLoop({ homeDir, rootDir, scope: "project" }); + const config = JSON.parse( + await Bun.file(facultAiEvolutionLoopConfigPath(homeDir, rootDir)).text() + ); + await Bun.write( + facultAiEvolutionLoopConfigPath(homeDir, rootDir), + JSON.stringify({ ...config, enabled: false }) + ); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.status).toBe("blocked"); + expect(result.blocker).toBe("loop-disabled"); + expect(result.checks.length).toBeGreaterThan(0); +}); + +it("does not treat a --separate-git-dir checkout as a generated worktree", async () => { + const homeDir = await realpath( + await mkdtemp(join(tmpdir(), "fclt-preflight-sep-")) + ); + roots.push(homeDir); + const primary = join(homeDir, "repo"); + await mkdir(primary, { recursive: true }); + await runFixtureGit({ + argv: [ + "init", + "-q", + "--separate-git-dir", + join(homeDir, "meta.git"), + primary, + ], + repoDir: primary, + homeDir: join(homeDir, ".git-home"), + }); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir: join(primary, ".ai"), + scope: "project", + }); + expect(result.blocker).not.toBe("linked-worktree-without-loop-config"); + expect(result.blocker).not.toBe("worktree-unresolved"); + expect(result.checks.length).toBeGreaterThan(0); + expect(result.loopInvoked).toBe(false); +}); + +async function worktreeFixture(prefix: string) { + const homeDir = await realpath(await mkdtemp(join(tmpdir(), prefix))); + roots.push(homeDir); + const gitHome = join(homeDir, ".git-home"); + const git = (repo: string, ...argv: string[]) => + runFixtureGit({ + argv: ["-C", repo, ...argv], + repoDir: repo, + homeDir: gitHome, + }); + const commit = (repo: string) => + git( + repo, + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-q", + "--allow-empty", + "-m", + "init" + ); + return { homeDir, git, commit }; +} + +function stateDirsFor(homeDir: string, rootDir: string) { + return [ + facultAiEvolutionReviewDir(homeDir, rootDir), + facultAiDraftDir(homeDir, rootDir), + dirname(facultAiEvolutionLoopConfigPath(homeDir, rootDir)), + dirname(facultAiReconciliationStatePath(homeDir, rootDir)), + ]; +} + +for (const subdir of ["", "packages/app"]) { + it(`creates no per-worktree state for a generated worktree (.ai at ${subdir || "root"})`, async () => { + const { homeDir, git, commit } = + await worktreeFixture("fclt-preflight-ns-"); + const primary = join(homeDir, "repo"); + await mkdir(primary, { recursive: true }); + await git(primary, "init", "-q"); + await commit(primary); + const worktree = join(homeDir, "wt"); + await git(primary, "worktree", "add", "-q", "--detach", worktree); + const rootDir = join(worktree, subdir, ".ai"); + await mkdir(rootDir, { recursive: true }); + const dirs = stateDirsFor(homeDir, rootDir); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.blocker).toBe("linked-worktree-without-loop-config"); + expect(result.primaryCheckout).toBe(join(primary, subdir)); + expect(result.checks).toEqual([]); + expect(result.configError ?? "").not.toContain("review init"); + expect(result.recovery).toContain("Do not run `fclt ai review init`"); + for (const dir of dirs) { + expect( + await readdir(dir).then( + () => true, + () => false + ) + ).toBe(false); + } + }); +} + +it("resolves the primary checkout for a worktree of a submodule", async () => { + const { homeDir, git, commit } = await worktreeFixture("fclt-preflight-sub-"); + const upstream = join(homeDir, "upstream"); + const superRepo = join(homeDir, "super"); + await mkdir(upstream, { recursive: true }); + await mkdir(superRepo, { recursive: true }); + await git(upstream, "init", "-q"); + await commit(upstream); + await git(superRepo, "init", "-q"); + await commit(superRepo); + await git( + superRepo, + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + upstream, + "mod" + ); + const moduleRoot = join(superRepo, "mod"); + const worktree = join(homeDir, "modwt"); + await git(moduleRoot, "worktree", "add", "-q", "--detach", worktree); + const rootDir = join(worktree, ".ai"); + await mkdir(rootDir, { recursive: true }); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.blocker).toBe("linked-worktree-without-loop-config"); + expect(result.primaryCheckout).toBe(moduleRoot); + const plain = await preflightEvolutionLoop({ + homeDir, + rootDir: join(moduleRoot, ".ai"), + scope: "project", + }); + expect(plain.blocker).not.toBe("linked-worktree-without-loop-config"); +}); + +it("reports an unresolvable Git pointer as worktree-unresolved without probing", async () => { + const homeDir = await realpath( + await mkdtemp(join(tmpdir(), "fclt-preflight-bad-")) + ); + roots.push(homeDir); + const checkout = join(homeDir, "broken"); + await mkdir(join(checkout, ".ai"), { recursive: true }); + await Bun.write( + join(checkout, ".git"), + `gitdir: ${join(homeDir, "missing")}\n` + ); + const rootDir = join(checkout, ".ai"); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.blocker).toBe("worktree-unresolved"); + expect(result.checks).toEqual([]); + expect(result.recovery).toContain("could not read"); +}); + +it("reports missing git as worktree-unresolved with an accurate message", async () => { + const { homeDir, git, commit } = await worktreeFixture( + "fclt-preflight-nogit-" + ); + const primary = join(homeDir, "repo"); + await mkdir(primary, { recursive: true }); + await git(primary, "init", "-q"); + await commit(primary); + const worktree = join(homeDir, "wt"); + await git(primary, "worktree", "add", "-q", "--detach", worktree); + const rootDir = join(worktree, ".ai"); + await mkdir(rootDir, { recursive: true }); + const emptyBin = join(homeDir, "empty-bin"); + await mkdir(emptyBin); + const previousPath = process.env.PATH; + process.env.PATH = emptyBin; + try { + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.blocker).toBe("worktree-unresolved"); + expect(result.recovery).toContain("not available on PATH"); + expect(result.checks).toEqual([]); + } finally { + process.env.PATH = previousPath; + } +}); + +it("names the real primary checkout when the worktree path goes through a symlink", async () => { + const { homeDir, git, commit } = await worktreeFixture( + "fclt-preflight-link-" + ); + const primary = join(homeDir, "repo"); + await mkdir(primary, { recursive: true }); + await git(primary, "init", "-q"); + await commit(primary); + const worktree = join(homeDir, "wt"); + await git(primary, "worktree", "add", "-q", "--detach", worktree); + await mkdir(join(worktree, "packages", "app", ".ai"), { recursive: true }); + const alias = join(homeDir, "alias"); + await symlink(homeDir, alias); + for (const sub of ["", join("packages", "app")]) { + const rootDir = join(alias, "wt", sub, ".ai"); + await mkdir(rootDir, { recursive: true }); + const result = await preflightEvolutionLoop({ + homeDir, + rootDir, + scope: "project", + }); + expect(result.blocker).toBe("linked-worktree-without-loop-config"); + expect(result.primaryCheckout).toBe(join(primary, sub)); + } +}); diff --git a/src/evolution-preflight.ts b/src/evolution-preflight.ts index 6b32322..9de8876 100644 --- a/src/evolution-preflight.ts +++ b/src/evolution-preflight.ts @@ -1,5 +1,14 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, rmdir } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, +} from "node:path"; import { loadEvolutionLoopConfig } from "./evolution-loop"; import { facultAiActivityHistoryDir, @@ -22,6 +31,128 @@ import { loadReconciliationConfig, selectReconciliationSources, } from "./reconciliation-config"; +import { withoutLocalGitEnvironment } from "./util/git-environment"; + +const GIT_TIMEOUT_MS = 5000; +const REVIEW_INIT_HINT_RE = /\s*Run fclt ai review init\.?/; + +export interface WorktreeClassification { + linked: boolean; + primary: string | null; + /** Set when Git metadata exists but could not be inspected. */ + unresolved: string | null; +} + +function runGitProbe(argv: string[]) { + return spawnSync("git", argv, { + encoding: "utf8", + env: { + ...withoutLocalGitEnvironment(process.env), + GIT_OPTIONAL_LOCKS: "0", + }, + stdio: ["ignore", "pipe", "ignore"], + timeout: GIT_TIMEOUT_MS, + }); +} + +function hasGitMetadataAbove(start: string): boolean { + let current = resolve(start); + for (;;) { + if (existsSync(join(current, ".git"))) { + return true; + } + const parent = dirname(current); + if (parent === current) { + return false; + } + current = parent; + } +} + +function submodulePrimary(commonDir: string): string | null { + const result = runGitProbe([ + `--git-dir=${commonDir}`, + "config", + "--get", + "core.worktree", + ]); + const value = + result.status === 0 && typeof result.stdout === "string" + ? result.stdout.trim() + : ""; + return value ? resolve(commonDir, value) : null; +} + +/** + * Classify the repository containing `projectRoot` (which may be a + * subdirectory). Only a linked worktree has a private Git directory that + * differs from the shared common directory; submodules and + * `--separate-git-dir` checkouts are not linked. + */ +export function linkedWorktreePrimaryCheckout( + projectRoot: string +): WorktreeClassification { + const result = runGitProbe([ + "-C", + projectRoot, + "rev-parse", + "--path-format=absolute", + "--git-dir", + "--git-common-dir", + "--show-toplevel", + ]); + const lines = + result.status === 0 && typeof result.stdout === "string" + ? result.stdout.trim().split("\n") + : []; + // Git < 2.31 echoes the unknown --path-format option instead of failing; + // accept only exactly three absolute paths. + const parsed = + lines.length === 3 && lines.every((line) => isAbsolute(line)) ? lines : []; + const [gitDir, commonDir, topLevel] = parsed; + if (lines.length > 0 && parsed.length === 0) { + return { + linked: false, + primary: null, + unresolved: + "git returned unexpected rev-parse output (git 2.31 or newer is required)", + }; + } + if (!(gitDir && commonDir && topLevel)) { + if (!hasGitMetadataAbove(projectRoot)) { + return { linked: false, primary: null, unresolved: null }; + } + const error = result.error as NodeJS.ErrnoException | undefined; + const unresolved = + error?.code === "ENOENT" + ? "git is not available on PATH in this execution environment" + : error?.code === "ETIMEDOUT" || result.signal + ? `git did not answer within ${GIT_TIMEOUT_MS} ms` + : "git could not read this repository's metadata"; + return { linked: false, primary: null, unresolved }; + } + if (resolve(gitDir) === resolve(commonDir)) { + return { linked: false, primary: null, unresolved: null }; + } + const common = resolve(commonDir); + const primary = + basename(common) === ".git" ? dirname(common) : submodulePrimary(common); + const self = resolve(topLevel); + // --show-toplevel is symlink-resolved; compare against the real project path. + let realProject = resolve(projectRoot); + try { + realProject = realpathSync.native(realProject); + } catch { + // Keep the lexical path; relative() below then degrades to the root. + } + // Map a subdirectory .ai root to the matching subdirectory in the primary. + const matching = primary ? join(primary, relative(self, realProject)) : null; + return { + linked: true, + primary: primary && resolve(primary) !== self ? matching : null, + unresolved: null, + }; +} /** Probe the actual execution environment without reconciling or creating queue state. */ export async function preflightEvolutionLoop(args: { @@ -43,14 +174,52 @@ export async function preflightEvolutionLoop(args: { []; let enabled = false; let configError: string | undefined; + let loopConfigAbsent = false; try { const config = await loadEvolutionLoopConfig(args); + loopConfigAbsent = config === null; enabled = config?.enabled === true; const reconciliation = await loadReconciliationConfig(args); selectReconciliationSources(reconciliation.config, config?.sourceIds); } catch (error) { configError = error instanceof Error ? error.message : String(error); } + const projectRoot = projectRootFromAiRoot(args.rootDir, args.homeDir); + const worktree: WorktreeClassification = projectRoot + ? linkedWorktreePrimaryCheckout(projectRoot) + : { linked: false, primary: null, unresolved: null }; + const reviewConfigAbsent = + loopConfigAbsent || + !existsSync(join(args.rootDir, "reconciliation.json")); + if ((worktree.linked || worktree.unresolved) && reviewConfigAbsent) { + // `.ai/` review configuration is machine-local and ignored, so a + // generated worktree never carries the enrolled loop. Probing writes + // here would only create orphaned per-worktree state directories. + const primaryCheckout = worktree.primary; + const target = primaryCheckout + ? `the primary checkout ${primaryCheckout}` + : "the repository's primary checkout (it could not be resolved from here)"; + const recovery = worktree.unresolved + ? `Review configuration is absent and the checkout type could not be determined: ${worktree.unresolved}. Fix Git access in the task execution environment and run preflight again. Do not run \`fclt ai review init\` or invoke the loop here.` + : `This is a generated/linked worktree without the enrolled review configuration. Run the scheduled review from ${target} (task execution environment: local, not a generated worktree). Do not run \`fclt ai review init\` or invoke the loop here; that would create orphaned per-worktree state.`; + return { + version: 1, + status: "blocked" as const, + blocker: worktree.unresolved + ? "worktree-unresolved" + : "linked-worktree-without-loop-config", + queueAvailable: false, + loopInvoked: false, + runtime: process.execPath, + enabled, + configError: configError + ? configError.replace(REVIEW_INIT_HINT_RE, "") + : undefined, + primaryCheckout, + checks: [], + recovery, + }; + } const paths = new Set([ facultAiActivityHistoryDir(args.homeDir, args.rootDir), facultAiActivityHistorySegmentDir(args.homeDir, args.rootDir), @@ -86,11 +255,20 @@ export async function preflightEvolutionLoop(args: { version: 1, status: enabled && !configError && denied.length === 0 ? "ready" : "blocked", + blocker: + denied.length > 0 + ? "state-write-denied" + : configError + ? "loop-config-invalid" + : enabled + ? null + : "loop-disabled", queueAvailable: false, loopInvoked: false, runtime: process.execPath, enabled, configError, + primaryCheckout: null, checks, recovery: denied.length > 0