Git 集成深度解析

📑 目录

在 Claude Code 的工程基础设施中,Git 集成是最为核心且复杂的子系统之一。它不仅仅是简单地调用 git statusgit diff,而是构建了一套从安全执行、状态解析、差异计算到工作树隔离的完整体系。本文将深入解析 utils/git.tsutils/gitDiff.tsutils/worktree.ts 以及 commands/commit.tscommands/commit-push-pr.ts 等关键模块的实现细节,揭示 Claude Code 如何将 Git 的能力深度融入 AI Agent 的工作流中。

一、Git 操作封装:utils/git.ts

utils/git.ts 是 Claude Code 与 Git 交互的基石,文件大小约 30KB,提供了从仓库发现、状态查询到安全检测的全套封装。它的设计哲学是:将 Git 命令的安全执行、结果解析和错误处理统一抽象,同时注入安全机制防止恶意仓库的攻击

1.1 仓库根目录查找与规范化

Claude Code 需要快速确定当前工作目录是否处于 Git 仓库中,以及仓库的根目录在哪里。findGitRoot 函数通过向上遍历目录树寻找 .git 目录或文件来实现这一目标:

// utils/git.ts,第 15-45 行
const findGitRootImpl = memoizeWithLRU(
  (startPath: string): string | typeof GIT_ROOT_NOT_FOUND => {
    let current = resolve(startPath)
    const root = current.substring(0, current.indexOf(sep) + 1) || sep
    let statCount = 0

    while (current !== root) {
      try {
        const gitPath = join(current, '.git')
        statCount++
        const stat = statSync(gitPath)
        if (stat.isDirectory() || stat.isFile()) {
          return current.normalize('NFC')
        }
      } catch {
        // .git doesn't exist at this level, continue up
      }
      const parent = dirname(current)
      if (parent === current) { break }
      current = parent
    }
    // ... 检查根目录 ...
    return GIT_ROOT_NOT_FOUND
  },
  path => path,
  50,
)

这里有几个关键设计点:

  • LRU 缓存:使用 memoizeWithLRU 对结果进行缓存,最多保留 50 个条目。因为 gitDiff 等模块会以 dirname(file) 调用此函数,编辑大量文件时防止无限制增长。
  • 支持 worktree 和 submodule.git 既可以是目录(普通仓库),也可以是文件(worktree/submodule 使用 gitdir: 指针),因此同时检查 isDirectory()isFile()
  • NFC 规范化:路径使用 .normalize('NFC') 处理 Unicode 组合字符,确保跨平台一致性。

对于 worktree 场景,findGitRoot 返回的是 worktree 目录(.git 文件所在位置),而 findCanonicalGitRoot 则通过解析 .git 文件中的 gitdir:commondir 链,返回主仓库的工作目录。这确保了所有 worktree 共享相同的项目身份(用于 auto-memory、项目配置等)。

// utils/git.ts,第 58-105 行
const resolveCanonicalRoot = memoizeWithLRU(
  (gitRoot: string): string => {
    try {
      const gitContent = readFileSync(join(gitRoot, '.git'), 'utf-8').trim()
      if (!gitContent.startsWith('gitdir:')) {
        return gitRoot
      }
      const worktreeGitDir = resolve(gitRoot, gitContent.slice('gitdir:'.length).trim())
      const commonDir = resolve(worktreeGitDir, readFileSync(join(worktreeGitDir, 'commondir'), 'utf-8').trim())
      // SECURITY: 验证 worktreeGitDir 结构防止路径遍历攻击
      if (resolve(dirname(worktreeGitDir)) !== join(commonDir, 'worktrees')) {
        return gitRoot
      }
      // ... 进一步验证 backlink ...
      return dirname(commonDir).normalize('NFC')
    } catch {
      return gitRoot
    }
  },
  root => root,
  50,
)

安全机制是这里的一大亮点。Claude Code 明确考虑了恶意仓库的攻击场景:攻击者可能构造特殊的 .git 文件和 commondir 文件,指向受害者信任的目录,从而绕过信任对话框并执行恶意 hook。代码通过两个验证条件来防御:

  1. worktreeGitDir 必须是 /worktrees/ 的直接子目录,确保 commondir 文件位于解析后的 common dir 内部,而非攻击者的仓库中。
  2. gitdir 文件必须指回当前目录的 .git,防止攻击者借用受害者已有的 worktree 条目。

1.2 Git 命令的安全执行

Claude Code 使用 execFileNoThrow(来自 utils/execFileNoThrow.ts)执行 Git 命令。这个封装层确保:

  • 非抛出式错误处理:通过返回 { stdout, stderr, code } 而不是抛出异常,调用者可以优雅地处理错误。
  • 超时控制:避免 Git 命令(如 git fetch 等待凭证输入)无限挂起。
  • 环境变量隔离:通过 stdin: 'ignore' 和设置 GIT_TERMINAL_PROMPT=0GIT_ASKPASS='' 防止交互式提示阻塞 CLI。
// utils/git.ts,第 178-180 行
export const gitExe = memoize((): string => {
  return whichSync('git') || 'git'
})

gitExe 使用 memoize 缓存 git 可执行文件的路径查找结果,避免每次 spawn 进程时都进行 PATH 搜索。

1.3 状态查询 API

utils/git.ts 提供了一组高层状态查询函数,封装了常见的 Git 信息获取:

// utils/git.ts,第 200-250 行(近似)
export const getHead = async (): Promise<string | null> => getCachedHead()
export const getBranch = async (): Promise<string | null> => getCachedBranch()
export const getDefaultBranch = async (): Promise<string | null> => getCachedDefaultBranch()
export const getRemoteUrl = async (): Promise<string | null> => getCachedRemoteUrl()

export const getIsClean = async (options?: { ignoreUntracked?: boolean }): Promise<boolean> => {
  const args = ['--no-optional-locks', 'status', '--porcelain']
  if (options?.ignoreUntracked) { args.push('-uno') }
  const { stdout } = await execFileNoThrow(gitExe(), args, { preserveOutputOnError: false })
  return stdout.trim().length === 0
}

注意到 --no-optional-locks 标志的使用——这在频繁调用 git status 时非常重要,可以避免 Git 创建不必要的索引锁文件,减少与其他 Git 操作的冲突。

1.4 Git 状态聚合

getGitState 函数将多个独立的 Git 查询并行化,聚合成一个完整的仓库状态对象:

// utils/git.ts,第 390-415 行(近似)
export async function getGitState(): Promise<GitRepoState | null> {
  try {
    const [commitHash, branchName, remoteUrl, isHeadOnRemote, isClean, worktreeCount] =
      await Promise.all([
        getHead(), getBranch(), getRemoteUrl(),
        getIsHeadOnRemote(), getIsClean(), getWorktreeCount(),
      ])
    return { commitHash, branchName, remoteUrl, isHeadOnRemote, isClean, worktreeCount }
  } catch (_) {
    return null
  }
}

通过 Promise.all 并行执行 6 个独立的 Git 查询,将串行的 ~6×90ms 降低到约 90ms。

1.5 状态保留:preserveGitStateForIssue

当用户提交 Issue 或分享对话时,Claude Code 需要保留可重现的 Git 状态。preserveGitStateForIssue 函数实现了这一功能:

// utils/git.ts,第 470-560 行(近似)
export async function preserveGitStateForIssue(): Promise<PreservedGitState | null> {
  // ... 查找 remote base ...
  const remoteBase = await findRemoteBase()
  const { stdout: mergeBase } = await execFileNoThrow(
    gitExe(), ['merge-base', 'HEAD', remoteBase], { preserveOutputOnError: false }
  )
  const remoteBaseSha = mergeBase.trim()

  // 5 个命令并行执行
  const [{ stdout: patch }, untrackedFiles, { stdout: formatPatchOut },
        { stdout: headSha }, { stdout: branchName }] = await Promise.all([
    execFileNoThrow(gitExe(), ['diff', remoteBaseSha]),
    captureUntrackedFiles(),
    execFileNoThrow(gitExe(), ['format-patch', `${remoteBaseSha}..HEAD`, '--stdout']),
    execFileNoThrow(gitExe(), ['rev-parse', 'HEAD']),
    execFileNoThrow(gitExe(), ['rev-parse', '--abbrev-ref', 'HEAD']),
  ])
  // ...
}

这里的设计非常精巧:

  • 使用 merge-base 找到与远程分支的最近公共祖先,而非直接使用 HEAD,这样即使本地分支被 force-push,远程基准仍然稳定。
  • format-patch 保留完整的提交链(作者、日期、消息),使得 replay 容器可以重建真实提交,而不是使用压扁的 diff。
  • 未跟踪文件单独捕获,并设置了 500MB 单文件限制、5GB 总限制和 20000 文件数限制。

1.6 安全检测:裸仓库检测

Claude Code 还包含了一个重要的安全函数 isCurrentDirectoryBareGitRepo,用于检测当前目录是否被伪装成裸 Git 仓库:

// utils/git.ts,第 850-910 行(近似)
export function isCurrentDirectoryBareGitRepo(): boolean {
  const fs = getFsImplementation()
  const cwd = getCwd()
  const gitPath = join(cwd, '.git')
  try {
    const stats = fs.statSync(gitPath)
    if (stats.isFile()) { return false } // worktree/submodule
    if (stats.isDirectory()) {
      const gitHeadPath = join(gitPath, 'HEAD')
      try {
        if (fs.statSync(gitHeadPath).isFile()) { return false }
      } catch { /* fall through */ }
    }
  } catch { /* fall through */ }

  // 检查裸仓库指标
  try { if (fs.statSync(join(cwd, 'HEAD')).isFile()) return true } catch {}
  try { if (fs.statSync(join(cwd, 'objects')).isDirectory()) return true } catch {}
  try { if (fs.statSync(join(cwd, 'refs')).isDirectory()) return true } catch {}
  return false
}

这个函数防御的攻击场景是:攻击者在当前目录创建 HEADobjects/refs/ 并破坏 .git/HEAD,使得 git status 将当前目录视为 Git 目录并执行 hooks/pre-commit

二、Diff 计算:utils/gitDiff.ts

utils/gitDiff.ts(约 16KB)负责计算和解析 Git 差异,是 Claude Code 向模型展示代码变更的核心模块。它需要处理工作区变更、暂存区变更、未跟踪文件,以及生成结构化的 diff 输出。

2.1 工作区 Diff 获取

fetchGitDiff 是 diff 计算的入口函数,采用分层策略避免在 diff 过大时消耗过多资源:

// utils/gitDiff.ts,第 30-85 行
export async function fetchGitDiff(): Promise<GitDiffResult | null> {
  const isGit = await getIsGit()
  if (!isGit) return null

  if (await isInTransientGitState()) { return null }

  // 第一层:quick probe,使用 --shortstat 获取总体统计
  const { stdout: shortstatOut, code: shortstatCode } = await execFileNoThrow(
    gitExe(), ['--no-optional-locks', 'diff', 'HEAD', '--shortstat'],
    { timeout: GIT_TIMEOUT_MS, preserveOutputOnError: false }
  )

  if (shortstatCode === 0) {
    const quickStats = parseShortstat(shortstatOut)
    if (quickStats && quickStats.filesCount > MAX_FILES_FOR_DETAILS) {
      return { stats: quickStats, perFileStats: new Map(), hunks: new Map() }
    }
  }

  // 第二层:--numstat 获取每文件统计
  const { stdout: numstatOut, code: numstatCode } = await execFileNoThrow(
    gitExe(), ['--no-optional-locks', 'diff', 'HEAD', '--numstat'],
    { timeout: GIT_TIMEOUT_MS, preserveOutputOnError: false }
  )
  if (numstatCode !== 0) return null
  const { stats, perFileStats } = parseGitNumstat(numstatOut)

  // 包含未跟踪文件
  const remainingSlots = MAX_FILES - perFileStats.size
  if (remainingSlots > 0) {
    const untrackedStats = await fetchUntrackedFiles(remainingSlots)
    if (untrackedStats) { /* merge */ }
  }
  return { stats, perFileStats, hunks: new Map() }
}

分层策略非常明智:

  1. Quick Probe:先用 --shortstat(O(1) 内存)探测 diff 规模,如果文件数超过 500,直接返回总量统计,跳过逐文件解析。
  2. Numstat:如果规模可控,使用 --numstat 获取每文件的增删行数,这是比完整 diff 更轻量的操作。
  3. 延迟加载 Hunks:hunks(具体的代码差异块)不在 fetchGitDiff 中加载,而是通过 fetchGitDiffHunks 按需获取,避免每次轮询都执行昂贵的 git diff HEAD

2.2 结构化 Diff 解析

parseGitDiff 函数将 git diff 的统一格式输出解析为结构化的 Map<string, StructuredPatchHunk[]>

// utils/gitDiff.ts,第 155-220 行(近似)
export function parseGitDiff(stdout: string): Map<string, StructuredPatchHunk[]> {
  const result = new Map()
  if (!stdout.trim()) return result

  const fileDiffs = stdout.split(/^diff --git /m).filter(Boolean)

  for (const fileDiff of fileDiffs) {
    if (result.size >= MAX_FILES) break
    if (fileDiff.length > MAX_DIFF_SIZE_BYTES) { continue }

    const lines = fileDiff.split('\n')
    const headerMatch = lines[0]?.match(/^a\/(.+?) b\/(.+)$/)
    if (!headerMatch) continue
    const filePath = headerMatch[2] ?? headerMatch[1] ?? ''

    const fileHunks: StructuredPatchHunk[] = []
    let currentHunk: StructuredPatchHunk | null = null
    let lineCount = 0

    for (let i = 1; i < lines.length; i++) {
      const line = lines[i] ?? ''
      const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
      if (hunkMatch) {
        if (currentHunk) { fileHunks.push(currentHunk) }
        currentHunk = {
          oldStart: parseInt(hunkMatch[1] ?? '0', 10),
          oldLines: parseInt(hunkMatch[2] ?? '1', 10),
          newStart: parseInt(hunkMatch[3] ?? '0', 10),
          newLines: parseInt(hunkMatch[4] ?? '1', 10),
          lines: [],
        }
        continue
      }
      // 跳过元数据行...
      if (currentHunk && /* 是 diff 内容行 */) {
        if (lineCount >= MAX_LINES_PER_FILE) { continue }
        currentHunk.lines.push('' + line) // 强制 flat string,避免 V8 sliced string 泄漏
        lineCount++
      }
    }
    if (fileHunks.length > 0) { result.set(filePath, fileHunks) }
  }
  return result
}

解析过程中有两个值得注意的性能优化:

  • 强制 Flat String'' + line 强制分配新的扁平字符串。因为 split() 创建的 "sliced string" 会引用父字符串,如果保留任何一行,整个父字符串(可能达数 MB)都无法被 GC。
  • 多层级限制MAX_FILES(50 个文件)、MAX_DIFF_SIZE_BYTES(1MB 单文件)、MAX_LINES_PER_FILE(400 行)三层防护,确保内存和计算可控。

2.3 单文件 PR-like Diff

fetchSingleFileGitDiff 为单个文件生成类似 PR 的 diff(相对于分支分叉点,而非 HEAD):

// utils/gitDiff.ts,第 280-330 行(近似)
export async function fetchSingleFileGitDiff(absoluteFilePath: string): Promise<ToolUseDiff | null> {
  const gitRoot = findGitRoot(dirname(absoluteFilePath))
  if (!gitRoot) return null

  const gitPath = relative(gitRoot, absoluteFilePath).split(sep).join('/')
  const repository = getCachedRepository()

  const { code: lsFilesCode } = await execFileNoThrowWithCwd(
    gitExe(), ['--no-optional-locks', 'ls-files', '--error-unmatch', gitPath],
    { cwd: gitRoot, timeout: SINGLE_FILE_DIFF_TIMEOUT_MS }
  )

  if (lsFilesCode === 0) {
    // 文件已跟踪,对比 merge base
    const diffRef = await getDiffRef(gitRoot)
    const { stdout, code } = await execFileNoThrowWithCwd(
      gitExe(), ['--no-optional-locks', 'diff', diffRef, '--', gitPath],
      { cwd: gitRoot, timeout: SINGLE_FILE_DIFF_TIMEOUT_MS }
    )
    if (code !== 0 || !stdout) return null
    return { ...parseRawDiffToToolUseDiff(gitPath, stdout, 'modified'), repository }
  }

  // 文件未跟踪,生成 synthetic diff
  const syntheticDiff = await generateSyntheticDiff(gitPath, absoluteFilePath)
  if (!syntheticDiff) return null
  return { ...syntheticDiff, repository }
}

这里的关键是 getDiffRef 函数,它按优先级选择对比基准:

  1. CLAUDE_CODE_BASE_REF 环境变量(由 CCR 管理的容器外部设置)
  2. 与默认分支的 merge base
  3. 回退到 HEAD

对于未跟踪文件,Claude Code 会读取文件内容并构造一个 synthetic diff,模拟 @@ -0,0 +1,N @@ 的统一 diff 格式。

三、Commit 和 PR 流程

Claude Code 将 Git 的 commit、push、PR 操作封装为内置命令(/ 命令),通过精心构造的 prompt 引导模型完成整个工作流。

3.1 Commit 命令:commit.ts

/commit 命令是最简单的 Git 工作流,仅创建一个提交:

// commands/commit.ts,第 6-60 行
const ALLOWED_TOOLS = [
  'Bash(git add:*)',
  'Bash(git status:*)',
  'Bash(git commit:*)',
]

function getPromptContent(): string {
  return `## Context
- Current git status: !\`git status\`
- Current git diff (staged and unstaged changes): !\`git diff HEAD\`
- Current branch: !\`git branch --show-current\`
- Recent commits: !\`git log --oneline -10\`

## Git Safety Protocol
- NEVER update the git config
- NEVER skip hooks unless the user explicitly requests it
- CRITICAL: ALWAYS create NEW commits. NEVER use git commit --amend
- Do not commit files that likely contain secrets
- If there are no changes to commit, do not create an empty commit
- Never use git commands with the -i flag

## Your task
Based on the above changes, create a single git commit:
1. Analyze all staged changes and draft a commit message...
2. Stage relevant files and create the commit using HEREDOC syntax...`
}

Commit prompt 中的几个关键设计:

  • 上下文注入:通过 !\command`语法(executeShellCommandsInPrompt` 处理)在 prompt 构建阶段执行 Git 命令,将实时的仓库状态注入到模型上下文中。
  • Git Safety Protocol:明确禁止修改 git config、跳过 hook、使用 --amend 等危险操作,确保模型行为可预测。
  • Heredoc 语法:使用 $(cat <<'EOF'...EOF) 包裹提交消息,避免 shell 转义问题,同时支持多行提交消息和 attribution 文本的自动追加。

3.2 Commit-Push-PR 命令:commit-push-pr.ts

/commit-push-pr 命令扩展了 commit 流程,增加了分支创建、推送到远程和 PR 创建/更新:

// commands/commit-push-pr.ts,第 7-95 行
const ALLOWED_TOOLS = [
  'Bash(git checkout --branch:*)', 'Bash(git checkout -b:*)',
  'Bash(git add:*)', 'Bash(git status:*)', 'Bash(git push:*)',
  'Bash(git commit:*)', 'Bash(gh pr create:*)', 'Bash(gh pr edit:*)',
  'Bash(gh pr view:*)', 'Bash(gh pr merge:*)',
  'ToolSearch',
]

Prompt 中增加了对默认分支的 diff 上下文 git diff ${defaultBranch}...HEAD,让模型能看到 PR 将包含的所有变更,而非仅仅是最近一次提交。同时引入了 PR attribution 和 Slack 通知集成:

4. If a PR already exists for this branch, update the PR title and body using `gh pr edit`.
   Otherwise, create a pull request using `gh pr create` with heredoc syntax for the body.
   - IMPORTANT: Keep PR titles short (under 70 characters).

PR 创建使用 heredoc 语法构建 body,包含 Summary、Test plan、Changelog 和 attribution 等标准化章节。

3.3 PR 状态查询:ghPrStatus.ts

utils/ghPrStatus.ts 封装了对 GitHub CLI (gh) 的调用,用于获取当前分支关联的 PR 状态:

// utils/ghPrStatus.ts,第 30-85 行
export async function fetchPrStatus(): Promise<PrStatus | null> {
  const isGit = await getIsGit()
  if (!isGit) return null

  const [branch, defaultBranch] = await Promise.all([getBranch(), getDefaultBranch()])
  if (branch === defaultBranch) return null

  const { stdout, code } = await execFileNoThrow('gh', [
    'pr', 'view', '--json', 'number,url,reviewDecision,isDraft,headRefName,state',
  ], { timeout: GH_TIMEOUT_MS, preserveOutputOnError: false })

  if (code !== 0 || !stdout.trim()) return null

  const data = jsonParse(stdout)
  // 过滤已合并/已关闭的 PR,以及来自默认分支的 PR
  if (data.state === 'MERGED' || data.state === 'CLOSED') return null
  if (data.headRefName === defaultBranch) return null

  return {
    number: data.number,
    url: data.url,
    reviewState: deriveReviewState(data.isDraft, data.reviewDecision),
  }
}

deriveReviewState 函数将 GitHub API 的 reviewDecisionAPPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED)和 isDraft 标志映射为 Claude Code 内部的 PrReviewState 枚举值。这个状态通常显示在 Claude Code 的 UI 状态栏中,让用户实时了解 PR 的评审进展。

四、工作树管理:utils/worktree.ts

utils/worktree.ts(约 49KB)是 Claude Code 工作树隔离功能的核心实现。它支持通过 EnterWorktreeToolExitWorktreeTool 创建和管理 Git worktree,实现任务级的工作目录隔离。

4.1 Worktree 创建与恢复

getOrCreateWorktree 函数实现了 worktree 的获取或创建逻辑:

// utils/worktree.ts,第 200-290 行(近似)
async function getOrCreateWorktree(repoRoot: string, slug: string, options?: { prNumber?: number }): Promise<WorktreeCreateResult> {
  const worktreePath = worktreePathFor(repoRoot, slug)
  const worktreeBranch = worktreeBranchName(slug)

  // Fast resume path: 直接读取 .git/worktrees/<name>/HEAD,无需子进程
  const existingHead = await readWorktreeHeadSha(worktreePath)
  if (existingHead) {
    return { worktreePath, worktreeBranch, headCommit: existingHead, existed: true }
  }

  await mkdir(worktreesDir(repoRoot), { recursive: true })

  // 获取基础分支
  let baseBranch: string
  if (options?.prNumber) {
    await execFileNoThrowWithCwd(gitExe(), ['fetch', 'origin', `pull/${options.prNumber}/head`], { cwd: repoRoot, stdin: 'ignore', env: fetchEnv })
    baseBranch = 'FETCH_HEAD'
  } else {
    // 优先使用本地已有的 origin/<branch>,避免不必要的 fetch
    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'
    }
  }

  // 使用 -B(而非 -b)重置可能存在的孤儿分支
  const addArgs = ['worktree', 'add']
  if (sparsePaths?.length) { addArgs.push('--no-checkout') }
  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}`) }
  // ...
}

性能优化是这里的关键主题:

  • Fast Resume:通过直接读取 Git 内部文件(.git/worktrees/<name>/HEAD)来检测 worktree 是否存在,避免了 rev-parse HEAD 的 ~15ms 子进程开销。
  • 避免不必要的 fetch:在大型仓库(21万文件、1600万对象)中,git fetch 可能花费 6-8 秒进行本地 commit-graph 扫描。如果 origin/<branch> 已在本地,直接使用它。
  • -B 而非 -b:重置孤儿分支,避免在重新创建已删除 worktree 时额外运行 git branch -D

4.2 创建后配置

performPostCreationSetup 负责 worktree 创建后的环境配置:

// utils/worktree.ts,第 400-480 行(近似)
async function performPostCreationSetup(repoRoot: string, worktreePath: string): Promise<void> {
  // 1. 传播 settings.local.json(可能包含密钥)
  // 2. 配置 git hooks 路径指向主仓库
  // 3. 符号链接目录(如 node_modules)避免磁盘膨胀
  // 4. 复制 .worktreeinclude 指定的 gitignored 文件
  // 5. 安装 post-commit attribution hook
}

符号链接目录(symlinkDirectories)是一个重要的磁盘空间优化。对于包含 node_modules 的大型项目,每个 worktree 都复制一份会造成严重的磁盘膨胀。Claude Code 允许在 settings.json 中配置 worktree.symlinkDirectories,自动将指定目录符号链接到主仓库的对应目录。

.worktreeinclude 机制则解决了另一个问题:某些被 .gitignore 的文件(如本地配置文件、密钥)需要在 worktree 中可用。用户可以在主仓库根目录创建 .worktreeinclude 文件(使用 .gitignore 语法),Claude Code 会将被 gitignore 且匹配模式的文件复制到新 worktree。

4.3 Slug 验证与安全

validateWorktreeSlug 函数对工作树名称进行严格的验证,防止路径遍历和目录逃逸:

// utils/worktree.ts,第 25-55 行
const VALID_WORKTREE_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/
const MAX_WORKTREE_SLUG_LENGTH = 64

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`)
  }
  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 contain only letters, digits, dots, underscores, and dashes`)
    }
  }
}

由于 slug 会被拼接到 .claude/worktrees/ 下,如果不验证,../../../target 这样的输入会导致路径遍历。每个 / 分隔的段落独立验证,同时拒绝 ...

4.4 过期 Worktree 清理

Claude Code 会自动清理过期的临时 worktree(由 AgentTool、WorkflowTool 或 bridge 创建):

// utils/worktree.ts,第 740-830 行(近似)
const EPHEMERAL_WORKTREE_PATTERNS = [
  /^agent-a[0-9a-f]{7}$/,
  /^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/,
  /^wf-\d+$/, // Legacy
  /^bridge-[A-Za-z0-9_]+(-[A-Za-z0-9_]+)*$/,
  /^job-[a-zA-Z0-9._-]{1,55}-[0-9a-f]{8}$/,
]

export async function cleanupStaleAgentWorktrees(cutoffDate: Date): Promise<number> {
  // ... 只匹配符合 ephemeral 模式的 slug ...
  for (const slug of entries) {
    if (!EPHEMERAL_WORKTREE_PATTERNS.some(p => p.test(slug))) { continue }
    // Fail-closed: 如果 git status 非空或有未推送提交,跳过删除
    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 }
    await removeAgentWorktree(worktreePath, worktreeBranchName(slug), gitRoot)
  }
}

清理策略采用 fail-closed 原则:

  • 只删除符合特定命名模式(agent、workflow、bridge、job)的临时 worktree,永远不会删除用户命名的 worktree。
  • 如果 git status 显示有变更(包括未跟踪文件),或存在未推送到远程的提交,跳过删除。
  • 使用 -uno 跳过未跟踪文件扫描,在大型仓库中快 5-10 倍。

五、Git 状态集成与模型决策

Claude Code 的 Git 集成不仅仅是工具层的封装,更重要的是将 Git 状态深度融入模型的上下文和决策过程中。

5.1 上下文注入架构

graph TD
    A[Prompt 构建阶段] --> B[executeShellCommandsInPrompt]
    B --> C[调用 git status]
    B --> D[调用 git diff HEAD]
    B --> E[调用 git branch --show-current]
    B --> F[调用 git log --oneline]
    C --> G[将输出嵌入 Prompt 文本]
    D --> G
    E --> G
    F --> G
    G --> H[发送给 LLM]
    H --> I[模型决策: 如何操作 Git]
    I --> J[调用 Bash Tool 执行 git 命令]

通过 executeShellCommandsInPrompt 机制,Claude Code 在每次 prompt 构建时动态执行 Git 命令,将仓库的实时状态嵌入到模型上下文中。这种方式相比静态状态快照有以下优势:

  • 实时性:模型看到的是最新的仓库状态,而非启动时的快照。
  • 可定制性:不同命令(/commit/commit-push-pr)可以注入不同的 Git 上下文。
  • 安全性:通过 ALLOWED_TOOLS 白名单限制模型可执行的 Git 命令范围。

5.2 Git 状态与模型行为的关联

Claude Code 在多个层面利用 Git 状态来影响模型行为:

Git 状态影响
isClean === false在启动时提示用户有未提交变更;在 /commit 中分析变更内容
isHeadOnRemote === false提示用户当前提交未推送到远程
hasUnpushedCommits === true/commit-push-pr 流程中触发 push 步骤
worktreeCount > 1提示用户当前存在多个 worktree
branch === defaultBranch/commit-push-pr 中强制创建新分支
PR status在状态栏显示 PR 评审状态(approved/pending/changes_requested)

5.3 安全协议与模型约束

Git 相关的 prompt 中始终包含 Git Safety Protocol,这是 Claude Code 防止模型执行危险操作的核心机制:

- NEVER update the git config
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc)
- NEVER use git commit --amend(除非用户明确要求)
- NEVER run destructive commands (push --force, hard reset)
- Do not commit files that likely contain secrets
- Never use git commands with the -i flag

这些约束不是通过代码硬限制(模型理论上可以执行任意 Bash 命令),而是通过 prompt 工程引导模型自觉遵守。配合 ALLOWED_TOOLS 白名单,形成了双层防护。

5.4 工作树隔离的工作流

sequenceDiagram
    participant User
    participant ClaudeCode
    participant WorktreeMgr as utils/worktree.ts
    participant Git

    User->>ClaudeCode: /enter-worktree my-feature
    ClaudeCode->>WorktreeMgr: createWorktreeForSession("my-feature")
    WorktreeMgr->>Git: git worktree add -B worktree-my-feature .claude/worktrees/my-feature origin/main
    Git-->>WorktreeMgr: worktree created
    WorktreeMgr->>WorktreeMgr: performPostCreationSetup()
    WorktreeMgr->>ClaudeCode: session active
    ClaudeCode->>User: Switched to worktree: my-feature

    User->>ClaudeCode: (在 worktree 中工作并提交)
    ClaudeCode->>Git: git commit / git push

    User->>ClaudeCode: /exit-worktree
    ClaudeCode->>WorktreeMgr: cleanupWorktree()
    WorktreeMgr->>Git: git worktree remove --force .claude/worktrees/my-feature
    WorktreeMgr->>Git: git branch -D worktree-my-feature
    WorktreeMgr->>ClaudeCode: session cleared
    ClaudeCode->>User: Returned to original directory

工作树隔离是 Claude Code 支持复杂工作流的重要特性。当用户需要同时处理多个任务(如修复一个 bug 的同时 review 另一个 PR)时,可以为每个任务创建独立的 worktree,避免分支切换带来的上下文中断和构建缓存失效。

六、总结

Claude Code 的 Git 集成展现了一个成熟的 AI Agent 如何将外部工具系统深度融入自身架构。从 utils/git.ts 的安全封装和仓库发现,到 utils/gitDiff.ts 的分层 diff 计算,再到 utils/worktree.ts 的隔离工作流管理,每个模块都体现了工程上的深思熟虑:

  1. 性能优先:LRU 缓存、fast resume、避免不必要的 fetch、延迟加载 hunks、并行化独立查询。
  2. 安全防御:worktree 路径验证、裸仓库检测、commondir 结构验证、fail-closed 的过期清理策略。
  3. 用户体验:实时状态注入 prompt、PR 状态显示、tmux 集成、自动符号链接和配置传播。
  4. 可扩展性:hook-based worktree 允许用户接入其他 VCS 系统;.worktreeinclude 提供了灵活的 gitignored 文件复制机制。

理解这些实现细节,不仅有助于我们更好地使用 Claude Code 的 Git 相关功能,也为构建类似的 AI 辅助开发工具提供了宝贵的架构参考。在下一篇文章中,我们将继续深入 Claude Code 的工程基础设施,探讨其工具执行和权限控制系统。