[ARTICLE] 让 Claude Code / Codex 用上 Git Worktree:并行开发的正确姿势

2026-06-03

当你开始频繁地让 AI 帮你写代码,很快会撞到一个尴尬的限制:一个工作目录,同一时间只能 checkout 一个分支

于是常见的场景就变成了——你让 AI 在 feat-a 分支上改东西,改到一半你又想起来 feat-b 还有个 bug 要修,只能先 git stash、切分支、再切回来。如果你想同时跑两个 AI 会话各干各的,那基本是不可能的:它们会在同一个目录里互相踩脚。

git worktree 就是为了解决这个问题而生的,而它恰好和「AI 编程」这件事天然契合。

什么是 Git Worktree

一句话:一个仓库,多个工作目录

平时我们用 git,是「一个 .git 仓库 + 一个工作目录」。git worktree 允许你从同一个仓库里 checkout 出多个工作目录,每个目录可以停在不同的分支上,但它们共享同一份 .git 数据(提交历史、对象、远程配置都是同一套)。

my-repo/                      <- 主工作树 (main)
my-repo/.worktrees/
  ├── codex-feat-a/           <- worktree (codex/feat-a)
  ├── codex-feat-b/           <- worktree (codex/feat-b)
  └── codex-fix-login/        <- worktree (codex/fix-login)

它和「克隆多份仓库」相比,优势很明显:

对比项 多次 clone git worktree
磁盘占用 每份都是完整副本 共享 .git,只多出工作文件
提交/分支 各自独立,要手动同步 共享同一套历史和分支
远程配置 每份都要配 配一次,全部可用
适合场景 完全隔离的两个项目 同一项目的并行任务

对 AI 编程来说,这意味着:你可以为每一个「任务」开一个独立的 worktree,让一个 AI 会话在里面专心干活,互不干扰。任务做完了,连同分支一起删掉即可,主目录始终保持干净。

Claude Code 这边:基本开箱即用

Claude Code 对 worktree 有比较完善的内置支持。它把 worktree 当成一种「隔离工作区」的能力:

换句话说,在 Claude Code 里你大多数时候只要表达意图——「用一个独立 worktree 来做这件事」——剩下的创建、进入、收尾它会帮你处理。

Codex 这边:需要自己包一层

Codex CLI 目前没有把 worktree 封装成顺手的命令。你当然可以手动敲:

git worktree add ../.worktrees/my-task -b codex/my-task main
cd ../.worktrees/my-task
codex

但这套流程每次都要重复:算路径、起分支名、cd 进去、启动 codex;删的时候还要先 worktree removebranch -D,稍不留神就会把没合并的提交删掉,或者误删了 main

所以更省心的做法,是用三个 shell 函数把它包起来:

cw     创建 worktree,自动进入并启动 codex
cwrm   删除 codex worktree 和对应的本地分支
cwls   查看当前仓库的 worktree 列表

下面这一版同时支持 bash / zsh,直接放进 ~/.zshrc~/.bashrc 即可。

三个函数能做什么

命令 作用
cw 基于某个基准分支创建一个 codex/ 开头的 worktree,自动 cd 进去并启动 codex
cwrm 安全地删除一个 codex worktree 及其本地分支(带未提交检查、可达性检查、二次确认)
cwls 列出当前仓库的所有 worktree

它在设计上刻意做了几层「防呆」:

  1. 只允许创建/删除 codex/ 开头的分支——避免手滑误删 mainfeat/*dev 这类正常开发分支;
  2. 删除前检查未提交改动——有改动时默认拒绝,提示你先 commit 或 stash,除非显式 --force
  3. 删除前检查分支是否「可达」——如果分支上有还没合并、也没推送到任何地方的提交,默认拒绝删除,防止你丢掉工作成果;
  4. 从任意 worktree 子目录都能操作——函数会自动找到主工作树(admin worktree)来执行 git 命令。

完整代码

把下面这段完整粘贴进 ~/.zshrc~/.bashrc

# ============================================================
# Codex Worktree Helpers
#
# Usage:
#   cw
#   cw main codex/my-feature
#   cw origin/main codex/some-task
#
#   cwrm
#   cwrm codex/my-feature
#   cwrm --force codex/my-feature
#   cwrm --yes --force codex/my-feature
#
#   cwls
#
# Optional:
#   export CW_START_CODEX=0
#   export CW_WORKTREE_ROOT=/some/path
# ============================================================

_cw_git_root() {
  git rev-parse --show-toplevel 2>/dev/null
}

_cw_main_worktree() {
  local root="$1"

  git -C "$root" worktree list --porcelain 2>/dev/null | awk '
    /^worktree / {
      print substr($0, 10)
      exit
    }
  '
}

_cw_worktree_for_branch() {
  local admin="$1"
  local branch="$2"

  git -C "$admin" worktree list --porcelain 2>/dev/null | awk -v br="refs/heads/$branch" '
    /^worktree / {
      p = substr($0, 10)
    }
    /^branch / {
      b = substr($0, 8)
      if (b == br) {
        print p
        exit
      }
    }
  '
}

_cw_branch_exists() {
  local admin="$1"
  local branch="$2"

  git -C "$admin" show-ref --verify --quiet "refs/heads/$branch"
}

_cw_branch_reachable_elsewhere() {
  local admin="$1"
  local branch="$2"
  local self_ref="refs/heads/$branch"
  local found

  found=$(
    git -C "$admin" for-each-ref \
      --format='%(refname)' \
      --contains "$self_ref" \
      refs/heads refs/remotes 2>/dev/null | awk -v self="$self_ref" '
        $0 == self {
          next
        }
        $0 ~ /^refs\/remotes\/[^\/]+\/HEAD$/ {
          next
        }
        {
          print
          exit
        }
      '
  )

  [ -n "$found" ]
}

cwls() {
  local root
  root=$(_cw_git_root) || {
    echo "Not inside a git repository."
    return 1
  }

  local admin
  admin=$(_cw_main_worktree "$root")

  if [ -z "$admin" ]; then
    admin="$root"
  fi

  git -C "$admin" worktree list
}

cw() {
  local base="${1:-main}"
  local name="$2"

  local root
  root=$(_cw_git_root) || {
    echo "Not inside a git repository."
    return 1
  }

  local admin
  admin=$(_cw_main_worktree "$root")

  if [ -z "$admin" ]; then
    admin="$root"
  fi

  local repo
  repo=$(basename "$admin")

  if [ -z "$name" ]; then
    name="codex/$(date +%Y%m%d-%H%M%S)"
  fi

  case "$name" in
    codex/*)
      ;;
    *)
      echo "Refusing to create non-codex branch: $name"
      echo "Use a branch name starting with codex/, for example:"
      echo "  cw main codex/my-task"
      return 1
      ;;
  esac

  local safe_name
  safe_name=$(printf "%s" "$name" | sed 's#[/ ]#-#g')

  local wt_parent
  wt_parent="${CW_WORKTREE_ROOT:-$(dirname "$admin")/.worktrees/$repo}"

  local wt_dir="$wt_parent/$safe_name"

  mkdir -p "$wt_parent" || return 1

  echo "Repo:      $admin"
  echo "Base:      $base"
  echo "Branch:    $name"
  echo "Worktree:  $wt_dir"
  echo

  local existing_wt
  existing_wt=$(_cw_worktree_for_branch "$admin" "$name")

  if [ -n "$existing_wt" ] && [ -d "$existing_wt" ]; then
    echo "Worktree already exists:"
    echo "  $existing_wt"
    echo
    cd "$existing_wt" || return 1

    if [ "${CW_START_CODEX:-1}" = "1" ]; then
      echo "Starting Codex..."
      echo
      command -v codex >/dev/null 2>&1 || {
        echo "codex command not found."
        return 127
      }
      codex
    fi

    return 0
  fi

  if [ -e "$wt_dir" ]; then
    echo "Target path already exists:"
    echo "  $wt_dir"
    return 1
  fi

  if git -C "$admin" remote get-url origin >/dev/null 2>&1; then
    git -C "$admin" fetch origin --prune
  fi

  local start_point="$base"

  if git -C "$admin" rev-parse --verify --quiet "$base^{commit}" >/dev/null; then
    start_point="$base"
  elif git -C "$admin" rev-parse --verify --quiet "origin/$base^{commit}" >/dev/null; then
    start_point="origin/$base"
  fi

  if _cw_branch_exists "$admin" "$name"; then
    git -C "$admin" worktree add "$wt_dir" "$name" || return 1
  else
    git -C "$admin" worktree add -b "$name" "$wt_dir" "$start_point" || return 1
  fi

  cd "$wt_dir" || return 1

  echo
  echo "Now in: $(pwd)"
  echo

  if [ "${CW_START_CODEX:-1}" = "1" ]; then
    echo "Starting Codex..."
    echo

    command -v codex >/dev/null 2>&1 || {
      echo "codex command not found."
      return 127
    }

    codex
  fi
}

cwrm() {
  local force=0
  local yes=0
  local target=""

  while [ $# -gt 0 ]; do
    case "$1" in
      -f|--force)
        force=1
        ;;
      -y|--yes)
        yes=1
        ;;
      -h|--help)
        echo "Usage:"
        echo "  cwrm"
        echo "  cwrm codex/branch-name"
        echo "  cwrm /path/to/worktree"
        echo "  cwrm --force codex/branch-name"
        echo "  cwrm --yes --force codex/branch-name"
        return 0
        ;;
      *)
        target="$1"
        ;;
    esac
    shift
  done

  local root=""

  if [ -n "$target" ] && [ -d "$target" ]; then
    root=$(cd "$target" && git rev-parse --show-toplevel 2>/dev/null) || {
      echo "Target path is not a git worktree:"
      echo "  $target"
      return 1
    }
  else
    root=$(_cw_git_root) || {
      echo "Not inside a git repository."
      return 1
    }
  fi

  local admin
  admin=$(_cw_main_worktree "$root")

  if [ -z "$admin" ]; then
    admin="$root"
  fi

  local wt=""
  local branch=""

  if [ -z "$target" ]; then
    wt="$root"
    branch=$(git -C "$wt" branch --show-current)
  elif [ -d "$target" ]; then
    wt=$(cd "$target" && git rev-parse --show-toplevel 2>/dev/null) || {
      echo "Target path is not a git worktree:"
      echo "  $target"
      return 1
    }
    branch=$(git -C "$wt" branch --show-current)
  else
    branch="$target"
    wt=$(_cw_worktree_for_branch "$admin" "$branch")
  fi

  if [ -z "$branch" ]; then
    echo "Cannot detect branch. Detached HEAD worktrees are not supported by cwrm."
    return 1
  fi

  case "$branch" in
    codex/*)
      ;;
    *)
      echo "Refusing to delete non-codex branch:"
      echo "  $branch"
      echo
      echo "This function only deletes branches starting with codex/."
      return 1
      ;;
  esac

  if ! _cw_branch_exists "$admin" "$branch"; then
    echo "Local branch does not exist:"
    echo "  $branch"
    return 1
  fi

  if [ -n "$wt" ] && [ -d "$wt" ]; then
    if [ "$force" -ne 1 ] && [ -n "$(git -C "$wt" status --porcelain)" ]; then
      echo "Worktree has uncommitted changes:"
      git -C "$wt" status --short
      echo
      echo "Commit or stash them first, or use:"
      echo "  cwrm --force $branch"
      return 1
    fi
  fi

  if [ "$force" -ne 1 ]; then
    if ! _cw_branch_reachable_elsewhere "$admin" "$branch"; then
      echo "Branch tip is not reachable from another local or remote ref:"
      echo "  $branch"
      echo
      echo "This usually means the branch has commits that are not merged or preserved elsewhere."
      echo "To delete anyway, use:"
      echo "  cwrm --force $branch"
      return 1
    fi
  fi

  echo "Repo:      $admin"
  echo "Worktree:  ${wt:-<none found>}"
  echo "Branch:    $branch"
  echo "Force:     $force"
  echo

  if [ "$yes" -ne 1 ]; then
    printf "Remove this worktree and delete the local branch? [y/N] "
    IFS= read -r ans

    case "$ans" in
      y|Y|yes|YES)
        ;;
      *)
        echo "Canceled."
        return 1
        ;;
    esac
  fi

  if [ -n "$wt" ] && [ -d "$wt" ]; then
    local here
    here=$(pwd -P)

    local wt_real
    wt_real=$(cd "$wt" && pwd -P)

    case "$here/" in
      "$wt_real"/*)
        cd "$(dirname "$wt_real")" || return 1
        ;;
    esac

    if [ "$force" -eq 1 ]; then
      git -C "$admin" worktree remove --force "$wt_real" || return 1
    else
      git -C "$admin" worktree remove "$wt_real" || return 1
    fi
  fi

  git -C "$admin" branch -D "$branch" || return 1
  git -C "$admin" worktree prune

  echo
  echo "Removed:"
  echo "  branch:   $branch"

  if [ -n "$wt" ]; then
    echo "  worktree: $wt"
  fi
}

也可以直接下载本文附带的 function.sh,然后在配置文件里 source /path/to/function.sh

安装与加载

粘贴(或 source)之后,重新加载你的 shell 配置:

source ~/.zshrc

或者:

source ~/.bashrc

两个可选的环境变量:

# 创建 worktree 后不自动启动 codex(只创建并 cd 进去)
export CW_START_CODEX=0

# 自定义 worktree 的存放根目录(默认在仓库同级的 .worktrees/<repo>/ 下)
export CW_WORKTREE_ROOT=/some/path

使用示例

创建 worktree

不带参数时,默认基于 main 创建一个时间戳命名的分支:

cw

指定基准分支和分支名:

cw main codex/my-feature

基准也可以是远程分支,函数会先 fetch 再创建:

cw origin/main codex/some-task

创建成功后,你会直接落在新 worktree 目录里,并自动进入 codex 会话。

查看 worktree 列表

cwls

删除 worktree 和分支

在某个 worktree 里直接删它自己:

cwrm

按分支名删除:

cwrm codex/my-feature

如果分支里有未提交改动、或者有还没合并出去的提交,默认会被拦下来。确认不要这些内容时,加 --force

cwrm --force codex/my-feature

不想要交互确认(比如写在脚本里)时,加 --yes

cwrm --yes --force codex/my-feature

几个值得注意的设计细节

为什么只让删 codex/ 分支?

因为这套函数的定位就是「管理 AI 临时分支」。把 maindevfeat/* 这些真正的开发分支挡在门外,是为了让 cwrm --force 这种带破坏性的操作有一个明确的边界——你不可能用它把主分支误删掉。

「可达性检查」具体在防什么?

cwrm 在非 --force 模式下,会用 git for-each-ref --contains 检查这个分支的最新提交,是否还能从另一个本地或远程引用到达。如果不能,说明这些提交只活在这一个分支上,删了就真的没了。这时它会拒绝,并提示你用 --force。这一层专门用来兜住「AI 刚跑完一堆改动、还没来得及合并就被删」的情况。

从子目录也能用

worktree 的 git 操作需要落在主工作树上执行,函数里的 _cw_main_worktree 会通过 git worktree list --porcelain 找到那个「admin」目录,所以你在任何一个 worktree 的任意子目录里调用 cw / cwrm / cwls 都能正常工作。

小结

把它扔进 shell 配置,下次想让 codex 单独跑一条线时,一个 cw 就出发了。

Return_To_Blog_Index