Git Worktree 入门:从并行开发到共享仓库原理

从最小示例掌握 Git Worktree 的创建、并行开发、锁定修复、稀疏检出与底层共享机制。

阅读 -- 次 参与评论

你正在一个功能分支上重构,工作区里有改到一半的文件,测试还没通过,也不适合提交。此时线上突然出现一个问题,需要马上从 main 拉出修复分支。

常见做法是先 git stash,切到 main,完成修复,再切回来恢复现场。流程本身不复杂,但只要未跟踪文件、暂存区或冲突多一点,临时保存和恢复就会打断原来的工作节奏。

也可以把仓库再克隆一份。这样两个目录互不干扰,不过对象数据库、远程配置和维护动作也跟着多了一份。仓库较大时,额外克隆的时间和磁盘占用都很明显。

Git Worktree 提供了第三种选择:让同一个 Git 仓库同时拥有多个工作目录,每个目录检出自己的分支,共享提交历史和分支引用。

你可以让原目录继续停在重构分支,再用另一个目录处理热修复。两边的未提交文件不会混在一起,也不需要为了切分支先整理现场。

这篇文章不会只列一张命令表。我们会从一个可复现的小仓库开始,依次回答几个问题:Worktree 到底共享什么、隔离什么?怎样创建和回收工作区?为什么同一分支默认不能被两处同时检出?锁定、修复、稀疏检出和独立配置分别解决什么问题?最后再拆开 .git,看看它是如何把多个目录连接到同一个仓库的。

文中基础流程、分支占用失败、锁定与移动、目录修复、工作区独立配置和稀疏检出,均在 macOS 的 Git 2.33.0 上完成验证。命令能力同时依据 2026 年 7 月 29 日可访问的 Git 官方 git-worktree 文档核对。较新的选项可能不适用于更早版本,执行前可以先用 git --version 查看本机版本。

一句话理解 Worktree

Git Worktree 是一套“一个仓库,多个工作目录”的机制。

执行 git clonegit init 时得到的原始工作目录,官方称为主工作树,也就是 main worktree。通过 git worktree add 增加的目录叫关联工作树,也就是 linked worktree

每个工作树都有自己的工作目录、HEAD 和暂存区,因此可以保留不同的检出状态和未提交修改;它们又共享对象数据库和大部分引用,因此任何一处创建的提交与分支,其他工作树都能立即识别。

主工作树:main ──────────────┐
关联工作树 A:feature/search ├──> 共享仓库数据:对象、分支、标签、默认配置
关联工作树 B:hotfix/login ──┘

每个工作树仍分别保存自己的工作目录、HEAD 和暂存区。

可以把它理解成酒店给同一位住客配了几张房卡。每张卡打开的是不同房间,房间里的桌面和行李互不影响;但这些房间仍登记在同一家酒店系统里。这个类比只用于建立直觉:Git 实际共享的是对象与引用,并不是把不同目录里的文件实时同步。

Worktree 和重新克隆一份仓库的差异,可以先看这张表:

对比项Git Worktree再次 git clone
提交对象共享一份对象数据库每个克隆通常有自己的对象数据库
分支与标签大部分引用共享各仓库分别维护,需要 fetch 或 push 同步
工作目录与暂存区每个工作树独立每个克隆独立
仓库级配置与 Hooks默认共享分别维护
适合场景同一仓库的并行开发、热修复、测试需要真正独立的仓库配置、权限或生命周期

从一个最小仓库开始

如果你手头已经有 Git 仓库,可以直接跳到下一节。为了让后面的现象能够稳定复现,下面先创建一个只有 README.md 的示例仓库。

# 创建主工作树,并显式把初始分支命名为 main。
# shop 是主工作目录,后续新增的工作树会放在它的同级目录。
git init -b main shop

# 只为示例仓库配置提交身份,避免修改用户的全局 Git 配置。
git -C shop config user.name "Worktree Demo"
git -C shop config user.email "worktree-demo@example.com"

# 创建第一份受 Git 跟踪的内容,保证 main 已经有可作为分支起点的提交。
printf '第一版\n' > shop/README.md
git -C shop add README.md
git -C shop commit -m "chore: 初始化示例仓库"

# 后文统一从主工作树中执行命令。
# 因此 ../shop-search 始终表示与 shop 同级的关联工作树,不会产生相对路径歧义。
cd shop

验证时,git status 应显示当前位于 main,并且工作区没有待提交内容。后文使用 git -C <目录> 时,是为了让某条命令明确作用于另一个工作树,效果等同于先进入对应目录再执行 Git 命令。

创建第一个关联工作树

现在要开发搜索功能。我们希望创建 feature/search 分支,并把它检出到同级目录 shop-search

# -b 表示创建新分支;最后的 main 是新分支的起点。
# 新目录不能位于已有工作树内部,放在 shop 的同级目录最容易管理。
git worktree add -b feature/search ../shop-search main

命令完成了三件事:创建 feature/search 分支、创建 shop-search 目录、在新目录中检出这个分支。

通过列表确认登记结果:

# 面向人阅读的默认格式会显示路径、当前提交和分支。
git worktree list

预期能看到两个条目,类似下面这样。提交短哈希会因仓库而异:

/path/to/shop         06bcdac [main]
/path/to/shop-search  06bcdac [feature/search]

路径不是随手创建的副本,而是已经登记到仓库中的工作树。后面移动或删除它时,应该优先使用 git worktree 命令,让目录和管理记录一起更新。

在两个目录中并行工作

接下来在搜索功能目录中添加文件并提交:

# 只修改关联工作树中的文件;主工作树的目录内容不会因此改变。
printf '搜索功能\n' > ../shop-search/search.txt

# 关联工作树拥有独立暂存区,所以 add 不会把主工作树的修改一起加入。
git -C ../shop-search add search.txt
git -C ../shop-search commit -m "feat: 添加搜索功能"

现在做一组对照验证:

# 两个工作树的 HEAD 分别指向 main 与 feature/search。
git branch --show-current
git -C ../shop-search branch --show-current

# 主工作树的目录中没有 search.txt,因为工作目录彼此隔离。
test -f search.txt && printf '有\n' || printf '没有\n'

# 主工作树仍能读取 feature/search 的最新提交,因为提交对象与分支引用是共享的。
git log -1 --format='%s' feature/search

本文实验依次得到 mainfeature/search没有feat: 添加搜索功能。这四个结果共同说明:分支检出状态和文件是隔离的,分支引用与提交对象是共享的。

这也是 Worktree 最核心的边界。它不是目录同步工具,也不是容器;不同工作树仍运行在同一台机器上,会使用同一套外部数据库、端口和系统环境。代码文件隔离了,不代表运行时资源也自动隔离。

为什么同一分支默认不能检出两次

这里很容易产生一个疑问:既然工作目录互相独立,能不能再创建一个目录,也检出 feature/search

我们直接制造这个失败条件:

# feature/search 已经被 shop-search 检出。
# 不加 --force 时,Git 会拒绝把同一分支再次绑定到另一个工作树。
git worktree add ../shop-search-copy feature/search

在本文环境中,真实错误为:

fatal: 'feature/search' is already checked out at '/path/to/shop-search'

限制的根因不是两个目录会覆盖彼此的文件,而是它们会更新同一个分支引用。假如两个工作树都认为自己位于 feature/search,一边提交后会移动共享的分支指针,另一边的 HEAD、暂存区和工作文件却还停留在旧基线。后续提交、重置和变基的含义会变得很难判断。

因此,Git 把“一个分支同时只由一个工作树检出”作为默认保护。--force 可以绕过部分检查,但日常并行开发更稳妥的做法是给每个任务单独建分支,而不是取消这层保护。

如果只是想在同一个提交上执行构建或阅读代码,不需要移动任何分支,可以使用分离 HEAD

# --detach 让新工作树直接指向 main 对应的提交,不绑定本地分支。
# 这种目录适合临时测试、代码审查和不同版本的并行构建。
git worktree add --detach ../shop-review main

# 用完后通过 Git 删除目录和管理记录。
git worktree remove ../shop-review

分离 HEAD 中也可以提交,但提交不会自动挂在本地分支上。如果临时修改需要保留,应及时用 git switch -c <新分支> 创建分支,再移除工作树。

日常操作:创建、使用与回收

掌握 Worktree 不需要记住所有选项。日常使用主要是下面四类动作。

从新分支创建工作树

# 从 main 创建 hotfix/login,并立刻在 shop-hotfix 中开始修复。
git worktree add -b hotfix/login ../shop-hotfix main

检出已经存在的分支

# 仅当 release/2.0 没有被其他工作树检出时,这条命令才会成功。
# 第二个位置参数是已有分支,不需要再写 -b。
git worktree add ../shop-release release/2.0

如果远程只有 origin/release/2.0,建议把起点和跟踪关系写清楚,避免多个远程存在同名分支时产生歧义:

# 从远程跟踪分支创建同名本地分支,并设置 upstream。
# --track 让后续 git pull、git status 能识别默认上游。
git worktree add --track -b release/2.0 ../shop-release origin/release/2.0

当前官方文档也描述了自动猜测唯一远程分支的规则,以及 worktree.guessRemote 配置。团队脚本中仍建议显式写出远程和本地分支,因为行为更容易审阅。

查看全部工作树

# 默认格式适合人阅读,快速确认路径、提交和分支。
git worktree list

# --porcelain 是供程序解析的稳定格式。
# -z 使用 NUL 分隔字段,能正确处理路径中包含换行等特殊字符的情况。
git worktree list --porcelain -z

自动化脚本不要解析默认表格中的空格和括号。官方承诺的是 --porcelain 格式稳定,而不是面向人的展示宽度稳定。

完成任务后回收

假设 feature/search 已完成,需要合并回 main

# 在主工作树合并共享分支;另一个工作树不需要先 push 或 fetch。
git merge feature/search

# remove 默认只允许删除干净的关联工作树。
# 如果里面还有修改或未跟踪文件,Git 会拒绝并提示先处理现场。
git worktree remove ../shop-search

# remove 不会删除分支;确认合并后,再按团队流程单独删除分支。
git branch -d feature/search

本文实验还验证了一个常被忽略的职责边界:git worktree remove 删除工作目录和关联管理记录,但 feature/search 分支依然存在。工作树与分支有关联,却不是同一个生命周期对象。

对于有未提交内容的工作树,remove --force 可以强制删除;对于已锁定的工作树,当前官方文档要求两次 --force 才能越过保护。除非已经确认内容可以丢弃,否则不要把强制删除当作常规清理手段。

进阶一:热修复时不打断当前现场

回到开头的场景。主目录正在 feature/refactor 上工作,文件尚未提交;线上问题必须从 main 修复。完整流程可以是:

# 在原工作树中执行,只用于确认当前修改仍留在原地。
# 不需要 stash,也不需要为了热修复切换当前分支。
git status --short

# 从 main 创建独立热修复分支和目录。
git worktree add -b hotfix/payment-timeout ../project-hotfix main

# 在热修复目录中完成修改、测试和提交。
# 这里用注释代替业务命令,因为真实项目的测试入口各不相同。
git -C ../project-hotfix status
# 修改代码并运行项目自己的测试命令
# 确认该工作树只包含本次修复后,统一暂存其中的已跟踪与未跟踪修改。
git -C ../project-hotfix add --all
git -C ../project-hotfix commit -m "fix: 修复支付超时"

# 推送与合并完成后,先回收工作树,再按团队策略删除本地分支。
git worktree remove ../project-hotfix
git branch -d hotfix/payment-timeout

这个流程的价值不是少敲几条命令,而是避免改变原工作树的 HEAD、暂存区与未提交文件。热修复结束后,回到原目录就能继续工作。

需要注意,git stash 本身存放在共享的 refs/stash 中。因此在任意工作树执行 git stash list,看到的是同一组 stash 记录。使用多个工作树时,stash 消息最好写清任务和来源分支,避免在错误目录中应用了另一项工作的临时内容。

进阶二:锁定、移动、修复与清理

Worktree 除了管理代码,还维护“工作目录在哪里”的登记信息。下面四个命令都围绕这层连接关系工作。

lock:防止暂时离线的目录被清理

如果关联工作树位于移动硬盘或偶尔离线的网络盘,目录暂时消失不代表已经废弃。可以在设备离线前锁定:

# 锁定会阻止该工作树被 prune,也会阻止普通的 move 和 remove。
# reason 会写入管理记录,方便其他维护者理解锁定原因。
git worktree lock --reason "位于移动硬盘,暂时离线" ../shop-release

# verbose 列表会展示锁定状态与原因。
git worktree list --verbose

# 设备恢复后解锁,重新允许移动、删除和过期清理。
git worktree unlock ../shop-release

move:让 Git 同时更新目录和记录

# 使用 worktree move 调整关联工作树位置,Git 会同步修改双向连接。
# 目标的父目录必须先存在,避免普通文件系统移动因目录缺失而失败。
mkdir -p ../archive
git worktree move ../shop-release ../archive/shop-release

主工作树不能通过该命令移动,包含子模块的关联工作树也不能通过该命令移动。遇到这两种情况,应先阅读当前版本官方限制,不要直接假设与普通目录相同。

repair:修复手动移动后的失联

如果目录已经被文件管理器或普通 mv 移走,仓库仍记录旧路径。本文实验在手动移动后执行 dry-run,真实输出为:

Removing worktrees/demo-advanced: gitdir file points to non-existent location

这说明仓库把旧管理记录识别成了可清理项。此时不应继续 prune,而应根据新路径修复连接:

# 先用 dry-run 观察,不删除任何管理记录。
git worktree prune --dry-run --verbose --expire now

# 从移动后的工作树执行 repair,并把它的新路径传给 Git。
# 命令会重建工作目录与公共仓库之间的双向连接。
git -C ../shop-release-new worktree repair ../shop-release-new

# 再次列出工作树,确认登记路径已经变成新位置。
git worktree list --porcelain

在本文 Git 2.33.0 实验中,repair 退出码为 0,列表中的路径更新为手动移动后的新路径,分支仍能正常读取。

prune:只清理已经失效的管理记录

如果有人直接删除了关联工作树目录,公共仓库中的 .git/worktrees/<id> 管理记录可能仍然存在。prune 用来清理这类陈旧记录:

# 先预演将被清理的记录,核对路径与原因。
git worktree prune --dry-run --verbose

# 只有确认对应工作目录确实不再需要时,才执行实际清理。
git worktree prune --verbose

prune 不等于删除某个正常工作树。正常回收应使用 git worktree removeprune 更像是在目录已意外消失后收拾遗留登记。

进阶三:让某个工作树只检出部分目录

多个工作树共享对象数据库,但每个工作目录仍要展开自己的文件。对于大型单仓库,如果某项任务只关心 docs,可以把 Worktree 与 sparse-checkout 组合起来。

假设 main 同时追踪 docs/guide.mdsrc/app.txt

# --no-checkout 先创建关联工作树,但暂不把提交内容展开到目录中。
# 这样可以在第一次 checkout 前配置该工作树的稀疏规则。
git worktree add --no-checkout -b docs/review ../shop-docs main

# cone 模式按目录设置规则,适合只关心少数顶层目录的常见场景。
git -C ../shop-docs sparse-checkout init --cone
git -C ../shop-docs sparse-checkout set docs

# 完成配置后再检出,工作目录只展开规则需要的内容。
git -C ../shop-docs checkout

# 正向验证目标文件存在,同时反向验证被排除目录没有展开。
test -f ../shop-docs/docs/guide.md && printf 'docs 已检出\n'
test ! -f ../shop-docs/src/app.txt && printf 'src 未检出\n'

本文回归实验先用 git cat-file 确认当前提交本来同时包含两个文件,再得到“工作区中 docs 存在、src 不存在”的结果。这能排除“分支历史本来就没有 src”这一干扰因素。

稀疏检出减少的是工作目录展开的文件,不会把对象数据库拆成另一份。具体规则和命令在不同 Git 版本中有变化,团队落地前应按所用 Git 版本补充验证。

进阶四:为不同工作树设置独立配置

默认情况下,仓库的 .git/config 由所有工作树共享。也就是说,在关联工作树中执行普通的 git config user.email ...,改到的仍是公共仓库配置。

如果同一个仓库的不同工作树确实需要不同配置,可以开启 worktreeConfig 扩展:

# 为仓库启用工作树级配置能力。
# 开启后,较老且不支持该扩展的 Git 会拒绝访问这个仓库,升级前需要评估团队版本。
git config extensions.worktreeConfig true

# --worktree 把配置写入当前工作树自己的 config.worktree,而不是公共 config。
git config --worktree user.email "main@example.com"
git -C ../shop-docs config --worktree user.email "docs@example.com"

# 分别读取,预期返回两个不同邮箱。
git config user.email
git -C ../shop-docs config user.email

本文实验中,主工作树返回 main@example.com,关联工作树返回 docs@example.com。后者的实际配置文件位于公共仓库管理区的 .git/worktrees/<id>/config.worktree

工作树独立配置适合差异确实与工作目录绑定的场景,例如不同稀疏检出设置。不要为了普通分支差异随意开启它,因为仓库格式扩展会带来旧版本兼容成本。

拆开 .git:它到底共享了什么

到这里,命令已经能用了。接下来进入原理层,看看为什么两个目录能共享历史,却保留各自现场。

先在关联工作树中检查 .git

# 主工作树中的 .git 通常是目录;关联工作树中的 .git 通常是文本文件。
cat ../shop-docs/.git

# --git-dir 返回当前工作树自己的管理目录,其中保存 HEAD、index 等独立状态。
git -C ../shop-docs rev-parse --git-dir

# --git-common-dir 返回全部工作树共享的公共仓库目录。
git -C ../shop-docs rev-parse --git-common-dir

本文实验得到的结构与下面一致,真实绝对路径会不同:

gitdir: /path/to/shop/.git/worktrees/shop-docs
/path/to/shop/.git/worktrees/shop-docs
/path/to/shop/.git

关联工作树顶层的 .git 文件指向公共仓库中的私有管理目录。该私有目录里有当前工作树自己的 HEADindexgitdircommondir

shop/.git/
├── objects/                         # 全部工作树共享的提交、树和文件对象
├── refs/                            # 大部分分支与标签引用由全部工作树共享
├── config                           # 默认共享的仓库配置
└── worktrees/
    └── shop-docs/
        ├── HEAD                     # shop-docs 自己检出的分支或提交
        ├── index                    # shop-docs 自己的暂存区
        ├── gitdir                   # 反向记录关联工作树的 .git 文件位置
        ├── commondir                # 指回公共仓库目录,常见内容为 ../..
        └── config.worktree          # 启用扩展后可选的工作树独立配置

这是一条双向连接:关联目录的 .git 指向管理区,管理区的 gitdir 又指回关联目录。move 会同时维护两边,repair 则用于外部移动让两边失去一致后的恢复。

共享与隔离可以更准确地归纳为:

状态是否共享直接结果
对象数据库共享一处提交后,其他工作树立即能解析该提交
大部分分支和标签引用共享一处创建、移动或删除分支,其他工作树立即感知
HEAD每个工作树独立不同目录可以停在不同分支或提交
index 暂存区每个工作树独立两边可以分别 git add,不会混入同一暂存区
工作目录文件每个工作树独立未提交修改与检出文件互不覆盖
仓库配置和 Hooks默认共享在任一工作树修改仓库级设置,可能影响全部工作树
config.worktree可选独立开启扩展后可保存与工作树绑定的配置

官方给出的引用规则比表格更严格:一般来说,直接位于 $GIT_DIR 下的伪引用是每个工作树独立的,refs/ 下的引用共享;但 refs/bisectrefs/worktreerefs/rewritten 等存在例外。因此,脚本不要自己拼接 .git 内部路径,应使用 git rev-parse --git-path <名称> 让 Git 按当前工作树解析。

常见误区与边界

误区一:Worktree 不占额外磁盘

对象数据库确实共享,但每个工作目录仍会有一份实际检出的文件,构建产物和依赖目录也可能各占一份空间。大型项目可以配合稀疏检出,也可以让构建缓存指向经过验证的共享位置,但不能把“共享对象”理解成“所有文件都只存一份”。

误区二:任意删除目录都等同于 worktree remove

直接删除只处理了文件系统目录,没有同步处理公共仓库中的管理记录。日常清理使用 git worktree remove;误删后再用 git worktree prune --dry-run 检查遗留项。

误区三:每个工作树的所有 Git 状态都独立

分支、标签、stash、仓库配置和 Hooks 等仍然共享。一个工作树中执行分支删除、重置共享分支或修改仓库配置,可能立刻影响其他工作树。隔离的是工作现场,不是整个 Git 仓库。

误区四:Worktree 能替代所有独立环境

不同工作树不会自动分配不同端口、数据库、容器名、环境变量和依赖缓存。两个服务若都默认监听 3000 端口,照样会冲突。需要并行运行时,应在项目层显式区分运行时资源。

边界:包含子模块的项目要额外谨慎

当前官方文档仍把多工作树对子模块的支持列为不完整,并明确不建议对 superproject 做多份检出。git worktree move 也不能移动包含子模块的关联工作树。此类仓库不要直接套用普通项目流程,应先建立最小样本验证初始化、更新、移动和清理行为。

一份可以直接执行的验收清单

完成第一次 Worktree 实践后,可以逐项确认:

  • git worktree list 同时显示主工作树和关联工作树。
  • 两个目录的 git branch --show-current 返回不同分支。
  • 在关联工作树提交后,主工作树能通过分支名读取提交,但主目录文件没有自动变化。
  • 重复检出同一分支时,Git 给出“already checked out”保护性错误。
  • 有未提交或未跟踪文件时,普通 git worktree remove 会拒绝删除。
  • 工作树移除后,对应分支仍存在,分支删除需要单独执行。
  • 自动化脚本使用 git worktree list --porcelain -z,不解析默认表格。
  • 对手动移动或误删场景,先执行 prune --dry-run,再选择 repair 或实际 prune

总结

Git Worktree 解决的不是“怎样更快切分支”,而是“怎样不切走当前现场,也能在同一仓库开始另一项工作”。它通过给每个工作树分配独立的 HEAD、暂存区和工作目录,保留各自的未提交状态;再通过共享对象数据库与大部分引用,让提交和分支无需额外同步。

基本使用只需要记住 addlistremove。当目录位于离线设备、被外部移动、需要程序化管理或只想检出部分文件时,再引入 lockrepairprune--porcelain 与 sparse-checkout。

最重要的边界也很清楚:工作目录隔离不等于仓库完全隔离,更不等于运行环境隔离。理解哪些状态共享、哪些状态独立,才能在热修复、并行开发、代码审查和多版本测试中放心使用 Worktree。

参考资料

评论

使用 GitHub 登录参与讨论。