diff --git a/packages/engine/src/__tests__/executor.test.ts b/packages/engine/src/__tests__/executor.test.ts index 97b3aab1d..eab4f89bb 100644 --- a/packages/engine/src/__tests__/executor.test.ts +++ b/packages/engine/src/__tests__/executor.test.ts @@ -871,6 +871,101 @@ describe("TaskExecutor worktree recovery", () => { ); }); + it("fails fast with a clear error when rootDir is not a git repository", async () => { + const store = createMockStore(); + const onError = vi.fn(); + + mockedExecSync.mockImplementation((cmd: string | string[]) => { + const command = typeof cmd === "string" ? cmd : cmd[0]; + if (command === "git rev-parse --git-dir") { + const error: any = new Error("fatal: not a git repository (or any of the parent directories): .git"); + error.stderr = Buffer.from("fatal: not a git repository (or any of the parent directories): .git"); + throw error; + } + return Buffer.from(""); + }); + + const executor = new TaskExecutor(store, "/tmp/test", { onError }); + await executor.execute(makeTask()); + + expect(store.logEntry).toHaveBeenCalledWith( + "FN-050", + expect.stringContaining("Cannot execute task: project directory is not a Git repository"), + ); + expect(store.updateTask).toHaveBeenCalledWith( + "FN-050", + expect.objectContaining({ + status: "failed", + error: expect.stringContaining("not a Git repository"), + }), + ); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ id: "FN-050" }), + expect.objectContaining({ message: expect.stringContaining("not a Git repository") }), + ); + }); + + it("does not attempt git worktree add when rootDir is not a git repository", async () => { + const store = createMockStore(); + + mockedExecSync.mockImplementation((cmd: string | string[]) => { + const command = typeof cmd === "string" ? cmd : cmd[0]; + if (command === "git rev-parse --git-dir") { + const error: any = new Error("fatal: not a git repository"); + error.stderr = Buffer.from("fatal: not a git repository"); + throw error; + } + return Buffer.from(""); + }); + + const executor = new TaskExecutor(store, "/tmp/test"); + await executor.execute(makeTask()); + + const worktreeAddCalls = mockedExecSync.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].includes("git worktree add"), + ); + expect(worktreeAddCalls).toHaveLength(0); + }); + + it("extractWorktreeConflictInfo classifies not-a-git-repository errors", () => { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test"); + + const error: any = new Error("fatal: not a git repository"); + error.stderr = Buffer.from("fatal: not a git repository"); + + const conflictInfo = (executor as any).extractWorktreeConflictInfo(error); + expect(conflictInfo.type).toBe("not-git-repo"); + expect(conflictInfo.message).toContain("not a git repository"); + }); + + it("treats not-a-git-repository as non-retryable in tryCreateWorktree flow", async () => { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test"); + + mockedExecSync.mockImplementation((cmd: string | string[]) => { + const command = typeof cmd === "string" ? cmd : cmd[0]; + if (command === "git worktree list --porcelain") { + return Buffer.from(["worktree /tmp/test", "HEAD abc123", "branch refs/heads/main", ""].join("\n")); + } + if (command.includes("git worktree add -b")) { + const error: any = new Error("fatal: not a git repository (or any of the parent directories): .git"); + error.stderr = Buffer.from("fatal: not a git repository (or any of the parent directories): .git"); + throw error; + } + return Buffer.from(""); + }); + + await expect( + (executor as any).createWorktree("fusion/fn-050", "/tmp/test/.worktrees/swift-falcon", "FN-050"), + ).rejects.toThrow("not a Git repository"); + + const worktreeAddCalls = mockedExecSync.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].includes("git worktree add -b"), + ); + expect(worktreeAddCalls).toHaveLength(1); + }); + it("recovers from worktree conflict and retries", async () => { const store = createMockStore(); let callCount = 0; diff --git a/packages/engine/src/__tests__/worktree-pool.test.ts b/packages/engine/src/__tests__/worktree-pool.test.ts index 3b8947b7b..55a2abb9a 100644 --- a/packages/engine/src/__tests__/worktree-pool.test.ts +++ b/packages/engine/src/__tests__/worktree-pool.test.ts @@ -48,6 +48,7 @@ vi.mock("node:fs", () => ({ import { WorktreePool, getRegisteredWorktreePaths, + isGitRepository, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWorktrees, @@ -408,6 +409,49 @@ describe("WorktreePool", () => { }); }); +describe("isGitRepository", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when git rev-parse succeeds", async () => { + mockedExecSync.mockImplementation((cmd: any) => { + if (String(cmd) === "git rev-parse --git-dir") { + return Buffer.from(".git\n"); + } + return Buffer.from(""); + }); + + await expect(isGitRepository("/tmp/repo")).resolves.toBe(true); + }); + + it("returns false when target directory is not a git repository", async () => { + mockedExecSync.mockImplementation((cmd: any, opts?: any) => { + if (String(cmd) === "git rev-parse --git-dir" && opts?.cwd === "/tmp/plain") { + const error: any = new Error("fatal: not a git repository"); + error.stderr = Buffer.from("fatal: not a git repository"); + throw error; + } + return Buffer.from(""); + }); + + await expect(isGitRepository("/tmp/plain")).resolves.toBe(false); + }); + + it("returns false when directory does not exist", async () => { + mockedExecSync.mockImplementation((cmd: any, opts?: any) => { + if (String(cmd) === "git rev-parse --git-dir" && opts?.cwd === "/tmp/missing") { + const error: any = new Error("spawn ENOENT"); + error.code = "ENOENT"; + throw error; + } + return Buffer.from(""); + }); + + await expect(isGitRepository("/tmp/missing")).resolves.toBe(false); + }); +}); + describe("getRegisteredWorktreePaths", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index c62ce3a6f..c87927764 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -16,7 +16,7 @@ import { buildSessionSkillContext } from "./session-skill-context.js"; import { reviewStep, type ReviewVerdict } from "./reviewer.js"; import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent"; import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js"; -import { getRegisteredWorktreePaths, isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.js"; +import { getRegisteredWorktreePaths, isGitRepository, isRegisteredGitWorktree, isUsableTaskWorktree, type WorktreePool } from "./worktree-pool.js"; import { AgentLogger } from "./agent-logger.js"; import { executorLog, reviewerLog, formatError } from "./logger.js"; import { TokenCapDetector } from "./token-cap-detector.js"; @@ -1356,6 +1356,16 @@ export class TaskExecutor { return; } + if (!await isGitRepository(this.rootDir)) { + await this.store.logEntry( + task.id, + "Cannot execute task: project directory is not a Git repository. Fusion requires a Git repository for worktree-based task execution.", + ); + throw new Error( + "Project directory is not a Git repository. Fusion requires a Git repository for worktree creation. Initialize with 'git init' or run from a Git project directory.", + ); + } + // Create or reuse worktree — try pool first when recycling is enabled const branchName = `fusion/${task.id.toLowerCase()}`; // Use generateWorktreeName for human-friendly directory names (adjective-noun pattern) @@ -4067,6 +4077,12 @@ and show an appropriate message to the user.\` } catch (initialError: unknown) { const conflictInfo = this.extractWorktreeConflictInfo(initialError); + if (conflictInfo.type === "not-git-repo") { + throw new NonRetryableWorktreeError( + "Project directory is not a Git repository. Fusion requires a Git repository for worktree creation. Initialize with 'git init' or run from a Git project directory.", + ); + } + // Handle "already used by worktree" conflict if (conflictInfo.type === "already-used" && conflictInfo.path) { const result = await this.handleWorktreeConflict( @@ -4119,6 +4135,12 @@ and show an appropriate message to the user.\` const fallbackErrorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); // Check if the fallback also hit an "already used" conflict const fallbackConflictInfo = this.extractWorktreeConflictInfo(fallbackError); + if (fallbackConflictInfo.type === "not-git-repo") { + throw new NonRetryableWorktreeError( + "Project directory is not a Git repository. Fusion requires a Git repository for worktree creation. Initialize with 'git init' or run from a Git project directory.", + ); + } + if (fallbackConflictInfo.type === "already-used" && fallbackConflictInfo.path) { const result = await this.handleWorktreeConflict( fallbackConflictInfo.path, @@ -4391,7 +4413,7 @@ and show an appropriate message to the user.\` * - "working tree already exists" */ private extractWorktreeConflictInfo(error: unknown): { - type: "already-used" | "invalid-reference" | "leading-directories" | "already-exists" | "unknown"; + type: "already-used" | "invalid-reference" | "leading-directories" | "already-exists" | "not-git-repo" | "unknown"; path?: string; message?: string; } { @@ -4432,6 +4454,11 @@ and show an appropriate message to the user.\` return { type: "already-exists", message: output }; } + // Pattern: not a git repository / not a git repo + if (output.match(/not a git repo(sitory)?/i)) { + return { type: "not-git-repo", message: output }; + } + return { type: "unknown", message: output }; } diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index 8da84de5c..5b2ffe1f3 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -14,7 +14,7 @@ import type { import { isEphemeralAgent } from "@fusion/core"; import { Scheduler } from "../scheduler.js"; import { TaskExecutor, type TaskExecutorOptions } from "../executor.js"; -import { WorktreePool } from "../worktree-pool.js"; +import { WorktreePool, isGitRepository } from "../worktree-pool.js"; import { AgentSemaphore } from "../concurrency.js"; import { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "../agent-heartbeat.js"; import { RoutineRunner, type RoutineRunnerOptions } from "../routine-runner.js"; @@ -197,6 +197,14 @@ export class InProcessRuntime runtimeLog.warn(`reapOrphanWorktrees failed (continuing): ${msg}`); } + if (!(await isGitRepository(this.config.workingDirectory))) { + runtimeLog.warn( + `Project directory "${this.config.workingDirectory}" is not a Git repository. ` + + `Task execution will fail until a Git repository is initialized. ` + + `Run 'git init' in the project directory to enable worktree-based task execution.`, + ); + } + this.worktreePool = new WorktreePool(); // Rehydrate pool from disk state (idle worktrees) diff --git a/packages/engine/src/worktree-pool.ts b/packages/engine/src/worktree-pool.ts index c05851651..6e6e36c98 100644 --- a/packages/engine/src/worktree-pool.ts +++ b/packages/engine/src/worktree-pool.ts @@ -7,6 +7,20 @@ import { worktreePoolLog } from "./logger.js"; const execAsync = promisify(exec); +export async function isGitRepository(dir: string): Promise { + try { + await execAsync("git rev-parse --git-dir", { + cwd: dir, + encoding: "utf-8", + }); + return true; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + worktreePoolLog.log(`isGitRepository check failed for ${dir}: ${errorMessage}`); + return false; + } +} + export async function getRegisteredWorktreePaths(rootDir: string): Promise> { try { const { stdout } = await execAsync("git worktree list --porcelain", {