FN-7385: preserve live worktrees with fresh fallback

Preserve active worktree owners by retrying acquisition on a fresh sibling checkout.

- Add executor fallback that detects active cleanup refusals and creates bounded sibling branches in fresh generated worktrees.\n- Cover DB-only, same-task workflow-step, existing-branch, and exhausted-suffix conflict paths with regression tests.\n- Document the live worktree conflict fallback and add a patch changeset for the published CLI package.\n\nFiles changed:\n .../fn-7385-active-worktree-fresh-fallback.md      |   7 +\n docs/architecture.md                               |   1 +\n .../__tests__/executor-worktree-conflict.test.ts   | 103 +++++++++++++-\n .../engine/src/__tests__/executor-worktree.test.ts | 153 ++++++++++++++++++---\n packages/engine/src/executor.ts                    |  90 ++++++++----\n 5 files changed, 312 insertions(+), 42 deletions(-)

Fusion-Task-Id: FN-7385

Fusion-Task-Lineage: 02c31656-1248-49c0-9063-0750cc8e41c6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 10:55:57 -07:00
parent d94c3591d3
commit af6e671e72
5 changed files with 312 additions and 42 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Recover live worktree conflicts by retrying with a fresh task worktree.
category: fix
dev: Executor worktree acquisition now preserves active-session conflict owners and retries bounded sibling branches instead of surfacing automatic cleanup failure.

View File

@@ -1818,6 +1818,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f
- **Executor pre-session liveness gate (FN-4935/FN-6861)**: the gate now skips for fresh acquisitions (`acquisition.source === "fresh"`), emits structured `not_usable_task_worktree:<classification>` diagnostics (including canonicalized registered-path snapshots) and a `worktree:incomplete-detected` audit event with `source: "executor-liveness-gate"`, while preserving the existing `taskDoneRetryCount` / `MAX_TASK_DONE_REQUEUE_RETRIES` requeue contract. The project repo root is never a usable task worktree even though it is a legitimately registered Git worktree; `classifyTaskWorktree` returns `repo-root` for canonical root-equal paths, and resume acquisition treats that as self-healable stale metadata by clearing `task.worktree` and creating a fresh checkout under the configured worktrees directory. FN-5772 adds a bounded nested-root self-heal: when `task.worktree` points at a strict descendant of a registered worktree root inside the configured worktrees dir, executor re-anchors `task.worktree` to the git top-level, emits `worktree:reanchored` (`fromPath`, `toPath`, `source`), and proceeds; repo-root/outside-dir/unregistered top-level mismatches still fail. FN-4651 `worktreeSessionRetryCount` remains scoped to the in-review/session-start recovery path.
- **Stale self-owned active-session reconcile on conflict cleanup (FN-4973)**: when executor worktree-conflict cleanup finds only a same-task stale `activeSessionRegistry` entry and no live in-memory `activeWorktrees` binding for that task/path, it must unregister the stale entry before `removeWorktree` (plus one-shot backstop reconcile on same-task `ActiveSessionWorktreeRemovalError` races). Foreign-task entries remain protected by FN-4811 and must never be reconciled by the requesting task.
- **Same-task stale removal canonical helper (FN-5346)**: executor same-task cleanup paths now route pre-removal reconciliation through `reconcileSelfOwnedActiveSessionForRemoval` (via executor helper wiring), so stale self-owned `activeSessionRegistry` residues are cleared only when no live in-memory binding exists, while FN-4811 foreign-owner refusals and live-owner protections remain intact.
- **Live worktree conflict fallback (FN-7385)**: when branch-conflict cleanup is refused because the conflicting path belongs to an active executor/workflow-step session (including same-task process-active sessions, foreign `activeWorktrees` owners, or DB-only live owners), executor acquisition must preserve that path and retry with a fresh generated worktree plus bounded sibling branch. Stale/non-live conflicts still use the existing cleanup/reclaim path, and unrecoverable non-active cleanup failures remain actionable errors.
- **Task title/ID drift (FN-4898)**: active and archived title writes normalize foreign embedded `FN-NNN` tokens via `packages/core/src/task-title-id-drift.ts`. Empty placeholder groups (`()`, `[]`, `{}`) left behind by token stripping are also removed in both `normalizeTitleForTaskId` and `sanitizeTitle` (FN-4978). Lineage is preserved in `sourceParentTaskId` / description markers, not title embeds. FN-5077 extends drift normalization to reject dangling-connector fragments (`"Close as duplicate of"`) so token-stripped residuals never persist as task titles.
- **PR-conflict reclaim wiring (FN-4763)**: GitHub PR refresh now persists normalized `prInfo.mergeable` conflict state and, when conflicting, funnels tasks into self-healing’s existing reclaim machinery (`reclaimPrConflictForTask` / `reclaim-pr-conflicts` stage) so branch-conflict handling stays centralized with existing `inspectBranchConflict` outcomes and unrecoverable pause semantics. PR refresh also captures `prInfo.conflictDiagnostics` (conflicting files + suggested local recovery commands) for dashboard surfacing.
- **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level stale/ conflict reclaim stays native. Orphan `fusion/*` branches are operator-managed via standard git tooling (no auto-rescue task filing).

View File

@@ -4,7 +4,7 @@ import { TaskExecutor } from "../executor.js";
import { activeSessionRegistry } from "../active-session-registry.js";
import { ActiveSessionWorktreeRemovalError } from "../worktree-backend.js";
import * as worktreePoolModule from "../worktree-pool.js";
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
import { createMockStore, mockedGenerateWorktreeName, resetExecutorMocks } from "./executor-test-helpers.js";
const CONFLICT_PATH = "/tmp/test/.worktrees/stale-self-owned";
@@ -122,6 +122,107 @@ describe("FN-4973: executor worktree conflict cleanup", () => {
);
});
it("falls back to a fresh sibling branch for a DB-only live owner", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
executorAllowSiblingBranchRename: true,
});
store.listTasks.mockResolvedValue([
{ id: "FN-LIVE", worktree: CONFLICT_PATH, column: "in-progress", paused: false },
]);
mockedGenerateWorktreeName.mockReturnValueOnce("fresh-eagle");
const executor = new TaskExecutor(store, "/tmp/test");
const createSpy = vi.spyOn(executor as any, "tryCreateWorktree").mockResolvedValue({
path: "/tmp/test/.worktrees/fresh-eagle",
branch: "fusion/fn-4973-2",
});
const result = await (executor as any).handleWorktreeConflict(
CONFLICT_PATH,
"fusion/fn-4973",
"/tmp/test/.worktrees/stale-self-owned",
"FN-4973",
"main",
0,
true,
await store.getSettings(),
);
expect(result).toEqual({ path: "/tmp/test/.worktrees/fresh-eagle", branch: "fusion/fn-4973-2" });
expect(createSpy).toHaveBeenCalledWith(
"fusion/fn-4973-2",
"/tmp/test/.worktrees/fresh-eagle",
"FN-4973",
"fusion/fn-4973",
0,
0,
true,
expect.any(Object),
);
});
it("keeps non-live cleanup failures unrecoverable", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
vi.spyOn(executor as any, "cleanupConflictingWorktree").mockResolvedValue(false);
const result = await (executor as any).handleWorktreeConflict(
CONFLICT_PATH,
"fusion/fn-4973",
"/tmp/test/.worktrees/stale-self-owned",
"FN-4973",
"main",
0,
true,
await store.getSettings(),
);
expect(result).toBeNull();
});
it("bounds fresh sibling fallback when every generated branch is already used", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "workflow-step", ownerKey: "FN-4973/workflow-step" });
(executor as any).addActiveWorktree("FN-4973", CONFLICT_PATH);
mockedGenerateWorktreeName
.mockReturnValueOnce("fresh-2")
.mockReturnValueOnce("fresh-3")
.mockReturnValueOnce("fresh-4")
.mockReturnValueOnce("fresh-5")
.mockReturnValueOnce("fresh-6");
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new ActiveSessionWorktreeRemovalError({
worktreePath: CONFLICT_PATH,
taskId: "FN-4973",
kind: "workflow-step",
ownerKey: "FN-4973/workflow-step",
reason: worktreePoolModule.RemovalReason.ExecutorDispose,
}),
);
vi.spyOn(executor as any, "tryCreateWorktree").mockImplementation(async (branch: string) => {
throw new Error(`fatal: '${branch}' is already used by worktree at '/tmp/test/.worktrees/other'`);
});
await expect((executor as any).handleWorktreeConflict(
CONFLICT_PATH,
"fusion/fn-4973",
"/tmp/test/.worktrees/stale-self-owned",
"FN-4973",
"main",
0,
true,
await store.getSettings(),
)).rejects.toThrow(/live conflicting worktree .* was preserved and suffixes -2 through -6/);
});
it("reconciles once on race-window ActiveSessionWorktreeRemovalError then retries removal", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");

View File

@@ -13,6 +13,8 @@ import { WorktreePool } from "../worktree-pool.js";
import * as worktreePoolModule from "../worktree-pool.js";
import { BranchConflictError } from "../branch-conflicts.js";
import * as branchConflictModule from "../branch-conflicts.js";
import { activeSessionRegistry } from "../active-session-registry.js";
import { ActiveSessionWorktreeRemovalError } from "../worktree-backend.js";
import { generateWorktreeName, slugify } from "../worktree-names.js";
import type { Task, TaskDetail } from "@fusion/core";
import { SessionManager } from "@earendil-works/pi-coding-agent";
@@ -136,7 +138,6 @@ describe("TaskExecutor with semaphore", () => {
});
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "failed", error: expect.any(String) });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
expect(onError).toHaveBeenCalled();
});
@@ -1173,6 +1174,127 @@ describe("TaskExecutor worktree recovery", () => {
);
});
it("falls back to a fresh worktree when same-task workflow-step cleanup is refused", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
executorAllowSiblingBranchRename: true,
});
store.listTasks.mockResolvedValue([]);
const conflictPath = "/tmp/test/.worktrees/keen-eagle";
const freshPath = "/tmp/test/.worktrees/maple-delta";
activeSessionRegistry.registerPath(conflictPath, { taskId: "FN-050", kind: "workflow-step", ownerKey: "FN-050/workflow-step" });
mockedGenerateWorktreeName
.mockReturnValueOnce("swift-falcon")
.mockReturnValueOnce("maple-delta");
mockedExistsSync.mockImplementation((path) => path === conflictPath);
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new ActiveSessionWorktreeRemovalError({
worktreePath: conflictPath,
taskId: "FN-050",
kind: "workflow-step",
ownerKey: "FN-050/workflow-step",
reason: worktreePoolModule.RemovalReason.ExecutorDispose,
}),
);
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command.includes('git worktree add -b "fusion/fn-050"')) {
const error: any = new Error(
`fatal: 'fusion/fn-050' is already used by worktree at '${conflictPath}'`,
);
error.stderr = Buffer.from(error.message);
throw error;
}
return Buffer.from("");
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask());
expect(removeSpy).toHaveBeenCalledTimes(1);
expect(activeSessionRegistry.lookupByPath(conflictPath)?.taskId).toBe("FN-050");
expect(store.updateTask).toHaveBeenCalledWith(
"FN-050",
expect.objectContaining({ worktree: freshPath, branch: "fusion/fn-050-2" }),
);
expect(store.updateTask).not.toHaveBeenCalledWith(
"FN-050",
expect.objectContaining({ error: expect.stringContaining("automatic cleanup failed") }),
);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Preserved active conflicting worktree"),
`${conflictPath} -> ${freshPath}`,
);
});
it("falls back to a fresh worktree when existing-branch add hits an active conflict", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
executorAllowSiblingBranchRename: true,
});
store.listTasks.mockResolvedValue([]);
const conflictPath = "/tmp/test/.worktrees/keen-eagle";
const freshPath = "/tmp/test/.worktrees/opal-otter";
activeSessionRegistry.registerPath(conflictPath, { taskId: "FN-050", kind: "workflow-step", ownerKey: "FN-050/workflow-step" });
mockedGenerateWorktreeName
.mockReturnValueOnce("swift-falcon")
.mockReturnValueOnce("opal-otter");
mockedExistsSync.mockImplementation((path) => path === conflictPath);
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new ActiveSessionWorktreeRemovalError({
worktreePath: conflictPath,
taskId: "FN-050",
kind: "workflow-step",
ownerKey: "FN-050/workflow-step",
reason: worktreePoolModule.RemovalReason.ExecutorDispose,
}),
);
mockedExecSync.mockImplementation((cmd: string | string[]) => {
const command = typeof cmd === "string" ? cmd : cmd[0];
if (command.includes('git worktree add -b "fusion/fn-050"')) {
const error: any = new Error("fatal: A branch named 'fusion/fn-050' already exists.");
error.stderr = Buffer.from(error.message);
throw error;
}
if (command.includes(`git worktree add "/tmp/test/.worktrees/swift-falcon" "fusion/fn-050"`)) {
const error: any = new Error(
`fatal: 'fusion/fn-050' is already used by worktree at '${conflictPath}'`,
);
error.stderr = Buffer.from(error.message);
throw error;
}
return Buffer.from("");
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask());
expect(activeSessionRegistry.lookupByPath(conflictPath)?.kind).toBe("workflow-step");
expect(store.updateTask).toHaveBeenCalledWith(
"FN-050",
expect.objectContaining({ worktree: freshPath, branch: "fusion/fn-050-2" }),
);
const logMessages = store.logEntry.mock.calls.map((call: any[]) => String(call[1] ?? ""));
expect(logMessages.some((message: string) => message.includes("automatic cleanup failed"))).toBe(false);
});
it("generates new worktree name when conflicting worktree belongs to active task in legacy rename mode (FN-4811: refuses force-removal of active worktree)", async () => {
// FN-4811: When the conflicting worktree is bound to a live in-progress task, the
// executor MUST NOT force-remove it (doing so yanks the active session's filesystem
@@ -1751,6 +1873,7 @@ describe("TaskExecutor worktree recovery", () => {
});
it("handles locked worktree by unlocking before removal", async () => {
vi.useRealTimers();
const store = createMockStore();
let callCount = 0;
@@ -1824,7 +1947,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
expect(worktreeAddCalls[0][0]).toContain("fusion/fn-059");
});
it("creates worktree from HEAD when baseBranch is not set", async () => {
it("creates worktree from integration branch when baseBranch is not set", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
@@ -1833,16 +1956,12 @@ describe("TaskExecutor dependency-based worktree creation", () => {
// no baseBranch
}));
// The git worktree add command should NOT include a startPoint
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add -b"),
);
expect(worktreeAddCalls.length).toBeGreaterThan(0);
// Command format: git worktree add -b "branch" "path" (no extra ref after path)
const cmd = worktreeAddCalls[0][0] as string;
// Count quoted segments: branch + path = 2 quoted args
const quoted = cmd.match(/"[^"]+"/g) || [];
expect(quoted).toHaveLength(2);
expect(cmd).toContain('"main"');
});
it("logs base branch in worktree creation log entry", async () => {
@@ -1862,7 +1981,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
);
});
it("does not mention base branch in log when baseBranch is not set", async () => {
it("logs integration branch in worktree creation log when baseBranch is not set", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
@@ -1870,12 +1989,11 @@ describe("TaskExecutor dependency-based worktree creation", () => {
id: "FN-063",
}));
// Check that log entry does NOT mention "based on"
const logCalls = store.logEntry.mock.calls.filter(
(call: any[]) => typeof call[1] === "string" && call[1].includes("Worktree created"),
);
expect(logCalls.length).toBeGreaterThan(0);
expect(logCalls[0][1]).not.toContain("based on");
expect(logCalls[0][1]).toContain("based on main");
});
it("retries worktree creation after cleaning up conflicting worktree", async () => {
@@ -1909,11 +2027,6 @@ describe("TaskExecutor dependency-based worktree creation", () => {
settings: expect.any(Object),
}),
);
expect(mockedExecSync).toHaveBeenCalledWith(
'git branch -D "fusion/fn-064"',
expect.objectContaining({ cwd: "/tmp/test" }),
);
const worktreeCreateCalls = mockedExecSync.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes('git worktree add') && call[0].includes("-b"),
);
@@ -1990,7 +2103,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
);
});
it("passes undefined to pool prepareForTask when no baseBranch", async () => {
it("passes integration branch to pool prepareForTask when no baseBranch", async () => {
const pool = new WorktreePool();
pool.release("/tmp/test/.worktrees/idle-wt");
mockedExistsSync.mockImplementation(
@@ -2018,7 +2131,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
expect(prepareSpy).toHaveBeenCalledWith(
"/tmp/test/.worktrees/idle-wt",
"fusion/fn-065",
undefined,
"main",
{ allowSiblingBranchRename: false, repoDir: "/tmp/test", requestingTaskId: "FN-065" },
);
});
@@ -2374,7 +2487,11 @@ describe("TaskExecutor worktree pool integration", () => {
paused: true,
}),
);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenCalledWith(
"FN-020",
"todo",
expect.objectContaining({ preserveProgress: true, preserveResumeState: true, preserveWorktree: false }),
);
});
});

View File

@@ -15845,7 +15845,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
* Handle "already used by worktree" conflict.
* Either generates a new worktree name (if conflicting worktree is in use by active task)
* or cleans up the conflicting worktree and retries.
*
*
* @returns The worktree path if recovery succeeded, null if recovery failed
*/
private async handleWorktreeConflict(
@@ -15858,6 +15858,15 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
allowSiblingBranchRename = false,
settings: Partial<Settings> = {},
): Promise<{ path: string; branch: string } | null> {
const tryFreshFallback = () => this.tryFreshWorktreeAfterLiveConflict({
conflictPath,
branch,
taskId,
startPoint,
attemptNumber,
allowSiblingBranchRename,
settings,
});
const shouldGenerateNewName = await this.shouldGenerateNewWorktreeName(
conflictPath,
taskId,
@@ -15922,28 +15931,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
throw new Error(`Branch ${branch} conflict could not be auto-resolved`);
}
const conflictStartPoint = branch;
const newPath = resolveTaskWorktreePath(this.rootDir, settings, generateWorktreeName(this.rootDir, settings));
for (let suffix = 2; suffix <= 6; suffix++) {
const suffixedBranch = `${branch}-${suffix}`;
try {
await this.store.logEntry(
taskId,
`Conflicting worktree in use by active task, trying new path with branch ${suffixedBranch}`,
newPath,
);
return await this.tryCreateWorktree(suffixedBranch, newPath, taskId, conflictStartPoint, attemptNumber, 0, true, settings);
} catch (suffixErr: unknown) {
const info = this.extractWorktreeConflictInfo(suffixErr);
if (info.type === "already-used") {
continue;
}
throw suffixErr;
}
}
throw new Error(
`Cannot create branch for task: "${branch}" and suffixes -2 through -6 are all in use by other worktrees`,
);
return tryFreshFallback();
}
const cleanupSuccess = await this.cleanupConflictingWorktree(conflictPath, branch, taskId);
@@ -15952,9 +15940,65 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
return this.tryCreateWorktree(branch, path, taskId, startPoint, attemptNumber, 0, allowSiblingBranchRename, settings);
}
if (await this.isLiveCleanupRefusal(conflictPath, taskId)) {
return tryFreshFallback();
}
return null;
}
private async tryFreshWorktreeAfterLiveConflict(input: {
conflictPath: string;
branch: string;
taskId: string;
startPoint?: string;
attemptNumber?: number;
allowSiblingBranchRename: boolean;
settings: Partial<Settings>;
}): Promise<{ path: string; branch: string }> {
const { conflictPath, branch, taskId, attemptNumber, allowSiblingBranchRename, settings } = input;
if (!allowSiblingBranchRename) {
throw new Error(`Branch ${branch} conflict could not be auto-resolved`);
}
const conflictStartPoint = branch;
for (let suffix = 2; suffix <= 6; suffix++) {
const suffixedBranch = `${branch}-${suffix}`;
const newPath = resolveTaskWorktreePath(this.rootDir, settings, generateWorktreeName(this.rootDir, settings));
try {
await this.store.logEntry(
taskId,
`Preserved active conflicting worktree and retrying with fresh worktree branch ${suffixedBranch}`,
`${conflictPath} -> ${newPath}`,
);
/*
* FNXC:ExecutorWorktree 2026-07-01-00:00:
* Active-session cleanup refusal must allocate a fresh worktree/branch instead of bubbling automatic cleanup failure. Removing the live conflicting path violates the FN-4811 invariant, so bounded sibling branches preserve the owner while letting the requesting task continue.
*/
return await this.tryCreateWorktree(suffixedBranch, newPath, taskId, conflictStartPoint, attemptNumber, 0, true, settings);
} catch (suffixErr: unknown) {
const info = this.extractWorktreeConflictInfo(suffixErr);
if (info.type === "already-used") {
continue;
}
throw suffixErr;
}
}
throw new Error(
`Cannot create branch for task: "${branch}"; live conflicting worktree ${conflictPath} was preserved and suffixes -2 through -6 are all in use by other worktrees`,
);
}
private async isLiveCleanupRefusal(worktreePath: string, taskId: string): Promise<boolean> {
const activeOwner = await this.findActiveWorktreeOwner(worktreePath, taskId);
if (activeOwner !== null) return true;
const activeRecord = activeSessionRegistry.lookupByPath(worktreePath);
if (!activeRecord) return false;
if (activeRecord.taskId !== taskId) return true;
return executingTaskLock.has(taskId) || this.hasActiveWorktreeBinding(taskId, worktreePath);
}
/**
* Check if a path is registered as a git worktree.
*/