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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user