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
这样能跑,但代价比想象中大:
- 磁盘直接翻倍。整个
.git被复制,历史越长越痛;node_modules、target/、.venv也一起复制。 - 两边的分支是"平行宇宙"。你在副本里
git fetch拉了新提交,主仓库看不到;在副本里建的分支,主仓库也不存在。要同步只能再来一次 fetch,或者互相 add remote。 - 容易推错仓库、搞错 origin。副本里的
origin还是同一个,但因为引用不同步,git push前你得反复确认自己到底基于哪个 commit。 - 副本会不断繁殖。第一次复制之后,团队里就会出现
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. 八个常见坑
-
用
rm -rf删工作区。 目录没了,但.git/worktrees/<name>还在,list里永远挂着一个prunable。正确做法是git worktree remove,实在删了就git worktree prune。 -
同一分支检出两次。 会报
fatal: '<branch>' is already used by worktree。要并行看同一个 commit,用--detach。 -
想删分支却删不掉。
error: cannot delete branch 'docs' used by worktree at '...'。先remove那个工作区(或把它切到别的分支),再branch -d。 -
以为构建缓存会共享。 不会。
node_modules、target/、.next/都是各自一份。三个 worktree 就是三份依赖,磁盘和安装时间都要算进去。 -
submodule 不会自动初始化。 新建的 worktree 里 submodule 目录是空的,需要手动:
git -C ../myapp-hotfix submodule update --init --recursive -
工具假设
.git是目录。worktree/.git是一个文件。有些老版本 IDE、打包脚本、Docker 构建上下文处理、文件监视器会在这里翻车。遇到"识别不到 Git 仓库"的问题,先怀疑这一点。 -
mv搬目录而不是worktree move。 搬完就失联。补救:git worktree repair -
hook 和 stash 是全局的。 你在任一 worktree 里
git stash,其他 worktree 也能stash pop到(虽然是共享的好处,但也容易误操作)。同理,pre-commit之类的 hook 会对所有工作区生效,某个 worktree 里跑得慢的 hook 会拖慢全部。
8. 总结
Git worktree 不是一个"高级技巧",它更像一次认知修正:git checkout 从来不是"切换分支"的唯一方式,它只是"在唯一的工作区里换内容"。当你承认仓库可以有多个工作区之后,很多以前只能靠 stash 和复制仓库硬扛的场景,就变成了"多开一个目录"这么简单。
记住三件事就够用:
- 一个仓库,多个目录——对象库共享,工作区独立;
- 一个分支只能在一个工作区——要并行看同一个 commit 用
--detach; - 用
add建,用remove删,忘了删就prune——不要rm -rf。
版本要求:git worktree 在 Git 2.5 引入,remove/move 在 2.17 补齐,repair 在 2.30 加入。先 git --version 看一眼,2.17 以上基本没有兼容顾虑。

浙公网安备 33010602011771号