/ src / utils / worktree.ts
worktree.ts
   1  import { feature } from 'bun:bundle'
   2  import chalk from 'chalk'
   3  import { spawnSync } from 'child_process'
   4  import {
   5    copyFile,
   6    mkdir,
   7    readdir,
   8    readFile,
   9    stat,
  10    symlink,
  11    utimes,
  12  } from 'fs/promises'
  13  import ignore from 'ignore'
  14  import { basename, dirname, join } from 'path'
  15  import { saveCurrentProjectConfig } from './config.js'
  16  import { getCwd } from './cwd.js'
  17  import { logForDebugging } from './debug.js'
  18  import { errorMessage, getErrnoCode } from './errors.js'
  19  import { execFileNoThrow, execFileNoThrowWithCwd } from './execFileNoThrow.js'
  20  import { parseGitConfigValue } from './git/gitConfigParser.js'
  21  import {
  22    getCommonDir,
  23    readWorktreeHeadSha,
  24    resolveGitDir,
  25    resolveRef,
  26  } from './git/gitFilesystem.js'
  27  import {
  28    findCanonicalGitRoot,
  29    findGitRoot,
  30    getBranch,
  31    getDefaultBranch,
  32    gitExe,
  33  } from './git.js'
  34  import {
  35    executeWorktreeCreateHook,
  36    executeWorktreeRemoveHook,
  37    hasWorktreeCreateHook,
  38  } from './hooks.js'
  39  import { containsPathTraversal } from './path.js'
  40  import { getPlatform } from './platform.js'
  41  import {
  42    getInitialSettings,
  43    getRelativeSettingsFilePathForSource,
  44  } from './settings/settings.js'
  45  import { sleep } from './sleep.js'
  46  import { isInITerm2 } from './swarm/backends/detection.js'
  47  
  48  const VALID_WORKTREE_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/
  49  const MAX_WORKTREE_SLUG_LENGTH = 64
  50  
  51  /**
  52   * Validates a worktree slug to prevent path traversal and directory escape.
  53   *
  54   * The slug is joined into `.claude/worktrees/<slug>` via path.join, which
  55   * normalizes `..` segments — so `../../../target` would escape the worktrees
  56   * directory. Similarly, an absolute path (leading `/` or `C:\`) would discard
  57   * the prefix entirely.
  58   *
  59   * Forward slashes are allowed for nesting (e.g. `asm/feature-foo`); each
  60   * segment is validated independently against the allowlist, so `.` / `..`
  61   * segments and drive-spec characters are still rejected.
  62   *
  63   * Throws synchronously — callers rely on this running before any side effects
  64   * (git commands, hook execution, chdir).
  65   */
  66  export function validateWorktreeSlug(slug: string): void {
  67    if (slug.length > MAX_WORKTREE_SLUG_LENGTH) {
  68      throw new Error(
  69        `Invalid worktree name: must be ${MAX_WORKTREE_SLUG_LENGTH} characters or fewer (got ${slug.length})`,
  70      )
  71    }
  72    // Leading or trailing `/` would make path.join produce an absolute path
  73    // or a dangling segment. Splitting and validating each segment rejects
  74    // both (empty segments fail the regex) while allowing `user/feature`.
  75    for (const segment of slug.split('/')) {
  76      if (segment === '.' || segment === '..') {
  77        throw new Error(
  78          `Invalid worktree name "${slug}": must not contain "." or ".." path segments`,
  79        )
  80      }
  81      if (!VALID_WORKTREE_SLUG_SEGMENT.test(segment)) {
  82        throw new Error(
  83          `Invalid worktree name "${slug}": each "/"-separated segment must be non-empty and contain only letters, digits, dots, underscores, and dashes`,
  84        )
  85      }
  86    }
  87  }
  88  
  89  // Helper function to create directories recursively
  90  async function mkdirRecursive(dirPath: string): Promise<void> {
  91    await mkdir(dirPath, { recursive: true })
  92  }
  93  
  94  /**
  95   * Symlinks directories from the main repository to avoid duplication.
  96   * This prevents disk bloat from duplicating node_modules and other large directories.
  97   *
  98   * @param repoRootPath - Path to the main repository root
  99   * @param worktreePath - Path to the worktree directory
 100   * @param dirsToSymlink - Array of directory names to symlink (e.g., ['node_modules'])
 101   */
 102  async function symlinkDirectories(
 103    repoRootPath: string,
 104    worktreePath: string,
 105    dirsToSymlink: string[],
 106  ): Promise<void> {
 107    for (const dir of dirsToSymlink) {
 108      // Validate directory doesn't escape repository boundaries
 109      if (containsPathTraversal(dir)) {
 110        logForDebugging(
 111          `Skipping symlink for "${dir}": path traversal detected`,
 112          { level: 'warn' },
 113        )
 114        continue
 115      }
 116  
 117      const sourcePath = join(repoRootPath, dir)
 118      const destPath = join(worktreePath, dir)
 119  
 120      try {
 121        await symlink(sourcePath, destPath, 'dir')
 122        logForDebugging(
 123          `Symlinked ${dir} from main repository to worktree to avoid disk bloat`,
 124        )
 125      } catch (error) {
 126        const code = getErrnoCode(error)
 127        // ENOENT: source doesn't exist yet (expected - skip silently)
 128        // EEXIST: destination already exists (expected - skip silently)
 129        if (code !== 'ENOENT' && code !== 'EEXIST') {
 130          // Unexpected error (e.g., permission denied, unsupported platform)
 131          logForDebugging(
 132            `Failed to symlink ${dir} (${code ?? 'unknown'}): ${errorMessage(error)}`,
 133            { level: 'warn' },
 134          )
 135        }
 136      }
 137    }
 138  }
 139  
 140  export type WorktreeSession = {
 141    originalCwd: string
 142    worktreePath: string
 143    worktreeName: string
 144    worktreeBranch?: string
 145    originalBranch?: string
 146    originalHeadCommit?: string
 147    sessionId: string
 148    tmuxSessionName?: string
 149    hookBased?: boolean
 150    /** How long worktree creation took (unset when resuming an existing worktree). */
 151    creationDurationMs?: number
 152    /** True if git sparse-checkout was applied via settings.worktree.sparsePaths. */
 153    usedSparsePaths?: boolean
 154  }
 155  
 156  let currentWorktreeSession: WorktreeSession | null = null
 157  
 158  export function getCurrentWorktreeSession(): WorktreeSession | null {
 159    return currentWorktreeSession
 160  }
 161  
 162  /**
 163   * Restore the worktree session on --resume. The caller must have already
 164   * verified the directory exists (via process.chdir) and set the bootstrap
 165   * state (cwd, originalCwd).
 166   */
 167  export function restoreWorktreeSession(session: WorktreeSession | null): void {
 168    currentWorktreeSession = session
 169  }
 170  
 171  export function generateTmuxSessionName(
 172    repoPath: string,
 173    branch: string,
 174  ): string {
 175    const repoName = basename(repoPath)
 176    const combined = `${repoName}_${branch}`
 177    return combined.replace(/[/.]/g, '_')
 178  }
 179  
 180  type WorktreeCreateResult =
 181    | {
 182        worktreePath: string
 183        worktreeBranch: string
 184        headCommit: string
 185        existed: true
 186      }
 187    | {
 188        worktreePath: string
 189        worktreeBranch: string
 190        headCommit: string
 191        baseBranch: string
 192        existed: false
 193      }
 194  
 195  // Env vars to prevent git/SSH from prompting for credentials (which hangs the CLI).
 196  // GIT_TERMINAL_PROMPT=0 prevents git from opening /dev/tty for credential prompts.
 197  // GIT_ASKPASS='' disables askpass GUI programs.
 198  // stdin: 'ignore' closes stdin so interactive prompts can't block.
 199  const GIT_NO_PROMPT_ENV = {
 200    GIT_TERMINAL_PROMPT: '0',
 201    GIT_ASKPASS: '',
 202  }
 203  
 204  function worktreesDir(repoRoot: string): string {
 205    return join(repoRoot, '.claude', 'worktrees')
 206  }
 207  
 208  // Flatten nested slugs (`user/feature` → `user+feature`) for both the branch
 209  // name and the directory path. Nesting in either location is unsafe:
 210  //   - git refs: `worktree-user` (file) vs `worktree-user/feature` (needs dir)
 211  //     is a D/F conflict that git rejects.
 212  //   - directory: `.claude/worktrees/user/feature/` lives inside the `user`
 213  //     worktree; `git worktree remove` on the parent deletes children with
 214  //     uncommitted work.
 215  // `+` is valid in git branch names and filesystem paths but NOT in the
 216  // slug-segment allowlist ([a-zA-Z0-9._-]), so the mapping is injective.
 217  function flattenSlug(slug: string): string {
 218    return slug.replaceAll('/', '+')
 219  }
 220  
 221  export function worktreeBranchName(slug: string): string {
 222    return `worktree-${flattenSlug(slug)}`
 223  }
 224  
 225  function worktreePathFor(repoRoot: string, slug: string): string {
 226    return join(worktreesDir(repoRoot), flattenSlug(slug))
 227  }
 228  
 229  /**
 230   * Creates a new git worktree for the given slug, or resumes it if it already exists.
 231   * Named worktrees reuse the same path across invocations, so the existence check
 232   * prevents unconditionally running `git fetch` (which can hang waiting for credentials)
 233   * on every resume.
 234   */
 235  async function getOrCreateWorktree(
 236    repoRoot: string,
 237    slug: string,
 238    options?: { prNumber?: number },
 239  ): Promise<WorktreeCreateResult> {
 240    const worktreePath = worktreePathFor(repoRoot, slug)
 241    const worktreeBranch = worktreeBranchName(slug)
 242  
 243    // Fast resume path: if the worktree already exists skip fetch and creation.
 244    // Read the .git pointer file directly (no subprocess, no upward walk) — a
 245    // subprocess `rev-parse HEAD` burns ~15ms on spawn overhead even for a 2ms
 246    // task, and the await yield lets background spawnSyncs pile on (seen at 55ms).
 247    const existingHead = await readWorktreeHeadSha(worktreePath)
 248    if (existingHead) {
 249      return {
 250        worktreePath,
 251        worktreeBranch,
 252        headCommit: existingHead,
 253        existed: true,
 254      }
 255    }
 256  
 257    // New worktree: fetch base branch then add
 258    await mkdir(worktreesDir(repoRoot), { recursive: true })
 259  
 260    const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV }
 261  
 262    let baseBranch: string
 263    let baseSha: string | null = null
 264    if (options?.prNumber) {
 265      const { code: prFetchCode, stderr: prFetchStderr } =
 266        await execFileNoThrowWithCwd(
 267          gitExe(),
 268          ['fetch', 'origin', `pull/${options.prNumber}/head`],
 269          { cwd: repoRoot, stdin: 'ignore', env: fetchEnv },
 270        )
 271      if (prFetchCode !== 0) {
 272        throw new Error(
 273          `Failed to fetch PR #${options.prNumber}: ${prFetchStderr.trim() || 'PR may not exist or the repository may not have a remote named "origin"'}`,
 274        )
 275      }
 276      baseBranch = 'FETCH_HEAD'
 277    } else {
 278      // If origin/<branch> already exists locally, skip fetch. In large repos
 279      // (210k files, 16M objects) fetch burns ~6-8s on a local commit-graph
 280      // scan before even hitting the network. A slightly stale base is fine —
 281      // the user can pull in the worktree if they want latest.
 282      // resolveRef reads the loose/packed ref directly; when it succeeds we
 283      // already have the SHA, so the later rev-parse is skipped entirely.
 284      const [defaultBranch, gitDir] = await Promise.all([
 285        getDefaultBranch(),
 286        resolveGitDir(repoRoot),
 287      ])
 288      const originRef = `origin/${defaultBranch}`
 289      const originSha = gitDir
 290        ? await resolveRef(gitDir, `refs/remotes/origin/${defaultBranch}`)
 291        : null
 292      if (originSha) {
 293        baseBranch = originRef
 294        baseSha = originSha
 295      } else {
 296        const { code: fetchCode } = await execFileNoThrowWithCwd(
 297          gitExe(),
 298          ['fetch', 'origin', defaultBranch],
 299          { cwd: repoRoot, stdin: 'ignore', env: fetchEnv },
 300        )
 301        baseBranch = fetchCode === 0 ? originRef : 'HEAD'
 302      }
 303    }
 304  
 305    // For the fetch/PR-fetch paths we still need the SHA — the fs-only resolveRef
 306    // above only covers the "origin/<branch> already exists locally" case.
 307    if (!baseSha) {
 308      const { stdout, code: shaCode } = await execFileNoThrowWithCwd(
 309        gitExe(),
 310        ['rev-parse', baseBranch],
 311        { cwd: repoRoot },
 312      )
 313      if (shaCode !== 0) {
 314        throw new Error(
 315          `Failed to resolve base branch "${baseBranch}": git rev-parse failed`,
 316        )
 317      }
 318      baseSha = stdout.trim()
 319    }
 320  
 321    const sparsePaths = getInitialSettings().worktree?.sparsePaths
 322    const addArgs = ['worktree', 'add']
 323    if (sparsePaths?.length) {
 324      addArgs.push('--no-checkout')
 325    }
 326    // -B (not -b): reset any orphan branch left behind by a removed worktree dir.
 327    // Saves a `git branch -D` subprocess (~15ms spawn overhead) on every create.
 328    addArgs.push('-B', worktreeBranch, worktreePath, baseBranch)
 329  
 330    const { code: createCode, stderr: createStderr } =
 331      await execFileNoThrowWithCwd(gitExe(), addArgs, { cwd: repoRoot })
 332    if (createCode !== 0) {
 333      throw new Error(`Failed to create worktree: ${createStderr}`)
 334    }
 335  
 336    if (sparsePaths?.length) {
 337      // If sparse-checkout or checkout fail after --no-checkout, the worktree
 338      // is registered and HEAD is set but the working tree is empty. Next run's
 339      // fast-resume (rev-parse HEAD) would succeed and present a broken worktree
 340      // as "resumed". Tear it down before propagating the error.
 341      const tearDown = async (msg: string): Promise<never> => {
 342        await execFileNoThrowWithCwd(
 343          gitExe(),
 344          ['worktree', 'remove', '--force', worktreePath],
 345          { cwd: repoRoot },
 346        )
 347        throw new Error(msg)
 348      }
 349      const { code: sparseCode, stderr: sparseErr } =
 350        await execFileNoThrowWithCwd(
 351          gitExe(),
 352          ['sparse-checkout', 'set', '--cone', '--', ...sparsePaths],
 353          { cwd: worktreePath },
 354        )
 355      if (sparseCode !== 0) {
 356        await tearDown(`Failed to configure sparse-checkout: ${sparseErr}`)
 357      }
 358      const { code: coCode, stderr: coErr } = await execFileNoThrowWithCwd(
 359        gitExe(),
 360        ['checkout', 'HEAD'],
 361        { cwd: worktreePath },
 362      )
 363      if (coCode !== 0) {
 364        await tearDown(`Failed to checkout sparse worktree: ${coErr}`)
 365      }
 366    }
 367  
 368    return {
 369      worktreePath,
 370      worktreeBranch,
 371      headCommit: baseSha,
 372      baseBranch,
 373      existed: false,
 374    }
 375  }
 376  
 377  /**
 378   * Copy gitignored files specified in .worktreeinclude from base repo to worktree.
 379   *
 380   * Only copies files that are BOTH:
 381   * 1. Matched by patterns in .worktreeinclude (uses .gitignore syntax)
 382   * 2. Gitignored (not tracked by git)
 383   *
 384   * Uses `git ls-files --others --ignored --exclude-standard --directory` to list
 385   * gitignored entries with fully-ignored dirs collapsed to single entries (so large
 386   * build outputs like node_modules/ don't force a full tree walk), then filters
 387   * against .worktreeinclude patterns in-process using the `ignore` library. If a
 388   * .worktreeinclude pattern explicitly targets a path inside a collapsed directory,
 389   * that directory is expanded with a second scoped `ls-files` call.
 390   */
 391  export async function copyWorktreeIncludeFiles(
 392    repoRoot: string,
 393    worktreePath: string,
 394  ): Promise<string[]> {
 395    let includeContent: string
 396    try {
 397      includeContent = await readFile(join(repoRoot, '.worktreeinclude'), 'utf-8')
 398    } catch {
 399      return []
 400    }
 401  
 402    const patterns = includeContent
 403      .split(/\r?\n/)
 404      .map(line => line.trim())
 405      .filter(line => line.length > 0 && !line.startsWith('#'))
 406    if (patterns.length === 0) {
 407      return []
 408    }
 409  
 410    // Single pass with --directory: collapses fully-gitignored dirs (node_modules/,
 411    // .turbo/, etc.) into single entries instead of listing every file inside.
 412    // In a large repo this cuts ~500k entries/~7s down to ~hundreds of entries/~100ms.
 413    const gitignored = await execFileNoThrowWithCwd(
 414      gitExe(),
 415      ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory'],
 416      { cwd: repoRoot },
 417    )
 418    if (gitignored.code !== 0 || !gitignored.stdout.trim()) {
 419      return []
 420    }
 421  
 422    const entries = gitignored.stdout.trim().split('\n').filter(Boolean)
 423    const matcher = ignore().add(includeContent)
 424  
 425    // --directory emits collapsed dirs with a trailing slash; everything else is
 426    // an individual file.
 427    const collapsedDirs = entries.filter(e => e.endsWith('/'))
 428    const files = entries.filter(e => !e.endsWith('/') && matcher.ignores(e))
 429  
 430    // Edge case: a .worktreeinclude pattern targets a path inside a collapsed dir
 431    // (e.g. pattern `config/secrets/api.key` when all of `config/secrets/` is
 432    // gitignored with no tracked siblings). Expand only dirs where a pattern has
 433    // that dir as its explicit path prefix (stripping redundant leading `/`), the
 434    // dir falls under an anchored glob's literal prefix (e.g. `config/**/*.key`
 435    // expands `config/secrets/`), or the dir itself matches a pattern. We don't
 436    // expand for `**/` or anchorless patterns -- those match files in tracked dirs
 437    // (already listed individually) and expanding every collapsed dir for them
 438    // would defeat the perf win.
 439    const dirsToExpand = collapsedDirs.filter(dir => {
 440      if (
 441        patterns.some(p => {
 442          const normalized = p.startsWith('/') ? p.slice(1) : p
 443          // Literal prefix match: pattern starts with the collapsed dir path
 444          if (normalized.startsWith(dir)) return true
 445          // Anchored glob: dir falls under the pattern's literal (non-glob) prefix
 446          // e.g. `config/**/*.key` has literal prefix `config/` → expand `config/secrets/`
 447          const globIdx = normalized.search(/[*?[]/)
 448          if (globIdx > 0) {
 449            const literalPrefix = normalized.slice(0, globIdx)
 450            if (dir.startsWith(literalPrefix)) return true
 451          }
 452          return false
 453        })
 454      )
 455        return true
 456      if (matcher.ignores(dir.slice(0, -1))) return true
 457      return false
 458    })
 459    if (dirsToExpand.length > 0) {
 460      const expanded = await execFileNoThrowWithCwd(
 461        gitExe(),
 462        [
 463          'ls-files',
 464          '--others',
 465          '--ignored',
 466          '--exclude-standard',
 467          '--',
 468          ...dirsToExpand,
 469        ],
 470        { cwd: repoRoot },
 471      )
 472      if (expanded.code === 0 && expanded.stdout.trim()) {
 473        for (const f of expanded.stdout.trim().split('\n').filter(Boolean)) {
 474          if (matcher.ignores(f)) {
 475            files.push(f)
 476          }
 477        }
 478      }
 479    }
 480    const copied: string[] = []
 481  
 482    for (const relativePath of files) {
 483      const srcPath = join(repoRoot, relativePath)
 484      const destPath = join(worktreePath, relativePath)
 485      try {
 486        await mkdir(dirname(destPath), { recursive: true })
 487        await copyFile(srcPath, destPath)
 488        copied.push(relativePath)
 489      } catch (e: unknown) {
 490        logForDebugging(
 491          `Failed to copy ${relativePath} to worktree: ${(e as Error).message}`,
 492          { level: 'warn' },
 493        )
 494      }
 495    }
 496  
 497    if (copied.length > 0) {
 498      logForDebugging(
 499        `Copied ${copied.length} files from .worktreeinclude: ${copied.join(', ')}`,
 500      )
 501    }
 502  
 503    return copied
 504  }
 505  
 506  /**
 507   * Post-creation setup for a newly created worktree.
 508   * Propagates settings.local.json, configures git hooks, and symlinks directories.
 509   */
 510  async function performPostCreationSetup(
 511    repoRoot: string,
 512    worktreePath: string,
 513  ): Promise<void> {
 514    // Copy settings.local.json to the worktree's .claude directory
 515    // This propagates local settings (which may contain secrets) to the worktree
 516    const localSettingsRelativePath =
 517      getRelativeSettingsFilePathForSource('localSettings')
 518    const sourceSettingsLocal = join(repoRoot, localSettingsRelativePath)
 519    try {
 520      const destSettingsLocal = join(worktreePath, localSettingsRelativePath)
 521      await mkdirRecursive(dirname(destSettingsLocal))
 522      await copyFile(sourceSettingsLocal, destSettingsLocal)
 523      logForDebugging(
 524        `Copied settings.local.json to worktree: ${destSettingsLocal}`,
 525      )
 526    } catch (e: unknown) {
 527      const code = getErrnoCode(e)
 528      if (code !== 'ENOENT') {
 529        logForDebugging(
 530          `Failed to copy settings.local.json: ${(e as Error).message}`,
 531          { level: 'warn' },
 532        )
 533      }
 534    }
 535  
 536    // Configure the worktree to use hooks from the main repository
 537    // This solves issues with .husky and other git hooks that use relative paths
 538    const huskyPath = join(repoRoot, '.husky')
 539    const gitHooksPath = join(repoRoot, '.git', 'hooks')
 540    let hooksPath: string | null = null
 541    for (const candidatePath of [huskyPath, gitHooksPath]) {
 542      try {
 543        const s = await stat(candidatePath)
 544        if (s.isDirectory()) {
 545          hooksPath = candidatePath
 546          break
 547        }
 548      } catch {
 549        // Path doesn't exist or can't be accessed
 550      }
 551    }
 552    if (hooksPath) {
 553      // `git config` (no --worktree flag) writes to the main repo's .git/config,
 554      // shared by all worktrees. Once set, every subsequent worktree create is a
 555      // no-op — skip the subprocess (~14ms spawn) when the value already matches.
 556      const gitDir = await resolveGitDir(repoRoot)
 557      const configDir = gitDir ? ((await getCommonDir(gitDir)) ?? gitDir) : null
 558      const existing = configDir
 559        ? await parseGitConfigValue(configDir, 'core', null, 'hooksPath')
 560        : null
 561      if (existing !== hooksPath) {
 562        const { code: configCode, stderr: configError } =
 563          await execFileNoThrowWithCwd(
 564            gitExe(),
 565            ['config', 'core.hooksPath', hooksPath],
 566            { cwd: worktreePath },
 567          )
 568        if (configCode === 0) {
 569          logForDebugging(
 570            `Configured worktree to use hooks from main repository: ${hooksPath}`,
 571          )
 572        } else {
 573          logForDebugging(`Failed to configure hooks path: ${configError}`, {
 574            level: 'error',
 575          })
 576        }
 577      }
 578    }
 579  
 580    // Symlink directories to avoid disk bloat (opt-in via settings)
 581    const settings = getInitialSettings()
 582    const dirsToSymlink = settings.worktree?.symlinkDirectories ?? []
 583    if (dirsToSymlink.length > 0) {
 584      await symlinkDirectories(repoRoot, worktreePath, dirsToSymlink)
 585    }
 586  
 587    // Copy gitignored files specified in .worktreeinclude (best-effort)
 588    await copyWorktreeIncludeFiles(repoRoot, worktreePath)
 589  
 590    // The core.hooksPath config-set above is fragile: husky's prepare script
 591    // (`git config core.hooksPath .husky`) runs on every `bun install` and
 592    // resets the SHARED .git/config value back to relative, causing each
 593    // worktree to resolve to its OWN .husky/ again. The attribution hook
 594    // file isn't tracked (it's in .git/info/exclude), so fresh worktrees
 595    // don't have it. Install it directly into the worktree's .husky/ —
 596    // husky won't delete it (husky install is additive-only), and for
 597    // non-husky repos this resolves to the shared .git/hooks/ (idempotent).
 598    //
 599    // Pass the worktree-local .husky explicitly: getHooksDir would return
 600    // the absolute core.hooksPath we just set above (main repo's .husky),
 601    // not the worktree's — `git rev-parse --git-path hooks` echoes the config
 602    // value verbatim when it's absolute.
 603    if (feature('COMMIT_ATTRIBUTION')) {
 604      const worktreeHooksDir =
 605        hooksPath === huskyPath ? join(worktreePath, '.husky') : undefined
 606      void import('./postCommitAttribution.js')
 607        .then(m =>
 608          m
 609            .installPrepareCommitMsgHook(worktreePath, worktreeHooksDir)
 610            .catch(error => {
 611              logForDebugging(
 612                `Failed to install attribution hook in worktree: ${error}`,
 613              )
 614            }),
 615        )
 616        .catch(error => {
 617          // Dynamic import() itself rejected (module load failure). The inner
 618          // .catch above only handles installPrepareCommitMsgHook rejection —
 619          // without this outer handler an import failure would surface as an
 620          // unhandled promise rejection.
 621          logForDebugging(`Failed to load postCommitAttribution module: ${error}`)
 622        })
 623    }
 624  }
 625  
 626  /**
 627   * Parses a PR reference from a string.
 628   * Accepts GitHub-style PR URLs (e.g., https://github.com/owner/repo/pull/123,
 629   * or GHE equivalents like https://ghe.example.com/owner/repo/pull/123)
 630   * or `#N` format (e.g., #123).
 631   * Returns the PR number or null if the string is not a recognized PR reference.
 632   */
 633  export function parsePRReference(input: string): number | null {
 634    // GitHub-style PR URL: https://<host>/owner/repo/pull/123 (with optional trailing slash, query, hash)
 635    // The /pull/N path shape is specific to GitHub — GitLab uses /-/merge_requests/N,
 636    // Bitbucket uses /pull-requests/N — so matching any host here is safe.
 637    const urlMatch = input.match(
 638      /^https?:\/\/[^/]+\/[^/]+\/[^/]+\/pull\/(\d+)\/?(?:[?#].*)?$/i,
 639    )
 640    if (urlMatch?.[1]) {
 641      return parseInt(urlMatch[1], 10)
 642    }
 643  
 644    // #N format
 645    const hashMatch = input.match(/^#(\d+)$/)
 646    if (hashMatch?.[1]) {
 647      return parseInt(hashMatch[1], 10)
 648    }
 649  
 650    return null
 651  }
 652  
 653  export async function isTmuxAvailable(): Promise<boolean> {
 654    const { code } = await execFileNoThrow('tmux', ['-V'])
 655    return code === 0
 656  }
 657  
 658  export function getTmuxInstallInstructions(): string {
 659    const platform = getPlatform()
 660    switch (platform) {
 661      case 'macos':
 662        return 'Install tmux with: brew install tmux'
 663      case 'linux':
 664      case 'wsl':
 665        return 'Install tmux with: sudo apt install tmux (Debian/Ubuntu) or sudo dnf install tmux (Fedora/RHEL)'
 666      case 'windows':
 667        return 'tmux is not natively available on Windows. Consider using WSL or Cygwin.'
 668      default:
 669        return 'Install tmux using your system package manager.'
 670    }
 671  }
 672  
 673  export async function createTmuxSessionForWorktree(
 674    sessionName: string,
 675    worktreePath: string,
 676  ): Promise<{ created: boolean; error?: string }> {
 677    const { code, stderr } = await execFileNoThrow('tmux', [
 678      'new-session',
 679      '-d',
 680      '-s',
 681      sessionName,
 682      '-c',
 683      worktreePath,
 684    ])
 685  
 686    if (code !== 0) {
 687      return { created: false, error: stderr }
 688    }
 689  
 690    return { created: true }
 691  }
 692  
 693  export async function killTmuxSession(sessionName: string): Promise<boolean> {
 694    const { code } = await execFileNoThrow('tmux', [
 695      'kill-session',
 696      '-t',
 697      sessionName,
 698    ])
 699    return code === 0
 700  }
 701  
 702  export async function createWorktreeForSession(
 703    sessionId: string,
 704    slug: string,
 705    tmuxSessionName?: string,
 706    options?: { prNumber?: number },
 707  ): Promise<WorktreeSession> {
 708    // Must run before the hook branch below — hooks receive the raw slug as an
 709    // argument, and the git branch builds a path from it via path.join.
 710    validateWorktreeSlug(slug)
 711  
 712    const originalCwd = getCwd()
 713  
 714    // Try hook-based worktree creation first (allows user-configured VCS)
 715    if (hasWorktreeCreateHook()) {
 716      const hookResult = await executeWorktreeCreateHook(slug)
 717      logForDebugging(
 718        `Created hook-based worktree at: ${hookResult.worktreePath}`,
 719      )
 720  
 721      currentWorktreeSession = {
 722        originalCwd,
 723        worktreePath: hookResult.worktreePath,
 724        worktreeName: slug,
 725        sessionId,
 726        tmuxSessionName,
 727        hookBased: true,
 728      }
 729    } else {
 730      // Fall back to git worktree
 731      const gitRoot = findGitRoot(getCwd())
 732      if (!gitRoot) {
 733        throw new Error(
 734          'Cannot create a worktree: not in a git repository and no WorktreeCreate hooks are configured. ' +
 735            'Configure WorktreeCreate/WorktreeRemove hooks in settings.json to use worktree isolation with other VCS systems.',
 736        )
 737      }
 738  
 739      const originalBranch = await getBranch()
 740  
 741      const createStart = Date.now()
 742      const { worktreePath, worktreeBranch, headCommit, existed } =
 743        await getOrCreateWorktree(gitRoot, slug, options)
 744  
 745      let creationDurationMs: number | undefined
 746      if (existed) {
 747        logForDebugging(`Resuming existing worktree at: ${worktreePath}`)
 748      } else {
 749        logForDebugging(
 750          `Created worktree at: ${worktreePath} on branch: ${worktreeBranch}`,
 751        )
 752        await performPostCreationSetup(gitRoot, worktreePath)
 753        creationDurationMs = Date.now() - createStart
 754      }
 755  
 756      currentWorktreeSession = {
 757        originalCwd,
 758        worktreePath,
 759        worktreeName: slug,
 760        worktreeBranch,
 761        originalBranch,
 762        originalHeadCommit: headCommit,
 763        sessionId,
 764        tmuxSessionName,
 765        creationDurationMs,
 766        usedSparsePaths:
 767          (getInitialSettings().worktree?.sparsePaths?.length ?? 0) > 0,
 768      }
 769    }
 770  
 771    // Save to project config for persistence
 772    saveCurrentProjectConfig(current => ({
 773      ...current,
 774      activeWorktreeSession: currentWorktreeSession ?? undefined,
 775    }))
 776  
 777    return currentWorktreeSession
 778  }
 779  
 780  export async function keepWorktree(): Promise<void> {
 781    if (!currentWorktreeSession) {
 782      return
 783    }
 784  
 785    try {
 786      const { worktreePath, originalCwd, worktreeBranch } = currentWorktreeSession
 787  
 788      // Change back to original directory first
 789      process.chdir(originalCwd)
 790  
 791      // Clear the session but keep the worktree intact
 792      currentWorktreeSession = null
 793  
 794      // Update config
 795      saveCurrentProjectConfig(current => ({
 796        ...current,
 797        activeWorktreeSession: undefined,
 798      }))
 799  
 800      logForDebugging(
 801        `Linked worktree preserved at: ${worktreePath}${worktreeBranch ? ` on branch: ${worktreeBranch}` : ''}`,
 802      )
 803      logForDebugging(
 804        `You can continue working there by running: cd ${worktreePath}`,
 805      )
 806    } catch (error) {
 807      logForDebugging(`Error keeping worktree: ${error}`, {
 808        level: 'error',
 809      })
 810    }
 811  }
 812  
 813  export async function cleanupWorktree(): Promise<void> {
 814    if (!currentWorktreeSession) {
 815      return
 816    }
 817  
 818    try {
 819      const { worktreePath, originalCwd, worktreeBranch, hookBased } =
 820        currentWorktreeSession
 821  
 822      // Change back to original directory first
 823      process.chdir(originalCwd)
 824  
 825      if (hookBased) {
 826        // Hook-based worktree: delegate cleanup to WorktreeRemove hook
 827        const hookRan = await executeWorktreeRemoveHook(worktreePath)
 828        if (hookRan) {
 829          logForDebugging(`Removed hook-based worktree at: ${worktreePath}`)
 830        } else {
 831          logForDebugging(
 832            `No WorktreeRemove hook configured, hook-based worktree left at: ${worktreePath}`,
 833            { level: 'warn' },
 834          )
 835        }
 836      } else {
 837        // Git-based worktree: use git worktree remove.
 838        // Explicit cwd: process.chdir above does NOT update getCwd() (the state
 839        // CWD that execFileNoThrow defaults to). If the model cd'd to a non-repo
 840        // dir, the bare execFileNoThrow variant would fail silently here.
 841        const { code: removeCode, stderr: removeError } =
 842          await execFileNoThrowWithCwd(
 843            gitExe(),
 844            ['worktree', 'remove', '--force', worktreePath],
 845            { cwd: originalCwd },
 846          )
 847  
 848        if (removeCode !== 0) {
 849          logForDebugging(`Failed to remove linked worktree: ${removeError}`, {
 850            level: 'error',
 851          })
 852        } else {
 853          logForDebugging(`Removed linked worktree at: ${worktreePath}`)
 854        }
 855      }
 856  
 857      // Clear the session
 858      currentWorktreeSession = null
 859  
 860      // Update config
 861      saveCurrentProjectConfig(current => ({
 862        ...current,
 863        activeWorktreeSession: undefined,
 864      }))
 865  
 866      // Delete the temporary worktree branch (git-based only)
 867      if (!hookBased && worktreeBranch) {
 868        // Wait a bit to ensure git has released all locks
 869        await sleep(100)
 870  
 871        const { code: deleteBranchCode, stderr: deleteBranchError } =
 872          await execFileNoThrowWithCwd(
 873            gitExe(),
 874            ['branch', '-D', worktreeBranch],
 875            { cwd: originalCwd },
 876          )
 877  
 878        if (deleteBranchCode !== 0) {
 879          logForDebugging(
 880            `Could not delete worktree branch: ${deleteBranchError}`,
 881            { level: 'error' },
 882          )
 883        } else {
 884          logForDebugging(`Deleted worktree branch: ${worktreeBranch}`)
 885        }
 886      }
 887  
 888      logForDebugging('Linked worktree cleaned up completely')
 889    } catch (error) {
 890      logForDebugging(`Error cleaning up worktree: ${error}`, {
 891        level: 'error',
 892      })
 893    }
 894  }
 895  
 896  /**
 897   * Create a lightweight worktree for a subagent.
 898   * Reuses getOrCreateWorktree/performPostCreationSetup but does NOT touch
 899   * global session state (currentWorktreeSession, process.chdir, project config).
 900   * Falls back to hook-based creation if not in a git repository.
 901   */
 902  export async function createAgentWorktree(slug: string): Promise<{
 903    worktreePath: string
 904    worktreeBranch?: string
 905    headCommit?: string
 906    gitRoot?: string
 907    hookBased?: boolean
 908  }> {
 909    validateWorktreeSlug(slug)
 910  
 911    // Try hook-based worktree creation first (allows user-configured VCS)
 912    if (hasWorktreeCreateHook()) {
 913      const hookResult = await executeWorktreeCreateHook(slug)
 914      logForDebugging(
 915        `Created hook-based agent worktree at: ${hookResult.worktreePath}`,
 916      )
 917  
 918      return { worktreePath: hookResult.worktreePath, hookBased: true }
 919    }
 920  
 921    // Fall back to git worktree
 922    // findCanonicalGitRoot (not findGitRoot) so agent worktrees always land in
 923    // the main repo's .claude/worktrees/ even when spawned from inside a session
 924    // worktree — otherwise they nest at <worktree>/.claude/worktrees/ and the
 925    // periodic cleanup (which scans the canonical root) never finds them.
 926    const gitRoot = findCanonicalGitRoot(getCwd())
 927    if (!gitRoot) {
 928      throw new Error(
 929        'Cannot create agent worktree: not in a git repository and no WorktreeCreate hooks are configured. ' +
 930          'Configure WorktreeCreate/WorktreeRemove hooks in settings.json to use worktree isolation with other VCS systems.',
 931      )
 932    }
 933  
 934    const { worktreePath, worktreeBranch, headCommit, existed } =
 935      await getOrCreateWorktree(gitRoot, slug)
 936  
 937    if (!existed) {
 938      logForDebugging(
 939        `Created agent worktree at: ${worktreePath} on branch: ${worktreeBranch}`,
 940      )
 941      await performPostCreationSetup(gitRoot, worktreePath)
 942    } else {
 943      // Bump mtime so the periodic stale-worktree cleanup doesn't consider this
 944      // worktree stale — the fast-resume path is read-only and leaves the original
 945      // creation-time mtime intact, which can be past the 30-day cutoff.
 946      const now = new Date()
 947      await utimes(worktreePath, now, now)
 948      logForDebugging(`Resuming existing agent worktree at: ${worktreePath}`)
 949    }
 950  
 951    return { worktreePath, worktreeBranch, headCommit, gitRoot }
 952  }
 953  
 954  /**
 955   * Remove a worktree created by createAgentWorktree.
 956   * For git-based worktrees, removes the worktree directory and deletes the temporary branch.
 957   * For hook-based worktrees, delegates to the WorktreeRemove hook.
 958   * Must be called with the main repo's git root (for git worktrees), not the worktree path,
 959   * since the worktree directory is deleted during this operation.
 960   */
 961  export async function removeAgentWorktree(
 962    worktreePath: string,
 963    worktreeBranch?: string,
 964    gitRoot?: string,
 965    hookBased?: boolean,
 966  ): Promise<boolean> {
 967    if (hookBased) {
 968      const hookRan = await executeWorktreeRemoveHook(worktreePath)
 969      if (hookRan) {
 970        logForDebugging(`Removed hook-based agent worktree at: ${worktreePath}`)
 971      } else {
 972        logForDebugging(
 973          `No WorktreeRemove hook configured, hook-based agent worktree left at: ${worktreePath}`,
 974          { level: 'warn' },
 975        )
 976      }
 977      return hookRan
 978    }
 979  
 980    if (!gitRoot) {
 981      logForDebugging('Cannot remove agent worktree: no git root provided', {
 982        level: 'error',
 983      })
 984      return false
 985    }
 986  
 987    // Run from the main repo root, not the worktree (which we're about to delete)
 988    const { code: removeCode, stderr: removeError } =
 989      await execFileNoThrowWithCwd(
 990        gitExe(),
 991        ['worktree', 'remove', '--force', worktreePath],
 992        { cwd: gitRoot },
 993      )
 994  
 995    if (removeCode !== 0) {
 996      logForDebugging(`Failed to remove agent worktree: ${removeError}`, {
 997        level: 'error',
 998      })
 999      return false
1000    }
1001    logForDebugging(`Removed agent worktree at: ${worktreePath}`)
1002  
1003    if (!worktreeBranch) {
1004      return true
1005    }
1006  
1007    // Delete the temporary worktree branch from the main repo
1008    const { code: deleteBranchCode, stderr: deleteBranchError } =
1009      await execFileNoThrowWithCwd(gitExe(), ['branch', '-D', worktreeBranch], {
1010        cwd: gitRoot,
1011      })
1012  
1013    if (deleteBranchCode !== 0) {
1014      logForDebugging(
1015        `Could not delete agent worktree branch: ${deleteBranchError}`,
1016        { level: 'error' },
1017      )
1018    }
1019    return true
1020  }
1021  
1022  /**
1023   * Slug patterns for throwaway worktrees created by AgentTool (`agent-a<7hex>`,
1024   * from earlyAgentId.slice(0,8)), WorkflowTool (`wf_<runId>-<idx>` where runId
1025   * is randomUUID().slice(0,12) = 8 hex + `-` + 3 hex), and bridgeMain
1026   * (`bridge-<safeFilenameId>`). These leak when the parent process is killed
1027   * (Ctrl+C, ESC, crash) before their in-process cleanup runs. Exact-shape
1028   * patterns avoid sweeping user-named EnterWorktree slugs like `wf-myfeature`.
1029   */
1030  const EPHEMERAL_WORKTREE_PATTERNS = [
1031    /^agent-a[0-9a-f]{7}$/,
1032    /^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/,
1033    // Legacy wf-<idx> slugs from before workflowRunId disambiguation — kept so
1034    // the 30-day sweep still cleans up worktrees leaked by older builds.
1035    /^wf-\d+$/,
1036    // Real bridge slugs are `bridge-${safeFilenameId(sessionId)}`.
1037    /^bridge-[A-Za-z0-9_]+(-[A-Za-z0-9_]+)*$/,
1038    // Template job worktrees: job-<templateName>-<8hex>. Prefix distinguishes
1039    // from user-named EnterWorktree slugs that happen to end in 8 hex.
1040    /^job-[a-zA-Z0-9._-]{1,55}-[0-9a-f]{8}$/,
1041  ]
1042  
1043  /**
1044   * Remove stale agent/workflow worktrees older than cutoffDate.
1045   *
1046   * Safety:
1047   * - Only touches slugs matching ephemeral patterns (never user-named worktrees)
1048   * - Skips the current session's worktree
1049   * - Fail-closed: skips if git status fails or shows tracked changes
1050   *   (-uno: untracked files in a 30-day-old crashed agent worktree are build
1051   *   artifacts; skipping the untracked scan is 5-10× faster on large repos)
1052   * - Fail-closed: skips if any commits aren't reachable from a remote
1053   *
1054   * `git worktree remove --force` handles both the directory and git's internal
1055   * worktree tracking. If git doesn't recognize the path as a worktree (orphaned
1056   * dir), it's left in place — a later readdir finding it stale again is harmless.
1057   */
1058  export async function cleanupStaleAgentWorktrees(
1059    cutoffDate: Date,
1060  ): Promise<number> {
1061    const gitRoot = findCanonicalGitRoot(getCwd())
1062    if (!gitRoot) {
1063      return 0
1064    }
1065  
1066    const dir = worktreesDir(gitRoot)
1067    let entries: string[]
1068    try {
1069      entries = await readdir(dir)
1070    } catch {
1071      return 0
1072    }
1073  
1074    const cutoffMs = cutoffDate.getTime()
1075    const currentPath = currentWorktreeSession?.worktreePath
1076    let removed = 0
1077  
1078    for (const slug of entries) {
1079      if (!EPHEMERAL_WORKTREE_PATTERNS.some(p => p.test(slug))) {
1080        continue
1081      }
1082  
1083      const worktreePath = join(dir, slug)
1084      if (currentPath === worktreePath) {
1085        continue
1086      }
1087  
1088      let mtimeMs: number
1089      try {
1090        mtimeMs = (await stat(worktreePath)).mtimeMs
1091      } catch {
1092        continue
1093      }
1094      if (mtimeMs >= cutoffMs) {
1095        continue
1096      }
1097  
1098      // Both checks must succeed with empty output. Non-zero exit (corrupted
1099      // worktree, git not recognizing it, etc.) means skip — we don't know
1100      // what's in there.
1101      const [status, unpushed] = await Promise.all([
1102        execFileNoThrowWithCwd(
1103          gitExe(),
1104          ['--no-optional-locks', 'status', '--porcelain', '-uno'],
1105          { cwd: worktreePath },
1106        ),
1107        execFileNoThrowWithCwd(
1108          gitExe(),
1109          ['rev-list', '--max-count=1', 'HEAD', '--not', '--remotes'],
1110          { cwd: worktreePath },
1111        ),
1112      ])
1113      if (status.code !== 0 || status.stdout.trim().length > 0) {
1114        continue
1115      }
1116      if (unpushed.code !== 0 || unpushed.stdout.trim().length > 0) {
1117        continue
1118      }
1119  
1120      if (
1121        await removeAgentWorktree(worktreePath, worktreeBranchName(slug), gitRoot)
1122      ) {
1123        removed++
1124      }
1125    }
1126  
1127    if (removed > 0) {
1128      await execFileNoThrowWithCwd(gitExe(), ['worktree', 'prune'], {
1129        cwd: gitRoot,
1130      })
1131      logForDebugging(
1132        `cleanupStaleAgentWorktrees: removed ${removed} stale worktree(s)`,
1133      )
1134    }
1135    return removed
1136  }
1137  
1138  /**
1139   * Check whether a worktree has uncommitted changes or new commits since creation.
1140   * Returns true if there are uncommitted changes (dirty working tree), if commits
1141   * were made on the worktree branch since `headCommit`, or if git commands fail
1142   * — callers use this to decide whether to remove a worktree, so fail-closed.
1143   */
1144  export async function hasWorktreeChanges(
1145    worktreePath: string,
1146    headCommit: string,
1147  ): Promise<boolean> {
1148    const { code: statusCode, stdout: statusOutput } =
1149      await execFileNoThrowWithCwd(gitExe(), ['status', '--porcelain'], {
1150        cwd: worktreePath,
1151      })
1152    if (statusCode !== 0) {
1153      return true
1154    }
1155    if (statusOutput.trim().length > 0) {
1156      return true
1157    }
1158  
1159    const { code: revListCode, stdout: revListOutput } =
1160      await execFileNoThrowWithCwd(
1161        gitExe(),
1162        ['rev-list', '--count', `${headCommit}..HEAD`],
1163        { cwd: worktreePath },
1164      )
1165    if (revListCode !== 0) {
1166      return true
1167    }
1168    if (parseInt(revListOutput.trim(), 10) > 0) {
1169      return true
1170    }
1171  
1172    return false
1173  }
1174  
1175  /**
1176   * Fast-path handler for --worktree --tmux.
1177   * Creates the worktree and execs into tmux running Claude inside.
1178   * This is called early in cli.tsx before loading the full CLI.
1179   */
1180  export async function execIntoTmuxWorktree(args: string[]): Promise<{
1181    handled: boolean
1182    error?: string
1183  }> {
1184    // Check platform - tmux doesn't work on Windows
1185    if (process.platform === 'win32') {
1186      return {
1187        handled: false,
1188        error: 'Error: --tmux is not supported on Windows',
1189      }
1190    }
1191  
1192    // Check if tmux is available
1193    const tmuxCheck = spawnSync('tmux', ['-V'], { encoding: 'utf-8' })
1194    if (tmuxCheck.status !== 0) {
1195      const installHint =
1196        process.platform === 'darwin'
1197          ? 'Install tmux with: brew install tmux'
1198          : 'Install tmux with: sudo apt install tmux'
1199      return {
1200        handled: false,
1201        error: `Error: tmux is not installed. ${installHint}`,
1202      }
1203    }
1204  
1205    // Parse worktree name and tmux mode from args
1206    let worktreeName: string | undefined
1207    let forceClassicTmux = false
1208    for (let i = 0; i < args.length; i++) {
1209      const arg = args[i]
1210      if (!arg) continue
1211      if (arg === '-w' || arg === '--worktree') {
1212        // Check if next arg exists and isn't another flag
1213        const next = args[i + 1]
1214        if (next && !next.startsWith('-')) {
1215          worktreeName = next
1216        }
1217      } else if (arg.startsWith('--worktree=')) {
1218        worktreeName = arg.slice('--worktree='.length)
1219      } else if (arg === '--tmux=classic') {
1220        forceClassicTmux = true
1221      }
1222    }
1223  
1224    // Check if worktree name is a PR reference
1225    let prNumber: number | null = null
1226    if (worktreeName) {
1227      prNumber = parsePRReference(worktreeName)
1228      if (prNumber !== null) {
1229        worktreeName = `pr-${prNumber}`
1230      }
1231    }
1232  
1233    // Generate a slug if no name provided
1234    if (!worktreeName) {
1235      const adjectives = ['swift', 'bright', 'calm', 'keen', 'bold']
1236      const nouns = ['fox', 'owl', 'elm', 'oak', 'ray']
1237      const adj = adjectives[Math.floor(Math.random() * adjectives.length)]
1238      const noun = nouns[Math.floor(Math.random() * nouns.length)]
1239      const suffix = Math.random().toString(36).slice(2, 6)
1240      worktreeName = `${adj}-${noun}-${suffix}`
1241    }
1242  
1243    // worktreeName is joined into worktreeDir via path.join below; apply the
1244    // same allowlist used by the in-session worktree tool so the constraint
1245    // holds uniformly regardless of entry point.
1246    try {
1247      validateWorktreeSlug(worktreeName)
1248    } catch (e) {
1249      return {
1250        handled: false,
1251        error: `Error: ${(e as Error).message}`,
1252      }
1253    }
1254  
1255    // Mirror createWorktreeForSession(): hook takes precedence over git so the
1256    // WorktreeCreate hook substitutes the VCS backend for this fast-path too
1257    // (anthropics/claude-code#39281). Git path below runs only when no hook.
1258    let worktreeDir: string
1259    let repoName: string
1260    if (hasWorktreeCreateHook()) {
1261      try {
1262        const hookResult = await executeWorktreeCreateHook(worktreeName)
1263        worktreeDir = hookResult.worktreePath
1264      } catch (error) {
1265        return {
1266          handled: false,
1267          error: `Error: ${errorMessage(error)}`,
1268        }
1269      }
1270      repoName = basename(findCanonicalGitRoot(getCwd()) ?? getCwd())
1271      // biome-ignore lint/suspicious/noConsole: intentional console output
1272      console.log(`Using worktree via hook: ${worktreeDir}`)
1273    } else {
1274      // Get main git repo root (resolves through worktrees)
1275      const repoRoot = findCanonicalGitRoot(getCwd())
1276      if (!repoRoot) {
1277        return {
1278          handled: false,
1279          error: 'Error: --worktree requires a git repository',
1280        }
1281      }
1282  
1283      repoName = basename(repoRoot)
1284      worktreeDir = worktreePathFor(repoRoot, worktreeName)
1285  
1286      // Create or resume worktree
1287      try {
1288        const result = await getOrCreateWorktree(
1289          repoRoot,
1290          worktreeName,
1291          prNumber !== null ? { prNumber } : undefined,
1292        )
1293        if (!result.existed) {
1294          // biome-ignore lint/suspicious/noConsole: intentional console output
1295          console.log(
1296            `Created worktree: ${worktreeDir} (based on ${result.baseBranch})`,
1297          )
1298          await performPostCreationSetup(repoRoot, worktreeDir)
1299        }
1300      } catch (error) {
1301        return {
1302          handled: false,
1303          error: `Error: ${errorMessage(error)}`,
1304        }
1305      }
1306    }
1307  
1308    // Sanitize for tmux session name (replace / and . with _)
1309    const tmuxSessionName =
1310      `${repoName}_${worktreeBranchName(worktreeName)}`.replace(/[/.]/g, '_')
1311  
1312    // Build new args without --tmux and --worktree (we're already in the worktree)
1313    const newArgs: string[] = []
1314    for (let i = 0; i < args.length; i++) {
1315      const arg = args[i]
1316      if (!arg) continue
1317      if (arg === '--tmux' || arg === '--tmux=classic') continue
1318      if (arg === '-w' || arg === '--worktree') {
1319        // Skip the flag and its value if present
1320        const next = args[i + 1]
1321        if (next && !next.startsWith('-')) {
1322          i++ // Skip the value too
1323        }
1324        continue
1325      }
1326      if (arg.startsWith('--worktree=')) continue
1327      newArgs.push(arg)
1328    }
1329  
1330    // Get tmux prefix for user guidance
1331    let tmuxPrefix = 'C-b' // default
1332    const prefixResult = spawnSync('tmux', ['show-options', '-g', 'prefix'], {
1333      encoding: 'utf-8',
1334    })
1335    if (prefixResult.status === 0 && prefixResult.stdout) {
1336      const match = prefixResult.stdout.match(/prefix\s+(\S+)/)
1337      if (match?.[1]) {
1338        tmuxPrefix = match[1]
1339      }
1340    }
1341  
1342    // Check if tmux prefix conflicts with Claude keybindings
1343    // Claude binds: ctrl+b (task:background), ctrl+c, ctrl+d, ctrl+t, ctrl+o, ctrl+r, ctrl+s, ctrl+g, ctrl+e
1344    const claudeBindings = [
1345      'C-b',
1346      'C-c',
1347      'C-d',
1348      'C-t',
1349      'C-o',
1350      'C-r',
1351      'C-s',
1352      'C-g',
1353      'C-e',
1354    ]
1355    const prefixConflicts = claudeBindings.includes(tmuxPrefix)
1356  
1357    // Set env vars for the inner Claude to display tmux info in welcome message
1358    const tmuxEnv = {
1359      ...process.env,
1360      CLAUDE_CODE_TMUX_SESSION: tmuxSessionName,
1361      CLAUDE_CODE_TMUX_PREFIX: tmuxPrefix,
1362      CLAUDE_CODE_TMUX_PREFIX_CONFLICTS: prefixConflicts ? '1' : '',
1363    }
1364  
1365    // Check if session already exists
1366    const hasSessionResult = spawnSync(
1367      'tmux',
1368      ['has-session', '-t', tmuxSessionName],
1369      { encoding: 'utf-8' },
1370    )
1371    const sessionExists = hasSessionResult.status === 0
1372  
1373    // Check if we're already inside a tmux session
1374    const isAlreadyInTmux = Boolean(process.env.TMUX)
1375  
1376    // Use tmux control mode (-CC) for native iTerm2 tab/pane integration
1377    // This lets users use iTerm2's UI instead of learning tmux keybindings
1378    // Use --tmux=classic to force traditional tmux even in iTerm2
1379    // Control mode doesn't make sense when already in tmux (would need to switch-client)
1380    const useControlMode = isInITerm2() && !forceClassicTmux && !isAlreadyInTmux
1381    const tmuxGlobalArgs = useControlMode ? ['-CC'] : []
1382  
1383    // Print hint about iTerm2 preferences when using control mode
1384    if (useControlMode && !sessionExists) {
1385      const y = chalk.yellow
1386      // biome-ignore lint/suspicious/noConsole: intentional user guidance
1387      console.log(
1388        `\n${y('╭─ iTerm2 Tip ────────────────────────────────────────────────────────╮')}\n` +
1389          `${y('│')} To open as a tab instead of a new window:                           ${y('│')}\n` +
1390          `${y('│')} iTerm2 > Settings > General > tmux > "Tabs in attaching window"     ${y('│')}\n` +
1391          `${y('╰─────────────────────────────────────────────────────────────────────╯')}\n`,
1392      )
1393    }
1394  
1395    // For ants in claude-cli-internal, set up dev panes (watch + start)
1396    const isAnt = process.env.USER_TYPE === 'ant'
1397    const isClaudeCliInternal = repoName === 'claude-cli-internal'
1398    const shouldSetupDevPanes = isAnt && isClaudeCliInternal && !sessionExists
1399  
1400    if (shouldSetupDevPanes) {
1401      // Create detached session with Claude in first pane
1402      spawnSync(
1403        'tmux',
1404        [
1405          'new-session',
1406          '-d', // detached
1407          '-s',
1408          tmuxSessionName,
1409          '-c',
1410          worktreeDir,
1411          '--',
1412          process.execPath,
1413          ...newArgs,
1414        ],
1415        { cwd: worktreeDir, env: tmuxEnv },
1416      )
1417  
1418      // Split horizontally and run watch
1419      spawnSync(
1420        'tmux',
1421        ['split-window', '-h', '-t', tmuxSessionName, '-c', worktreeDir],
1422        { cwd: worktreeDir },
1423      )
1424      spawnSync(
1425        'tmux',
1426        ['send-keys', '-t', tmuxSessionName, 'bun run watch', 'Enter'],
1427        { cwd: worktreeDir },
1428      )
1429  
1430      // Split vertically and run start
1431      spawnSync(
1432        'tmux',
1433        ['split-window', '-v', '-t', tmuxSessionName, '-c', worktreeDir],
1434        { cwd: worktreeDir },
1435      )
1436      spawnSync('tmux', ['send-keys', '-t', tmuxSessionName, 'bun run start'], {
1437        cwd: worktreeDir,
1438      })
1439  
1440      // Select the first pane (Claude)
1441      spawnSync('tmux', ['select-pane', '-t', `${tmuxSessionName}:0.0`], {
1442        cwd: worktreeDir,
1443      })
1444  
1445      // Attach or switch to the session
1446      if (isAlreadyInTmux) {
1447        // Switch to sibling session (avoid nesting)
1448        spawnSync('tmux', ['switch-client', '-t', tmuxSessionName], {
1449          stdio: 'inherit',
1450        })
1451      } else {
1452        // Attach to the session
1453        spawnSync(
1454          'tmux',
1455          [...tmuxGlobalArgs, 'attach-session', '-t', tmuxSessionName],
1456          {
1457            stdio: 'inherit',
1458            cwd: worktreeDir,
1459          },
1460        )
1461      }
1462    } else {
1463      // Standard behavior: create or attach
1464      if (isAlreadyInTmux) {
1465        // Already in tmux - create detached session, then switch to it (sibling)
1466        // Check if session already exists first
1467        if (sessionExists) {
1468          // Just switch to existing session
1469          spawnSync('tmux', ['switch-client', '-t', tmuxSessionName], {
1470            stdio: 'inherit',
1471          })
1472        } else {
1473          // Create new detached session
1474          spawnSync(
1475            'tmux',
1476            [
1477              'new-session',
1478              '-d', // detached
1479              '-s',
1480              tmuxSessionName,
1481              '-c',
1482              worktreeDir,
1483              '--',
1484              process.execPath,
1485              ...newArgs,
1486            ],
1487            { cwd: worktreeDir, env: tmuxEnv },
1488          )
1489  
1490          // Switch to the new session
1491          spawnSync('tmux', ['switch-client', '-t', tmuxSessionName], {
1492            stdio: 'inherit',
1493          })
1494        }
1495      } else {
1496        // Not in tmux - create and attach (original behavior)
1497        const tmuxArgs = [
1498          ...tmuxGlobalArgs,
1499          'new-session',
1500          '-A', // Attach if exists, create if not
1501          '-s',
1502          tmuxSessionName,
1503          '-c',
1504          worktreeDir,
1505          '--', // Separator before command
1506          process.execPath,
1507          ...newArgs,
1508        ]
1509  
1510        spawnSync('tmux', tmuxArgs, {
1511          stdio: 'inherit',
1512          cwd: worktreeDir,
1513          env: tmuxEnv,
1514        })
1515      }
1516    }
1517  
1518    return { handled: true }
1519  }