import { readFile, realpath } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { LRUMap } from "./LRUMap.js";
const cache = new LRUMap<string, string>(50);
* Resolve a git root directory (containing `.git` file or dir) to the
* canonical main-repository working directory.
*
* For a regular repo (`.git` is a directory) → returns `gitRoot` as-is.
* For a git worktree (`.git` is a file containing `gitdir: <path>`) →
* follows the chain to the main repo:
* `.git` file → gitdir → commondir → realpath validation → main repo root
*
* Behaviour mirrors the legacy upstream git worktree resolver,
* including the SECURITY validations:
* 1. layout check: `dirname(worktreeGitDir)` must equal
* `<commonDir>/worktrees`. Without this, a malicious repo could write a
* `.git` file pointing commondir at the victim's trusted dir.
* 2. back-link: `realpath(<worktreeGitDir>/gitdir)` must equal
* `realpath(gitRoot)/.git`. Prevents an attacker from borrowing a
* victim's existing worktree entry by guessing its path.
*
* Bare-repo worktree (commondir does not end in `.git`) → returns commondir
* itself as identity (legacy line 173-176).
*
* Submodules have no `commondir` file → readFile throws ENOENT and we
* gracefully fall through to returning `gitRoot` (legacy line 137-138).
*
* Failures (any thrown error) → return `gitRoot` (graceful fallback). PilotDeck
* never throws from this function: callers always have *some* canonical root.
*/
export async function resolveCanonicalRoot(gitRoot: string): Promise<string> {
const cached = cache.get(gitRoot);
if (cached !== undefined) {
return cached;
}
const result = await resolveImpl(gitRoot);
cache.set(gitRoot, result);
return result;
}
async function resolveImpl(gitRoot: string): Promise<string> {
let gitContent: string;
try {
gitContent = (await readFile(join(gitRoot, ".git"), "utf-8")).trim();
} catch {
return canonicalize(gitRoot);
}
if (!gitContent.startsWith("gitdir:")) {
return canonicalize(gitRoot);
}
const worktreeGitDir = resolve(gitRoot, gitContent.slice("gitdir:".length).trim());
let commonDirRaw: string;
try {
commonDirRaw = (await readFile(join(worktreeGitDir, "commondir"), "utf-8")).trim();
} catch {
return canonicalize(gitRoot);
}
const commonDir = resolve(worktreeGitDir, commonDirRaw);
if (resolve(dirname(worktreeGitDir)) !== join(commonDir, "worktrees")) {
return canonicalize(gitRoot);
}
let backlinkRaw: string;
try {
backlinkRaw = (await readFile(join(worktreeGitDir, "gitdir"), "utf-8")).trim();
} catch {
return canonicalize(gitRoot);
}
let backlinkResolved: string;
let gitRootResolved: string;
try {
backlinkResolved = await realpath(backlinkRaw);
gitRootResolved = await realpath(gitRoot);
} catch {
return canonicalize(gitRoot);
}
if (backlinkResolved !== join(gitRootResolved, ".git")) {
return canonicalize(gitRoot);
}
if (basename(commonDir) !== ".git") {
return canonicalize(commonDir);
}
return canonicalize(dirname(commonDir));
}
* Canonicalize a directory path: realpath (resolves symlinks like
* `/tmp -> /private/tmp` on macOS) + NFC unicode normalization.
*
* Why both sides realpath: regular repos return `gitRoot` (lexical), but
* worktrees return paths derived from git's own `gitdir:` content (which git
* writes with `strbuf_realpath()`). Without normalizing both, two worktrees
* of the same repo on macOS would yield different project IDs.
*
* Falls back to the lexical NFC-normalized path on realpath failure (e.g.
* dangling symlink or permission error).
*/
async function canonicalize(p: string): Promise<string> {
try {
const real = await realpath(p);
return real.normalize("NFC");
} catch {
return p.normalize("NFC");
}
}
* can reset both caches between cases. */
export function __clearResolveCanonicalRootCacheForTesting(): void {
cache.clear();
}