/** * Upward search for project configuration, shared across extensions. * * Claude anchors project config (.claude/*, .mcp.json, CLAUDE.local.md) at the * project root, so a session started in a subdirectory must still find it. The * walk runs from cwd up to the repository root and no further: without the bound, * config planted in a world-writable ancestor such as /tmp would be offered to * every session beneath it. With no project marker the extent is unknown, so only * cwd is considered. The project-approval walk uses the same markers, so whatever * these find is exactly what that walk gated. */ import * as fs from 'node:fs' import * as path from 'node:path' /** The project root marker. `.git` is a file in worktrees and submodules, a directory * in an ordinary clone. * * Only `.git`: a package.json marker would make every package of a monorepo its own * project (its own memory directory, settings.local.json, CLAUDE_PROJECT_DIR and trust * decision), and a repository can add a package.json wherever it likes, so a marker it * controls is a marker it can move. Claude's project is the repository. */ export const ROOT_MARKERS = ['.git'] /** Project root at or above `from`, or undefined outside a repository. */ export function repoRoot(from: string): string | undefined { const root = gitRoot(from) if (root === undefined) return undefined return mainCheckout(root) } /** The root of the checkout a session runs in, or `from` itself outside one. Claude's * `CLAUDE_PROJECT_DIR` is "the project root where the session started", and `/`-rooted path * rules anchor there. In a worktree that is the worktree, not the main checkout repoRoot * resolves to: right for shared state (settings.local.json, auto memory), wrong here, where * a hook script or a rule anchor must land in the tree the session is editing. */ export function checkoutRoot(from: string): string { return gitRoot(from) ?? from } /** Whether two paths are the same file or directory once symlinks resolve. A session at * `$HOME` finds the user's own `~/.claude/{rules,agents,CLAUDE.md}` as its "project" ones; * comparing by realpath also catches a stow-style symlinked `~/.claude`. */ export function sameLocation(a: string, b: string): boolean { const resolve = (target: string): string => { try { return fs.realpathSync(target) } catch { return path.resolve(target) } } return resolve(a) === resolve(b) } /** The git checkout at or above `from`, or undefined outside one. * * Narrower than repoRoot on purpose: repoRoot resolves a worktree to its main checkout, * which is the right key for shared state (settings.local.json, auto memory) but is a * sibling of the worktree, never an ancestor. The upward walks above bound themselves * here instead, since a boundary that is not on the path from cwd to / is never reached * and the walk would run on to the filesystem root. `.git` cannot be committed into a * repository, so it is not a marker the repository can add to move its own key. */ export function gitRoot(from: string): string | undefined { let currentDir = from while (true) { if (fs.existsSync(path.join(currentDir, '.git'))) return currentDir const parentDir = path.dirname(currentDir) if (parentDir === currentDir) return undefined currentDir = parentDir } } /** The main checkout for a git directory. * * A worktree carries a `.git` FILE holding `gitdir:
/.git/worktrees/`, so * resolving it gives the checkout the repository's state actually belongs to. Claude * reads settings.local.json from "the file at the main checkout's root" and shares one * auto memory directory across "all worktrees and subdirectories within the same repo", * so a worktree is not its own project. An unreadable or unexpected `.git` file leaves * the directory as its own root, which is the safe direction. */ function mainCheckout(root: string): string { const dotGit = path.join(root, '.git') let pointer: string try { if (!fs.statSync(dotGit).isFile()) return root pointer = fs.readFileSync(dotGit, 'utf-8') } catch { return root } // Parsed rather than matched: the file is one `gitdir: ` line, and a regex // over an arbitrary-length path is a backtracking cost for nothing. const [firstLine = ''] = pointer.split('\n') const prefix = 'gitdir:' if (!firstLine.startsWith(prefix)) return root const target = firstLine.slice(prefix.length).trim() if (!target) return root //
/.git/worktrees/ ->
const worktreeDir = path.resolve(root, target) const marker = `${path.sep}.git${path.sep}worktrees${path.sep}` const cut = worktreeDir.lastIndexOf(marker) if (cut === -1) return root // The .git file is attacker-writable: an unpacked archive can ship one naming any // directory, and following it as text moved the repository root, and with it the // CLAUDE.md import boundary, to wherever it pointed. if (!pointsBackAt(worktreeDir, dotGit)) return root return worktreeDir.slice(0, cut) } /** Whether a worktree's admin directory names `dotGit` as its worktree. git writes * `
/.git/worktrees//gitdir` holding the path of the worktree's `.git` file * (relative to that directory since git 2.48), and nothing outside an archive can be made * to. Compared by realpath: git records the real path, a session cwd may be a symlink. */ function pointsBackAt(worktreeDir: string, dotGit: string): boolean { try { const [line = ''] = fs.readFileSync(path.join(worktreeDir, 'gitdir'), 'utf-8').split('\n') return fs.realpathSync(path.resolve(worktreeDir, line.trim())) === fs.realpathSync(dotGit) } catch { return false } } function statOf(target: string): fs.Stats | null { try { return fs.statSync(target) } catch { return null } } function findNearest(cwd: string, relative: string, wantDir: boolean): string | null { const boundary = gitRoot(cwd) ?? cwd let currentDir = cwd while (true) { const candidate = path.join(currentDir, relative) const stat = statOf(candidate) if (stat && (wantDir ? stat.isDirectory() : stat.isFile())) return candidate if (currentDir === boundary) return null const parentDir = path.dirname(currentDir) if (parentDir === currentDir) return null currentDir = parentDir } } /** Nearest `relative` directory at or above `cwd`, stopping at the repository root. */ export function findNearestDir(cwd: string, relative: string): string | null { return findNearest(cwd, relative, true) } /** Nearest `relative` file at or above `cwd`, stopping at the repository root. */ export function findNearestFile(cwd: string, relative: string): string | null { return findNearest(cwd, relative, false) } /** Every `relative` directory between cwd and the repository root, nearest first, * matching Claude's "every .claude/ between the working directory and the * repository root" discovery where the entry closest to cwd wins a name clash. */ export function ancestorDirs(cwd: string, relative: string): string[] { const boundary = gitRoot(cwd) ?? cwd const found: string[] = [] let currentDir = cwd while (true) { const candidate = path.join(currentDir, relative) if (statOf(candidate)?.isDirectory()) found.push(candidate) if (currentDir === boundary) break const parentDir = path.dirname(currentDir) if (parentDir === currentDir) break currentDir = parentDir } return found } /** Every `relative` file between the repository root and cwd, ordered root first, * matching Claude's root-down ordering for hierarchy-loaded context. */ export function ancestorFiles(cwd: string, relative: string): string[] { const boundary = gitRoot(cwd) ?? cwd const found: string[] = [] let currentDir = cwd while (true) { const candidate = path.join(currentDir, relative) if (statOf(candidate)?.isFile()) found.push(candidate) if (currentDir === boundary) break const parentDir = path.dirname(currentDir) if (parentDir === currentDir) break currentDir = parentDir } return found.reverse() }