diff --git a/.changeset/fn-7799-git-detection-false-negative.md b/.changeset/fn-7799-git-detection-false-negative.md new file mode 100644 index 0000000000..646bd0fc19 --- /dev/null +++ b/.changeset/fn-7799-git-detection-false-negative.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix a false "Project directory is not a Git repository" error that blocked all task execution in valid repos. +category: fix +dev: Git detection is now tri-state (repo/not-repo/error) via detectGitRepository(); dubious-ownership/PATH/timeout git failures no longer masquerade as "not a Git repository". FN-7799. diff --git a/docs/solutions/logic-errors/git-detection-false-not-repo.md b/docs/solutions/logic-errors/git-detection-false-not-repo.md new file mode 100644 index 0000000000..e1ad59c12e --- /dev/null +++ b/docs/solutions/logic-errors/git-detection-false-not-repo.md @@ -0,0 +1,54 @@ +--- +title: "Git probe failures falsely reported as not a repository" +date: 2026-07-10 +category: docs/solutions/logic-errors +module: "engine Git detection + task executor preflight" +problem_type: logic_error +component: engine +symptoms: + - "Every task fails immediately with Project directory is not a Git repository" + - "The project is a valid Git repo, but git rev-parse fails for an environmental reason" + - "Restarting the engine does not clear the failure because the environment condition persists" +root_cause: error_classification_collapse +resolution_type: code_fix +severity: high +related_components: + - "packages/engine/src/worktree-pool.ts (detectGitRepository)" + - "packages/engine/src/executor.ts (dispatch preflight guard)" + - "packages/engine/src/runtimes/in-process-runtime.ts (startup warning)" +tags: + - git + - worktrees + - executor + - dubious-ownership + - false-negative +--- + +# Git probe failures falsely reported as not a repository + +## Problem + +A boolean Git repository probe collapses every `git rev-parse --git-dir` failure into `false`. That makes a positive non-repo response indistinguishable from environmental failures such as `fatal: detected dubious ownership`, `spawn ENOENT`, or a hung Git command. The executor then tells operators to run `git init` even when the checkout is already a valid repository, blocking all task execution until the underlying Git environment is fixed. + +## Solution + +Use tri-state Git detection: `repo`, `not-repo`, or `error`. Only the `not-repo` state may produce the existing "Project directory is not a Git repository" / `git init` guidance. Environmental failures surface the original Git error instead; dubious ownership also includes the explicit safe-directory command: + +```bash +git config --global --add safe.directory "" +``` + +The probe remains async and bounded with a timeout, so a hung Git process cannot silently become a false non-repo verdict. + +## Verification + +Cover the invariant at every consumer of repository detection: + +- Detection helper: genuine repo → `repo`; genuine `fatal: not a git repository` → `not-repo`; dubious ownership, missing Git, and timeout → `error`. +- Executor guard: `not-repo` preserves the legacy log/error strings; `error` logs and throws a distinct message without `git init` guidance and does not attempt `git worktree add`. +- Runtime startup: `not-repo` preserves the startup warning; `error` warns with the real Git failure and any safe-directory remedy. +- Worktree-add conflict parsing: `detected dubious ownership` stays `unknown`, not `not-git-repo`, so it does not become a non-retryable `git init` error. + +## Prevention + +Do not use boolean wrappers at guardrails that need operator-facing diagnosis. Keep positive semantic states separate from probe failures, preserve stderr in the result, and add tests for both a POSIX path and a Windows path with spaces passed through `cwd` rather than interpolated into the shell command. diff --git a/packages/engine/src/__tests__/executor-worktree.test.ts b/packages/engine/src/__tests__/executor-worktree.test.ts index 3250d6dd31..28d210105b 100644 --- a/packages/engine/src/__tests__/executor-worktree.test.ts +++ b/packages/engine/src/__tests__/executor-worktree.test.ts @@ -728,6 +728,54 @@ describe("TaskExecutor worktree recovery", () => { expect(worktreeAddCalls).toHaveLength(0); }); + it("surfaces dubious ownership as a distinct git detection error without suggesting git init", async () => { + const rootDir = "C:/Users/drewd/Documents/1. App Development/1. Active/NextGenEHS"; + const store = createMockStore(); + const onError = vi.fn(); + + mockedExecSync.mockImplementation((cmd: string | string[], opts?: any) => { + const command = typeof cmd === "string" ? cmd : cmd[0]; + if (command === "git rev-parse --git-dir" && opts?.cwd === rootDir) { + const error: any = new Error(`fatal: detected dubious ownership in repository at '${rootDir}'`); + error.stderr = Buffer.from(`fatal: detected dubious ownership in repository at '${rootDir}'`); + throw error; + } + return Buffer.from(""); + }); + + const executor = new TaskExecutor(store, rootDir, { onError }); + 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); + expect(store.logEntry).not.toHaveBeenCalledWith( + "FN-050", + expect.stringContaining("Cannot execute task: project directory is not a Git repository"), + ); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-050", + expect.stringContaining("detected dubious ownership"), + ); + expect(store.updateTask).toHaveBeenCalledWith( + "FN-050", + expect.objectContaining({ + status: "failed", + error: expect.stringContaining(`git config --global --add safe.directory "${rootDir}"`), + }), + ); + const failedPatch = store.updateTask.mock.calls.find( + ([, patch]) => (patch as { status?: string }).status === "failed", + )?.[1] as { error?: string } | undefined; + expect(failedPatch?.error).not.toContain("Initialize with 'git init'"); + expect(failedPatch?.error).not.toContain("Project directory is not a Git repository"); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ id: "FN-050" }), + expect.objectContaining({ message: expect.stringContaining("detected dubious ownership") }), + ); + }); + it("extractWorktreeConflictInfo classifies not-a-git-repository errors", () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); @@ -740,6 +788,19 @@ describe("TaskExecutor worktree recovery", () => { expect(conflictInfo.message).toContain("not a git repository"); }); + it("extractWorktreeConflictInfo does not misclassify dubious ownership as not-git-repo", () => { + const store = createMockStore(); + const executor = new TaskExecutor(store, "/tmp/test"); + const rootDir = "C:/Users/drewd/Documents/1. App Development/1. Active/NextGenEHS"; + + const error: any = new Error(`fatal: detected dubious ownership in repository at '${rootDir}'`); + error.stderr = Buffer.from(`fatal: detected dubious ownership in repository at '${rootDir}'`); + + const conflictInfo = (executor as any).extractWorktreeConflictInfo(error); + expect(conflictInfo.type).toBe("unknown"); + expect(conflictInfo.message).toContain("detected dubious ownership"); + }); + it("treats not-a-git-repository as non-retryable in tryCreateWorktree flow", async () => { const store = createMockStore(); const executor = new TaskExecutor(store, "/tmp/test"); diff --git a/packages/engine/src/__tests__/worktree-pool.test.ts b/packages/engine/src/__tests__/worktree-pool.test.ts index 4f82235315..968fdfe3d2 100644 --- a/packages/engine/src/__tests__/worktree-pool.test.ts +++ b/packages/engine/src/__tests__/worktree-pool.test.ts @@ -64,6 +64,7 @@ import * as desktopArtifacts from "../worktree-desktop-artifacts.js"; import * as worktreePrune from "../worktree-prune.js"; import { WorktreePool, + detectGitRepository, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isGitRepository, @@ -643,46 +644,96 @@ describe("WorktreePool", () => { }); }); -describe("isGitRepository", () => { +describe("detectGitRepository", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("returns true when git rev-parse succeeds", async () => { - mockedExecSync.mockImplementation((cmd: any) => { + it("classifies a POSIX git repository as repo", async () => { + mockedExecSync.mockImplementation((cmd: any, opts?: any) => { + expect(opts).toEqual(expect.objectContaining({ cwd: "/tmp/repo", timeout: 10_000 })); if (String(cmd) === "git rev-parse --git-dir") { return Buffer.from(".git\n"); } return Buffer.from(""); }); + await expect(detectGitRepository("/tmp/repo")).resolves.toEqual({ status: "repo" }); await expect(isGitRepository("/tmp/repo")).resolves.toBe(true); }); - it("returns false when target directory is not a git repository", async () => { + it("classifies a genuine non-git directory as not-repo", 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"); + 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(detectGitRepository("/tmp/plain")).resolves.toEqual({ + status: "not-repo", + stderr: "fatal: not a git repository (or any of the parent directories): .git", + }); await expect(isGitRepository("/tmp/plain")).resolves.toBe(false); }); - it("returns false when directory does not exist", async () => { + it("classifies dubious ownership on a Windows OneDrive Documents path as an error", async () => { + const windowsPath = "C:/Users/drewd/Documents/1. App Development/1. Active/NextGenEHS"; 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"); + if (String(cmd) === "git rev-parse --git-dir" && opts?.cwd === windowsPath) { + const error: any = new Error(`fatal: detected dubious ownership in repository at '${windowsPath}'`); + error.stderr = Buffer.from(`fatal: detected dubious ownership in repository at '${windowsPath}'`); + throw error; + } + return Buffer.from(""); + }); + + await expect(detectGitRepository(windowsPath)).resolves.toEqual({ + status: "error", + reason: "dubious-ownership", + stderr: `fatal: detected dubious ownership in repository at '${windowsPath}'`, + }); + await expect(isGitRepository(windowsPath)).resolves.toBe(false); + }); + + it("classifies git missing from PATH as an error", async () => { + mockedExecSync.mockImplementation((cmd: any, opts?: any) => { + if (String(cmd) === "git rev-parse --git-dir" && opts?.cwd === "/tmp/repo") { + const error: any = new Error("spawn git ENOENT"); error.code = "ENOENT"; throw error; } return Buffer.from(""); }); - await expect(isGitRepository("/tmp/missing")).resolves.toBe(false); + await expect(detectGitRepository("/tmp/repo")).resolves.toEqual({ + status: "error", + reason: "git-missing", + stderr: "spawn git ENOENT", + }); + await expect(isGitRepository("/tmp/repo")).resolves.toBe(false); + }); + + it("classifies a timed-out git probe as an error", async () => { + mockedExecSync.mockImplementation((cmd: any, opts?: any) => { + if (String(cmd) === "git rev-parse --git-dir" && opts?.cwd === "/tmp/repo") { + const error: any = new Error("Command failed: git rev-parse --git-dir"); + error.code = "ETIMEDOUT"; + error.killed = true; + error.stderr = Buffer.from("Timed out: git rev-parse --git-dir"); + throw error; + } + return Buffer.from(""); + }); + + await expect(detectGitRepository("/tmp/repo")).resolves.toEqual({ + status: "error", + reason: "timeout", + stderr: "Timed out: git rev-parse --git-dir", + }); + await expect(isGitRepository("/tmp/repo")).resolves.toBe(false); }); }); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 080b1d1fae..d7098d277b 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -107,7 +107,7 @@ import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js"; // filter reuses the SAME always-allowed/scope-match surface as the non-workspace path (F5). One-way // executor→workspace-paths edge (workspace-paths imports nothing). import { deriveRepoScopeSubset, normalizeRepoRelPath } from "./workspace-paths.js"; -import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, detectNestedWorktreeRoot, getRegisteredWorktreePaths, isGitRepository, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type WorktreePool } from "./worktree-pool.js"; +import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, detectGitRepository, detectNestedWorktreeRoot, getRegisteredWorktreePaths, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type GitRepoDetection, type WorktreePool } from "./worktree-pool.js"; import { attemptBranchAutocorrect } from "./branch-autocorrect.js"; import { ActiveSessionWorktreeRemovalError } from "./worktree-backend.js"; import { @@ -948,6 +948,14 @@ function evaluatePromptDerivedNoCommitEligibility(task: Task, promptContent: str class NonRetryableWorktreeError extends Error {} +function formatGitRepositoryDetectionError(rootDir: string, detection: Extract): string { + const stderr = detection.stderr.trim() || "git rev-parse --git-dir failed without stderr"; + const remedy = detection.reason === "dubious-ownership" + ? ` Resolve Git safe-directory ownership with: git config --global --add safe.directory "${rootDir}"` + : ""; + return `Git repository detection failed for project directory "${rootDir}". Fusion could not verify worktree support because git reported: ${stderr}.${remedy}`; +} + function buildSessionWorktreePathRegex(rootDir: string, settings: Partial): RegExp { const configuredBase = resolveWorktreesDir(rootDir, settings).split(/[\\/]/).filter(Boolean).pop() ?? ".worktrees"; const escapedBase = configuredBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -9526,14 +9534,26 @@ export class TaskExecutor { and enable a workspace with nothing to work on. Gate every workspace check on repos.length > 0. */ const hasWorkspaceRepos = (this.workspaceConfig?.repos.length ?? 0) > 0; - if (!hasWorkspaceRepos && !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.", - ); + if (!hasWorkspaceRepos) { + const gitDetection = await detectGitRepository(this.rootDir); + if (gitDetection.status === "not-repo") { + 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.", + ); + } + if (gitDetection.status === "error") { + /* + FNXC:Worktree 2026-07-10-00:00: + FN-7799 requires environmental Git probe failures in valid repos to surface the real cause instead of telling operators to run `git init`. Dubious ownership and similar persistent failures otherwise block every task across restarts with a false non-repo diagnosis. + */ + const message = formatGitRepositoryDetectionError(this.rootDir, gitDetection); + await this.store.logEntry(task.id, message); + throw new Error(message); + } } const hadAssignedWorktree = Boolean(task.worktree); diff --git a/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts b/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts index 1fdeb1edd5..90fcbf9579 100644 --- a/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts +++ b/packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts @@ -25,7 +25,7 @@ const { mockTaskStoreUpdateSettings, mockMessageStoreSetHook, mockSchedulerConfigurePrMonitoring, - mockIsGitRepository, + mockDetectGitRepository, mockReapOrphanWorktrees, mockScanIdleWorktrees, mockGetRegisteredWorktreePaths, @@ -44,7 +44,7 @@ const { mockTaskStoreUpdateSettings: vi.fn().mockResolvedValue(undefined), mockMessageStoreSetHook: vi.fn(), mockSchedulerConfigurePrMonitoring: vi.fn(), - mockIsGitRepository: vi.fn().mockResolvedValue(true), + mockDetectGitRepository: vi.fn().mockResolvedValue({ status: "repo" }), mockReapOrphanWorktrees: vi.fn().mockResolvedValue(0), mockScanIdleWorktrees: vi.fn().mockResolvedValue([]), mockGetRegisteredWorktreePaths: vi.fn().mockResolvedValue(new Set()), @@ -130,7 +130,7 @@ vi.mock("../../worktree-pool.js", async () => { // Stub them out so runtime.start() never spawns git. return { ...actual, - isGitRepository: mockIsGitRepository, + detectGitRepository: mockDetectGitRepository, reapOrphanWorktrees: mockReapOrphanWorktrees, scanIdleWorktrees: mockScanIdleWorktrees, getRegisteredWorktreePaths: mockGetRegisteredWorktreePaths, @@ -261,8 +261,8 @@ describe("InProcessRuntime", () => { mockTaskStoreGetTask.mockResolvedValue(null); mockResumeTaskForAgent.mockReset(); mockResumeTaskForAgent.mockResolvedValue(undefined); - mockIsGitRepository.mockReset(); - mockIsGitRepository.mockResolvedValue(true); + mockDetectGitRepository.mockReset(); + mockDetectGitRepository.mockResolvedValue({ status: "repo" }); mockReapOrphanWorktrees.mockReset(); mockReapOrphanWorktrees.mockResolvedValue(0); mockScanIdleWorktrees.mockReset(); @@ -346,7 +346,7 @@ describe("InProcessRuntime", () => { expect(gitExecFileCalls).toHaveLength(0); expect(gitSpawnCalls).toHaveLength(0); expect(mockReapOrphanWorktrees).toHaveBeenCalledWith(testDir, expect.any(Object)); - expect(mockIsGitRepository).toHaveBeenCalledWith(testDir); + expect(mockDetectGitRepository).toHaveBeenCalledWith(testDir); expect(mockScanIdleWorktrees).toHaveBeenCalled(); } finally { execSpy.mockRestore(); @@ -355,6 +355,47 @@ describe("InProcessRuntime", () => { } }, 30000); + it("warns with git init guidance only when startup detection positively reports not-repo", async () => { + const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined as any); + mockDetectGitRepository.mockResolvedValueOnce({ + status: "not-repo", + stderr: "fatal: not a git repository (or any of the parent directories): .git", + }); + + await runtime.start(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("is not a Git repository")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Run 'git init'")); + warnSpy.mockRestore(); + }, 30000); + + it("warns with the real git detection failure instead of not-repo guidance on startup errors", async () => { + const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined as any); + mockDetectGitRepository.mockResolvedValueOnce({ + status: "error", + reason: "dubious-ownership", + stderr: `fatal: detected dubious ownership in repository at '${testDir}'`, + }); + + await runtime.start(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("detected dubious ownership")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(`git config --global --add safe.directory "${testDir}"`)); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("is not a Git repository")); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Run 'git init'")); + warnSpy.mockRestore(); + }, 30000); + + it("does not warn about git repository status when startup detection succeeds", async () => { + const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined as any); + + await runtime.start(); + + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Git repository")); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Git error")); + warnSpy.mockRestore(); + }, 30000); + it("passes executor recovery callbacks into SelfHealingManager", async () => { await runtime.start(); diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index fd802f030f..c9bbbc4081 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -24,7 +24,7 @@ import { WorkflowAuthoritativeDriver } from "../workflow-authoritative-driver.js import { buildPrNodeDeps } from "../pr-nodes.js"; import { isExperimentalFeatureEnabled } from "@fusion/core"; import { createCliAgentRuntime, type BootstrappedCliAgentRuntime } from "../cli-agent/runtime.js"; -import { WorktreePool, isGitRepository, type PoolInvariantViolation } from "../worktree-pool.js"; +import { WorktreePool, detectGitRepository, type GitRepoDetection, type PoolInvariantViolation } from "../worktree-pool.js"; import { AgentSemaphore, ScopedAgentSemaphore } from "../concurrency.js"; import { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "../agent-heartbeat.js"; import { AutoClaimSnapshotManager } from "../auto-claim-snapshot.js"; @@ -169,6 +169,15 @@ export function buildCliAgentAwaitingInputNotificationPayload(input: { * await runtime.stop(); * ``` */ +function formatRuntimeGitDetectionWarning(workingDirectory: string, detection: Extract): string { + const stderr = detection.stderr.trim() || "git rev-parse --git-dir failed without stderr"; + const remedy = detection.reason === "dubious-ownership" + ? ` Resolve Git safe-directory ownership with: git config --global --add safe.directory "${workingDirectory}"` + : ""; + return `Project directory "${workingDirectory}" could not be verified as a Git repository. ` + + `Task execution will fail until the Git error is resolved. Git reported: ${stderr}.${remedy}`; +} + export class InProcessRuntime extends EventEmitter implements ProjectRuntime @@ -335,12 +344,15 @@ export class InProcessRuntime runtimeLog.warn(`reapOrphanWorktrees failed (continuing): ${msg}`); } - if (!(await isGitRepository(this.config.workingDirectory))) { + const gitDetection = await detectGitRepository(this.config.workingDirectory); + if (gitDetection.status === "not-repo") { 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.`, ); + } else if (gitDetection.status === "error") { + runtimeLog.warn(formatRuntimeGitDetectionWarning(this.config.workingDirectory, gitDetection)); } this.worktreePool = new WorktreePool(); diff --git a/packages/engine/src/worktree-pool.ts b/packages/engine/src/worktree-pool.ts index 2720af47d7..46625774a1 100644 --- a/packages/engine/src/worktree-pool.ts +++ b/packages/engine/src/worktree-pool.ts @@ -87,20 +87,76 @@ function getExecStdout(result: unknown): string { return ""; } -export async function isGitRepository(dir: string): Promise { +function stringifyExecOutput(value: unknown): string { + if (Buffer.isBuffer(value)) return value.toString("utf-8"); + return typeof value === "string" ? value : String(value ?? ""); +} + +function getExecErrorOutput(error: unknown): string { + if (!error || typeof error !== "object") return String(error ?? ""); + const record = error as { stderr?: unknown; message?: unknown }; + const stderr = stringifyExecOutput(record.stderr).trim(); + if (stderr) return stderr; + return stringifyExecOutput(record.message).trim(); +} + +export type GitRepoDetection = + | { status: "repo" } + | { status: "not-repo"; stderr: string } + | { status: "error"; reason: "dubious-ownership" | "git-missing" | "timeout" | "unknown"; stderr: string }; + +function classifyGitRepoDetectionError(error: unknown): GitRepoDetection { + const stderr = getExecErrorOutput(error); + const output = stderr || String(error ?? ""); + const errorRecord = (error && typeof error === "object") ? error as { code?: unknown; killed?: unknown; signal?: unknown } : {}; + + if (/not a git repo(sitory)?/i.test(output)) { + return { status: "not-repo", stderr: output }; + } + + if (/detected dubious ownership/i.test(output)) { + return { status: "error", reason: "dubious-ownership", stderr: output }; + } + + if (errorRecord.code === "ENOENT" || /(?:spawn\s+)?ENOENT/i.test(output) || /command not found/i.test(output)) { + return { status: "error", reason: "git-missing", stderr: output }; + } + + if (errorRecord.code === "ETIMEDOUT" || errorRecord.killed === true || /timed out|timeout/i.test(output)) { + return { status: "error", reason: "timeout", stderr: output }; + } + + return { status: "error", reason: "unknown", stderr: output }; +} + +/* +FNXC:Worktree 2026-07-10-00:00: +FN-7799 requires Git repository detection to distinguish a positive non-repo verdict from environmental Git failures. Dubious ownership on OneDrive-backed Windows Documents paths, git-not-on-PATH, index locks, and timeouts must never be reported as "not a Git repository", because that false negative permanently blocks valid repos across engine restarts. +*/ +export async function detectGitRepository(dir: string): Promise { try { await execAsync("git rev-parse --git-dir", { cwd: dir, encoding: "utf-8", + timeout: 10_000, + maxBuffer: 10 * 1024 * 1024, }); - return true; + return { status: "repo" }; } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - worktreePoolLog.log(`isGitRepository check failed for ${dir}: ${errorMessage}`); - return false; + const detection = classifyGitRepoDetectionError(err); + const reasonText = detection.status === "error" ? ` reason=${detection.reason}` : ""; + const stderrText = detection.status === "repo" ? "" : detection.stderr; + worktreePoolLog.log( + `detectGitRepository check failed for ${dir}: status=${detection.status}${reasonText} stderr=${stderrText}`, + ); + return detection; } } +export async function isGitRepository(dir: string): Promise { + return (await detectGitRepository(dir)).status === "repo"; +} + export async function describeRegisteredWorktrees(rootDir: string): Promise<{ rawOutput: string; canonicalized: string[] }> { try { const result = await execAsync("git worktree list --porcelain", {