FN-7730: fix linked-worktree project-root resolution so board writes don't silently land in the wrong .fusion.db
Board mutations (fn_task_update, CEO override, direct SQL) issued from a pi-extension tool session could silently write into a task's throwaway, never-synced worktree-local .fusion/fusion.db instead of the true project root when git CLI resolution failed (missing git binary, Docker "dubious ownership" refusal, or a non-default settings.worktreesDir). This fixes root-cause resolution and adds regression coverage plus a docs writeup.
- getProjectRootFromGitLinkedWorktree now resolves a linked worktree's project root from git's own on-disk .git/commondir metadata via pure filesystem reads before falling back to the git rev-parse CLI, so writes no longer fall through to a local hydrated copy on git-invocation failure.
- Added getMainRepoRootFromGitFile and resolveCommonGitDirFromWorktreeGitFile helpers with FNXC:Storage comments documenting the FN-7730 root cause and fix rationale.
- Added packages/core/src/__tests__/pi-extensions-write-path-durability.test.ts regression coverage for the write-path durability invariant.
- Extended packages/core/src/__tests__/pi-extensions.test.ts with additional resolution-path assertions.
- Documented the failure mode and fix in docs/storage.md ("Silent board-mutation write loss (FN-7730)").
- Added a patch changeset for @runfusion/fusion describing the user-facing fix.
Files changed:
.changeset/fn-7730-worktree-project-root-resolution.md | 7 ++
docs/storage.md | 54 ++++++++++
packages/core/src/__tests__/pi-extensions-write-path-durability.test.ts | 98 ++++++++++++++++++
packages/core/src/__tests__/pi-extensions.test.ts | 87 +++++++++++++++-
packages/core/src/pi-extensions.ts | 114 +++++++++++++++++++++
5 files changed, 359 insertions(+), 1 deletion(-)
Fusion-Task-Id: FN-7730
Fusion-Task-Lineage: 00753a2d-a934-42cf-8fde-0f9b8ad98142
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7730-worktree-project-root-resolution.md
Normal file
7
.changeset/fn-7730-worktree-project-root-resolution.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Board mutations from a tool session no longer silently land in the wrong project database.
|
||||
category: fix
|
||||
dev: FN-7730. `packages/core/src/pi-extensions.ts`'s `getProjectRootFromGitLinkedWorktree` now resolves a linked worktree's project root from git's own on-disk `.git`/`commondir` metadata (pure filesystem reads) before falling back to the `git rev-parse` CLI. Previously, for a non-standard `settings.worktreesDir` location combined with a failing `git` invocation (missing binary, Docker "dubious ownership" `safe.directory` refusal, etc.), resolution silently fell through to the task's own locally-hydrated `.fusion/fusion.db` instead of the true project root, so `fn_task_update` and other pi-extension write tools wrote to a throwaway, never-synced-back copy with no error surfaced. See docs/storage.md "Silent board-mutation write loss (FN-7730)" for the full root-cause writeup.
|
||||
@@ -667,3 +667,57 @@ Hydrated worktree DB: 4 tasks, 12 task_documents, 3 artifacts
|
||||
A concrete recovered failure mode now covered by tests: when a worktree directory exists but its local `.fusion/` scratch state is missing, opening `DatabaseSync(<worktree>/.fusion/fusion.db)` can fail with `unable to open database file`. Hydration now performs destination bootstrap (`mkdir -p .fusion` + schema init) and retries the destination open once before degrading.
|
||||
|
||||
Failure policy remains strict non-blocking for genuinely unrecoverable cases: hydration warnings are logged, but worktree creation/execution continues. Examples that still intentionally degrade include source DB missing, destination write-permission failures, and irreconcilable schema/open errors after bootstrap retry. Canonical task data remains the root project TaskStore DB; if an agent needs non-hydrated rows immediately, `fn_task_show` remains the canonical fallback path.
|
||||
|
||||
## Silent board-mutation write loss (FN-7730)
|
||||
|
||||
**Symptom:** board mutations issued through `fn_task_update` (and other pi-extension write
|
||||
tools invoked from an executor agent session) appeared to succeed, but the change was never
|
||||
visible on the project-root `.fusion/fusion.db` that the engine and dashboard read from — with
|
||||
no error surfaced on any write path.
|
||||
|
||||
**Root cause — a DB-path resolution mismatch, not a WAL/durability defect.** Investigation
|
||||
(see task FN-7730's `research` document for the full trace) disproved the WAL `-shm`-unavailable
|
||||
and uncheckpointed-WAL/`.recover` hypotheses on a normally-writable POSIX filesystem: both
|
||||
failure conditions were reproduced directly and confirmed to throw a loud SQLite error rather
|
||||
than silently drop data with the `node:sqlite` bindings and `sqlite3` CLI version this repo
|
||||
requires. The real defect was in **project-root resolution** for pi-extension tool calls
|
||||
(`packages/cli/src/extension.ts`'s `resolveProjectRoot(cwd)` → `getProjectRootFromWorktree`
|
||||
in `packages/core/src/pi-extensions.ts`):
|
||||
|
||||
1. `getProjectRootFromWorktree` matches the standard `.worktrees/<id>` and
|
||||
`.fusion/worktrees/<id>` path shapes via hardcoded regex. A project with a non-default
|
||||
`settings.worktreesDir` (an arbitrary relative/absolute location — common in containerized
|
||||
deployments) doesn't match either pattern.
|
||||
2. The only remaining resolution path, `getProjectRootFromGitLinkedWorktree`, shelled out to
|
||||
`git rev-parse --git-common-dir`/`--git-dir` via `spawnSync`. A failing `git` invocation —
|
||||
missing binary in a minimal container image, Docker's "detected dubious ownership"
|
||||
`safe.directory` refusal on a bind-mounted repo owned by a different UID, or any other
|
||||
non-zero exit — returned `null` with **no thrown error** (by design, so a non-worktree `cwd`
|
||||
doesn't explode), but with no non-git fallback.
|
||||
3. `resolveProjectRoot`'s caller then fell back to a naive upward walk for the nearest ancestor
|
||||
with a `.fusion` directory. Because the task's own worktree already has a locally-hydrated
|
||||
`.fusion/fusion.db` (see "Per-Worktree DB Hydration" above), the walk matched **immediately**
|
||||
at the worktree itself, never reaching the true project root.
|
||||
4. Every subsequent write tool call for that agent session then silently landed in the
|
||||
throwaway, one-way-hydrated worktree-local `fusion.db` — never synced back to the project
|
||||
root — with zero error surfaced.
|
||||
|
||||
**Fix:** `getProjectRootFromGitLinkedWorktree` now resolves the linked-worktree relationship
|
||||
directly from git's own on-disk metadata first — the worktree's `.git` file (`gitdir: <path>`)
|
||||
plus its `commondir` sidecar, the same contract `git worktree add` writes — via plain
|
||||
filesystem reads. This has **no subprocess dependency and is unaffected by the `git` binary's
|
||||
availability or Docker UID/`safe.directory` permission checks**. The `git rev-parse` CLI path is
|
||||
kept as a secondary fallback for any layout the filesystem parser can't resolve, preserving
|
||||
prior behavior for those edge cases.
|
||||
|
||||
**Operator guidance:** if you suspect a write went to the wrong file, confirm which path a tool
|
||||
session resolves by checking `.fusion/fusion.db` for a `mtime` change immediately after the
|
||||
write in both the project root and (if applicable) the task's worktree directory. A
|
||||
non-standard `settings.worktreesDir` combined with a broken `git` CLI in the execution
|
||||
environment (missing binary, or `git config --global --add safe.directory '*'` not set for a
|
||||
bind-mounted repo owned by a different UID) was the confirmed trigger; setting
|
||||
`safe.directory` correctly or installing `git` resolves the underlying condition even without
|
||||
this fix, and this fix additionally removes the dependency on that condition being addressed.
|
||||
There is no data-recovery step needed once the resolver is fixed — no rows were corrupted, they
|
||||
were written to (and remain recoverable from) the worktree-local `.fusion/fusion.db` if it still
|
||||
exists on disk.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* FNXC:Storage 2026-07-09-00:00:
|
||||
* FN-7730 symptom verification. The original report: board mutations applied
|
||||
* through fn_task_update / CEO override / Release Manager closure / direct SQL
|
||||
* "appear to complete" but are never durably visible to the engine or a
|
||||
* subsequent read, with NO error surfaced. The root cause (see task FN-7730
|
||||
* `research` document) is a project-root resolution mismatch: a pi-extension
|
||||
* tool session running inside a non-standard-location linked worktree could
|
||||
* silently resolve its TaskStore against the worktree's own locally-hydrated
|
||||
* `.fusion/fusion.db` instead of the true project root, when the `git` CLI
|
||||
* path-resolution fallback failed (missing binary / Docker dubious-ownership /
|
||||
* NFS-overlay permission issues) with no thrown error.
|
||||
*
|
||||
* This test reproduces the full write -> second-connection-read shape end to
|
||||
* end: it resolves the project root the SAME way `packages/cli/src/extension.ts`
|
||||
* does (via `resolvePiExtensionProjectRoot`) from inside a non-standard-location
|
||||
* worktree with the git CLI made to fail, writes a dependency-edit mutation and
|
||||
* an archival (column move) mutation through a TaskStore opened at the resolved
|
||||
* path, and asserts a FRESH second TaskStore instance opened directly against
|
||||
* the true project root sees both mutations immediately — proving the write
|
||||
* path and the engine's read path now agree on the same physical database file.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("node:child_process");
|
||||
vi.resetModules();
|
||||
for (const dir of cleanupDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("write-path durability across a non-standard-location worktree (FN-7730)", () => {
|
||||
it("mutations written via the resolved project root are visible to a fresh second connection at the true root", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: vi.fn(() => ({ status: 1, stdout: "", stderr: "fatal: detected dubious ownership" })),
|
||||
};
|
||||
});
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "fn-7730-durability-root-"));
|
||||
const worktreeDir = mkdtempSync(join(tmpdir(), "fn-7730-durability-wt-"));
|
||||
cleanupDirs.push(root, worktreeDir);
|
||||
|
||||
mkdirSync(join(root, ".fusion"), { recursive: true });
|
||||
|
||||
// Fabricate the on-disk linked-worktree metadata for a NON-standard
|
||||
// location (outside `.worktrees`), matching a configured settings.worktreesDir.
|
||||
const worktreeGitDir = join(root, ".git", "worktrees", "durability-wt");
|
||||
mkdirSync(worktreeGitDir, { recursive: true });
|
||||
writeFileSync(join(worktreeGitDir, "commondir"), "../..\n");
|
||||
writeFileSync(join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`);
|
||||
|
||||
// Simulate hydrateWorktreeDb's ensureWorktreeSchema having already given
|
||||
// the worktree its own local `.fusion` directory (a decoy target the old
|
||||
// naive fallback walk would have matched).
|
||||
mkdirSync(join(worktreeDir, ".fusion"), { recursive: true });
|
||||
|
||||
const { resolvePiExtensionProjectRoot } = await import("../pi-extensions.js");
|
||||
const { TaskStore } = await import("../store.js");
|
||||
|
||||
const resolvedRoot = resolvePiExtensionProjectRoot(worktreeDir);
|
||||
expect(resolvedRoot).toBe(resolve(root));
|
||||
expect(resolvedRoot).not.toBe(resolve(worktreeDir));
|
||||
|
||||
// Write path: a TaskStore opened exactly the way the CLI extension's
|
||||
// getStore(cwd) would, at the resolved root.
|
||||
const writerStore = new TaskStore(resolvedRoot);
|
||||
await writerStore.init();
|
||||
|
||||
const depTarget = await writerStore.createTask({ description: "FN-7730 dependency target" });
|
||||
const task = await writerStore.createTask({ description: "FN-7730 mutated task" });
|
||||
await writerStore.updateTaskDependencies(task.id, { operation: "add", dependency: depTarget.id });
|
||||
await writerStore.archiveTask(task.id);
|
||||
await writerStore.close();
|
||||
|
||||
// Read path: a FRESH second TaskStore instance opened directly against the
|
||||
// true project root, modeling the engine's own store.
|
||||
const readerStore = new TaskStore(resolve(root));
|
||||
await readerStore.init();
|
||||
try {
|
||||
const reReadTask = await readerStore.getTask(task.id, { includeDeleted: true });
|
||||
expect(reReadTask).toBeTruthy();
|
||||
expect(reReadTask?.dependencies ?? []).toContain(depTarget.id);
|
||||
expect(reReadTask?.column).toBe("archived");
|
||||
} finally {
|
||||
await readerStore.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { getProjectRootFromWorktree, resolvePiExtensionProjectRoot } from "../pi-extensions.js";
|
||||
@@ -86,6 +86,91 @@ describe("getProjectRootFromWorktree", () => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// FNXC:Storage 2026-07-09-00:00: FN-7730 regression — a non-standard-location
|
||||
// linked worktree (settings.worktreesDir configured off the default
|
||||
// `.worktrees`, e.g. containerized deployments) must resolve to the true
|
||||
// project root via git's own on-disk `.git`/`commondir` worktree metadata,
|
||||
// WITHOUT depending on the `git` CLI succeeding. Previously the only
|
||||
// non-standard-location resolution path was `git rev-parse`, which silently
|
||||
// returns null (no thrown error) when the `git` binary is unavailable or
|
||||
// fails (e.g. Docker "detected dubious ownership" safe.directory refusal on a
|
||||
// bind-mounted repo) — collapsing resolution to a naive `.fusion` upward walk
|
||||
// that stops at the worktree's OWN locally-hydrated `.fusion/fusion.db`
|
||||
// instead of the real project root.
|
||||
it("resolves a non-standard-location linked worktree via .git/commondir metadata even when the git CLI is unavailable", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("node:child_process", () => ({
|
||||
spawnSync: vi.fn(() => ({ status: 1, stdout: "", stderr: "fatal: detected dubious ownership" })),
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "fn-7730-root-"));
|
||||
// Deliberately NOT under a `.worktrees`/`.fusion/worktrees` path so the
|
||||
// hardcoded regex fast paths in getProjectRootFromWorktree cannot match.
|
||||
const worktreeDir = mkdtempSync(join(tmpdir(), "fn-7730-custom-wt-"));
|
||||
try {
|
||||
const expectedRoot = resolve(root);
|
||||
mkdirSync(join(root, ".fusion"), { recursive: true });
|
||||
|
||||
// Fabricate the on-disk linked-worktree metadata git itself would write
|
||||
// for `git worktree add <worktreeDir>` — no real git repo/binary needed.
|
||||
const worktreeGitDir = join(root, ".git", "worktrees", "custom-wt");
|
||||
mkdirSync(worktreeGitDir, { recursive: true });
|
||||
writeFileSync(join(worktreeGitDir, "commondir"), "../..\n");
|
||||
writeFileSync(join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`);
|
||||
|
||||
const { getProjectRootFromWorktree: fresh } = await import("../pi-extensions.js");
|
||||
expect(fresh(worktreeDir)).toBe(expectedRoot);
|
||||
expect(fresh(join(worktreeDir, "subdir", "file.ts"))).toBe(expectedRoot);
|
||||
} finally {
|
||||
vi.doUnmock("node:child_process");
|
||||
vi.resetModules();
|
||||
rmSync(worktreeDir, { recursive: true, force: true });
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// FNXC:Storage 2026-07-09-00:00: FN-7730 symptom verification — reproduces the
|
||||
// exact silent-write-loss shape: a non-standard-location worktree that ALSO
|
||||
// already has its own locally-hydrated `.fusion/fusion.db` (created by
|
||||
// hydrateWorktreeDb for dependency-closure hydration) must still resolve to
|
||||
// the TRUE project root, not the worktree's decoy `.fusion` — even with the
|
||||
// git CLI unavailable. Before the fix this returned the worktree itself,
|
||||
// silently redirecting every fn_task_update-style write into the throwaway
|
||||
// hydration copy.
|
||||
it("prefers the true project root over a worktree's own hydrated .fusion when git is unavailable (FN-7730)", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("node:child_process", () => ({
|
||||
spawnSync: vi.fn(() => ({ status: 1, stdout: "", stderr: "fatal: detected dubious ownership" })),
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "fn-7730-root2-"));
|
||||
const worktreeDir = mkdtempSync(join(tmpdir(), "fn-7730-custom-wt2-"));
|
||||
try {
|
||||
const expectedRoot = resolve(root);
|
||||
mkdirSync(join(root, ".fusion"), { recursive: true });
|
||||
|
||||
const worktreeGitDir = join(root, ".git", "worktrees", "custom-wt2");
|
||||
mkdirSync(worktreeGitDir, { recursive: true });
|
||||
writeFileSync(join(worktreeGitDir, "commondir"), "../..\n");
|
||||
writeFileSync(join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`);
|
||||
|
||||
// Simulate hydrateWorktreeDb's ensureWorktreeSchema: the worktree gets its
|
||||
// own local `.fusion` directory as a hydration/decoy target.
|
||||
mkdirSync(join(worktreeDir, ".fusion"), { recursive: true });
|
||||
|
||||
const { resolvePiExtensionProjectRoot: fresh } = await import("../pi-extensions.js");
|
||||
expect(fresh(worktreeDir)).toBe(expectedRoot);
|
||||
expect(fresh(worktreeDir)).not.toBe(resolve(worktreeDir));
|
||||
} finally {
|
||||
vi.doUnmock("node:child_process");
|
||||
vi.resetModules();
|
||||
rmSync(worktreeDir, { recursive: true, force: true });
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("@fusion/core export surface", () => {
|
||||
|
||||
@@ -86,7 +86,121 @@ export function getProjectRootFromWorktree(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:Storage 2026-07-09-00:00:
|
||||
* FN-7730 root cause: board mutations issued from a pi-extension tool session
|
||||
* (fn_task_update, etc.) resolve their TaskStore against `resolveProjectRoot(cwd)`
|
||||
* in packages/cli/src/extension.ts, which calls getProjectRootFromWorktree(cwd)
|
||||
* with NO worktreesDirCandidates. When a project configures a non-default
|
||||
* `settings.worktreesDir` (packages/engine/src/worktree-paths.ts
|
||||
* resolveWorktreesDir supports an arbitrary relative/absolute location — common in
|
||||
* containerized deployments), neither hardcoded regex above matches, and the ONLY
|
||||
* remaining path was getProjectRootFromGitLinkedWorktree(), which shelled out to
|
||||
* `git rev-parse` via spawnSync. A failing git invocation (missing `git` binary in
|
||||
* a minimal container, Docker's "detected dubious ownership" safe.directory
|
||||
* refusal on a bind-mounted repo owned by a different UID, or any other non-zero
|
||||
* exit) returned null with NO thrown error — by design, so a non-worktree cwd
|
||||
* doesn't explode — but with no non-git fallback. resolveProjectRoot's caller then
|
||||
* fell back to a naive upward walk for the first ancestor with a `.fusion` dir,
|
||||
* which matched IMMEDIATELY at the task's own worktree (hydrateWorktreeDb's
|
||||
* ensureWorktreeSchema already created a local, one-way-hydrated `.fusion/fusion.db`
|
||||
* there for the dependency-closure copy). Every write tool call then silently
|
||||
* landed in that throwaway worktree-local db — never synced back to the project
|
||||
* root — with zero error surfaced. See task FN-7730 `research` document for the
|
||||
* full investigation.
|
||||
*
|
||||
* Fix: resolve the linked-worktree relationship directly from git's own on-disk
|
||||
* worktree metadata (the `.git` file + its `commondir` sidecar) FIRST. This is
|
||||
* pure filesystem I/O — no subprocess, no git-binary dependency, unaffected by
|
||||
* Docker UID/safe.directory restrictions. The `git rev-parse` CLI path is kept as
|
||||
* a secondary fallback for any layout the fs parser can't resolve (e.g. detached
|
||||
* gitdir configurations outside the standard worktree layout), preserving prior
|
||||
* behavior for those edge cases.
|
||||
*/
|
||||
function getMainRepoRootFromGitFile(cwd: string): string | null {
|
||||
let current = resolve(cwd);
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (!visited.has(current)) {
|
||||
visited.add(current);
|
||||
const gitPath = join(current, ".git");
|
||||
let gitStat: ReturnType<typeof statSync> | undefined;
|
||||
try {
|
||||
gitStat = statSync(gitPath);
|
||||
} catch {
|
||||
gitStat = undefined;
|
||||
}
|
||||
|
||||
if (gitStat?.isDirectory()) {
|
||||
// A `.git` directory means `current` IS a normal repo root (or the main
|
||||
// worktree), not itself a linked worktree — no linked-worktree parent
|
||||
// to resolve at this level.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (gitStat?.isFile()) {
|
||||
const commonGitDir = resolveCommonGitDirFromWorktreeGitFile(gitPath, current);
|
||||
if (!commonGitDir) {
|
||||
return null;
|
||||
}
|
||||
const parentRoot = commonGitDir.endsWith(`${sep}.git`) ? dirname(commonGitDir) : commonGitDir;
|
||||
return existsSync(join(parentRoot, ".fusion")) ? parentRoot : null;
|
||||
}
|
||||
|
||||
const parent = resolve(current, "..");
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a linked worktree's `.git` file (`gitdir: <path>`) and its sidecar
|
||||
* `commondir` file (relative or absolute path to the shared `.git` directory) —
|
||||
* the same on-disk contract `git worktree add` writes and `git rev-parse
|
||||
* --git-common-dir` reads, but via plain file reads instead of a subprocess.
|
||||
*/
|
||||
function resolveCommonGitDirFromWorktreeGitFile(gitFilePath: string, gitFileDir: string): string | null {
|
||||
let gitFileContent: string;
|
||||
try {
|
||||
gitFileContent = readFileSync(gitFilePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = gitFileContent.match(/^gitdir:\s*(.+)\s*$/m);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const worktreeGitDir = resolve(gitFileDir, match[1]!.trim());
|
||||
|
||||
const commondirPath = join(worktreeGitDir, "commondir");
|
||||
try {
|
||||
const commondirContent = readFileSync(commondirPath, "utf8").trim();
|
||||
if (commondirContent) {
|
||||
return resolve(worktreeGitDir, commondirContent);
|
||||
}
|
||||
} catch {
|
||||
// commondir sidecar missing/unreadable — fall through to the pattern-based
|
||||
// derivation below.
|
||||
}
|
||||
|
||||
// Standard worktree gitdir shape: `<repo>/.git/worktrees/<name>`. Strip the
|
||||
// `worktrees/<name>` suffix to recover `<repo>/.git`.
|
||||
const worktreesSuffix = /^(.*[\\/]\.git)[\\/]worktrees[\\/][^\\/]+[\\/]?$/;
|
||||
const suffixMatch = worktreeGitDir.match(worktreesSuffix);
|
||||
return suffixMatch ? suffixMatch[1]! : null;
|
||||
}
|
||||
|
||||
function getProjectRootFromGitLinkedWorktree(cwd: string): string | null {
|
||||
const fsResolvedRoot = getMainRepoRootFromGitFile(cwd);
|
||||
if (fsResolvedRoot) {
|
||||
return fsResolvedRoot;
|
||||
}
|
||||
|
||||
const spawnSync = getSpawnSync();
|
||||
if (!spawnSync) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user