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:
7
.changeset/fn-7799-git-detection-false-negative.md
Normal file
7
.changeset/fn-7799-git-detection-false-negative.md
Normal 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.
|
||||
54
docs/solutions/logic-errors/git-detection-false-not-repo.md
Normal file
54
docs/solutions/logic-errors/git-detection-false-not-repo.md
Normal 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.
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user