Files
Codeman/src/live-worktree.ts
T
Codeman maintainer 0ec3a37bdd fix(files): refuse a .git swapped for a FIFO or symlink at open
classifyDotGit() checked with lstat and then opened by name, so a FIFO
swapped in between the two blocked a threadpool worker. The open itself
now refuses both, and the handle is judged.

Reported on #772.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:49:03 +01:00

101 lines
5.1 KiB
TypeScript

/**
* @fileoverview Which linked git worktree a session's pane is in.
*
* Claude Code's worktree feature moves the running `claude` process into
* `<repo>/.claude/worktrees/<name>` part way through a session. `Session.workingDir` is the
* session's identity and stays on the main checkout, so the Files panel kept listing a tree the
* agent had left (#765). `Session.paneCwd` does follow: tmux reads the process cwd, measured with
* Claude Code 2.1.318 going into a worktree on EnterWorktree and back on ExitWorktree. It also
* follows every `cd` in a shell session, so it cannot be the panel's root by itself. This module
* answers the narrower question: is the pane inside a linked worktree, and which one.
*
* A linked worktree is recognised by its `.git` FILE (`gitdir: <repo>/.git/worktrees/<id>`) rather
* than by running `git worktree list`: this runs from the pane watcher whenever a directory
* changes, and a file read starts no process, no repository config and no filter.
*
* Only a folder strictly below `workingDir` is ever an answer: the file routes already serve
* everything under it, so following a pane there widens nothing, and a planted `.git` file can
* only move where the listing STARTS inside that same folder. A worktree outside it
* (`git worktree add ../feature`) stays unfollowed, since listing it would need a second
* confinement root.
*
* That answer is lexical and only as fresh as the last time the pane moved: a symlink can take the
* folder's place afterwards without moving the pane. So nothing here is a confinement decision.
* `GET /api/sessions/:id/files` resolves the folder on every request and walks it only while it
* really lies inside `workingDir` (`canWalkLiveWorktree()` in file-routes.ts), and the routes keep
* judging every path against `workingDir`.
*
* Probes are bounded (#516). The folder can sit on a mount that stopped answering, and an
* `unknown` answer stops the walk rather than being read as "no `.git` here".
*
* @module live-worktree
*/
import { constants, promises as fs } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { probePathKind } from './utils/bounded-path-probe.js';
/** A linked worktree a session's pane is in. */
export interface LiveWorktree {
/** The worktree folder's name, for display. */
name: string;
/** Absolute path, spelled like the session's `workingDir`. */
path: string;
}
/** More than any real `.git` file. It is read only to recognise the first line. */
const MAX_DOT_GIT_FILE_BYTES = 4096;
/**
* Whether the text of a `.git` file names a linked worktree's git dir
* (`<common dir>/worktrees/<id>`), as opposed to a submodule's (`<common dir>/modules/<name>`).
* The path may be relative: `git worktree add --relative-paths` writes it that way.
*/
export function isLinkedWorktreeGitFile(text: string): boolean {
const gitDir = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
if (!gitDir) return false;
const segments = gitDir.split('/').filter(Boolean);
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
}
/** What `<dir>/.git` says: a linked worktree's root, a boundary the walk must stop at, or nothing. */
async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'> {
const dotGit = join(dir, '.git');
const kind = await probePathKind(dotGit);
if (kind === 'absent') return 'none';
// A `.git` directory is a repository of its own, and `unknown` is never read as absent.
if (kind !== 'file') return 'stop';
let handle: fs.FileHandle | undefined;
try {
// The probe follows symlinks and the file can change after it, so the open itself refuses:
// O_NOFOLLOW a symlink, and O_NONBLOCK a FIFO, which would otherwise wait for a writer and
// hold a threadpool worker for good. Whatever it did open is then judged on the handle.
handle = await fs.open(dotGit, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
if (!(await handle.stat()).isFile()) return 'stop';
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
return isLinkedWorktreeGitFile(buffer.toString('utf8', 0, bytesRead)) ? 'worktree' : 'stop';
} catch {
return 'stop';
} finally {
await handle?.close().catch(() => {});
}
}
/**
* The linked worktree strictly below `workingDir` that holds `cwd`, if any. Walks up from `cwd`
* and stops at `workingDir`, at a repository or submodule of its own, or at anything it cannot
* read. `workingDir` itself is never an answer: a session started in a worktree is already
* listing it. Never throws.
*/
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
if (!cwd?.startsWith('/')) return undefined;
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
const under = root === '/' ? '/' : `${root}/`;
for (let dir = cwd; dir !== root && dir.startsWith(under); dir = dirname(dir)) {
const found = await classifyDotGit(dir);
if (found === 'worktree') return { name: basename(dir), path: dir };
if (found === 'stop') return undefined;
}
return undefined;
}