把同一仓库的第二份检出停在自己的目录里。

git worktree 同时检出两个分支

关联工作树让同一个 Git 仓库拥有另一个工作目录,并保存各自的检出状态。功能开发、发布维护和评审可以并排展开,不必反复替换同一目录中的文件。真正有用的边界是工作文件与暂存状态分离,而不是完全隔离。对象、许多引用以及普通仓库配置仍然共享,所以操作时既要考虑当前目录,也要考虑它背后的公共仓库,不能把关联工作树等同于互不影响的独立克隆。 区分每份检出自己的状态 每个工作树都有自己的 HEAD 和索引。在一个目录暂存修改,不会让另一目录的索引出现同样变化;编辑一个受跟踪文件,也不会同步编辑旁边那份文件。关联工作树中的普通提交推动它当前选中的分支。新对象和共享分支引用则立即能被其他工作树里的 Git 命令看到,不需要在这些目录之间再执行获取操作。 关联目录中的 Git 入口通常是一个文件,指向它的管理目录。真正检出的源文件住在创建工作树时指定的路径,而不是内部的工作树管理条目里。假定 Git 入口必然是目录的工具,可能因此失效。需要定位公共目录、当前管理目录或索引时,应向 Git 查询对应路径,不能自行拼接内部文件名,把偶然的存储结构当作可靠接口。 对象可以共享,但工作文件和构建产物仍然各自占用磁盘。第二份检出不是第二套完整对象库,却也不是所有源码和生成文件都零复制。保留多个发布版本时,应按实际检出及构建需求估算空间,而不是只看创建一个引用很便宜,就认为新增整套开发环境没有存储成本。 共享不代表所有东西都共享 本地分支和标签通常属于公共仓库,HEAD 和操作状态则属于具体工作树;部分引用也按工作树分离,例如二分排查使用的引用。把所有引用一概当成全局共享,或全部当成当前目录私有,都会导致工具错误。应从目标工作树调用 Git 解析需要的引用,确保命令看到的是准备操作的那份状态。 普通仓库配置默认共享。因此,站在评审目录执行本地配置修改,也可能影响功能开发目录。Git 支持明确启用的工作树专用配置,但启用扩展本身就是仓库配置变更,需要遵守文档中的迁移规则。不能因为命令名称里有“本地”,就假定它的作用域已经缩小到当前这份检出;配置究竟共享到哪里,应依据实际机制确认。 同样,共享的暂存工作列表不是某个目录独享的草稿箱。应用或删除保存项之前,应明确命名并检查内容。源目录分离也不会自动隔离端口、数据库、外部缓存和命令使用的凭据。构建或运行服务时,仍需检查这些资源是否按任务分配,否则两个工作树可能通过仓库之外的资源互相干扰。 坏掉的样子 默认情况下,Git 拒绝在两个工作树同时检出同一本地分支,避免两个独立索引和工作目录竞争更新一个移动引用。这是可被强制参数绕过的保护,不是存储上绝对不可能的情况。独立改动应使用不同分支,检查同一提交可以采用分离检出;不要把绕过保护当作常规创建方式,否则表面上的目录隔离与实际引用关系会不一致。 分离检出没有一条本地分支随新提交前进。如果实验结果需要在移除工作树后保留,应先给目标提交创建分支名称。移除工作树不会自动删除它的分支;清理失效管理信息也不等于删除仍存在的检出。选择命令时必须先判断真实状态,不能把几种都叫“清理”的操作当成可互换步骤。 搬迁时,在受支持场景优先使用工作树移动命令;已经手动移动的检出,可以通过修复命令重新连接。可移动卷暂时不在线,并不代表目录已废弃,锁定对应工作树可以避免离线期间被清理注册信息。不要把直接修改内部管理文件当成通用恢复方案,否则可能进一步损坏双向路径关系。 快速验证 使用 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 清理可丢弃的主目录,没有把真实项目里的未提交工作纳入清理范围。 ...

七月 26, 2027 · 2 分钟 · XB Field Notes
一组很轻的指针,在图里的某个提交上贴一个名字。

git 分支是一个会移动的标签

Git 分支为一条历史线的末端提供名字。新建分支通常只是增加一个指向既有提交的引用,不会再复制一份全部受跟踪文件。两个名字可以先指向同一提交,再分别前进。这让分支创建成本很低,但不意味着名字内部装着历史,更不意味着删除指向某个提交的全部引用后,内容仍会永远保留。理解分支时,需要把名称、对象和保留条件分开。 它是引用,不是固定的存储文件 本地分支属于 refs/heads/ 引用命名空间,具体表示方式是实现细节:引用可能独立存储、被打包,或使用其他受支持的后端。不要假定一定存在某个松散文件,就直接编辑 Git 内部目录来移动分支。应使用 Git 的引用命令,让它遵守仓库实际的存储结构和更新规则,而不是把一种磁盘布局当作永久接口。 提交记录目录树和父提交,沿着父级关系向前追溯,就得到分支末端可达的历史。因此,两条分支可以共享大部分祖先,却拥有不同的末端。分支名称不是提交身份的一部分,新增或重命名分支也不会改写它指向的提交。图中的对象关系解释了共享历史,名称只是人们访问这些对象的入口。 创建分支与切换分支也不同。git branch topic 在当前提交创建名称,但当前分支不会改变;git switch topic 才选中它,并按需要更新工作树。git switch -c topic 把创建与切换合在一起。记录新工作之前,应检查真正活动的分支,不能因为名称已经存在,就误以为接下来的提交一定会记在那条线上。 HEAD 决定哪个名字前进 通常的附着状态下,HEAD 通过符号引用指向当前分支。创建提交时,该分支前进到新对象,其他原本共享旧末端的分支保持不动;新提交的父级关系保留祖先。分支也可能因合并或其他明确更新引用的操作而移动,并不是只有普通提交才能改变它。因此,判断某个名字为什么变化,需要检查实际发生的操作,而不只看提交按钮是否被点击。 直接选中一个提交,可以进入分离状态。之后的新提交会推动 HEAD,但不会推动某条本地分支。提交本身是真实对象,只是切换离开后可能没有持久的分支名称指向它。离开之前可以在目标提交创建分支,或者之后凭已知对象标识重新命名。引用日志有助于恢复,却受到保留时间和垃圾回收影响,不能被当作永久保存承诺。 origin/main 这类远端跟踪引用,是本地对远端状态的观察,通常随获取操作更新;它不是实时连接,也不等同于本地 main。上游配置描述供相关命令使用的分支关系,不会在任意一方提交时自动同步两个末端。排查本地与远端差异时,应明确比较的是哪两个引用,以及远端状态上次何时被获取。 坏掉的样子 未提交改动属于工作树和索引,不是一份已经保存在分支里的快照。切换时,兼容的改动可能被一起带走;如果会覆盖当前修改,Git 也可能拒绝切换。应先检查工作区状态,再有意识地保存未完成工作。仅仅创建一个分支名字,并没有保存那些从未提交的编辑,不能据此清理或丢弃文件。 删除分支是删除引用,不是要求立即擦除它曾经到达的全部对象;其他引用可能继续保留那些提交。但如果最终没有任何保留路径,对象以后仍可能被清理。普通删除命令检查目标是否已经合入配置的上游;没有上游时,则检查是否合入当前 HEAD。这种保护检查的是图上关系,不是合并后程序是否正确,更不是对所有业务改动都已经保留的验收。 合并也不只是多加一个名字。快进会移动已有引用,而分叉历史的普通合并通常创建带结果目录树和多个父级的新提交。父级可达关系保留历史对象,却不保证每项贡献都正确地出现在合并后的文件中。审阅时既要看图,也要看内容,不能把“历史还能找到”解释成“最终行为没有丢失”。 快速验证 使用 Python 3 和 Git 2.28 或更新版本运行下面的实验。它只在私有临时仓库创建空提交,排除个人 Git 配置和继承的 Git 路由变量,关闭钩子及签名,不配置任何远端。删除尝试也只针对这个可丢弃仓库里的演示分支,不会影响当前项目的引用。实验结束后,临时目录管理器会清理整套演示数据。 ...

二月 5, 2027 · 1 分钟 · XB Field Notes