feat(FN-2621): guard execution for non-git project directories

- Add isGitRepository utility in worktree-pool using git rev-parse checks
- Fail fast in TaskExecutor with actionable non-git errors before worktree creation starts
- Classify not-a-git-repository worktree add failures as non-retryable in recovery flows
- Warn from in-process runtime startup when the working directory is not a Git repository
- Expand executor and worktree-pool tests to cover non-git, missing-dir, and conflict-classification paths
This commit is contained in:
Fusion
2026-04-26 22:20:17 -07:00
committed by gsxdsm
parent 4f5b8f2260
commit 163a4c734c
5 changed files with 191 additions and 3 deletions

View File

@@ -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;

View File

@@ -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();

View File

@@ -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 };
}

View File

@@ -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)

View File

@@ -7,6 +7,20 @@ import { worktreePoolLog } from "./logger.js";
const execAsync = promisify(exec);
export async function isGitRepository(dir: string): Promise<boolean> {
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<Set<string>> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {