AIGC标识 Git Worktree全解析

Git Worktree:一个仓库,多个工作区

当你第 N 次为了改一行 hotfix 而被迫 git stash 掉半成品代码时,该认识一下 git worktree 了。

git worktree 允许同一个仓库同时拥有多个独立的工作目录,每个目录检出不同分支,但它们共享同一份 .git 对象库和引用。它解决的问题只有一个——"我需要同时工作在多个分支上,但不想为此复制整个仓库,也不想反复搞乱当前的工作区。"


目录


1. 背景:我们到底在忍什么

1.1 场景一:紧急 hotfix 打断了手上的活

你正在 feature/payment-v2 上改代码,三个文件改到一半,跑不起来,但逻辑刚写顺。这时线上报警:登录接口 500。

git checkout -b hotfix/login main 会怎样?

  • Git 会尝试把你的未提交改动带到新分支上。带得过去,你就得在一个已经"脏"的工作区里改 hotfix,一不小心把半成品代码一起提交进 hotfix 分支。
  • 带不过去(改动和目标分支的文件有冲突),Git 直接拒绝切换:error: Your local changes to the following files would be overwritten by checkout。于是你只能先 git stash。

于是就有了这套所有人都很熟、但没人喜欢的仪式:

git stash push -m "payment v2 wip"
git switch -c hotfix/login main
# ...改代码、测试、提交、推送、合并
git switch feature/payment-v2
git stash pop

问题在于:git stash pop 会冲突(因为期间 main 的改动可能已经合并进来);你会忘记自己 stash 过;多个 stash 堆在一起后 stash@{2} 到底是哪一坨,谁也说不清。更隐蔽的代价是:你的 IDE 索引、编译缓存、dev server 全部被重建成另一套,切回来又要等一遍索引。

1.2 场景二:cp -r 一份仓库

不想 stash,那就复制一份:

cp -r ~/code/myapp ~/code/myapp-hotfix
cd ~/code/myapp-hotfix && git switch hotfix/login

这样能跑,但代价比想象中大:

  1. 磁盘直接翻倍。整个 .git 被复制,历史越长越痛;node_modules、target/、.venv 也一起复制。
  2. 两边的分支是"平行宇宙"。你在副本里 git fetch 拉了新提交,主仓库看不到;在副本里建的分支,主仓库也不存在。要同步只能再来一次 fetch,或者互相 add remote。
  3. 容易推错仓库、搞错 origin。副本里的 origin 还是同一个,但因为引用不同步,git push 前你得反复确认自己到底基于哪个 commit。
  4. 副本会不断繁殖。第一次复制之后,团队里就会出现 myapp、myapp-hotfix、myapp-review、myapp-old……到某天没人敢删任何一个。

1.3 真正的矛盾

Git 的核心设计里,工作区(working tree)和仓库(repository)是一对一的:一个 .git 目录对应一块磁盘上的文件。而"同时开发多个分支"这个需求,本质上是想打破这个一对一。

历史上有三种解法:

解法 本质 代价
git stash + 反复切换 复用一个工作区,靠保存/恢复现场 上下文丢失、冲突、缓存重建
复制/clone 整个仓库 复制出多份仓库 磁盘、引用不同步、管理混乱
git worktree 一个仓库,挂多个工作区 需要理解它的约束

git worktree 是 Git 官方给出的正解。


2. worktree 是什么

2.1 一个仓库,多个工作区

官方定义很朴素:"Manage multiple working trees attached to the same repository."

一个普通的 Git 仓库叫 main worktree(主工作区)。执行 git worktree add 之后,你可以额外挂载任意多个 linked worktree(链接工作区)。它们是:

  • 不同的目录,各自有独立的 HEAD、独立的 index(暂存区)、独立的工作区文件;
  • 同一个仓库,共享 .git/objects、.git/refs、.git/config、hooks、stash。
~/code/myapp/                 ← main worktree   (分支 main)
~/code/myapp-hotfix/          ← linked worktree (分支 hotfix/login)
~/code/myapp-pr123/           ← linked worktree (detached HEAD)
        │
        └── 三者共享 ~/code/myapp/.git 这一份对象库

用一句话概括:分支是共享的,工作区是各自的。

2.2 它是怎么实现的

主仓库里多了这些东西:

myapp/
├── .git/                       # 主仓库,同时充当 main worktree 的元数据目录
│   └── worktrees/
│       ├── myapp-hotfix/
│       │   ├── HEAD            # 这个工作区当前指向哪
│       │   ├── index           # 这个工作区自己的暂存区
│       │   ├── gitdir          # 反指回工作区里的 .git 文件
│       │   ├── commondir       # 内容通常是 ../..,指向共享的 .git
│       │   └── locked          # 可选,lock 之后才有
│       └── myapp-pr123/
└── ...

myapp-hotfix/
└── .git                        # 注意:这是一个【文件】,不是目录

那个 .git 文件的内容就一行:

$ cat ~/code/myapp-hotfix/.git
gitdir: /Users/me/code/myapp/.git/worktrees/myapp-hotfix

这就是全部魔法:Git 通过这行路径找到共享仓库,同时用各自 worktrees/<name>/index 和 HEAD 保持工作区独立。所以 worktree 几乎不额外占磁盘——只有一份工作区文件,对象库是共享的。

这也解释了后面很多"坑"的来源:任何假设 .git 必须是目录的脚本、IDE、打包工具,在 worktree 里都可能出问题。

2.3 共享什么,隔离什么

项目 是否共享 说明
对象库(commits/blobs/trees) ✅ 共享 这是 worktree 省磁盘的根本
分支、tag、远程引用 ✅ 共享 在任一 worktree 建的分支,其他都能看到
stash ✅ 共享 git stash 是存在 refs/stash 里的,全局唯一
config(大部分) ✅ 共享 共享 .git/config;但 core.bare、core.worktree 是每个工作区独立的
hooks ✅ 共享 所以 hook 会作用于所有 worktree
refs 的 reflog ⚠️ 分开记录 每工作区有自己 worktrees/<name>/logs/HEAD
HEAD ❌ 独立 各自指向自己的分支/commit
index(暂存区) ❌ 独立 各暂存各的
工作区文件 ❌ 独立 各自的未提交改动互不去影响
构建产物 / node_modules / 编译缓存 ❌ 独立 不共享,这点常被误解,见下文

⚠️ 关键认知:worktree 省的是 .git 的历史对象和网络传输,不是构建缓存。如果你在三个 worktree 里各跑一次 npm install,就是三份 node_modules。想省构建时间,得手动共享(CARGO_TARGET_DIR、pnpm store、Gradle build cache),或者用软链。

2.4 为什么同一个分支不能检出两次

$ git worktree add ../myapp-x main
Preparing worktree (checking out 'main')
fatal: 'main' is already used by worktree at '/Users/me/code/myapp'

因为分支的语义是"一条会向前移动的线"。如果两个工作区同时检出 main,那么在 A 里 commit 会让 main 前进,B 的工作区文件就立刻"落后于它所在的分支"——你以为自己在 main 上,实际你脚下的 main 已经被别处推走了。这种状态下几乎所有 Git 操作(status、diff、merge)都失去意义。

Git 选择直接禁止。如果你确实需要两个工作区看同一个 commit,用 detached HEAD:

git worktree add --detach ../myapp-review main

detached HEAD 在工作区里是"只读观察"的定位,不占用分支,随便开多少个都行。


3. 常规操作命令速查

目的 命令
新建工作区(检出已有分支) git worktree add <path> <branch>
新建工作区(同时建新分支) git worktree add -b <new-branch> <path> [<start-point>]
新建工作区(分离头指针) git worktree add --detach <path> <commit-ish>
列出所有工作区 git worktree list / git worktree list --porcelain
删除工作区 git worktree remove <path>(--force 强删)
移动工作区 git worktree move <worktree> <new-path>
加锁 / 解锁 git worktree lock [--reason <msg>] <path> / git worktree unlock <path>
清理失效记录 git worktree prune [-n] [-v]
修复路径关系 git worktree repair [<path>...]

下面逐条说明。

3.1 add:创建工作区

# 检出已存在的分支
git worktree add ../myapp-hotfix hotfix/login

# 基于 main 新建 feature/x 分支,并检出到一个新目录
git worktree add -b feature/x ../myapp-x main

# 不占用分支,只看某个 commit(适合 review、临时验证)
git worktree add --detach ../myapp-review v1.4.0

# 只给路径:如果同名分支已存在就检出它,否则从当前 HEAD 新建同名分支
git worktree add ../myapp-docs

add 会自动创建目录并检出,输出大致是:

Preparing worktree (new branch 'feature/x')
HEAD is now at 3c42098 init

常用可选参数:

  • --no-checkout:只建目录和元数据,不检出文件(适合先做稀疏检出、或纯脚本场景);
  • --track:新分支自动跟踪远程同名分支。

3.2 list:查看现状

$ git worktree list
/Users/me/code/myapp         3c42098 [main]
/Users/me/code/myapp-hotfix  8f1b2c3 [hotfix/login]
/Users/me/code/myapp-review  3c42098 (detached HEAD)

三个值得注意的标记:

  • (detached HEAD):分离头指针;
  • locked:被 lock 了;
  • prunable:目录已经不存在了,但元数据还残留,等着 prune。

机器可读版本用 --porcelain,适合写脚本:

$ git worktree list --porcelain
worktree /Users/me/code/myapp
HEAD 3c42098f196ef7923b005a34c9b85969de12d144
branch refs/heads/main

worktree /Users/me/code/myapp-hotfix
HEAD 3c42098f196ef7923b005a34c9b85969de12d144
branch refs/heads/hotfix/login

3.3 remove:删除工作区

git worktree remove ../myapp-hotfix

它会先检查工作区是否干净。有未提交改动会拒绝:

fatal: '../myapp-hotfix' contains modified or untracked files, use --force to delete it

确认要丢弃就 --force。如果该工作区被 lock 了,--force 也不够,会提示:

fatal: cannot remove a locked working tree, lock reason: 外接硬盘
use 'remove -f -f' to override or unlock first

注意:remove 只删工作区目录和元数据,不删分支。分支还在,随时可以再检出。

3.4 move:搬动工作区

git worktree move ../myapp-docs ../archive/myapp-docs

move 会同时更新元数据和那个 .git 文件里的 gitdir 路径。不要用 mv 手动搬——搬完 worktree 就"失联"了(这时可以用 repair 补救)。

3.5 lock / unlock:保护工作区

典型场景:worktree 放在移动硬盘、U 盘或者网络盘上,拔掉之后 Git 会认为它"消失了",prune 时可能把元数据清掉。

git worktree lock --reason "在移动硬盘上" /Volumes/ext/myapp-wt
# ...拔盘、插回来...
git worktree unlock /Volumes/ext/myapp-wt

3.6 prune:清理残留

如果有人用 rm -rf 删掉了工作区目录(而不是 git worktree remove),元数据会留在 .git/worktrees/ 里。此时 list 会标 prunable,用 prune 清掉:

$ git worktree list
/Users/me/code/myapp         3c42098 [main]
/Users/me/code/myapp-review  3c42098 (detached HEAD) prunable

$ git worktree prune -n -v      # 先干跑,看看会清什么
$ git worktree prune -v
Removing worktrees/myapp-review: gitdir file points to non-existent location

建议养成习惯:先 -n 干跑,确认无误再真跑。

3.7 repair:修复路径

当你把主仓库整个目录移动/重命名之后,所有 linked worktree 里的 .git 文件都指向了旧路径,全部失联。这时在主仓库执行:

git worktree repair

它会把记录里的路径刷新成新位置。也可以反向修复:在某个 worktree 里执行 git worktree repair <主仓库路径>。

3.8 补充:在别的 worktree 里执行命令

不用 cd,用 git -C:

git -C ../myapp-hotfix status
git -C ../myapp-hotfix log --oneline -5

写脚本、写 alias 时特别顺手。


4. 实践例子

下面每个例子都是完整可跑的命令序列。

4.1 例一:紧急 hotfix 不打断当前开发(最经典)

当前状态:在 feature/payment-v2 上改到一半,工作区很脏。

# 1. 不切分支、不 stash,直接从 main 拉一个 hotfix 工作区
git worktree add -b hotfix/login-timeout ../myapp-hotfix main

# 2. 进去干活(此时原来的工作区一个字节都没动,IDE 也不用重开)
cd ../myapp-hotfix
# ...改代码、跑测试...
git add -A && git commit -m "fix: 登录接口超时导致 500"
git push -u origin hotfix/login-timeout

# 3. 提 PR、合并后清理
cd ../myapp
git worktree remove ../myapp-hotfix
git branch -d hotfix/login-timeout     # remove 之后才能删分支

对比一下:如果用 stash 方案,你会经历"保存现场 → 切分支 → 重建索引 → 切回来 → 恢复现场 → 解决冲突"。而这里,feature/payment-v2 的工作区从头到尾没被碰过,切回去就能接着写。

小技巧:把这个套路写成 alias 或脚本,比如 wtfix <branch>,hotfix 响应速度会有质变。

4.2 例二:本地评审别人的 PR

Review 时最怕的是"为了看一眼代码,把当前工作区切得乱七八糟"。

# 拉取 PR 的 head 到一个临时 ref
git fetch origin pull/123/head

# 用 detached HEAD 检出(不占分支,不污染现有分支列表)
git worktree add --detach ../myapp-pr123 FETCH_HEAD

cd ../myapp-pr123
git log --oneline -5
npm install && npm test          # 真跑一遍,比看 diff 靠谱

评审结束:

cd ../myapp
git worktree remove --force ../myapp-pr123

如果 PR 更新了,不用重建工作区,在工作区里直接 git fetch origin pull/123/head && git checkout FETCH_HEAD 即可。

4.3 例三:多版本并行对比 / 基准测试

要验证"v2 到底有没有变快",两个版本得同时在线。

git worktree add ../myapp-v1.4 v1.4.0
git worktree add ../myapp-v2.0 v2.0.0

# 分别在两个目录构建、跑同一套 benchmark
(cd ../myapp-v1.4 && make build && ./bench > /tmp/v1.txt)
(cd ../myapp-v2.0 && make build && ./bench > /tmp/v2.txt)
diff /tmp/v1.txt /tmp/v2.txt

如果构建很贵,可以让两个工作区共享编译缓存(以 Rust 为例):

export CARGO_TARGET_DIR=~/code/myapp-target-shared

但要注意:两个分支共享同一个 target 目录会互相覆盖产物,只能串行构建。真正要并行构建,反而应该各用各的目录。所以"共享缓存"和"并行构建"是矛盾的,按需选一个。

4.4 例四:文档分支独立部署

很多项目的 gh-pages 分支和代码分支文件结构完全不同。用 worktree 把它挂出来,就等于"一个只放静态站的目录":

git worktree add ../myapp-pages gh-pages

# 生成文档并发布
cd ../myapp-pages
cp -r ../myapp/docs/build/* .
git add -A && git commit -m "docs: 更新站点" && git push

好处是 gh-pages 分支不会在你 git status 里晃来晃去,也不用为了改一行文档而把整个代码工作区切走。

4.5 例五:长期维护多条发布线

维护 LTS 版本时,经常要同时给 release/1.x 和 release/2.x 打补丁:

git worktree add ../myapp-r1 release/1.x
git worktree add ../myapp-r2 release/2.x

# 同一个安全补丁,两边各 cherry-pick 一次
for wt in ../myapp-r1 ../myapp-r2; do
  git -C "$wt" cherry-pick a1b2c3d
  git -C "$wt" push
done

每条发布线一个固定目录,环境变量、部署脚本都可以写死路径,比反复 checkout 稳定得多。

4.6 例六:脚本/CI 里并行构建

因为 worktree 共享对象库,CI 里"一个仓库、多个并行任务"就不需要多次 clone(省流量、省时间):

#!/usr/bin/env bash
set -euo pipefail

git fetch --all --tags
failed=0

for ref in main release/1.x release/2.x; do
  wt="../build-$(echo "$ref" | tr '/' '-')"
  git worktree add --detach "$wt" "origin/$ref"
  (
    cd "$wt"
    npm ci --silent && npm run build
  ) &
done

wait || failed=1

# 无论成败都要清理,否则元数据会残留
for ref in main release/1.x release/2.x; do
  wt="../build-$(echo "$ref" | tr '/' '-')"
  git worktree remove --force "$wt" || true
done

exit $failed

注意 set -u 下清理逻辑不要依赖可能为空的变量,以及清理一定要放在 trap 或末尾,否则失败的构建会留下一堆 prunable 记录。


5. 目录该怎么放

推荐:和主仓库平级,不要放进主仓库内部。

~/code/
├── myapp/              # 主工作区
├── myapp-hotfix/       # linked worktree
├── myapp-pr123/
└── myapp-r1/

原因很直接:worktree 目录放在主仓库内部(比如 myapp/.worktrees/xxx),里面的文件会作为未跟踪文件出现在主仓库的 git status 里,除非你把它加进 .gitignore 或 .git/info/exclude。

如果你确实喜欢收在仓库内部(有些编辑器对同一父目录更友好),那就必须显式忽略:

echo ".worktrees/" >> .git/info/exclude    # 只影响本地,不提交到仓库
git worktree add .worktrees/hotfix -b hotfix/x main

用 .git/info/exclude 而不是 .gitignore,可以避免把个人习惯强加给团队。

另外,不建议把 worktree 放在 NFS / SMB 等网络盘上——Git 对文件锁和 inode 的假设在网络文件系统上不一定成立,lock 可以缓解"误 prune",但解决不了性能问题。


6. 和 stash、clone 的对比

维度 git stash git clone / cp -r git worktree
磁盘占用 几乎为零 一份完整仓库(含 .git) 一份工作区文件,共享对象库
分支/引用同步 同一个仓库,天然同步 各自独立,需手动 fetch 同一个仓库,天然同步
未提交改动的保全 存在 stash 栈里,容易丢、易冲突 留在副本工作区 留在各自工作区,最自然
上下文切换成本 高(重索引、重编译、易冲突) 中(需维护多份副本) 低(目录一直在那儿)
需要网络 否 是(除非 cp -r) 否
管理复杂度 低但易遗忘 高(副本会繁殖) 中(要记得 remove / prune)
适合 临时、几分钟的切换 完全隔离的独立环境(不同版本工具链、脏实验) 同时开发多个分支

结论:短期、一次性、几分钟的切换用 stash 更轻;需要长期并行、或改动很大不想反复重建环境的,用 worktree;只有当你需要完全独立的仓库(比如不同的 .git/config、不同的 hooks、要拿去给别人)时,才值得 clone。


7. 八个常见坑

  1. 用 rm -rf 删工作区。 目录没了,但 .git/worktrees/<name> 还在,list 里永远挂着一个 prunable。正确做法是 git worktree remove,实在删了就 git worktree prune。

  2. 同一分支检出两次。 会报 fatal: '<branch>' is already used by worktree。要并行看同一个 commit,用 --detach。

  3. 想删分支却删不掉。 error: cannot delete branch 'docs' used by worktree at '...'。先 remove 那个工作区(或把它切到别的分支),再 branch -d。

  4. 以为构建缓存会共享。 不会。node_modules、target/、.next/ 都是各自一份。三个 worktree 就是三份依赖,磁盘和安装时间都要算进去。

  5. submodule 不会自动初始化。 新建的 worktree 里 submodule 目录是空的,需要手动:

    git -C ../myapp-hotfix submodule update --init --recursive
    
  6. 工具假设 .git 是目录。 worktree/.git 是一个文件。有些老版本 IDE、打包脚本、Docker 构建上下文处理、文件监视器会在这里翻车。遇到"识别不到 Git 仓库"的问题,先怀疑这一点。

  7. mv 搬目录而不是 worktree move。 搬完就失联。补救:

    git worktree repair
    
  8. hook 和 stash 是全局的。 你在任一 worktree 里 git stash,其他 worktree 也能 stash pop 到(虽然是共享的好处,但也容易误操作)。同理,pre-commit 之类的 hook 会对所有工作区生效,某个 worktree 里跑得慢的 hook 会拖慢全部。


8. 总结

Git worktree 不是一个"高级技巧",它更像一次认知修正:git checkout 从来不是"切换分支"的唯一方式,它只是"在唯一的工作区里换内容"。当你承认仓库可以有多个工作区之后,很多以前只能靠 stash 和复制仓库硬扛的场景,就变成了"多开一个目录"这么简单。

记住三件事就够用:

  1. 一个仓库,多个目录——对象库共享,工作区独立;
  2. 一个分支只能在一个工作区——要并行看同一个 commit 用 --detach;
  3. 用 add 建,用 remove 删,忘了删就 prune——不要 rm -rf。

版本要求:git worktree 在 Git 2.5 引入,remove/move 在 2.17 补齐,repair 在 2.30 加入。先 git --version 看一眼,2.17 以上基本没有兼容顾虑。

posted @ 2026-11-07 22:56  jigsawecho  阅读(0)  评论(0)    收藏  举报