@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
118 lines (117 loc) • 4.85 kB
JavaScript
/**
* Git worktree detection.
*
* Agent tooling (Claude Code and friends) creates throwaway worktrees under
* `<repo>/.claude/worktrees/<codename>`. Each one looks like a standalone repo
* to a naive check — it has a `.git` entry and a directory name — so phren used
* to register them as brand-new top-level projects with auto-generated
* codenames, splitting a repo's memory across directories that look like junk.
*
* These helpers map such a directory back to the repository it belongs to.
* Deliberately dependency-free (fs + path only) so every layer — project
* locator, init/setup, and the session hooks' project detection — can import it
* without creating a cycle.
*/
import * as fs from "fs";
import * as path from "path";
/**
* Directory names that hold agent-managed worktrees. A `worktrees` directory
* only counts when it sits inside a dot-directory (`.claude/worktrees`,
* `.git/worktrees`, `.agent/worktrees`), so an ordinary project that happens to
* contain a `worktrees/` folder is left alone.
*/
const WORKTREE_CONTAINER_NAMES = new Set(["worktrees", ".worktrees"]);
/** Marker inside a linked worktree's gitdir path: `<repo>/.git/worktrees/<name>`. */
const LINKED_WORKTREE_SEGMENT = `${path.sep}.git${path.sep}worktrees${path.sep}`;
/**
* If `dir` is (or lives inside) a git worktree, return the repository it belongs
* to. Returns `null` for ordinary directories, for the main working tree, and
* for git submodules — a submodule is a separate repository and *should* get its
* own project.
*/
export function resolveWorktreeParent(dir) {
const resolved = path.resolve(dir);
const agentParent = parentFromAgentWorktreePath(resolved);
if (agentParent)
return { repoRoot: agentParent, reason: "agent-worktree-dir" };
const linkedParent = parentFromLinkedWorktree(resolved);
if (linkedParent)
return { repoRoot: linkedParent, reason: "linked-worktree" };
return null;
}
/**
* Normalize a path for project attribution: worktrees resolve to the repository
* they came from, everything else is returned unchanged. Safe to call on any
* path — it never throws.
*/
export function resolveRepoRootForPath(dir) {
try {
return resolveWorktreeParent(dir)?.repoRoot ?? path.resolve(dir);
}
catch {
return path.resolve(dir);
}
}
// ── Internal helpers ─────────────────────────────────────────────────────────
/**
* Path-shape detection: `<repo>/.claude/worktrees/<name>` and friends. Handles
* the case where the worktree has already been deleted or was never a real git
* worktree, which the gitdir probe below cannot.
*/
function parentFromAgentWorktreePath(resolved) {
const segments = resolved.split(path.sep);
// Scan shallowest-first so a worktree nested inside another worktree still
// attributes to the outermost real repository in a single pass.
for (let i = 1; i < segments.length; i++) {
if (!WORKTREE_CONTAINER_NAMES.has(segments[i]))
continue;
// Only trust `worktrees` when it is tucked inside a tool's dot-directory.
let j = i - 1;
if (!segments[j]?.startsWith("."))
continue;
while (j >= 1 && segments[j].startsWith("."))
j--;
if (j < 1)
continue;
return segments.slice(0, j + 1).join(path.sep) || path.sep;
}
return null;
}
/**
* Git plumbing detection, without shelling out: in a linked worktree `.git` is a
* *file* containing `gitdir: <repo>/.git/worktrees/<name>`. In the main working
* tree `.git` is a directory, and in a submodule the gitdir points at
* `.git/modules/...` instead — neither is treated as a worktree.
*/
function parentFromLinkedWorktree(resolved) {
let current = resolved;
while (true) {
const parent = repoRootFromGitFile(path.join(current, ".git"));
if (parent)
return parent;
const next = path.dirname(current);
if (next === current)
return null;
current = next;
}
}
function repoRootFromGitFile(gitPath) {
let raw;
try {
if (!fs.statSync(gitPath).isFile())
return null;
raw = fs.readFileSync(gitPath, "utf8");
}
catch {
return null;
}
const match = raw.match(/^\s*gitdir:\s*(.+?)\s*$/m);
if (!match)
return null;
// Relative gitdir pointers are resolved against the directory holding `.git`.
const gitDir = path.resolve(path.dirname(gitPath), match[1].replace(/[/\\]/g, path.sep));
const idx = gitDir.indexOf(LINKED_WORKTREE_SEGMENT);
if (idx === -1)
return null; // submodule or something else — not a worktree
return gitDir.slice(0, idx);
}