FN-7799: fix false-negative Git repository detection blocking task execution

Replace the boolean isGitRepository() check with a tri-state Git detection so environmental git failures (dubious ownership, missing git binary, timeouts) are no longer misreported as "not a Git repository", which previously blocked all task execution in valid repos and survived engine restarts.

- Add detectGitRepository() in worktree-pool.ts returning repo / not-repo / error (with reason: dubious-ownership, git-missing, timeout, unknown), classified from git's stderr; bound the git rev-parse call with a 10s timeout and maxBuffer; keep isGitRepository() as a backward-compatible wrapper
- Route the executor dispatch preflight guard through detectGitRepository(): only emit the original "not a Git repository / run git init" fatal on a positive not-repo verdict; on error, throw a distinct accurate error naming the real git failure, including the safe.directory remedy for dubious ownership
- Route the in-process runtime startup warning through the same tri-state detection so it only warns "not a Git repository" on a positive not-repo verdict
- Add a regression test locking extractWorktreeConflictInfo() to NOT misclassify a dubious-ownership git worktree add failure as not-git-repo
- Add targeted tests across worktree-pool, executor-worktree, and in-process-runtime test suites covering repo/not-repo/dubious-ownership/git-missing/timeout classifications on Windows OneDrive-style and POSIX paths
- Add changeset and a docs/solutions/logic-errors write-up of the false-negative root cause and fix

Files changed:
 .changeset/fn-7799-git-detection-false-negative.md |  7 +++
 .../logic-errors/git-detection-false-not-repo.md   | 54 ++++++++++++++++
 .../engine/src/__tests__/executor-worktree.test.ts | 61 +++++++++++++++++++
 .../engine/src/__tests__/worktree-pool.test.ts     | 71 +++++++++++++++++++---
 packages/engine/src/executor.ts                    | 38 +++++++++---
 .../runtimes/__tests__/in-process-runtime.test.ts  | 53 ++++++++++++++--
 packages/engine/src/runtimes/in-process-runtime.ts | 16 ++++-
 packages/engine/src/worktree-pool.ts               | 66 ++++++++++++++++++--
 8 files changed, 334 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-7799
Fusion-Task-Lineage: 25a84283-bf47-472b-8a98-a10bf7e494de
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 17:12:00 -07:00
parent 915c1e0152
commit 725ce45c5d
8 changed files with 334 additions and 32 deletions

View File

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

View File

@@ -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 "<project-root>"
```
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.

View File

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

View File

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

View File

@@ -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<GitRepoDetection, { status: "error" }>): 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<Settings>): 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);

View File

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

View File

@@ -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<GitRepoDetection, { status: "error" }>): 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<ProjectRuntimeEvents>
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();

View File

@@ -87,20 +87,76 @@ function getExecStdout(result: unknown): string {
return "";
}
export async function isGitRepository(dir: string): Promise<boolean> {
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<GitRepoDetection> {
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<boolean> {
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", {