Git Worktree 入门:从并行开发到共享仓库原理
Git Worktree 可以让同一个仓库连接多个工作目录,各自保留开发现场并共享提交历史。
你正在一个功能分支上重构,工作区里有改到一半的文件,测试还没通过,也不适合提交。此时线上突然出现一个问题,需要马上从 main 拉出修复分支。
直接切换分支之前,必须先处理当前修改。文件一多,暂存、切换和恢复现场就会打断原来的工作节奏。
另一个办法是重新克隆仓库。两个目录可以各自开发,同时也会增加下载时间、磁盘占用和维护成本。
Git Worktree 允许同一个 Git 仓库同时连接多个工作目录,每个目录都可以检出自己的分支。
原目录继续保留重构现场,新目录从 main 开始处理热修复。两边各自保存未提交文件,原来的开发节奏也能继续保持。
示例运行环境为 macOS,Git 版本为 2.33.0。
Git Worktree 是什么
Git Worktree 是一套“一个仓库,多个工作目录”的机制。
执行 git clone 或 git init 时得到的原始工作目录,官方称为 main worktree,下文简称主工作区。通过 git worktree add 增加的目录称为 linked worktree,下文简称关联工作区。
每个 Worktree 都有自己的工作目录、HEAD 和暂存区,可以保留不同的检出状态和未提交修改。它们共享对象数据库和大部分引用,任何一处创建的提交与分支都能被其他 Worktree 识别。
main worktree:main ──────────────┐
linked worktree A:feature/search ├──> 共享仓库数据:对象、分支、标签、默认配置
linked worktree B:hotfix/login ──┘
每个 Worktree 分别保存自己的工作目录、HEAD 和暂存区。
可以把它理解成酒店给同一位住客配了几张房卡。每张卡打开不同房间,房间里的桌面和行李互不影响;这些房间仍登记在同一家酒店系统里。对应到 Git,共享部分是对象与引用,各目录中的文件保持独立。
Worktree 和重新克隆一份仓库的差异,可以先看这张表:
| 对比项 | Git Worktree | 再次 git clone |
|---|---|---|
| 提交对象 | 共享一份对象数据库 | 每个克隆通常有自己的对象数据库 |
| 分支与标签 | 大部分引用共享 | 各仓库分别维护,需要 fetch 或 push 同步 |
| 工作目录与暂存区 | 每个 Worktree 独立 | 每个克隆独立 |
| 仓库级配置与 Hooks | 默认共享 | 分别维护 |
| 适合场景 | 同一仓库的并行开发、热修复、测试 | 需要真正独立的仓库配置、权限或生命周期 |
准备示例仓库
后续示例基于一个名为 shop 的 Git 仓库。当前终端已经位于仓库根目录,当前分支为 main,工作区没有待提交内容。
当前目录:/path/to/shop
当前分支:main
新建的 Worktree 都放在 shop 的同级目录,因此 ../shop-search 表示 /path/to/shop-search。
创建第一个关联工作树
现在要开发搜索功能。我们希望创建 feature/search 分支,并把它检出到同级目录 shop-search:
git worktree add -b feature/search ../shop-search main
↓ ↓ ↓ ↓ ↓
│ │ │ │ └──── 新分支从 main 当前提交开始
│ │ │ └─────────────────── 新工作目录,位于 shop 的同级目录
│ │ └────────────────────────────────── 新分支名称
│ └───────────────────────────────────── 创建新分支
└───────────────────────────────────────── 添加 linked worktree
命令完成了三件事:创建 feature/search 分支、创建 shop-search 目录、在新目录中检出这个分支。
通过列表确认登记结果:
git worktree list
↓
└──── 列出每个 Worktree 的路径、当前提交和分支
预期能看到两个条目,类似下面这样。提交短哈希会因仓库而异:
/path/to/shop 06bcdac [main]
/path/to/shop-search 06bcdac [feature/search]
这个路径已经登记到仓库中。后面移动或删除它时,应优先使用 git worktree 命令,让目录和管理记录一起更新。
在两个目录中并行工作
在 shop-search 中新增 search.txt 后,通过 -C 指定命令执行目录:
git -C ../shop-search add search.txt
↓ ↓ ↓ ↓
│ │ │ └──── 加入暂存区的文件
│ │ └──────── 执行 git add
│ └─────────────────────── 目标 Worktree
└────────────────────────── 在指定目录中执行 Git 命令
git -C ../shop-search commit -m "feat: 添加搜索功能"
↓ ↓ ↓ ↓ ↓
│ │ │ │ └──── 提交说明
│ │ │ └─────── 使用下一段文本作为提交说明
│ │ └────────────── 创建提交
│ └───────────────────────────── 目标 Worktree
└──────────────────────────────── 在指定目录中执行 Git 命令
回到主工作区读取 feature/search 的最新提交:
git log -1 feature/search
↓ ↓ ↓
│ │ └──── 要读取的分支
│ └─────── 只显示最新一条提交
└─────────── 查看提交历史
主工作区仍停在 main,目录中没有 search.txt;git log 可以读取 feature/search 上的“添加搜索功能”提交。分支检出状态和文件彼此隔离,分支引用与提交对象由各个 Worktree 共享。
Worktree 只隔离 Git 工作目录。不同 Worktree 仍运行在同一台机器上,并继续使用项目配置的数据库、端口和系统环境。并行启动服务时,这些运行时资源需要单独配置。
为什么同一分支默认不能检出两次
这里很容易产生一个疑问:既然工作目录互相独立,能不能再创建一个目录,也检出 feature/search?
我们直接制造这个失败条件:
git worktree add ../shop-search-copy feature/search
↓ ↓ ↓
│ │ └──── 准备再次检出的已有分支
│ └──────────────────────── 第二个工作目录
└──────────────────────────── 添加 linked worktree
Git 2.33.0 返回的错误为:
fatal: 'feature/search' is already checked out at '/path/to/shop-search'
两个目录同时检出 feature/search 时,会共同更新这一个分支引用。一边提交后,共享的分支指针已经移动,另一边的 HEAD、暂存区和工作文件却还停留在旧基线。后续提交、重置和变基的含义会变得很难判断。
因此,Git 默认限制一个分支只能被一个 Worktree 检出。--force 可以绕过部分检查;日常并行开发应给每个任务单独创建分支,保留这层保护。
如果只是想在同一个提交上执行构建或阅读代码,不需要移动任何分支,可以使用分离 HEAD:
git worktree add --detach ../shop-review main
↓ ↓ ↓ ↓
│ │ │ └──── 检出的目标提交
│ │ └─────────────────── 临时工作目录
│ └──────────────────────────── 使用 detached HEAD
└──────────────────────────────── 添加 linked worktree
git worktree remove ../shop-review
↓ ↓
│ └──── 要删除的 linked worktree
└─────────── 删除工作目录和对应管理记录
分离 HEAD 中也可以提交。这类提交缺少本地分支引用;如果临时修改需要保留,应及时用 git switch -c <新分支> 创建分支,再移除 Worktree。
日常操作:创建、使用与回收
日常使用主要涉及创建、查看和回收 Worktree。
从新分支创建工作树
前面的 feature/search 示例已经覆盖了创建新分支的形式。把分支名、目录和起点替换为实际任务即可,例如 hotfix/login、../shop-hotfix 和 main。
检出已经存在的分支
git worktree add ../shop-release release/2.0
↓ ↓ ↓
│ │ └──── 已存在且未被其他 Worktree 检出的分支
│ └──────────────────── 新工作目录
└──────────────────────── 添加 linked worktree
如果远程只有 origin/release/2.0,建议把起点和跟踪关系写清楚,避免多个远程存在同名分支时产生歧义:
git worktree add --track -b release/2.0 ../shop-release origin/release/2.0
↓ ↓ ↓ ↓ ↓ ↓
│ │ │ │ │ └──── 远程跟踪分支,也是新分支起点
│ │ │ │ └──────────────────── 新工作目录
│ │ │ └──────────────────────────────── 新建的本地分支名称
│ │ └─────────────────────────────────── 创建本地分支
│ └─────────────────────────────────────────── 设置 upstream
└─────────────────────────────────────────────── 添加 linked worktree
当前官方文档也描述了自动猜测唯一远程分支的规则,以及 worktree.guessRemote 配置。团队脚本中仍建议显式写出远程和本地分支,因为行为更容易审阅。
查看全部工作树
git worktree list
↓
└──── 人工查看路径、提交和分支
git worktree list --porcelain -z
↓ ↓ ↓
│ │ └──── 用 NUL 分隔字段
│ └─────────────── 输出稳定的机器可解析格式
└──────────────────── 列出 Worktree
自动化脚本应使用官方承诺稳定的 --porcelain 格式。默认表格面向人工阅读,列宽、空格和括号都不适合作为解析依据。
完成任务后回收
假设 feature/search 已完成,需要合并回 main:
git merge feature/search
↓ ↓
│ └──── 合并进入当前分支的任务分支
└────────── 执行合并
git worktree remove ../shop-search
↓ ↓
│ └──── 已完成任务的 linked worktree
└─────────── 删除工作目录和关联管理记录
git branch -d feature/search
↓ ↓ ↓
│ │ └──── 已合并、准备删除的分支
│ └─────── 仅删除已经合并的分支
└────────────── 管理本地分支
实测执行 git worktree remove 后,工作目录和关联管理记录已经删除,feature/search 分支依然存在。Worktree 与分支的生命周期需要分别管理。
remove 默认要求目标 Worktree 干净。存在未提交内容时,Git 会拒绝删除;remove --force 会丢弃工作目录中的内容。已锁定的 Worktree 需要连续两次 --force 才能删除。
热修复:保留当前现场
回到开头的场景。主目录正在 feature/refactor 上工作,文件尚未提交;线上问题必须从 main 修复。
git worktree add -b hotfix/payment-timeout ../project-hotfix main
↓ ↓ ↓ ↓ ↓
│ │ │ │ └──── 热修复分支的起点
│ │ │ └───────────────────── 热修复使用的工作目录
│ │ └───────────────────────────────────────────── 新分支名称
│ └──────────────────────────────────────────────── 创建新分支
└──────────────────────────────────────────────────── 添加 linked worktree
git worktree remove ../project-hotfix
↓ ↓
│ └──── 热修复完成后的 linked worktree
└─────────── 删除工作目录和关联管理记录
git branch -d hotfix/payment-timeout
↓ ↓ ↓
│ │ └──── 已合并的热修复分支
│ └─────── 仅删除已合并分支
└────────────── 管理本地分支
在 project-hotfix 中完成修改、测试、提交、推送和合并。原 Worktree 的 HEAD、暂存区与未提交文件保持原样;热修复结束后,回到原目录即可继续工作。
需要注意,git stash 本身存放在共享的 refs/stash 中。因此在任意工作树执行 git stash list,看到的是同一组 stash 记录。使用多个工作树时,stash 消息最好写清任务和来源分支,避免在错误目录中应用了另一项工作的临时内容。
管理离线、移动和残留目录
Git 会记录每个 Worktree 所在的位置。目录离线、移动或被误删时,需要同步维护这条连接关系。
lock:防止暂时离线的目录被清理
如果 linked worktree 位于移动硬盘或偶尔离线的网络盘,可以在设备离线前使用 lock 保留其管理记录:
git worktree lock --reason "位于移动硬盘,暂时离线" ../shop-release
↓ ↓ ↓ ↓
│ │ │ └──── 需要保留的 linked worktree
│ │ └───────────────────────── 锁定原因,会写入管理记录
│ └────────────────────────────────── 传入锁定原因
└─────────────────────────────────────── 锁定 linked worktree
git worktree list --verbose
↓ ↓
│ └──── 显示锁定原因和可清理原因
└───────── 列出 Worktree
git worktree unlock ../shop-release
↓ ↓
│ └──── 恢复可移动、可删除状态的 linked worktree
└─────────── 解除锁定
move:让 Git 同时更新目录和记录
目标目录的父目录需要事先存在。move 会同步更新工作目录和管理记录:
git worktree move ../shop-release ../archive/shop-release
↓ ↓ ↓
│ │ └──── 新位置
│ └──────────────────── 当前 Worktree 路径
└───────────────────────── 移动 linked worktree
主工作树不能通过该命令移动,包含子模块的关联工作树也不能通过该命令移动。遇到这两种情况,应先阅读当前版本官方限制,不要直接假设与普通目录相同。
repair:修复手动移动后的失联
如果目录已经被文件管理器或普通 mv 移走,仓库仍记录旧路径。手动移动后执行 dry-run,实测输出为:
Removing worktrees/demo-advanced: gitdir file points to non-existent location
这说明仓库把旧管理记录识别成了可清理项。此时不应继续 prune,而应根据新路径修复连接:
git worktree prune --dry-run --verbose --expire now
↓ ↓ ↓ ↓ ↓
│ │ │ │ └──── 将过期时间设为当前时刻
│ │ │ └───────────── 指定可清理记录的过期条件
│ │ └─────────────────────── 显示每条候选记录
│ └──────────────────────────────── 不删除,只预演清理结果
└─────────────────────────────────────── 清理失联 Worktree 的管理记录
git -C ../shop-release-new worktree repair ../shop-release-new
↓ ↓ ↓ ↓ ↓
│ │ │ │ └──── 移动后的实际路径
│ │ │ └─────────── 修复登记连接
│ │ └──────────────────── Worktree 子命令组
│ └───────────────────────────────────────── 在移动后的目录中执行
└──────────────────────────────────────────── 在指定目录中运行 Git
git worktree list --porcelain
↓ ↓
│ └──── 输出稳定的机器可解析格式,便于核对路径
└───────── 列出 Worktree
repair 执行后退出码为 0,列表中的路径更新为手动移动后的新路径,分支仍能正常读取。
prune:只清理已经失效的管理记录
如果有人直接删除了关联工作树目录,公共仓库中的 .git/worktrees/<id> 管理记录可能仍然存在。prune 用来清理这类陈旧记录:
git worktree prune --dry-run --verbose
↓ ↓ ↓
│ │ └──── 显示每条候选记录
│ └────────────── 不删除,只预演清理结果
└──────────────────── 清理失联 Worktree 的管理记录
git worktree prune --verbose
↓ ↓
│ └──── 显示实际移除的记录
└────────── 清理失联 Worktree 的管理记录
正常回收使用 git worktree remove,它会同时处理工作目录和管理记录。prune 用于工作目录已经消失、只剩陈旧管理记录的情况。
大型仓库只展开需要的目录
多个 Worktree 共享对象数据库,但每个工作目录仍要展开自己的文件。Git 把按规则只展开部分路径的能力称为 sparse-checkout。在大型单仓库中,如果某项任务只关心 docs,可以把它与 Worktree 组合使用。
假设 main 同时追踪 docs/guide.md 和 src/app.txt:
git worktree add --no-checkout -b docs/review ../shop-docs main
↓ ↓ ↓ ↓ ↓ ↓
│ │ │ │ │ └──── 检出的起点
│ │ │ │ └──────────────── 新工作目录
│ │ │ └──────────────────────────── 新分支名称
│ │ └─────────────────────────────── 创建新分支
│ └───────────────────────────────────────────── 创建目录时暂不检出文件
└───────────────────────────────────────────────── 添加 linked worktree
git -C ../shop-docs sparse-checkout init --cone
↓ ↓ ↓ ↓ ↓
│ │ │ │ └──── 使用按目录匹配的 cone 模式
│ │ │ └───────── 初始化 sparse-checkout
│ │ └──────────────────────── 稀疏检出子命令组
│ └───────────────────────────────────── 目标 Worktree
└──────────────────────────────────────── 在指定目录中运行 Git
git -C ../shop-docs sparse-checkout set docs
↓ ↓ ↓ ↓ ↓
│ │ │ │ └──── 需要展开的顶层目录
│ │ │ └──────── 设置稀疏检出规则
│ │ └─────────────────────── 稀疏检出子命令组
│ └──────────────────────────────────── 目标 Worktree
└─────────────────────────────────────── 在指定目录中运行 Git
git -C ../shop-docs checkout
↓ ↓ ↓
│ │ └──── 按当前 sparse-checkout 规则展开文件
│ └───────────────── 目标 Worktree
└──────────────────── 在指定目录中运行 Git
回归时先用 git cat-file 确认当前提交同时包含两个文件,再检查工作区,结果为 docs 存在、src 不存在。由此可以确认筛选发生在文件展开阶段。
sparse-checkout 减少工作目录展开的文件,对象数据库仍由各个 Worktree 完整共享。具体规则和命令在不同 Git 版本中有变化,团队落地前应按所用 Git 版本补充验证。
不同 Worktree 使用不同配置
默认情况下,仓库的 .git/config 由所有工作树共享。也就是说,在关联工作树中执行普通的 git config user.email ...,改到的仍是公共仓库配置。
如果同一个仓库的不同工作树确实需要不同配置,可以开启 worktreeConfig 扩展:
git config extensions.worktreeConfig true
↓ ↓ ↓
│ │ └──── 开启该扩展
│ └──────────────────────────────── 工作树独立配置扩展名
└─────────────────────────────────────── 修改仓库配置
git config --worktree user.email "main@example.com"
↓ ↓ ↓ ↓
│ │ │ └──── 主工作区使用的提交邮箱
│ │ └─────────────── 配置键
│ └────────────────────────── 写入当前 Worktree 的配置文件
└───────────────────────────────── 修改仓库配置
git -C ../shop-docs config --worktree user.email "docs@example.com"
↓ ↓ ↓ ↓ ↓ ↓
│ │ │ │ │ └──── docs Worktree 使用的提交邮箱
│ │ │ │ └─────────────── 配置键
│ │ │ └─────────────────────────── 写入目标 Worktree 的配置文件
│ │ └────────────────────────────────── 修改仓库配置
│ └─────────────────────────────────────────────── 目标 Worktree
└────────────────────────────────────────────────── 在指定目录中运行 Git
实测主工作区返回 main@example.com,关联工作区返回 docs@example.com。后者的配置文件位于公共仓库管理区的 .git/worktrees/<id>/config.worktree。
工作树独立配置适合差异确实与工作目录绑定的场景,例如不同稀疏检出设置。不要为了普通分支差异随意开启它,因为仓库格式扩展会带来旧版本兼容成本。
.git 如何连接多个 Worktree
先在关联工作树中检查 .git:
cat ../shop-docs/.git
↓ ↓
│ └──── linked worktree 顶层的 Git 连接文件
└──────── 读取文件内容
git -C ../shop-docs rev-parse --git-dir
↓ ↓ ↓ ↓
│ │ │ └──── 输出当前 Worktree 的私有管理目录
│ │ └────────────── 解析 Git 内部路径
│ └─────────────────────────── 目标 Worktree
└──────────────────────────────── 在指定目录中运行 Git
git -C ../shop-docs rev-parse --git-common-dir
↓ ↓ ↓ ↓
│ │ │ └──── 输出全部 Worktree 共用的仓库目录
│ │ └────────────── 解析 Git 内部路径
│ └─────────────────────────── 目标 Worktree
└──────────────────────────────── 在指定目录中运行 Git
实测目录结构与下面一致,真实绝对路径会随仓库位置变化:
gitdir: /path/to/shop/.git/worktrees/shop-docs
/path/to/shop/.git/worktrees/shop-docs
/path/to/shop/.git
关联工作树顶层的 .git 文件指向公共仓库中的私有管理目录。该私有目录里有当前工作树自己的 HEAD、index、gitdir 和 commondir:
shop/.git/
├── objects/
├── refs/
├── config
└── worktrees/
└── shop-docs/
├── HEAD
├── index
├── gitdir
├── commondir
└── config.worktree
objects 保存全部 Worktree 共享的提交、树和文件对象,refs 保存大部分共享分支与标签,config 是默认共享的仓库配置。HEAD 和 index 属于 shop-docs,gitdir 记录关联目录的 .git 文件位置,commondir 指回公共仓库目录,启用扩展后才会生成 config.worktree。
这是一条双向连接:关联目录的 .git 指向管理区,管理区的 gitdir 又指回关联目录。move 会同时维护两边,repair 则用于外部移动让两边失去一致后的恢复。
共享与隔离可以更准确地归纳为:
| 状态 | 是否共享 | 直接结果 |
|---|---|---|
| 对象数据库 | 共享 | 一处提交后,其他工作树立即能解析该提交 |
| 大部分分支和标签引用 | 共享 | 一处创建、移动或删除分支,其他工作树立即感知 |
HEAD | 每个工作树独立 | 不同目录可以停在不同分支或提交 |
index 暂存区 | 每个工作树独立 | 两边可以分别 git add,修改只进入当前暂存区 |
| 工作目录文件 | 每个工作树独立 | 未提交修改与检出文件互不覆盖 |
| 仓库配置和 Hooks | 默认共享 | 在任一工作树修改仓库级设置,可能影响全部工作树 |
config.worktree | 可选独立 | 开启扩展后可保存与工作树绑定的配置 |
官方给出的引用规则比表格更严格:一般来说,直接位于 $GIT_DIR 下的伪引用是每个工作树独立的,refs/ 下的引用共享;但 refs/bisect、refs/worktree 和 refs/rewritten 等存在例外。因此,脚本不要自己拼接 .git 内部路径,应使用 git rev-parse --git-path <名称> 让 Git 按当前工作树解析。
常见误区与边界
磁盘占用仍会增加
对象数据库由各个 Worktree 共享,每个工作目录仍有一份实际检出的文件,构建产物和依赖目录也可能各占一份空间。共享对象主要减少 Git 历史的重复存储;大型项目还可以配合 sparse-checkout,或让构建缓存指向经过验证的共享位置。
删除目录前先解除 Worktree 关联
直接删除只处理了文件系统目录,没有同步处理公共仓库中的管理记录。日常清理使用 git worktree remove;误删后再用 git worktree prune --dry-run 检查遗留项。
分支、标签和仓库配置仍然共享
分支、标签、stash、仓库配置和 Hooks 等仍然共享。一个 Worktree 中执行分支删除、重置共享分支或修改仓库配置,可能立刻影响其他 Worktree。独立范围主要包括工作目录、HEAD 和暂存区。
运行环境需要单独隔离
不同 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 让同一个仓库同时连接多个工作目录。每个 Worktree 拥有独立的 HEAD、暂存区和工作文件,同时共享对象数据库与大部分引用。当前任务可以留在原目录,临时任务则在另一个目录中开始。
基本使用只需要记住 add、list 和 remove。当目录位于离线设备、被外部移动、需要程序化管理或只想检出部分文件时,再引入 lock、repair、prune、--porcelain 与 sparse-checkout。
Worktree 隔离 Git 工作现场;分支、对象与仓库级配置仍有共享部分;端口、数据库和容器等运行资源由项目自行管理。掌握这三层边界后,就可以把它用于热修复、并行开发、代码审查和多版本测试。
评论
使用 GitHub 登录参与讨论。