Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
50 kB · 1519 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520import { feature } from 'bun:bundle'import chalk from 'chalk'import { spawnSync } from 'child_process'import { copyFile, mkdir, readdir, readFile, stat, symlink, utimes,} from 'fs/promises'import ignore from 'ignore'import { basename, dirname, join } from 'path'import { saveCurrentProjectConfig } from './config.js'import { getCwd } from './cwd.js'import { logForDebugging } from './debug.js'import { errorMessage, getErrnoCode } from './errors.js'import { execFileNoThrow, execFileNoThrowWithCwd } from './execFileNoThrow.js'import { parseGitConfigValue } from './git/gitConfigParser.js'import { getCommonDir, readWorktreeHeadSha, resolveGitDir, resolveRef,} from './git/gitFilesystem.js'import { findCanonicalGitRoot, findGitRoot, getBranch, getDefaultBranch, gitExe,} from './git.js'import { executeWorktreeCreateHook, executeWorktreeRemoveHook, hasWorktreeCreateHook,} from './hooks.js'import { containsPathTraversal } from './path.js'import { getPlatform } from './platform.js'import { getInitialSettings, getRelativeSettingsFilePathForSource,} from './settings/settings.js'import { sleep } from './sleep.js'import { isInITerm2 } from './swarm/backends/detection.js'
const VALID_WORKTREE_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/const MAX_WORKTREE_SLUG_LENGTH = 64
/** * Validates a worktree slug to prevent path traversal and directory escape. * * The slug is joined into `.claude/worktrees/<slug>` via path.join, which * normalizes `..` segments — so `../../../target` would escape the worktrees * directory. Similarly, an absolute path (leading `/` or `C:\`) would discard * the prefix entirely. * * Forward slashes are allowed for nesting (e.g. `asm/feature-foo`); each * segment is validated independently against the allowlist, so `.` / `..` * segments and drive-spec characters are still rejected. * * Throws synchronously — callers rely on this running before any side effects * (git commands, hook execution, chdir). */export function validateWorktreeSlug(slug: string): void { if (slug.length > MAX_WORKTREE_SLUG_LENGTH) { throw new Error( `Invalid worktree name: must be ${MAX_WORKTREE_SLUG_LENGTH} characters or fewer (got ${slug.length})`, ) } // Leading or trailing `/` would make path.join produce an absolute path // or a dangling segment. Splitting and validating each segment rejects // both (empty segments fail the regex) while allowing `user/feature`. for (const segment of slug.split('/')) { if (segment === '.' || segment === '..') { throw new Error( `Invalid worktree name "${slug}": must not contain "." or ".." path segments`, ) } if (!VALID_WORKTREE_SLUG_SEGMENT.test(segment)) { throw new Error( `Invalid worktree name "${slug}": each "/"-separated segment must be non-empty and contain only letters, digits, dots, underscores, and dashes`, ) } }}
// Helper function to create directories recursivelyasync function mkdirRecursive(dirPath: string): Promise<void> { await mkdir(dirPath, { recursive: true })}
/** * Symlinks directories from the main repository to avoid duplication. * This prevents disk bloat from duplicating node_modules and other large directories. * * @param repoRootPath - Path to the main repository root * @param worktreePath - Path to the worktree directory * @param dirsToSymlink - Array of directory names to symlink (e.g., ['node_modules']) */async function symlinkDirectories( repoRootPath: string, worktreePath: string, dirsToSymlink: string[],): Promise<void> { for (const dir of dirsToSymlink) { // Validate directory doesn't escape repository boundaries if (containsPathTraversal(dir)) { logForDebugging( `Skipping symlink for "${dir}": path traversal detected`, { level: 'warn' }, ) continue }
const sourcePath = join(repoRootPath, dir) const destPath = join(worktreePath, dir)
try { await symlink(sourcePath, destPath, 'dir') logForDebugging( `Symlinked ${dir} from main repository to worktree to avoid disk bloat`, ) } catch (error) { const code = getErrnoCode(error) // ENOENT: source doesn't exist yet (expected - skip silently) // EEXIST: destination already exists (expected - skip silently) if (code !== 'ENOENT' && code !== 'EEXIST') { // Unexpected error (e.g., permission denied, unsupported platform) logForDebugging( `Failed to symlink ${dir} (${code ?? 'unknown'}): ${errorMessage(error)}`, { level: 'warn' }, ) } } }}
export type WorktreeSession = { originalCwd: string worktreePath: string worktreeName: string worktreeBranch?: string originalBranch?: string originalHeadCommit?: string sessionId: string tmuxSessionName?: string hookBased?: boolean /** How long worktree creation took (unset when resuming an existing worktree). */ creationDurationMs?: number /** True if git sparse-checkout was applied via settings.worktree.sparsePaths. */ usedSparsePaths?: boolean}
let currentWorktreeSession: WorktreeSession | null = null
export function getCurrentWorktreeSession(): WorktreeSession | null { return currentWorktreeSession}
/** * Restore the worktree session on --resume. The caller must have already * verified the directory exists (via process.chdir) and set the bootstrap * state (cwd, originalCwd). */export function restoreWorktreeSession(session: WorktreeSession | null): void { currentWorktreeSession = session}
export function generateTmuxSessionName( repoPath: string, branch: string,): string { const repoName = basename(repoPath) const combined = `${repoName}_${branch}` return combined.replace(/[/.]/g, '_')}
type WorktreeCreateResult = | { worktreePath: string worktreeBranch: string headCommit: string existed: true } | { worktreePath: string worktreeBranch: string headCommit: string baseBranch: string existed: false }
// Env vars to prevent git/SSH from prompting for credentials (which hangs the CLI).// GIT_TERMINAL_PROMPT=0 prevents git from opening /dev/tty for credential prompts.// GIT_ASKPASS='' disables askpass GUI programs.// stdin: 'ignore' closes stdin so interactive prompts can't block.const GIT_NO_PROMPT_ENV = { GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '',}
function worktreesDir(repoRoot: string): string { return join(repoRoot, '.claude', 'worktrees')}
// Flatten nested slugs (`user/feature` → `user+feature`) for both the branch// name and the directory path. Nesting in either location is unsafe:// - git refs: `worktree-user` (file) vs `worktree-user/feature` (needs dir)// is a D/F conflict that git rejects.// - directory: `.claude/worktrees/user/feature/` lives inside the `user`// worktree; `git worktree remove` on the parent deletes children with// uncommitted work.// `+` is valid in git branch names and filesystem paths but NOT in the// slug-segment allowlist ([a-zA-Z0-9._-]), so the mapping is injective.function flattenSlug(slug: string): string { return slug.replaceAll('/', '+')}
export function worktreeBranchName(slug: string): string { return `worktree-${flattenSlug(slug)}`}
function worktreePathFor(repoRoot: string, slug: string): string { return join(worktreesDir(repoRoot), flattenSlug(slug))}
/** * Creates a new git worktree for the given slug, or resumes it if it already exists. * Named worktrees reuse the same path across invocations, so the existence check * prevents unconditionally running `git fetch` (which can hang waiting for credentials) * on every resume. */async function getOrCreateWorktree( repoRoot: string, slug: string, options?: { prNumber?: number },): Promise<WorktreeCreateResult> { const worktreePath = worktreePathFor(repoRoot, slug) const worktreeBranch = worktreeBranchName(slug)
// Fast resume path: if the worktree already exists skip fetch and creation. // Read the .git pointer file directly (no subprocess, no upward walk) — a // subprocess `rev-parse HEAD` burns ~15ms on spawn overhead even for a 2ms // task, and the await yield lets background spawnSyncs pile on (seen at 55ms). const existingHead = await readWorktreeHeadSha(worktreePath) if (existingHead) { return { worktreePath, worktreeBranch, headCommit: existingHead, existed: true, } }
// New worktree: fetch base branch then add await mkdir(worktreesDir(repoRoot), { recursive: true })
const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV }
let baseBranch: string let baseSha: string | null = null if (options?.prNumber) { const { code: prFetchCode, stderr: prFetchStderr } = await execFileNoThrowWithCwd( gitExe(), ['fetch', 'origin', `pull/${options.prNumber}/head`], { cwd: repoRoot, stdin: 'ignore', env: fetchEnv }, ) if (prFetchCode !== 0) { throw new Error( `Failed to fetch PR #${options.prNumber}: ${prFetchStderr.trim() || 'PR may not exist or the repository may not have a remote named "origin"'}`, ) } baseBranch = 'FETCH_HEAD' } else { // If origin/<branch> already exists locally, skip fetch. In large repos // (210k files, 16M objects) fetch burns ~6-8s on a local commit-graph // scan before even hitting the network. A slightly stale base is fine — // the user can pull in the worktree if they want latest. // resolveRef reads the loose/packed ref directly; when it succeeds we // already have the SHA, so the later rev-parse is skipped entirely. const [defaultBranch, gitDir] = await Promise.all([ getDefaultBranch(), resolveGitDir(repoRoot), ]) const originRef = `origin/${defaultBranch}` const originSha = gitDir ? await resolveRef(gitDir, `refs/remotes/origin/${defaultBranch}`) : null if (originSha) { baseBranch = originRef baseSha = originSha } else { const { code: fetchCode } = await execFileNoThrowWithCwd( gitExe(), ['fetch', 'origin', defaultBranch], { cwd: repoRoot, stdin: 'ignore', env: fetchEnv }, ) baseBranch = fetchCode === 0 ? originRef : 'HEAD' } }
// For the fetch/PR-fetch paths we still need the SHA — the fs-only resolveRef // above only covers the "origin/<branch> already exists locally" case. if (!baseSha) { const { stdout, code: shaCode } = await execFileNoThrowWithCwd( gitExe(), ['rev-parse', baseBranch], { cwd: repoRoot }, ) if (shaCode !== 0) { throw new Error( `Failed to resolve base branch "${baseBranch}": git rev-parse failed`, ) } baseSha = stdout.trim() }
const sparsePaths = getInitialSettings().worktree?.sparsePaths const addArgs = ['worktree', 'add'] if (sparsePaths?.length) { addArgs.push('--no-checkout') } // -B (not -b): reset any orphan branch left behind by a removed worktree dir. // Saves a `git branch -D` subprocess (~15ms spawn overhead) on every create. addArgs.push('-B', worktreeBranch, worktreePath, baseBranch)
const { code: createCode, stderr: createStderr } = await execFileNoThrowWithCwd(gitExe(), addArgs, { cwd: repoRoot }) if (createCode !== 0) { throw new Error(`Failed to create worktree: ${createStderr}`) }
if (sparsePaths?.length) { // If sparse-checkout or checkout fail after --no-checkout, the worktree // is registered and HEAD is set but the working tree is empty. Next run's // fast-resume (rev-parse HEAD) would succeed and present a broken worktree // as "resumed". Tear it down before propagating the error. const tearDown = async (msg: string): Promise<never> => { await execFileNoThrowWithCwd( gitExe(), ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot }, ) throw new Error(msg) } const { code: sparseCode, stderr: sparseErr } = await execFileNoThrowWithCwd( gitExe(), ['sparse-checkout', 'set', '--cone', '--', ...sparsePaths], { cwd: worktreePath }, ) if (sparseCode !== 0) { await tearDown(`Failed to configure sparse-checkout: ${sparseErr}`) } const { code: coCode, stderr: coErr } = await execFileNoThrowWithCwd( gitExe(), ['checkout', 'HEAD'], { cwd: worktreePath }, ) if (coCode !== 0) { await tearDown(`Failed to checkout sparse worktree: ${coErr}`) } }
return { worktreePath, worktreeBranch, headCommit: baseSha, baseBranch, existed: false, }}
/** * Copy gitignored files specified in .worktreeinclude from base repo to worktree. * * Only copies files that are BOTH: * 1. Matched by patterns in .worktreeinclude (uses .gitignore syntax) * 2. Gitignored (not tracked by git) * * Uses `git ls-files --others --ignored --exclude-standard --directory` to list * gitignored entries with fully-ignored dirs collapsed to single entries (so large * build outputs like node_modules/ don't force a full tree walk), then filters * against .worktreeinclude patterns in-process using the `ignore` library. If a * .worktreeinclude pattern explicitly targets a path inside a collapsed directory, * that directory is expanded with a second scoped `ls-files` call. */export async function copyWorktreeIncludeFiles( repoRoot: string, worktreePath: string,): Promise<string[]> { let includeContent: string try { includeContent = await readFile(join(repoRoot, '.worktreeinclude'), 'utf-8') } catch { return [] }
const patterns = includeContent .split(/\r?\n/) .map(line => line.trim()) .filter(line => line.length > 0 && !line.startsWith('#')) if (patterns.length === 0) { return [] }
// Single pass with --directory: collapses fully-gitignored dirs (node_modules/, // .turbo/, etc.) into single entries instead of listing every file inside. // In a large repo this cuts ~500k entries/~7s down to ~hundreds of entries/~100ms. const gitignored = await execFileNoThrowWithCwd( gitExe(), ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory'], { cwd: repoRoot }, ) if (gitignored.code !== 0 || !gitignored.stdout.trim()) { return [] }
const entries = gitignored.stdout.trim().split('\n').filter(Boolean) const matcher = ignore().add(includeContent)
// --directory emits collapsed dirs with a trailing slash; everything else is // an individual file. const collapsedDirs = entries.filter(e => e.endsWith('/')) const files = entries.filter(e => !e.endsWith('/') && matcher.ignores(e))
// Edge case: a .worktreeinclude pattern targets a path inside a collapsed dir // (e.g. pattern `config/secrets/api.key` when all of `config/secrets/` is // gitignored with no tracked siblings). Expand only dirs where a pattern has // that dir as its explicit path prefix (stripping redundant leading `/`), the // dir falls under an anchored glob's literal prefix (e.g. `config/**/*.key` // expands `config/secrets/`), or the dir itself matches a pattern. We don't // expand for `**/` or anchorless patterns -- those match files in tracked dirs // (already listed individually) and expanding every collapsed dir for them // would defeat the perf win. const dirsToExpand = collapsedDirs.filter(dir => { if ( patterns.some(p => { const normalized = p.startsWith('/') ? p.slice(1) : p // Literal prefix match: pattern starts with the collapsed dir path if (normalized.startsWith(dir)) return true // Anchored glob: dir falls under the pattern's literal (non-glob) prefix // e.g. `config/**/*.key` has literal prefix `config/` → expand `config/secrets/` const globIdx = normalized.search(/[*?[]/) if (globIdx > 0) { const literalPrefix = normalized.slice(0, globIdx) if (dir.startsWith(literalPrefix)) return true } return false }) ) return true if (matcher.ignores(dir.slice(0, -1))) return true return false }) if (dirsToExpand.length > 0) { const expanded = await execFileNoThrowWithCwd( gitExe(), [ 'ls-files', '--others', '--ignored', '--exclude-standard', '--', ...dirsToExpand, ], { cwd: repoRoot }, ) if (expanded.code === 0 && expanded.stdout.trim()) { for (const f of expanded.stdout.trim().split('\n').filter(Boolean)) { if (matcher.ignores(f)) { files.push(f) } } } } const copied: string[] = []
for (const relativePath of files) { const srcPath = join(repoRoot, relativePath) const destPath = join(worktreePath, relativePath) try { await mkdir(dirname(destPath), { recursive: true }) await copyFile(srcPath, destPath) copied.push(relativePath) } catch (e: unknown) { logForDebugging( `Failed to copy ${relativePath} to worktree: ${(e as Error).message}`, { level: 'warn' }, ) } }
if (copied.length > 0) { logForDebugging( `Copied ${copied.length} files from .worktreeinclude: ${copied.join(', ')}`, ) }
return copied}
/** * Post-creation setup for a newly created worktree. * Propagates settings.local.json, configures git hooks, and symlinks directories. */async function performPostCreationSetup( repoRoot: string, worktreePath: string,): Promise<void> { // Copy settings.local.json to the worktree's .claude directory // This propagates local settings (which may contain secrets) to the worktree const localSettingsRelativePath = getRelativeSettingsFilePathForSource('localSettings') const sourceSettingsLocal = join(repoRoot, localSettingsRelativePath) try { const destSettingsLocal = join(worktreePath, localSettingsRelativePath) await mkdirRecursive(dirname(destSettingsLocal)) await copyFile(sourceSettingsLocal, destSettingsLocal) logForDebugging( `Copied settings.local.json to worktree: ${destSettingsLocal}`, ) } catch (e: unknown) { const code = getErrnoCode(e) if (code !== 'ENOENT') { logForDebugging( `Failed to copy settings.local.json: ${(e as Error).message}`, { level: 'warn' }, ) } }
// Configure the worktree to use hooks from the main repository // This solves issues with .husky and other git hooks that use relative paths const huskyPath = join(repoRoot, '.husky') const gitHooksPath = join(repoRoot, '.git', 'hooks') let hooksPath: string | null = null for (const candidatePath of [huskyPath, gitHooksPath]) { try { const s = await stat(candidatePath) if (s.isDirectory()) { hooksPath = candidatePath break } } catch { // Path doesn't exist or can't be accessed } } if (hooksPath) { // `git config` (no --worktree flag) writes to the main repo's .git/config, // shared by all worktrees. Once set, every subsequent worktree create is a // no-op — skip the subprocess (~14ms spawn) when the value already matches. const gitDir = await resolveGitDir(repoRoot) const configDir = gitDir ? ((await getCommonDir(gitDir)) ?? gitDir) : null const existing = configDir ? await parseGitConfigValue(configDir, 'core', null, 'hooksPath') : null if (existing !== hooksPath) { const { code: configCode, stderr: configError } = await execFileNoThrowWithCwd( gitExe(), ['config', 'core.hooksPath', hooksPath], { cwd: worktreePath }, ) if (configCode === 0) { logForDebugging( `Configured worktree to use hooks from main repository: ${hooksPath}`, ) } else { logForDebugging(`Failed to configure hooks path: ${configError}`, { level: 'error', }) } } }
// Symlink directories to avoid disk bloat (opt-in via settings) const settings = getInitialSettings() const dirsToSymlink = settings.worktree?.symlinkDirectories ?? [] if (dirsToSymlink.length > 0) { await symlinkDirectories(repoRoot, worktreePath, dirsToSymlink) }
// Copy gitignored files specified in .worktreeinclude (best-effort) await copyWorktreeIncludeFiles(repoRoot, worktreePath)
// The core.hooksPath config-set above is fragile: husky's prepare script // (`git config core.hooksPath .husky`) runs on every `bun install` and // resets the SHARED .git/config value back to relative, causing each // worktree to resolve to its OWN .husky/ again. The attribution hook // file isn't tracked (it's in .git/info/exclude), so fresh worktrees // don't have it. Install it directly into the worktree's .husky/ — // husky won't delete it (husky install is additive-only), and for // non-husky repos this resolves to the shared .git/hooks/ (idempotent). // // Pass the worktree-local .husky explicitly: getHooksDir would return // the absolute core.hooksPath we just set above (main repo's .husky), // not the worktree's — `git rev-parse --git-path hooks` echoes the config // value verbatim when it's absolute. if (feature('COMMIT_ATTRIBUTION')) { const worktreeHooksDir = hooksPath === huskyPath ? join(worktreePath, '.husky') : undefined void import('./postCommitAttribution.js') .then(m => m .installPrepareCommitMsgHook(worktreePath, worktreeHooksDir) .catch(error => { logForDebugging( `Failed to install attribution hook in worktree: ${error}`, ) }), ) .catch(error => { // Dynamic import() itself rejected (module load failure). The inner // .catch above only handles installPrepareCommitMsgHook rejection — // without this outer handler an import failure would surface as an // unhandled promise rejection. logForDebugging(`Failed to load postCommitAttribution module: ${error}`) }) }}
/** * Parses a PR reference from a string. * Accepts GitHub-style PR URLs (e.g., https://github.com/owner/repo/pull/123, * or GHE equivalents like https://ghe.example.com/owner/repo/pull/123) * or `#N` format (e.g., #123). * Returns the PR number or null if the string is not a recognized PR reference. */export function parsePRReference(input: string): number | null { // GitHub-style PR URL: https://<host>/owner/repo/pull/123 (with optional trailing slash, query, hash) // The /pull/N path shape is specific to GitHub — GitLab uses /-/merge_requests/N, // Bitbucket uses /pull-requests/N — so matching any host here is safe. const urlMatch = input.match( /^https?:\/\/[^/]+\/[^/]+\/[^/]+\/pull\/(\d+)\/?(?:[?#].*)?$/i, ) if (urlMatch?.[1]) { return parseInt(urlMatch[1], 10) }
// #N format const hashMatch = input.match(/^#(\d+)$/) if (hashMatch?.[1]) { return parseInt(hashMatch[1], 10) }
return null}
export async function isTmuxAvailable(): Promise<boolean> { const { code } = await execFileNoThrow('tmux', ['-V']) return code === 0}
export function getTmuxInstallInstructions(): string { const platform = getPlatform() switch (platform) { case 'macos': return 'Install tmux with: brew install tmux' case 'linux': case 'wsl': return 'Install tmux with: sudo apt install tmux (Debian/Ubuntu) or sudo dnf install tmux (Fedora/RHEL)' case 'windows': return 'tmux is not natively available on Windows. Consider using WSL or Cygwin.' default: return 'Install tmux using your system package manager.' }}
export async function createTmuxSessionForWorktree( sessionName: string, worktreePath: string,): Promise<{ created: boolean; error?: string }> { const { code, stderr } = await execFileNoThrow('tmux', [ 'new-session', '-d', '-s', sessionName, '-c', worktreePath, ])
if (code !== 0) { return { created: false, error: stderr } }
return { created: true }}
export async function killTmuxSession(sessionName: string): Promise<boolean> { const { code } = await execFileNoThrow('tmux', [ 'kill-session', '-t', sessionName, ]) return code === 0}
export async function createWorktreeForSession( sessionId: string, slug: string, tmuxSessionName?: string, options?: { prNumber?: number },): Promise<WorktreeSession> { // Must run before the hook branch below — hooks receive the raw slug as an // argument, and the git branch builds a path from it via path.join. validateWorktreeSlug(slug)
const originalCwd = getCwd()
// Try hook-based worktree creation first (allows user-configured VCS) if (hasWorktreeCreateHook()) { const hookResult = await executeWorktreeCreateHook(slug) logForDebugging( `Created hook-based worktree at: ${hookResult.worktreePath}`, )
currentWorktreeSession = { originalCwd, worktreePath: hookResult.worktreePath, worktreeName: slug, sessionId, tmuxSessionName, hookBased: true, } } else { // Fall back to git worktree const gitRoot = findGitRoot(getCwd()) if (!gitRoot) { throw new Error( 'Cannot create a worktree: not in a git repository and no WorktreeCreate hooks are configured. ' + 'Configure WorktreeCreate/WorktreeRemove hooks in settings.json to use worktree isolation with other VCS systems.', ) }
const originalBranch = await getBranch()
const createStart = Date.now() const { worktreePath, worktreeBranch, headCommit, existed } = await getOrCreateWorktree(gitRoot, slug, options)
let creationDurationMs: number | undefined if (existed) { logForDebugging(`Resuming existing worktree at: ${worktreePath}`) } else { logForDebugging( `Created worktree at: ${worktreePath} on branch: ${worktreeBranch}`, ) await performPostCreationSetup(gitRoot, worktreePath) creationDurationMs = Date.now() - createStart }
currentWorktreeSession = { originalCwd, worktreePath, worktreeName: slug, worktreeBranch, originalBranch, originalHeadCommit: headCommit, sessionId, tmuxSessionName, creationDurationMs, usedSparsePaths: (getInitialSettings().worktree?.sparsePaths?.length ?? 0) > 0, } }
// Save to project config for persistence saveCurrentProjectConfig(current => ({ ...current, activeWorktreeSession: currentWorktreeSession ?? undefined, }))
return currentWorktreeSession}
export async function keepWorktree(): Promise<void> { if (!currentWorktreeSession) { return }
try { const { worktreePath, originalCwd, worktreeBranch } = currentWorktreeSession
// Change back to original directory first process.chdir(originalCwd)
// Clear the session but keep the worktree intact currentWorktreeSession = null
// Update config saveCurrentProjectConfig(current => ({ ...current, activeWorktreeSession: undefined, }))
logForDebugging( `Linked worktree preserved at: ${worktreePath}${worktreeBranch ? ` on branch: ${worktreeBranch}` : ''}`, ) logForDebugging( `You can continue working there by running: cd ${worktreePath}`, ) } catch (error) { logForDebugging(`Error keeping worktree: ${error}`, { level: 'error', }) }}
export async function cleanupWorktree(): Promise<void> { if (!currentWorktreeSession) { return }
try { const { worktreePath, originalCwd, worktreeBranch, hookBased } = currentWorktreeSession
// Change back to original directory first process.chdir(originalCwd)
if (hookBased) { // Hook-based worktree: delegate cleanup to WorktreeRemove hook const hookRan = await executeWorktreeRemoveHook(worktreePath) if (hookRan) { logForDebugging(`Removed hook-based worktree at: ${worktreePath}`) } else { logForDebugging( `No WorktreeRemove hook configured, hook-based worktree left at: ${worktreePath}`, { level: 'warn' }, ) } } else { // Git-based worktree: use git worktree remove. // Explicit cwd: process.chdir above does NOT update getCwd() (the state // CWD that execFileNoThrow defaults to). If the model cd'd to a non-repo // dir, the bare execFileNoThrow variant would fail silently here. const { code: removeCode, stderr: removeError } = await execFileNoThrowWithCwd( gitExe(), ['worktree', 'remove', '--force', worktreePath], { cwd: originalCwd }, )
if (removeCode !== 0) { logForDebugging(`Failed to remove linked worktree: ${removeError}`, { level: 'error', }) } else { logForDebugging(`Removed linked worktree at: ${worktreePath}`) } }
// Clear the session currentWorktreeSession = null
// Update config saveCurrentProjectConfig(current => ({ ...current, activeWorktreeSession: undefined, }))
// Delete the temporary worktree branch (git-based only) if (!hookBased && worktreeBranch) { // Wait a bit to ensure git has released all locks await sleep(100)
const { code: deleteBranchCode, stderr: deleteBranchError } = await execFileNoThrowWithCwd( gitExe(), ['branch', '-D', worktreeBranch], { cwd: originalCwd }, )
if (deleteBranchCode !== 0) { logForDebugging( `Could not delete worktree branch: ${deleteBranchError}`, { level: 'error' }, ) } else { logForDebugging(`Deleted worktree branch: ${worktreeBranch}`) } }
logForDebugging('Linked worktree cleaned up completely') } catch (error) { logForDebugging(`Error cleaning up worktree: ${error}`, { level: 'error', }) }}
/** * Create a lightweight worktree for a subagent. * Reuses getOrCreateWorktree/performPostCreationSetup but does NOT touch * global session state (currentWorktreeSession, process.chdir, project config). * Falls back to hook-based creation if not in a git repository. */export async function createAgentWorktree(slug: string): Promise<{ worktreePath: string worktreeBranch?: string headCommit?: string gitRoot?: string hookBased?: boolean}> { validateWorktreeSlug(slug)
// Try hook-based worktree creation first (allows user-configured VCS) if (hasWorktreeCreateHook()) { const hookResult = await executeWorktreeCreateHook(slug) logForDebugging( `Created hook-based agent worktree at: ${hookResult.worktreePath}`, )
return { worktreePath: hookResult.worktreePath, hookBased: true } }
// Fall back to git worktree // findCanonicalGitRoot (not findGitRoot) so agent worktrees always land in // the main repo's .claude/worktrees/ even when spawned from inside a session // worktree — otherwise they nest at <worktree>/.claude/worktrees/ and the // periodic cleanup (which scans the canonical root) never finds them. const gitRoot = findCanonicalGitRoot(getCwd()) if (!gitRoot) { throw new Error( 'Cannot create agent worktree: not in a git repository and no WorktreeCreate hooks are configured. ' + 'Configure WorktreeCreate/WorktreeRemove hooks in settings.json to use worktree isolation with other VCS systems.', ) }
const { worktreePath, worktreeBranch, headCommit, existed } = await getOrCreateWorktree(gitRoot, slug)
if (!existed) { logForDebugging( `Created agent worktree at: ${worktreePath} on branch: ${worktreeBranch}`, ) await performPostCreationSetup(gitRoot, worktreePath) } else { // Bump mtime so the periodic stale-worktree cleanup doesn't consider this // worktree stale — the fast-resume path is read-only and leaves the original // creation-time mtime intact, which can be past the 30-day cutoff. const now = new Date() await utimes(worktreePath, now, now) logForDebugging(`Resuming existing agent worktree at: ${worktreePath}`) }
return { worktreePath, worktreeBranch, headCommit, gitRoot }}
/** * Remove a worktree created by createAgentWorktree. * For git-based worktrees, removes the worktree directory and deletes the temporary branch. * For hook-based worktrees, delegates to the WorktreeRemove hook. * Must be called with the main repo's git root (for git worktrees), not the worktree path, * since the worktree directory is deleted during this operation. */export async function removeAgentWorktree( worktreePath: string, worktreeBranch?: string, gitRoot?: string, hookBased?: boolean,): Promise<boolean> { if (hookBased) { const hookRan = await executeWorktreeRemoveHook(worktreePath) if (hookRan) { logForDebugging(`Removed hook-based agent worktree at: ${worktreePath}`) } else { logForDebugging( `No WorktreeRemove hook configured, hook-based agent worktree left at: ${worktreePath}`, { level: 'warn' }, ) } return hookRan }
if (!gitRoot) { logForDebugging('Cannot remove agent worktree: no git root provided', { level: 'error', }) return false }
// Run from the main repo root, not the worktree (which we're about to delete) const { code: removeCode, stderr: removeError } = await execFileNoThrowWithCwd( gitExe(), ['worktree', 'remove', '--force', worktreePath], { cwd: gitRoot }, )
if (removeCode !== 0) { logForDebugging(`Failed to remove agent worktree: ${removeError}`, { level: 'error', }) return false } logForDebugging(`Removed agent worktree at: ${worktreePath}`)
if (!worktreeBranch) { return true }
// Delete the temporary worktree branch from the main repo const { code: deleteBranchCode, stderr: deleteBranchError } = await execFileNoThrowWithCwd(gitExe(), ['branch', '-D', worktreeBranch], { cwd: gitRoot, })
if (deleteBranchCode !== 0) { logForDebugging( `Could not delete agent worktree branch: ${deleteBranchError}`, { level: 'error' }, ) } return true}
/** * Slug patterns for throwaway worktrees created by AgentTool (`agent-a<7hex>`, * from earlyAgentId.slice(0,8)), WorkflowTool (`wf_<runId>-<idx>` where runId * is randomUUID().slice(0,12) = 8 hex + `-` + 3 hex), and bridgeMain * (`bridge-<safeFilenameId>`). These leak when the parent process is killed * (Ctrl+C, ESC, crash) before their in-process cleanup runs. Exact-shape * patterns avoid sweeping user-named EnterWorktree slugs like `wf-myfeature`. */const EPHEMERAL_WORKTREE_PATTERNS = [ /^agent-a[0-9a-f]{7}$/, /^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/, // Legacy wf-<idx> slugs from before workflowRunId disambiguation — kept so // the 30-day sweep still cleans up worktrees leaked by older builds. /^wf-\d+$/, // Real bridge slugs are `bridge-${safeFilenameId(sessionId)}`. /^bridge-[A-Za-z0-9_]+(-[A-Za-z0-9_]+)*$/, // Template job worktrees: job-<templateName>-<8hex>. Prefix distinguishes // from user-named EnterWorktree slugs that happen to end in 8 hex. /^job-[a-zA-Z0-9._-]{1,55}-[0-9a-f]{8}$/,]
/** * Remove stale agent/workflow worktrees older than cutoffDate. * * Safety: * - Only touches slugs matching ephemeral patterns (never user-named worktrees) * - Skips the current session's worktree * - Fail-closed: skips if git status fails or shows tracked changes * (-uno: untracked files in a 30-day-old crashed agent worktree are build * artifacts; skipping the untracked scan is 5-10× faster on large repos) * - Fail-closed: skips if any commits aren't reachable from a remote * * `git worktree remove --force` handles both the directory and git's internal * worktree tracking. If git doesn't recognize the path as a worktree (orphaned * dir), it's left in place — a later readdir finding it stale again is harmless. */export async function cleanupStaleAgentWorktrees( cutoffDate: Date,): Promise<number> { const gitRoot = findCanonicalGitRoot(getCwd()) if (!gitRoot) { return 0 }
const dir = worktreesDir(gitRoot) let entries: string[] try { entries = await readdir(dir) } catch { return 0 }
const cutoffMs = cutoffDate.getTime() const currentPath = currentWorktreeSession?.worktreePath let removed = 0
for (const slug of entries) { if (!EPHEMERAL_WORKTREE_PATTERNS.some(p => p.test(slug))) { continue }
const worktreePath = join(dir, slug) if (currentPath === worktreePath) { continue }
let mtimeMs: number try { mtimeMs = (await stat(worktreePath)).mtimeMs } catch { continue } if (mtimeMs >= cutoffMs) { continue }
// Both checks must succeed with empty output. Non-zero exit (corrupted // worktree, git not recognizing it, etc.) means skip — we don't know // what's in there. const [status, unpushed] = await Promise.all([ execFileNoThrowWithCwd( gitExe(), ['--no-optional-locks', 'status', '--porcelain', '-uno'], { cwd: worktreePath }, ), execFileNoThrowWithCwd( gitExe(), ['rev-list', '--max-count=1', 'HEAD', '--not', '--remotes'], { cwd: worktreePath }, ), ]) if (status.code !== 0 || status.stdout.trim().length > 0) { continue } if (unpushed.code !== 0 || unpushed.stdout.trim().length > 0) { continue }
if ( await removeAgentWorktree(worktreePath, worktreeBranchName(slug), gitRoot) ) { removed++ } }
if (removed > 0) { await execFileNoThrowWithCwd(gitExe(), ['worktree', 'prune'], { cwd: gitRoot, }) logForDebugging( `cleanupStaleAgentWorktrees: removed ${removed} stale worktree(s)`, ) } return removed}
/** * Check whether a worktree has uncommitted changes or new commits since creation. * Returns true if there are uncommitted changes (dirty working tree), if commits * were made on the worktree branch since `headCommit`, or if git commands fail * — callers use this to decide whether to remove a worktree, so fail-closed. */export async function hasWorktreeChanges( worktreePath: string, headCommit: string,): Promise<boolean> { const { code: statusCode, stdout: statusOutput } = await execFileNoThrowWithCwd(gitExe(), ['status', '--porcelain'], { cwd: worktreePath, }) if (statusCode !== 0) { return true } if (statusOutput.trim().length > 0) { return true }
const { code: revListCode, stdout: revListOutput } = await execFileNoThrowWithCwd( gitExe(), ['rev-list', '--count', `${headCommit}..HEAD`], { cwd: worktreePath }, ) if (revListCode !== 0) { return true } if (parseInt(revListOutput.trim(), 10) > 0) { return true }
return false}
/** * Fast-path handler for --worktree --tmux. * Creates the worktree and execs into tmux running Claude inside. * This is called early in cli.tsx before loading the full CLI. */export async function execIntoTmuxWorktree(args: string[]): Promise<{ handled: boolean error?: string}> { // Check platform - tmux doesn't work on Windows if (process.platform === 'win32') { return { handled: false, error: 'Error: --tmux is not supported on Windows', } }
// Check if tmux is available const tmuxCheck = spawnSync('tmux', ['-V'], { encoding: 'utf-8' }) if (tmuxCheck.status !== 0) { const installHint = process.platform === 'darwin' ? 'Install tmux with: brew install tmux' : 'Install tmux with: sudo apt install tmux' return { handled: false, error: `Error: tmux is not installed. ${installHint}`, } }
// Parse worktree name and tmux mode from args let worktreeName: string | undefined let forceClassicTmux = false for (let i = 0; i < args.length; i++) { const arg = args[i] if (!arg) continue if (arg === '-w' || arg === '--worktree') { // Check if next arg exists and isn't another flag const next = args[i + 1] if (next && !next.startsWith('-')) { worktreeName = next } } else if (arg.startsWith('--worktree=')) { worktreeName = arg.slice('--worktree='.length) } else if (arg === '--tmux=classic') { forceClassicTmux = true } }
// Check if worktree name is a PR reference let prNumber: number | null = null if (worktreeName) { prNumber = parsePRReference(worktreeName) if (prNumber !== null) { worktreeName = `pr-${prNumber}` } }
// Generate a slug if no name provided if (!worktreeName) { const adjectives = ['swift', 'bright', 'calm', 'keen', 'bold'] const nouns = ['fox', 'owl', 'elm', 'oak', 'ray'] const adj = adjectives[Math.floor(Math.random() * adjectives.length)] const noun = nouns[Math.floor(Math.random() * nouns.length)] const suffix = Math.random().toString(36).slice(2, 6) worktreeName = `${adj}-${noun}-${suffix}` }
// worktreeName is joined into worktreeDir via path.join below; apply the // same allowlist used by the in-session worktree tool so the constraint // holds uniformly regardless of entry point. try { validateWorktreeSlug(worktreeName) } catch (e) { return { handled: false, error: `Error: ${(e as Error).message}`, } }
// Mirror createWorktreeForSession(): hook takes precedence over git so the // WorktreeCreate hook substitutes the VCS backend for this fast-path too // (anthropics/claude-code#39281). Git path below runs only when no hook. let worktreeDir: string let repoName: string if (hasWorktreeCreateHook()) { try { const hookResult = await executeWorktreeCreateHook(worktreeName) worktreeDir = hookResult.worktreePath } catch (error) { return { handled: false, error: `Error: ${errorMessage(error)}`, } } repoName = basename(findCanonicalGitRoot(getCwd()) ?? getCwd()) // biome-ignore lint/suspicious/noConsole: intentional console output console.log(`Using worktree via hook: ${worktreeDir}`) } else { // Get main git repo root (resolves through worktrees) const repoRoot = findCanonicalGitRoot(getCwd()) if (!repoRoot) { return { handled: false, error: 'Error: --worktree requires a git repository', } }
repoName = basename(repoRoot) worktreeDir = worktreePathFor(repoRoot, worktreeName)
// Create or resume worktree try { const result = await getOrCreateWorktree( repoRoot, worktreeName, prNumber !== null ? { prNumber } : undefined, ) if (!result.existed) { // biome-ignore lint/suspicious/noConsole: intentional console output console.log( `Created worktree: ${worktreeDir} (based on ${result.baseBranch})`, ) await performPostCreationSetup(repoRoot, worktreeDir) } } catch (error) { return { handled: false, error: `Error: ${errorMessage(error)}`, } } }
// Sanitize for tmux session name (replace / and . with _) const tmuxSessionName = `${repoName}_${worktreeBranchName(worktreeName)}`.replace(/[/.]/g, '_')
// Build new args without --tmux and --worktree (we're already in the worktree) const newArgs: string[] = [] for (let i = 0; i < args.length; i++) { const arg = args[i] if (!arg) continue if (arg === '--tmux' || arg === '--tmux=classic') continue if (arg === '-w' || arg === '--worktree') { // Skip the flag and its value if present const next = args[i + 1] if (next && !next.startsWith('-')) { i++ // Skip the value too } continue } if (arg.startsWith('--worktree=')) continue newArgs.push(arg) }
// Get tmux prefix for user guidance let tmuxPrefix = 'C-b' // default const prefixResult = spawnSync('tmux', ['show-options', '-g', 'prefix'], { encoding: 'utf-8', }) if (prefixResult.status === 0 && prefixResult.stdout) { const match = prefixResult.stdout.match(/prefix\s+(\S+)/) if (match?.[1]) { tmuxPrefix = match[1] } }
// Check if tmux prefix conflicts with Claude keybindings // Claude binds: ctrl+b (task:background), ctrl+c, ctrl+d, ctrl+t, ctrl+o, ctrl+r, ctrl+s, ctrl+g, ctrl+e const claudeBindings = [ 'C-b', 'C-c', 'C-d', 'C-t', 'C-o', 'C-r', 'C-s', 'C-g', 'C-e', ] const prefixConflicts = claudeBindings.includes(tmuxPrefix)
// Set env vars for the inner Claude to display tmux info in welcome message const tmuxEnv = { ...process.env, CLAUDE_CODE_TMUX_SESSION: tmuxSessionName, CLAUDE_CODE_TMUX_PREFIX: tmuxPrefix, CLAUDE_CODE_TMUX_PREFIX_CONFLICTS: prefixConflicts ? '1' : '', }
// Check if session already exists const hasSessionResult = spawnSync( 'tmux', ['has-session', '-t', tmuxSessionName], { encoding: 'utf-8' }, ) const sessionExists = hasSessionResult.status === 0
// Check if we're already inside a tmux session const isAlreadyInTmux = Boolean(process.env.TMUX)
// Use tmux control mode (-CC) for native iTerm2 tab/pane integration // This lets users use iTerm2's UI instead of learning tmux keybindings // Use --tmux=classic to force traditional tmux even in iTerm2 // Control mode doesn't make sense when already in tmux (would need to switch-client) const useControlMode = isInITerm2() && !forceClassicTmux && !isAlreadyInTmux const tmuxGlobalArgs = useControlMode ? ['-CC'] : []
// Print hint about iTerm2 preferences when using control mode if (useControlMode && !sessionExists) { const y = chalk.yellow // biome-ignore lint/suspicious/noConsole: intentional user guidance console.log( `\n${y('╭─ iTerm2 Tip ────────────────────────────────────────────────────────╮')}\n` + `${y('│')} To open as a tab instead of a new window: ${y('│')}\n` + `${y('│')} iTerm2 > Settings > General > tmux > "Tabs in attaching window" ${y('│')}\n` + `${y('╰─────────────────────────────────────────────────────────────────────╯')}\n`, ) }
// For ants in claude-cli-internal, set up dev panes (watch + start) const isAnt = process.env.USER_TYPE === 'ant' const isClaudeCliInternal = repoName === 'claude-cli-internal' const shouldSetupDevPanes = isAnt && isClaudeCliInternal && !sessionExists
if (shouldSetupDevPanes) { // Create detached session with Claude in first pane spawnSync( 'tmux', [ 'new-session', '-d', // detached '-s', tmuxSessionName, '-c', worktreeDir, '--', process.execPath, ...newArgs, ], { cwd: worktreeDir, env: tmuxEnv }, )
// Split horizontally and run watch spawnSync( 'tmux', ['split-window', '-h', '-t', tmuxSessionName, '-c', worktreeDir], { cwd: worktreeDir }, ) spawnSync( 'tmux', ['send-keys', '-t', tmuxSessionName, 'bun run watch', 'Enter'], { cwd: worktreeDir }, )
// Split vertically and run start spawnSync( 'tmux', ['split-window', '-v', '-t', tmuxSessionName, '-c', worktreeDir], { cwd: worktreeDir }, ) spawnSync('tmux', ['send-keys', '-t', tmuxSessionName, 'bun run start'], { cwd: worktreeDir, })
// Select the first pane (Claude) spawnSync('tmux', ['select-pane', '-t', `${tmuxSessionName}:0.0`], { cwd: worktreeDir, })
// Attach or switch to the session if (isAlreadyInTmux) { // Switch to sibling session (avoid nesting) spawnSync('tmux', ['switch-client', '-t', tmuxSessionName], { stdio: 'inherit', }) } else { // Attach to the session spawnSync( 'tmux', [...tmuxGlobalArgs, 'attach-session', '-t', tmuxSessionName], { stdio: 'inherit', cwd: worktreeDir, }, ) } } else { // Standard behavior: create or attach if (isAlreadyInTmux) { // Already in tmux - create detached session, then switch to it (sibling) // Check if session already exists first if (sessionExists) { // Just switch to existing session spawnSync('tmux', ['switch-client', '-t', tmuxSessionName], { stdio: 'inherit', }) } else { // Create new detached session spawnSync( 'tmux', [ 'new-session', '-d', // detached '-s', tmuxSessionName, '-c', worktreeDir, '--', process.execPath, ...newArgs, ], { cwd: worktreeDir, env: tmuxEnv }, )
// Switch to the new session spawnSync('tmux', ['switch-client', '-t', tmuxSessionName], { stdio: 'inherit', }) } } else { // Not in tmux - create and attach (original behavior) const tmuxArgs = [ ...tmuxGlobalArgs, 'new-session', '-A', // Attach if exists, create if not '-s', tmuxSessionName, '-c', worktreeDir, '--', // Separator before command process.execPath, ...newArgs, ]
spawnSync('tmux', tmuxArgs, { stdio: 'inherit', cwd: worktreeDir, env: tmuxEnv, }) } }
return { handled: true }}