关联工作树让同一个 Git 仓库拥有另一个工作目录,并保存各自的检出状态。功能开发、发布维护和评审可以并排展开,不必反复替换同一目录中的文件。真正有用的边界是工作文件与暂存状态分离,而不是完全隔离。对象、许多引用以及普通仓库配置仍然共享,所以操作时既要考虑当前目录,也要考虑它背后的公共仓库,不能把关联工作树等同于互不影响的独立克隆。
区分每份检出自己的状态
每个工作树都有自己的 HEAD 和索引。在一个目录暂存修改,不会让另一目录的索引出现同样变化;编辑一个受跟踪文件,也不会同步编辑旁边那份文件。关联工作树中的普通提交推动它当前选中的分支。新对象和共享分支引用则立即能被其他工作树里的 Git 命令看到,不需要在这些目录之间再执行获取操作。
关联目录中的 Git 入口通常是一个文件,指向它的管理目录。真正检出的源文件住在创建工作树时指定的路径,而不是内部的工作树管理条目里。假定 Git 入口必然是目录的工具,可能因此失效。需要定位公共目录、当前管理目录或索引时,应向 Git 查询对应路径,不能自行拼接内部文件名,把偶然的存储结构当作可靠接口。
对象可以共享,但工作文件和构建产物仍然各自占用磁盘。第二份检出不是第二套完整对象库,却也不是所有源码和生成文件都零复制。保留多个发布版本时,应按实际检出及构建需求估算空间,而不是只看创建一个引用很便宜,就认为新增整套开发环境没有存储成本。
共享不代表所有东西都共享
本地分支和标签通常属于公共仓库,HEAD 和操作状态则属于具体工作树;部分引用也按工作树分离,例如二分排查使用的引用。把所有引用一概当成全局共享,或全部当成当前目录私有,都会导致工具错误。应从目标工作树调用 Git 解析需要的引用,确保命令看到的是准备操作的那份状态。
普通仓库配置默认共享。因此,站在评审目录执行本地配置修改,也可能影响功能开发目录。Git 支持明确启用的工作树专用配置,但启用扩展本身就是仓库配置变更,需要遵守文档中的迁移规则。不能因为命令名称里有“本地”,就假定它的作用域已经缩小到当前这份检出;配置究竟共享到哪里,应依据实际机制确认。
同样,共享的暂存工作列表不是某个目录独享的草稿箱。应用或删除保存项之前,应明确命名并检查内容。源目录分离也不会自动隔离端口、数据库、外部缓存和命令使用的凭据。构建或运行服务时,仍需检查这些资源是否按任务分配,否则两个工作树可能通过仓库之外的资源互相干扰。
坏掉的样子
默认情况下,Git 拒绝在两个工作树同时检出同一本地分支,避免两个独立索引和工作目录竞争更新一个移动引用。这是可被强制参数绕过的保护,不是存储上绝对不可能的情况。独立改动应使用不同分支,检查同一提交可以采用分离检出;不要把绕过保护当作常规创建方式,否则表面上的目录隔离与实际引用关系会不一致。
分离检出没有一条本地分支随新提交前进。如果实验结果需要在移除工作树后保留,应先给目标提交创建分支名称。移除工作树不会自动删除它的分支;清理失效管理信息也不等于删除仍存在的检出。选择命令时必须先判断真实状态,不能把几种都叫“清理”的操作当成可互换步骤。
搬迁时,在受支持场景优先使用工作树移动命令;它不能移动主工作树,也不能移动包含子模块的关联工作树。已经手动移动的检出,可以通过修复命令重新连接。可移动卷暂时不在线,并不代表目录已废弃,锁定对应工作树可以避免离线期间被清理注册信息。不要把直接修改内部管理文件当成通用恢复方案,否则可能进一步损坏双向路径关系。
快速验证
在 POSIX 系统上使用 Python 3 和 Git 2.28 或更新版本运行下方实验。临时仓库排除继承的 Git 路由和个人配置,使用示例身份,并关闭钩子与签名。唯一写入的配置是合成演示键。两个目录分别暂存不同文本,然后在关联工作树提交,观察新对象如何从主目录可见,同时主目录原来的暂存状态仍然保留。
import os
import subprocess
import tempfile
from pathlib import Path
env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
with tempfile.TemporaryDirectory(prefix="git-worktree-state-") as directory:
root = Path(directory)
main, review, detached = root / "main", root / "review", root / "detached"
main.mkdir()
def git(tree, *args, check=True):
return subprocess.run(
["git", "-c", "user.name=Worktree Example",
"-c", "user.email=worktree@example.invalid",
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args],
cwd=tree, env=env, text=True, capture_output=True, check=check,
)
git(main, "init", "-q", "-b", "main")
(main / "note.txt").write_text("base\n")
git(main, "add", "note.txt")
git(main, "commit", "-qm", "base")
git(main, "worktree", "add", "-qb", "review", str(review), "HEAD")
common = [(tree / git(tree, "rev-parse", "--git-common-dir").stdout.strip()).resolve()
for tree in (main, review)]
indexes = [(tree / git(tree, "rev-parse", "--git-path", "index").stdout.strip()).resolve()
for tree in (main, review)]
print("shared-common-dir:", common[0] == common[1])
print("separate-indexes:", indexes[0] != indexes[1])
(main / "note.txt").write_text("main staged\n")
git(main, "add", "note.txt")
(review / "note.txt").write_text("review staged\n")
git(review, "add", "note.txt")
print("independent-staging:", git(main, "show", ":note.txt").stdout == "main staged\n"
and git(review, "show", ":note.txt").stdout == "review staged\n")
git(main, "config", "--local", "demo.shared", "visible")
print("shared-local-config:", git(review, "config", "--get", "demo.shared").stdout.strip() == "visible")
git(review, "commit", "-qm", "review change")
print("commit-visible-from-main:", git(main, "show", "review:note.txt").stdout == "review staged\n")
print("main-staging-unchanged:", git(main, "show", ":note.txt").stdout == "main staged\n"
and (main / "note.txt").read_text() == "main staged\n")
duplicate = git(main, "worktree", "add", str(root / "duplicate"), "main", check=False)
print("duplicate-branch-refused:", duplicate.returncode != 0)
git(main, "worktree", "add", "-q", "--detach", str(detached), "main")
print("detached-copy-allowed:", git(detached, "symbolic-ref", "-q", "HEAD", check=False).returncode == 1)
git(main, "worktree", "remove", str(review))
git(main, "worktree", "remove", str(detached))
八个判断都应为真。路径查询证明公共仓库相同、索引不同;两个索引各自保留对应文本,合成配置与已提交的评审分支则共享。同一分支的重复附着检出被拒绝,同一提交的分离检出可以创建。实验只移除自己的干净关联目录,最后由 Python 清理可丢弃的主目录,没有把真实项目里的未提交工作纳入清理范围。
延伸阅读
来源
- Git worktree 参考,共享状态、工作树专用状态、保护和生命周期命令,已核验 2026-10-15。
- Git 仓库布局,关联管理目录、公共存储及索引位置,已核验 2026-10-15。