大家好！我一直在慢慢摸索如何解释 Git 中的各个核心理念（提交、分支、远程、暂存区），而提交这个概念却出奇地棘手。要明白 Git 提交是如何实现的对我来说相当简单（这些都是确定的！我可以直接查看！），但是要弄清楚别人是怎么看待提交的却相当困难。所以，就像我最近一直在做的那样，我在 Mastodon 上问了一些问题。大家是怎么看待 Git 提交的？我进行了一个 非常不科学的调查，询问大家是怎么看待 Git 提交的：是快照、差异，还是所有之前提交的列表？（当然，把它看作这三者都是合理的，但我很好奇人们的 主要 观点）。这是调查结果：结果是：51% 差异42% 快照4% 所有之前的提交的历史记录3% “其他”我很惊讶差异和快照两个选项的比例如此接近。人们还提出了一些有趣但相互矛盾的观点，比如 “在我看来，提交是一个差异，但我认为它实际上是以快照的形式实现的” 和 “在我看来，提交是一个快照，但我认为它实际上是以差异的形式实现的”。关于提交的实际实现方式，我们稍后再详谈。在我们进一步讨论之前：我们的说 “一个差异” 或 “一个快照” 都是什么意思？什么是差异？我说的“差异”可能相当明显：差异就是你在运行 git show COMMIT_ID 时得到的东西。例如，这是一个 rbspy 项目中的拼写错误修复：diff --git a/src/ui/summary.rs b/src/ui/summary.rs index 5c4ff9c..3ce9b3b 100644 --- a/src/ui/summary.rs +++ b/src/ui/summary.rs @@ -160,7 +160,7 @@ mod tests { "; let mut buf: Vec<u8> = Vec::new(); - stats.write(&mut buf).expect("Callgrind write failed"); + stats.write(&mut buf).expect("summary write failed"); let actual = String::from_utf8(buf).expect("summary output not utf8"); assert_eq!(actual, expected, "Unexpected summary output"); } 你可以在 GitHub 上看到它： https://github.com/rbspy/rbspy/commit/24ad81d2439f9e63dd91cc1126ca1bb5d3a4da5b什么是快照？我说的 “快照” 是指 “当你运行 git checkout COMMIT_ID 时得到的所有文件”。Git 通常将提交的文件列表称为 “树”（如“目录树”），你可以在 GitHub 上看到上述提交的所有文件：https://github.com/rbspy/rbspy/tree/24ad81d2439f9e63dd91cc1126ca1bb5d3a4da5b（它是 /tree/ 而不是 /commit/）“Git 是如何实现的”真的是正确的解释方式吗？我最常听到的关于学习 Git 的建议大概是 “只要学会 Git 在内部是如何表示事物的，一切都会变得清晰明了”。我显然非常喜欢这种观点（如果你花了一些时间阅读这个博客，你就会知道我 喜欢 思考事物在内部是如何实现的）。但是作为一个学习 Git 的方法，它并没有我希望的那么成功！通常我会兴奋地开始解释 “好的，所以 Git 提交是一个快照，它有一个指向它的父提交的指针，然后一个分支是一个指向提交的指针，然后……”，但是我试图帮助的人会告诉我，他们并没有真正发现这个解释有多有用，他们仍然不明白。所以我一直在考虑其他方案。但是让我们还是先谈谈内部实现吧。Git 是如何在内部表示提交的 —— 快照在内部，Git 将提交表示为快照（它存储每个文件当前版本的 “树”）。我在 在一个 Git 仓库中，你的文件在哪里？ 中写过这个，但下面是一个非常快速的内部格式概述。这是一个提交的表示方式：$ git cat-file -p 24ad81d2439f9e63dd91cc1126ca1bb5d3a4da5b tree e197a79bef523842c91ee06fa19a51446975ec35 parent 26707359cdf0c2db66eb1216bf7ff00eac782f65 author Adam Jensen <adam@acj.sh> 1672104452 -0500 committer Adam Jensen <adam@acj.sh> 1672104890 -0500 Fix typo in expectation message 以及，当我们查看这个树对象时，我们会看到这个提交中仓库根目录下每个文件/子目录的列表：$ git cat-file -p e197a79bef523842c91ee06fa19a51446975ec35 040000 tree 2fcc102acd27df8f24ddc3867b6756ac554b33ef .cargo 040000 tree 7714769e97c483edb052ea14e7500735c04713eb .github 100644 blob ebb410eb8266a8d6fbde8a9ffaf5db54a5fc979a .gitignore 100644 blob fa1edfb73ce93054fe32d4eb35a5c4bee68c5bf5 ARCHITECTURE.md 100644 blob 9c1883ee31f4fa8b6546a7226754cfc84ada5726 CODE_OF_CONDUCT.md 100644 blob 9fac1017cb65883554f821914fac3fb713008a34 CONTRIBUTORS.md 100644 blob b009175dbcbc186fb8066344c0e899c3104f43e5 Cargo.lock 100644 blob 94b87cd2940697288e4f18530c5933f3110b405b Cargo.toml 这意味着检出一个 Git 提交总是很快的：对 Git 来说，检出昨天的提交和检出 100 万个提交之前的提交一样容易。Git 永远不需要重新应用 10000 个差异来确定当前状态，因为提交根本就不是以差异的形式存储的。快照使用 packfile 进行压缩我刚刚提到了 Git 提交是一个快照，但是，当有人说 “在我看来，提交是一个快照，但我认为它在实现上是一个差异” 时，这其实也是对的！Git 提交并不是以你可能习惯的差异的形式表示的（它们不是以与上一个提交的差异的形式存储在磁盘上的），但基本的直觉是，如果你要对一个 10,000 行的文件编辑 500 次，那么存储 500 份文件的效率会很低。Git 有一个将文件以差异的形式存储的方法。这被称为 “packfile”，Git 会定期进行垃圾回收，将你的数据压缩成 packfile 以节省磁盘空间。当你 git clone 一个仓库时，Git 也会压缩数据。这里，我没有足够的篇幅来完整地解释 packfile 是如何工作的（Aditya Mukerjee 的 《解压 Git packfile》是我最喜欢的解释它们是如何工作的文章）。不过，我可以在这里简单总结一下我对 deltas 工作原理的理解，以及它们与 diff 的区别：对象存储为 “原始文件” 和一个 “ 变化量 delta ” 的引用变化量是一系列例如 “读取第 0 到 100 字节，然后插入字节 ‘hello there’，然后读取第 120 到 200 字节” 的指令。它从原始文件中拼凑出新的文本。所以没有 “删除” 的概念，只有复制和添加。我认为变化量的层次较少：我不知道如何检查 Git 究竟要经过多少层变化量才能得到一个给定的对象，但我的印象是通常不会很多。可能少于 10 层？不过，我很想知道如何才能真正查出来。原始文件不一定来自上一个提交，它可以是任何东西。也许它甚至可以来自一个更晚的提交？我不确定。没有一个 “正确的” 算法来计算变化量，Git 只是有一些近似的启发式算法当你查看差异时，实际上发生了一些奇怪的事情当我们运行 git show SOME_COMMIT 来查看某个提交的差异时，实际上发生的事情有点反直觉。我的理解是：Git 会在 packfile 中查找并应用变化量来重建该提交和其父提交的树。Git 会对两个目录树（当前提交的目录树和父提交的目录树）进行差异比较。通常这很快，因为几乎所有的文件都是完全一样的，所以 git 只需比较相同文件的哈希值就可以了，几乎所有时候都不用做什么。最后 Git 会展示差异所以，Git 会将变化量转换为快照，然后计算差异。它感觉有点奇怪，因为它从一个类似差异的东西开始，最终得到另一个类似差异的东西，但是变化量和差异实际上是完全不同的，所以这是说得通的。也就是说，我认为 Git 将提交存储为快照，而 packfile 只是一个实现细节，目的是节省磁盘空间并加快克隆速度。我其实从来没必要知道 packfile 是如何工作的，但它确实能帮助我理解 Git 是如何在不占用太多磁盘空间的情况下将提交快照化的。一个 “错误的” Git 理解：提交是差异我认为一个相当常见的，对 Git 的 “错误” 的理解是：提交是以基于上一个提交的差异的形式存储的（加上指向父提交的指针和作者和消息）。要获取提交的当前状态，Git 需要从头开始重新应用所有之前的提交。这个理解当然是错误的（在现实中，提交是以快照的形式存储的，差异是从这些快照计算出来的），但是对我来说它似乎非常有用而且有意义！在考虑合并提交时会有一点奇怪，但是或许我们可以说这只是基于合并提交的第一个父提交的差异。我认为这个错误的理解有的时候非常有用，而且对于日常 Git 使用来说它似乎并没有什么问题。我真的很喜欢它将我们最常使用的东西（差异）作为最基本的元素——它对我来说非常直观。我也一直在思考一些其他有用但 “错误” 的 Git 理解，比如：提交信息可以被编辑（实际上不能，你只是复制了一个相同的提交然后给了它一个新的信息，旧的提交仍然存在）提交可以被移动到一个不同的基础上（类似地，它们是被复制了）我认为有一系列非常有意义的、 “错误” 的对 Git 的理解，它们在很大程度上都受到 Git 用户界面的支持，并且在大多数情况下都不会产生什么问题。但是当你想要撤销一个更改或者出现问题时，它可能会变得混乱。将提交视为差异的一些优势就算我知道在 Git 中提交是快照，我可能大部分时间也都将它们视为差异，因为：大多时候我都在关注我正在做的 更改 —— 如果我只是改变了一行代码，显然我主要是在考虑那一行代码而不是整个代码库的当前状态点击 GitHub 上的 Git 提交或者使用 git show 时，你会看到差异，所以这只是我习惯看到的东西我经常使用变基，它就是关于重新应用差异的将提交视为快照的一些优势但是我有时也会将提交视为快照，因为：Git 经常对文件的移动感到困惑：有时我移动了一个文件并编辑了它，Git 无法识别它是否被移动过，而是显示为 “删除了 old.py，添加了 new.py”。这是因为 Git 只存储快照，所以当它显示 “移动 old.py -> new.py” 时，只是猜测，因为 old.py 和 new.py 的内容相似。这种方式更容易理解 git checkout COMMIT_ID 在做什么（重新应用 10000 个提交的想法让我感到很有压力）合并提交在我看来更像是快照，因为合并的提交实际上可以是任何东西（它只是一个新的快照！）。它帮助我理解为什么在解决合并冲突时可以进行任意更改，以及为什么在解决冲突时要小心。其他一些关于提交的理解Mastodon 的一些回复中还提到了：有关提交的 “额外的” 带外信息，比如电子邮件、GitHub 拉取请求或者你和同事的对话将“差异”视为一个“之前的状态 + 之后的状态”以及，当然，很多人根据情况的不同以不同的方式看待提交人们在谈论提交时使用的其他一些词可能不那么含糊：“修订”（似乎更像是快照）“补丁”（看起来更像是差异）就到这里吧！我很难了解人们对 Git 有哪些不同的理解。尤其棘手的是，尽管 “错误” 的理解往往非常有用，但人们却非常热衷于警惕 “错误” 的心智模式，所以人们不愿意分享他们 “错误” 的想法，生怕有什么 Git 解释者会站出来向他们解释为什么他们是错的。（这些 Git 解释者通常是出于善意的，但是无论如何它都会产生一种负面影响）但是我学到了很多！我仍然不完全清楚该如何谈论提交，但是我们最终会弄清楚的。感谢 Marco Rogers、Marie Flanagan 以及 Mastodon 上的所有人和我讨论 Git 提交。（题图：DA/cc0cada9-4945-4248-8635-3f89dcebd6ef）via: https://jvns.ca/blog/2024/01/05/do-we-think-of-git-commits-as-diffs--snapshots--or-histories/作者：Julia Evans 选题：lujun9972 译者：Cubik65536 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
大家好！某天，我突发奇想 —— 是否能把 Git 存储库制作成一个 FUSE 文件系统，然后把所有的提交记录做成文件夹呢？答案是肯定的！有 giblefs、 GitMounter 和用于 Plan 9 号的 git9。但在 Mac 上使用 FUSE 实在很烦人 —— 你需要安装一个内核扩展，但由于安全的原因，Mac OS 上安装内核扩展看起来越来越难了。此外，我还有一些想法，希望能用与这些项目不同的方式来组织文件系统。因此，我想在 Mac OS 上尝试 FUSE 以外的挂载文件系统的方法会很有趣，因此我创建了一个名为 git-commit-folders 的项目来做这个事。它可以同时使用 FUSE 和 NFS（至少在我的电脑上），WebDav 的实现起来还有点问题。这个项目很有实验性（我不确定这究竟是一个有用的软件，还是一个思考 Git 如何工作的有趣玩具），但写起来很有趣，我自己也很喜欢在小型存储库中使用它，下面是我在写这个项目时遇到的一些问题。目标：像文件夹一样显示提交记录我做这个事的主要目的是给大家一些启发：Git 核心是如何运行的。总结来说，Git 提交记录实际上和文件夹非常类似 —— 每个 Git 提交都包含一个目录，其中 列出了文件，这个目录也可以有子目录，依此类推。只是为了节省磁盘空间，Git 提交实际上并不是以文件夹的形式实现的。而在 git-commit-folders，所有的提交记录实际上看起来就是一个文件夹，如果你想浏览历史提交记录，你可以像浏览文件系统一样浏览它们！例如如果你像查看我的博客的初始提交记录，你可以如下操作：$ ls commits/8d/8dc0/8dc0cb0b4b0de3c6f40674198cb2bd44aeee9b86/ README 其他之后的提交记录，如下：$ ls /tmp/git-homepage/commits/c9/c94e/c94e6f531d02e658d96a3b6255bbf424367765e9/ _config.yml config.rb Rakefile rubypants.rb source 分支是符号链接通过 git-commit-folders 挂载的文件系统中，提交是唯一真正的文件夹 —— 其他一切（分支、标签等）都是提交记录的符号链接。这反映了 Git 底层的工作方式。$ ls -l branches/ lr-xr-xr-x 59 bork bazil-fuse -> ../commits/ff/ff56/ff563b089f9d952cd21ac4d68d8f13c94183dcd8 lr-xr-xr-x 59 bork follow-symlink -> ../commits/7f/7f73/7f73779a8ff79a2a1e21553c6c9cd5d195f33030 lr-xr-xr-x 59 bork go-mod-branch -> ../commits/91/912d/912da3150d9cfa74523b42fae028bbb320b6804f lr-xr-xr-x 59 bork mac-version -> ../commits/30/3008/30082dcd702b59435f71969cf453828f60753e67 lr-xr-xr-x 59 bork mac-version-debugging -> ../commits/18/18c0/18c0db074ec9b70cb7a28ad9d3f9850082129ce0 lr-xr-xr-x 59 bork main -> ../commits/04/043e/043e90debbeb0fc6b4e28cf8776e874aa5b6e673 $ ls -l tags/ lr-xr-xr-x - bork 31 Dec 1969 test-tag -> ../commits/16/16a3/16a3d776dc163aa8286fb89fde51183ed90c71d0 这个并不能完全呈现 Git 的所有工作机理（相比简单的类似文件夹的提交，还有很多复杂的细节），但是我希望大家对“每个提交如同一个文件夹，里面有你的旧版本代码”有一个直观的认识。这么做有什么好处呢？在我深入介绍它的实现之前，我想说下为什么把 Git 提交记录变成拥有文件夹的文件系统很有用。我的很多项目最终都没有真正使用过（比如 dnspeep），但我发现自己在做这个项目的时候确实使用到了一些。目前为止我发现主要用处是：查找已经删除的函数 - 可以用 grep someFunction branch_histories/main/*/commit.go 查找它的旧版本快速查看其他分支的一个文件并从其拷贝一行，如 vim branches/other-branch/go.mod在每个分支中搜索某个函数，如 grep someFunction branches/*/commit.go所有这些操作都通过提交记录的符号链接，来替代提交记录的直接引用。这些都不是最有效的方法（你可以用 git show 和 git log -S 或者 git grep 来完成类似操作），但是对我个人来说，我经常忘记 Git 语法，而浏览文件系统对我来说更简单。git worktree 还允许你同时签出多个分支，但对我来说，为了看一个文件而设置整个工作树感觉很奇怪。接下来我想谈谈我遇到的一些问题。问题 1: 用 WebDav 还是 NFS?Mac OS 原生支持的两个文件系统是 WebDav 和 NFS。我说不出那个更新容易实现，所以我就索性尝试两个都支持。起初，WebDav 的实现看起来更容易一些，在 golang.org/x/net 上有一个 WebDav 实现，这个很好配置。但这个实现不支持符号链接，我想可能原因是它用的是 io/fs 接口，而 io/fs 还不支持 符号链接。不过看起来正在进行中。所以我放弃了 WebDav，而决定重点放在 NFS 实现上了，用 go-nfs NFSv3 的库文件来实现。有人也提到了 Mac 上的 FileProvider，我还没有深入了解这个。问题 2: 如何确保所有的实现保持一致？我已经实现了三个不同的文件系统（FUSE、NFS 和 WebDav），但对我来说还是没搞清楚如何避免大量的重复代码。我的朋友 Dave 建议写一个核心实现，然后写一个适配器（如 fuse2nfs 和 fuse2dav）来转换成 NFS 和 WebDav 版本。这个看起来需要我着手实现三个文件系统的接口：对应 FUSE 的 fs.FS对应 NFS 的 billy.Filesystem对应 WebDav 的 webdav.Filesystem因此我把所有的核心逻辑放到 fs.FS 接口上，然后写两个函数：func Fuse2Dav(fs fs.FS) webdav.FileSystemfunc Fuse2NFS(fs fs.FS) billy.Filesystem所有的文件系统都比较类似，因此转换起来不是很难，但就是有大量的烦人的问题需要修复。问题 3: 我不想罗列所有的提交记录怎么办一些 Git 存储库有成千上万的提交记录。我的第一个想法是如何让 commits/ 看起来是空的，这样就可以如下展示：$ ls commits/ $ ls commits/80210c25a86f75440110e4bc280e388b2c098fbd/ fuse fuse2nfs go.mod go.sum main.go README.md 因此所有的提交记录可以直接查看，但是又不能罗列它们。这个对文件系统是一个奇怪的事情，实际上 FUSE 可以做到。但我在 NFS 上无法实现。我认为这里的原因是，如果你告诉 NFS 某个目录是空的，它就会认为该目录实际上是空的，这是合理的。我们最终是这样处理的：按照 .git/objects 的方式，以前两个字符组织管理提交记录（因此 ls commits 会显示 0b 03 05 06 07 09 1b 1e 3e 4a），但这样做会分为两层，这样 18d46e76d7c2eedd8577fae67e3f1d4db25018b0 则为 commits/18/18df/18d46e76d7c2eedd8577fae67e3f1d4db25018b0开始只罗列一次所有的已经打包的提交哈希，将它们缓存在内存中，然后后面仅更新稀疏对象。主要思路是版本库中几乎所有的提交都应该打包，而且 Git 不会经常重新打包提交这个看起来在拥有百万提交记录的 Linux 内核的 Git 存储库上似乎效果不错。在我的机器上实测它初始化大概需要一分钟，之后只需快速增量更新即可。每个提交哈希只有 20 个字节，因此缓存 1 百万个提交哈希也不是很大，大约 20MB。我认为更聪明的做法是延迟加载提交列表 —— Git 会按提交 ID 对其打包文件进行排序，所以你可以很容易地进行二叉树搜索，找到所有以 1b 或 1b8c 开始的提交。我用的 Git 库 对此并不支持，因为罗列出来 Git 存储库所有的提交记录确实一个奇怪的事情。我花了 几天时间 尝试实现它，但没有达到我想要的性能，所以就放弃了。问题 4: 不是目录我常遇到下面这个错误："/tmp/mnt2/commits/59/59167d7d09fd7a1d64aa1d5be73bc484f6621894/": Not a directory (os error 20) 这起初真的把我吓了一跳，但事实证明，这只是表示在列出目录时出现了错误，而 NFS 库处理该错误的方式就是显示 “Not a directory”（不是目录）。这个错误遇到了很多次，我需要每次跟踪这个错误的根源。有很多类似错误。我也遇到 cd: system call interrupted，令人沮丧的是，但最终也只是程序中的其他错误。我意识到终极大法是用 Wireshark 查看 NFS 发送和接受的数据包，很多问题便可迎刃而解。问题 5: inode 编号在开始的时候我不小心将所有的文件夹的 inode 设为 0。这很糟糕，因为如果在每个目录的 inode 都为 0 的目录上运行查找，它就会抱怨文件系统循环并放弃，这个也是符合逻辑的。我通过定义一个 inode(string) 来修复这个问题，通过散列字符串来获取 inode 编号，并使用树 ID / blob ID 作为散列字符串。问题 6: 过期文件句柄我一直遇到这个“Stale NFS file handle”（过期文件句柄）错误。问题是，我需要获取未知的 64 字节 NFS “文件句柄”，并将其映射到正确的目录。我使用的 NFS 库的工作方式是为每个文件生成一个文件句柄，并通过固定大小的缓存来缓存这些引用。这对小型存储库来说没问题，但是如果对于拥有海量的文件的存储库来说，由于缓存就会溢出，就会导致“stale file handle” 错误。这仍然是个问题，我不知道如何解决。我不明白真正的 NFS 服务器是如何做到这一点的，也许它们只是有一个非常大的缓存？NFS 文件句柄占用 64 个字节（不是比特），确实很大，所以很多时候似乎可以将整个文件路径编码到句柄中，根本不需要缓存。也许我会在某个时候尝试实现这一点。问题 7: 分支历史branch_histories/ 目录目前仅罗列对应分支的最近 100 个提交记录。我不知道该怎么做，如果能以某种方式列出分支的全部历史就更好了。也许我可以使用 commits/ 目录中类似的子文件夹技巧。问题 8: 子模块Git 存储库有时包含了子模块。由于目前我对子模块的理解还不深入，我先忽略它吧。因此这个算是一个问题。问题 9: NFSv4 是否更好？我构建这个项目使用的是 NFSv3 库，因为我当时只能找到一个 NFSv3 的 Go 库文件。可当我搞完的时候才发现了一个名叫 buildbarn 的项目里有 NFSv4 服务器。有没有可能用它会更好一些？我不知道这样做有什么问题，或者用 NFSv4 有哪些优点？我还有点不确定是否要使用 buildbarn NFS 库，因为不清楚他们是否希望其他人使用它。就这些吧之前已经解决了很多问题我都忘记了，这是我目前能回想起来的。我未来有可能解决或根本解决不了 NFS 的“过期文件句柄” 错误，或者“在 Linux 内核的存储库上启动需要 1 分钟”的问题，就这样吧。感谢我的朋友 vasi，他给我了很多文件系统方面的帮助。（题图：DA/d22b1c01-e80a-4529-b88a-419ceef74b5e）via: https://jvns.ca/blog/2023/12/04/mounting-git-commits-as-folders-with-nfs/作者：Julia Evans 选题：lujun9972 译者：guevaraya 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
我正在一步步解释 Git 的方方面面。在使用 Git 近 15 年后，我已经非常习惯于 Git 的特性，很容易忘记它令人困惑的地方。因此，我在 Mastodon 上进行了调查：你有觉得哪些 Git 术语很让人困惑吗？我计划写篇博客，来解读 Git 中一些奇怪的术语，如：“分离的 HEAD 状态”，“快速前移”，“索引/暂存区/已暂存”，“比 origin/main 提前 1 个提交”等等。我收到了许多有洞见的答案，我在这里试图概述其中的一部分。下面是这些术语的列表：HEAD 和 “heads”“分离的 HEAD 状态”在合并或变基时的 “ours” 和 “theirs”“你的分支已经与 'origin/main' 同步”HEAD^、HEAD~、HEAD^^、HEAD~~、HEAD^2、HEAD~2.. 和 ...“可以快速前移”“引用”、“符号引用”refspecs“tree-ish”“索引”、“暂存的”、“已缓存的”“重置”、“还原”、“恢复”“未跟踪的文件”、“追踪远程分支”、“跟踪远程分支”检出reflog合并、变基和遴选rebase –onto提交更多复杂的术语我已经尽力讲解了这些术语，但它们几乎覆盖了 Git 的每一个主要特性，这对一篇博客而言显然过于繁重，所以在某些地方可能会有一些粗糙。HEAD 和 “heads”有些人表示他们对 HEAD 和 refs/heads/main 这些术语感到困惑，因为听起来像是一些复杂的技术内部实现。以下是一个快速概述：“heads” 就是 “分支”。在 Git 内部，分支存储在一个名为 .git/refs/heads 的目录中。（从技术上讲，官方 Git 术语表 中明确表示分支是所有的提交，而 head 只是最近的提交，但这只是同一事物的两种不同思考方式）HEAD 是当前的分支，它被存储在 .git/HEAD 中。我认为，“head 是一个分支，HEAD 是当前的分支” 或许是 Git 中最奇怪的术语选择，但已经设定好了，想要更清晰的命名方案已经为时已晚，我们继续。“HEAD 是当前的分支” 有一些重要的例外情况，我们将在下面讨论。“分离的 HEAD 状态”你可能已经看到过这条信息：$ git checkout v0.1 You are in 'detached HEAD' state. You can look around, make experimental changes and commit them, and you can discard any commits you make in this state without impacting any branches by switching back to a branch. [...] （消息译文：你处于 “分离 HEAD” 的状态。你可以四处看看，进行试验性的更改并提交，你可以通过切换回一个分支来丢弃这个状态下做出的任何提交。）这条信息的实质是：在 Git 中，通常你有一个已经检出的 “当前分支”，例如 main。存放当前分支的地方被称为 HEAD。你做出的任何新提交都会被添加到你的当前分支，如果你运行 git merge other_branch，这也会影响你的当前分支。但是，HEAD 不一定必须是一个分支！它也可以是一个提交 ID。Git 会称这种状态（HEAD 是提交 ID 而不是分支）为 “分离的 HEAD 状态”例如，你可以通过检出一个标签来进入分离的 HEAD 状态，因为标签不是分支如果你没有当前分支，一系列事情就断链了：git pull 根本就无法工作（因为它的全部目的就是更新你的当前分支）除非以特殊方式使用 git push，否则它也无法工作git commit、git merge、git rebase 和 git cherry-pick 仍然可以工作，但它们会留下“孤儿”提交，这些提交没有连接到任何分支，因此找到这些提交会很困难你可以通过创建一个新的分支或切换到一个现有的分支来退出分离的 HEAD 状态在合并或变基中的 “ours” 和 “theirs”遇到合并冲突时，你可以运行 git checkout --ours file.txt 来选择 “ours” 版本中的 file.txt。但问题是，什么是 “ours”，什么是 “theirs” 呢？我总感觉此类术语混淆不清，也因此从未用过 git checkout --ours，但我还是查找相关资料试图理清。在合并的过程中，这是如何运作的：当前分支是 “ours”，你要合并进来的分支是 “theirs”，这样看来似乎很合理。$ git checkout merge-into-ours # 当前分支是 “ours” $ git merge from-theirs # 我们正要合并的分支是 “theirs” 而在变基的过程中就刚好相反 —— 当前分支是 “theirs”，我们正在变基到的目标分支是 “ours”，如下：$ git checkout theirs # 当前分支是 “theirs” $ git rebase ours # 我们正在变基到的目标分支是 “ours” 我以为之所以会如此，因为在操作过程中，git rebase main 其实是将当前分支合并到 main （它类似于 git checkout main; git merge current_branch），尽管如此我仍然觉得此类术语会造成混淆。这个精巧的小网站 对 “ours” 和 “theirs” 的术语进行了解释。人们也提到，VSCode 将 “ours”/“theirs” 称作 “当前的更改”/“收到的更改”，同样会引起混淆。“你的分支已经与 origin/main 同步”此信息貌似很直白 —— 你的 main 分支已经与源端同步！但它实际上有些误导。可能会让你以为这意味着你的 main 分支已经是最新的，其实不然。它真正的含义是 —— 如果你最后一次运行 git fetch 或 git pull 是五天前，那么你的 main 分支就是与五天前的所有更改同步。因此，如果你没有意识到这一点，它对你的安全感其实是一种误导。我认为 Git 理论上可以给出一个更有用的信息，像是“与五天前上一次获取的源端 main 是同步的”，因为最新一次获取的时间是在 reflog 中记录的，但它没有这么做。HEAD^、HEAD~、HEAD^^、HEAD~~、HEAD^2、HEAD~2我早就清楚 HEAD^ 代表前一次提交，但我很长一段时间都困惑于 HEAD~ 和 HEAD^ 之间的区别。我查询资料，得到了如下的对应关系：HEAD^ 和 HEAD~ 是同一件事情（指向前 1 个提交）HEAD^^^、HEAD~~~ 和 HEAD~3 是同一件事情（指向前 3 个提交）HEAD^3 指向提交的第三个父提交，它与 HEAD~3 是不同的这看起来有些奇怪，为什么 HEAD~ 和 HEAD^ 是同一个概念？以及，“第三个父提交”是什么？难道就是父提交的父提交的父提交？（剧透：并非如此）让我们一起深入探讨一下！大部分提交只有一个父提交。但是合并提交有多个父提交 - 因为它们合并了两个或更多的提交。在 Git 中，HEAD^ 意味着 “HEAD 提交的父提交”。但是如果 HEAD 是一个合并提交，那 HEAD^ 又代表怎么回事呢？答案是，HEAD^ 指向的是合并提交的第一个父提交，HEAD^2 是第二个父提交，HEAD^3 是第三个父提交，等等。但我猜他们也需要一个方式来表示“前三个提交”，所以 HEAD^3 是当前提交的第三个父提交（如果当前提交是一个合并提交，可能会有很多父提交），而 HEAD~3 是父提交的父提交的父提交。我想，从我们之前对合并提交 “ours”/“theirs” 的讨论来看，HEAD^ 是 “ours”，HEAD^2 是 “theirs”。.. 和 ...这是两个命令：git log main..testgit log main...test我从没用过 .. 和 ... 这两个命令，所以我得查一下 man git-range-diff。我的理解是比如这样一个情况：A - B main \ C - D test main..test 对应的是提交 C 和 Dtest..main 对应的是提交 Bmain...test 对应的是提交 B，C，和 D更有挑战的是，git diff 显然也支持 .. 和 ...，但它们在 git log 中的意思完全不同？我的理解如下：git log test..main 显示在 main 而不在 test 的更改，但是 git log test...main 则会显示 两边 的改动。git diff test..main 显示 test 变动 和 main 变动（它比较 B 和 D），而 git diff test...main 会比较 A 和 D（它只会给你显示一边的差异）。有关这个的更多讨论可以参考 这篇博客文章。“可以快速前移”在 git status 中，我们会经常遇到如下的信息：$ git status On branch main Your branch is behind 'origin/main' by 2 commits, and can be fast-forwarded. (use "git pull" to update your local branch) （消息译文：你现在处于 main 分支上。你的分支比 origin/main 分支落后了 2 个提交，可以进行快速前进。 (使用 git pull 命令可以更新你的本地分支)）但“快速前移” 到底是何意？本质上，它在告诉我们这两个分支基本如下图所示（最新的提交在右侧）：main: A - B - C origin/main: A - B - C - D - E 或者，从另一个角度理解就是：A - B - C - D - E (origin/main) | main 这里，origin/main 仅仅多出了 2 个 main 不存在的提交，因此我们可以轻松地让 main 更新至最新 —— 我们所需要做的就是添加上那 2 个提交。事实上，这几乎不可能出错 —— 不存在合并冲突。快速前进式合并是个非常棒的事情！这是合并两个分支最简单的方式。运行完 git pull 之后，你会得到如下状态：main: A - B - C - D - E origin/main: A - B - C - D - E 下面这个例子展示了一种不能快速前进的状态。A - B - C - X (main) | - - D - E (origin/main) 此时，main 分支上有一个 origin/main 分支上无的提交（X），所以无法执行快速前移。在此种情况，git status 就会如此显示：$ git status Your branch and 'origin/main' have diverged, and have 1 and 2 different commits each, respectively. （你的分支和 origin/main 分支已经产生了分歧，其中各有 1 个和 2 个不同的提交。）“引用”、“符号引用”在使用 Git 时，“引用” 一词可能会使人混淆。实际上，Git 中被称为 “引用” 的实例至少有三种：分支和标签，例如 main 和 v0.2HEAD，代表当前活跃的分支诸如 HEAD^^^ 这样的表达式，Git 会将其解析成一个提交 ID。确切说，这可能并非 “引用”，我想 Git 将其称作 “版本参数”，但我个人并未使用过这个术语。个人而言，“符号引用” 这个术语颇为奇特，因为我觉得我只使用过 HEAD（即当前分支）作为符号引用。而 HEAD 在 Git 中占据核心位置，多数 Git 核心命令的行为都基于 HEAD 的值，因此我不太确定将其泛化成一个概念的实际意义。refspecs在 .git/config 配置 Git 远程仓库时，你可能会看到这样的代码 +refs/heads/main:refs/remotes/origin/main 。[remote "origin"] url = git@github.com:jvns/pandas-cookbook fetch = +refs/heads/main:refs/remotes/origin/main 我对这段代码的含义并不十分清楚，我通常只是在使用 git clone 或 git remote add 配置远程仓库时采用默认配置，并没有动机去深究或改变。“tree-ish”在 git checkout 的手册页中，我们可以看到：git checkout [-f|--ours|--theirs|-m|--conflict= ] [<tree-ish>] [--] <pathspec>... 那么这里的 tree-ish 是什么意思呢？其实当你执行 git checkout THING . 时，THING 可以是以下的任一种：一个提交 ID（如 182cd3f）对一个提交 ID 的引用（如 main 或 HEAD^^ 或 v0.3.2）一个位于提交内的子目录（如 main:./docs）可能就这些？对我个人来说，“提交内的目录”这个功能我从未使用过，从我的视角看，tree-ish 可以解读为“提交或对提交的引用”。“索引”、“暂存”、“缓存”这些术语都指向的是同一样东西（文件 .git/index，当你执行 git add 时，你的变动会在这里被暂存）：git diff --cachedgit rm --cachedgit diff --staged文件 .git/index尽管它们都是指向同一个文件，但在实际使用中，这些术语的应用方式有所不同：很显然，--index 和 --cached 并不总是表示同一种意思。我自己从未使用 --index，所以具体细节我就不展开讨论了，但是你可以在 Junio Hamano（Git 的主管维护者）的博客文章 中找到详细解释。“索引” 会包含未跟踪的文件（我猜可能是对性能的考虑），但你通常不会把未跟踪的文件考虑在“暂存区”内。“重置”、“还原”、“恢复”许多人提到，“ 重置 reset ”、“ 还原 revert ” 和 “ 恢复 restore ” 这三个词非常相似，易使人混淆。我认为这部分的困惑来自以下原因：git reset --hard 和 git restore . 单独使用时，基本上达到的效果是一样的。然而，git reset --hard COMMIT 和 git restore --source COMMIT . 相互之间是完全不同的。相应的手册页没有给出特别有帮助的描述：git reset: “重置当前 HEAD 到指定的状态”git revert: “还原某些现有的提交”git restore: “恢复工作树文件”虽然这些简短的描述为你详细说明了哪个名词受到了影响（“当前 HEAD”，“某些提交”，“工作树文件”），但它们都预设了你已经知道在这种语境中，“重置”、“还原”和“恢复”的准确含义。以下是对它们各自功能的简要说明：重置 —— git revert COMMIT: 在你当前的分支上，创建一个新的提交，该提交是 COMMIT 的“反向”操作（如果 COMMIT 添加了 3 行，那么新的提交就会删除这 3 行）。还原 —— git reset --hard COMMIT: 强行将当前分支回退到 COMMIT 所在的状态，抹去自 COMMIT 以来的所有更改。这是一个高风险的操作。恢复 —— git restore --source=COMMIT PATH: 将 PATH 中的所有文件回退到 COMMIT 当时的状态，而不扰乱其他文件或提交历史。“未跟踪的文件”、“远程跟踪分支”、“跟踪远程分支”在 Git 中，“跟踪” 这个词以三种相关但不同的方式使用：“ 未跟踪的文件 Untracked files ”：在 git status 命令的输出中可以看到。这里，“未跟踪” 意味着这些文件不受 Git 管理，不会被计入提交。“ 远程跟踪分支 remote tracking branch ” 例如 origin/main。此处的“远程跟踪分支”是一个本地引用，旨在记住上次执行 git pull 或 git fetch 时，远程 origin 上 main 分支的状态。我们经常看到类似 “分支 foo 被设置为跟踪 origin 上的远程分支 bar ”这样的提示。即使“未跟踪的文件”和“远程跟踪分支”都用到了“跟踪”这个词，但是它们所在的上下文完全不同，所以没有太多混淆。但是，对于以下两种方式的“跟踪”使用，我觉得可能会产生些许困扰：main 是一个跟踪远程的分支origin/main 是一个远程跟踪分支然而，在 Git 中，“跟踪远程的分支” 和 “远程跟踪分支” 是不同的事物，理解它们之间的区别非常关键！下面是对这两者区别的一个简单概述：main 是一个分支。你可以在它上面做提交，进行合并等操作。在 .git/config 中，它通常被配置为 “追踪” 远程的 main 分支，这样你就可以用 git pull 和 git push 来同步和上传更改。origin/main 则并不是一个分支，而是一个“远程跟踪分支”，这并不是一种真正的分支（这有些抱歉）。你不能在此基础上做提交。只有通过运行 git pull 或 git fetch 获取远程 main 的最新状态，才能更新它。我以前没有深入思考过这种模糊的地方，但我认为很容易看出为什么它会让人感到困惑。签出签出做了两个完全无关的事情：git checkout BRANCH 用于切换分支git checkout file.txt 用于撤销对 file.txt 的未暂存修改这是众所周知的混淆点，因此 Git 实际上已经将这两个功能分离到了 git switch 和 git restore（尽管你还是可以使用 checkout，就像我一样，在不愿丢弃 15 年对 git checkout 肌肉记忆的情况下）。再者，即使用了 15 年，我仍然记不住 git checkout main file.txt 用于从 main 分支恢复 file.txt 版本的命令参数。我觉得有时你可能需要在 checkout 命令后面加上--，帮助区分哪个参数是分支名，哪个是路径，但我并未这么使用过，也不确定何时需要这样做。参考日志（reflog）有很多人把 reflog 读作 re-flog，而不是 ref-log。由于本文已经足够长，我这里不会深入讨论参考日志，但值得注意的是：在 Git 中，“参考” 是一个泛指分支、标签和 HEAD 的术语参考日志（“reflog”）则为你提供了一个参考历次记录的历史追踪它是从一些极端困境中拯救出来的利器，比如说你不小心删除了重要的分支我觉得参考日志是 Git 用户界面中最难懂的部分，我总是试图避免使用它。合并 vs 变基 vs 遴选有许多人提及他们常常对于合并和变基的区别感到迷惑，并且不理解变基中的“ 基 base ”指的是什么。我会在这里尽量简要的进行描述，但是这些一句话的解释最终可能并不那么明了，因为每个人使用合并和变基创建工作流程时的方式差别挺大，要真正理解合并和变基，你必须理解工作流程。此外，有图示会更好理解。不过这个话题可能需要一篇独立的博客文章来完整讨论，所以我不打算深入这个问题。合并会创建一个新的提交，用来融合两个分支变基则会逐个地把当前分支上的提交复制到目标分支遴选跟变基类似，但是语法完全不同（一个显著的差异是变基是从当前分支复制提交，而遴选则会把提交复制到当前分支）rebase --onto在 git rebase 中，存在一个被称为 --onto 的选项。这一直让我感到困惑，因为 git rebase main 的核心功能就是将当前分支变基到 main 运行上。那么，额外的 --onto 参数又是怎么回事呢？我进行了一番查找，--onto 显然解决了一个我几乎没有或者说从未遇到过的问题，但我还是会记录下我对它的理解。A - B - C (main) \ D - E - F - G (mybranch) | otherbranch 设想一下，出于某种原因，我只想把提交 F 和 G 变基到 main 上。我相信这应该是某些 Git 工作流中会经常遇到的场景。显然，你可以运行 git rebase --onto main otherbranch mybranch 来完成这个操作。对我来说，在这个语法中记住 3 个不同的分支名顺序似乎是不可能的（三个分支名，对我来说实在太多了），但由于我从很多人那里听说过，我想它一定有它的用途。提交有人提到他们对 Git 中的提交作为一词双义（既作为动词也作为名词）的用法感到困惑。例如：动词：“别忘了经常提交”名词：“main 分支上最新的提交”我觉得大多数人应该能很快适应这个双关的用法，但是在 SQL 数据库中的“提交”用法与 Git 是有所不同，我认为在 SQL 数据库中，“提交”只是作为一个动词（你使用 COMMIT 来结束一个事务），并不作为名词。此外，在 Git 中，你可以从以下三个不同的角度去考虑一个 Git 提交：表示当前每个文件状态的快照与父提交的差异记录所有先前提交的历史这些理解都是不错的：不同的命令在所有的这些情况下都会使用提交。例如，git show 将提交视为一个差异，git log 把提交看作是历史，git restore 则将提交理解为一个快照。然而，Git 的术语并无太多助于你理解一个给定的命令正在如何使用提交。更多令人困惑的术语以下是更多让人觉得混淆的术语。我对许多这些术语的意思并不十分清楚。我自己也不是很理解的东西：git pickaxe （也许这是 git log -S 和 git log -G，它们用于搜索以前提交的差异？）子模块（我知道的全部就是它们并不以我想要的方向工作）Git 稀疏检出中的 “cone mode” （没有任何关于这个的概念，但有人提到过）人们提及觉得混淆，但我在这篇已经 3000 字的文章中略过的东西：blob、tree“合并” 的方向“origin”、“upstream”，“downstream”push 和 pull 并不是对立面fetch 和 pull 的关系（pull = fetch + merge）git porcelain子树工作树暂存“master” 或者 “main” （听起来它在 Git 内部有特殊含义，但其实并没有）何时需要使用 origin main（如 git push origin main）vs origin/main人们提及感到困惑的 Github 术语：“ 拉取请求 pull request ” （与 Gitlab 中的 “ 合并请求 merge request ” 相比，人们似乎认为后者更清晰）“压扁并合并” 和 “变基并合并” 的作用 （在昨天我从未听说过 git merge --squash，我一直以为 “压扁并合并” 是 Github 的特殊功能）确实是 “每个 Git 术语”我惊讶地发现，几乎 Git 的每个其他核心特性都被至少一人提及为某种方式中的困惑。我对听到更多我错过的混淆的 Git 术语的例子也有兴趣。关于这个，有另一篇很棒的 2012 年的文章叫做《最困惑的 Git 术语》。它更多的讨论的是 Git 术语与 CVS 和 Subversion 术语的关联。如果我要选出我觉得最令人困惑的 3 个 Git 术语，我现在会选：head 是一个分支，HEAD 是当前分支“远程跟踪分支” 和 “跟踪远程的分支” 是不同的事物“索引”、“暂存的”、“已缓存的” 全部指的同一件事就这样了！在写这些的过程中，我学到了不少东西。我了解到了一些新的关于Git的事实，但更重要的是，现在我对于别人说Git的所有功能和特性都引起困惑有了更深的理解。许多问题我之前根本没考虑过，比如我从来没有意识到，在讨论分支时，“跟踪”这个词的用法是多么地特别。另外，尽管我已经尽力做到准确无误，但由于我涉猎到了一些我从未深入探讨过的Git的角落，所以可能还是出现了一些错误。（题图：DALL-E/A/e1e5b964-5f32-41bb-811e-8978fb8556d4）via: https://jvns.ca/blog/2023/11/01/confusing-git-terminology/作者：Julia Evans 选题：lujun9972 译者：ChatGPT 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
你好！我一直在投入写作一本关于 Git 的小册，因此我对 Git 分支投入了许多思考。我不断从他人那里听说他们觉得 Git 分支的操作方式违反直觉。这使我开始思考：直觉上的分支概念可能是什么样，以及它如何与 Git 的实际操作方式区别开来？在这篇文章中，我想简洁地讨论以下几点内容：我认为许多人可能有的一个直觉性的思维模型Git 如何在内部实现分支的表示（例如，“分支是对提交的指针”）这种“直觉模型”与实际操作方式之间的紧密关联直觉模型的某些局限性，以及为何它可能引发问题本文无任何突破性内容，我会尽量保持简洁。分支的直观模型当然，人们对分支有许多不同的直觉。我自己认为最符合“苹果树的一个分支”这一物理比喻的可能是下面这个。我猜想许多人可能会这样理解 Git 分支：在下图中，两个红色的提交就代表一个“分支”。我认为在这个示意图中有两点很重要：分支上有两个提交分支有一个“父级”（main），它是这个“父级”的分支虽然这个观点看似合理，但实际上它并不符合 Git 对于分支的定义 — 最重要的是，Git 并没有一个分支的“父级”的概念。那么，Git 又是如何定义分支的呢？在 Git 里，分支是完整的历史在 Git 中，一个分支是每个过去提交的完整历史记录，而不仅仅是那个“分支”提交。因此，在我们上述的示意图中，所有的分支（main 和 branch）都包含了 4 次提交。我创建了一个示例仓库，地址为：https://github.com/jvns/branch-example。它设置的分支方式与前图一样。现在，我们来看看这两个分支：main 分支包含了 4 次提交：$ git log --oneline main 70f727a d f654888 c 3997a46 b a74606f a mybranch 分支也有 4 次提交。最后两次提交在这两个分支里都存在。$ git log --oneline mybranch 13cb960 y 9554dab x 3997a46 b a74606f a 因此，mybranch 中的提交次数为 4，而不仅仅是 2 次“分支”提交，即 13cb960 和 9554dab。你可以用以下方式让 Git 绘制出这两个分支的所有提交：$ git log --all --oneline --graph * 70f727a (HEAD -> main, origin/main) d * f654888 c | * 13cb960 (origin/mybranch, mybranch) y | * 9554dab x |/ * 3997a46 b * a74606f a 分支以提交 ID 的形式存储在 Git 的内部，分支会以一种微小的文本文件的形式存储下来，其中包含了一个提交 ID。这就是我一开始提及到的“技术上正确”的定义。这个提交就是分支上最新的提交。我们来看一下示例仓库中 main 和 mybranch 的文本文件：$ cat .git/refs/heads/main 70f727acbe9ea3e3ed3092605721d2eda8ebb3f4 $ cat .git/refs/heads/mybranch 13cb960ad86c78bfa2a85de21cd54818105692bc 这很好理解：70f727 是 main 上的最新提交，而 13cb96 是 mybranch 上的最新提交。这样做的原因是，每个提交都包含一种指向其父级的指针，所以 Git 可以通过追踪这些指针链来找到分支上所有的提交。正如我前文所述，这里遗漏的一个重要因素是这两个分支间的任何关联关系。从这里能看出，mybranch 是 main 的一个分支——这一点并没有被表明出来。既然我们已经探讨了直观理解的分支概念是如何不成立的，我接下来想讨论的是，为何它在某些重要的方面又是如何成立的。人们的直观感觉通常并非全然错误我发现，告诉人们他们对 Git 的直觉理解是“错误的”的说法颇为流行。我觉得这样的说法有些可笑——总的来说，即使人们关于某个题目的直觉在某些方面在技术上不精确，但他们通常会有完全合理的理由来支持他们的直觉！即使是“不正确的”模型也可能极其有用。现在，我们来讨论三种情况，其中直觉上的“分支”概念与我们实际在操作中如何使用 Git 非常相符。变基操作使用的是“直观”的分支概念现在，让我们回到最初的图片。当你在 main 上对 mybranch 执行 变基 rebase 操作时，它将取出“直观”分支上的提交（只有两个红色的提交）然后将它们应用到 main 上。执行结果就是，只有两次提交（x 和 y）被复制。以下是相关操作的样子：$ git switch mybranch $ git rebase main $ git log --oneline mybranch 952fa64 (HEAD -> mybranch) y 7d50681 x 70f727a (origin/main, main) d f654888 c 3997a46 b a74606f a 在此，git rebase 创建了两个新的提交（952fa64 和 7d50681），这两个提交的信息来自之前的两个 x 和 y 提交。所以直觉上的模型并不完全错误！它很精确地告诉你在变基中发生了什么。但因为 Git 不知道 mybranch 是 main 的一个分叉，你需要显式地告诉它在何处进行变基。合并操作也使用了“直观”的分支概念合并操作并不复制提交，但它们确实需要一个“ 基础 base ”提交：合并的工作原理是查看两组更改（从共享基础开始），然后将它们合并。我们撤销刚才完成的变基操作，然后看看合并基础是什么。$ git switch mybranch $ git reset --hard 13cb960 # 撤销 rebase $ git merge-base main mybranch 3997a466c50d2618f10d435d36ef12d5c6f62f57 这里我们获得了分支分离出来的“基础”提交，也就是 3997a4。这正是你可能会基于我们的直观图片想到的提交。GitHub 的拉取请求也使用了直观的概念如果我们在 GitHub 上创建一个拉取请求，打算将 mybranch 合并到 main，这个请求会展示出两次提交：也就是 x 和 y。这完全符合我们的预期，也和我们对分支的直观认识相符。我想，如果你在 GitLab 上发起一个合并请求，那显示的内容应该会与此类似。直观理解颇为精准，但它有一定局限性这使我们的对分支直观定义看起来相当准确！这个“直观”的概念和合并、变基操作以及 GitHub 拉取请求的工作方式完全吻合。当你在进行合并、变基或创建拉取请求时，你需要明确指定另一个分支（如 git rebase main），因为 Git 不知道你的分支是基于哪个分支的。然而，关于分支的直观理解有一个比较严重的问题：你直觉上认为 main 分支和某个分离的分支有很大的区别，但 Git 并不清楚这点。所以，现在我们要来讨论一下 Git 分支的不同种类。主干和派生分支对于人类来说，main 和 mybranch 有着显著的区别，你可能针对如何使用它们，有着截然不同的意图。通常，我们会将某些分支视为“ 主干 trunk ”分支，同时将其他一些分支看作是“派生”。你甚至可能有派生的派生分支。当然，Git 自身并没有这样的区分（“派生”是我刚刚构造的术语！），但是分支的种类确实会影响你如何处理它。例如：你可能会想将 mybranch 变基到 main，但你大概不会想将 main 变基到 mybranch —— 那就太奇怪了！一般来说，人们在重写“主干”分支的历史时比短期存在的派生分支更为谨慎。Git 允许你进行“反向”的变基我认为人们经常对 Git 感到困惑的一点是 —— 由于 Git 并没有分支是否是另一个分支的“派生”的概念，它不会给你任何关于何时合适将分支 X 变基到分支 Y 的指引。这一切需要你自己去判断。例如，你可以执行以下命令：$ git checkout main $ git rebase mybranch 或者$ git checkout mybranch $ git rebase main Git 将会欣然允许你进行任一操作，尽管在这个案例中 git rebase main 是极其正常的，而 git rebase mybranch 则显得格外奇怪。许多人表示他们对此感到困惑，所以我提供了一个展示两种变基类型的图片以供参考：相似地，你可以进行“反向”的合并，尽管这相较于反向变基要正常得多——将 mybranch 合并到 main 和将 main 合并到 mybranch 都有各自的益处。下面是一个展示你可以进行的两种合并方式的示意图：Git 对于分支之间缺乏层次结构感觉有些奇怪我经常听到 “main 分支没什么特别的” 的表述，而这令我感到困惑——对于我来说，我处理的大部分仓库里，main 无疑是非常特别的！那么人们为何会称其为不特别呢？我觉得，重点在于：尽管分支确实存在彼此间的关系（main 通常是非常特别的！），但 Git 并不知情这些关系。每当你执行如 git rebase 或 git merge 这样的 git 命令时，你都必须明确地告诉 Git 分支间的关系，如果你出错，结果可能会相当混乱。我不知道 Git 在此方面的设计究竟“对”还是“错”（无疑它有利有弊，而我已对无休止的争论感到厌倦），但我认为，这对于许多人来说，原因在于它有些出人意料。Git 关于分支的用户界面也同样怪异假设你只想查看某个分支上的“派生”提交，正如我们之前讨论的，这是完全正常的需求。下面是用 git log 查看我们分支上的两次派生提交的方法：$ git switch mybranch $ git log main..mybranch --oneline 13cb960 (HEAD -> mybranch, origin/mybranch) y 9554dab x 你可以用 git diff 这样查看同样两次提交的合并差异：$ git diff main...mybranch 因此，如果你想使用 git log 查看 x 和 y 这两次提交，你需要用到两个点（..），但查看同样的提交使用 git diff，你却需要用到三个点（...）。我个人从来都记不住 .. 和 ... 的具体用意，所以我通常虽然它们在原则上可能很有用，但我选择尽量避免使用它们。在 GitHub 上，默认分支具有特殊性同样值得一提的是，在 GitHub 上存在一种“特殊的分支”：每一个 GitHub 仓库都有一个“默认分支”（在 Git 术语中，就是 HEAD 所指向的地方），具有以下的特别之处：初次克隆仓库时，默认会检出这个分支它作为拉取请求的默认接收分支GitHub 建议应该保护这个默认分支，防止被强制推送，等等。很可能还有许多我未曾想到的场景。总结这些说法在回顾时看似是显而易见的，但实际上我花费了大量时间去搞清楚一个更“直观”的分支概念，这是因为我已经习惯了技术性的定义，“分支是对某次提交的引用”。同样，我也没有真正去思索过如何在每次执行 git rebase 或 git merge 命令时，让 Git 明确理解你分支之间的层次关系——对我而言，这已经成为第二天性，并没有觉得有何困扰。但当我反思这个问题时，可以明显看出，这很容易导致某些人混淆。（题图：MJ/a5a52832-fac8-4190-b3bd-fec70166aa16）via: https://jvns.ca/blog/2023/11/23/branches-intuition-reality/作者：Julia Evans 选题：lujun9972 译者：ChatGPT 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
Git 的遴选和撤销操作是如何利用三路合并的
大家好！几天前，我尝试向其他人解释 Git 遴选（git cherry-pick）的工作原理，结果发现自己反而更混淆了。我原先以为 Git 遴选是简单地应用一个补丁，但当我真正这样尝试时，却未能成功！因此，接下来我们将谈论我原来以为的遴选操作（即应用一个补丁），这个理解为何不准确，以及实际上它是如何执行的（进行“三路合并”）。尽管本文的内容有些深入，但你并不需要全部理解才能有效地使用 Git。不过，如果你（和我一样）对 Git 的内部运作感到好奇，那就跟我一起深入探讨一下吧！遴选操作并不只是应用一个补丁我先前理解的 git cherry-pick COMMIT_ID 的步骤如下：首先是计算 COMMIT_ID 的差异，就如同执行 git show COMMIT_ID --patch > out.patch 这个命令然后是将补丁应用到当前分支，就如同执行 git apply out.patch 这个命令在我们详细讨论之前，我想指出的是，虽然大部分情况下这个模型是正确的，如果这是你的认知模型，那就没有问题。但是在一些细微的地方，它可能会错，我觉得这个疑惑挺有意思的，所以我们来看看它究竟是如何运作的。如果我在存在合并冲突的情况下尝试进行“计算差异并应用补丁”的操作，下面我们就看看具体会发生什么情况：$ git show 10e96e46 --patch > out.patch $ git apply out.patch error: patch failed: content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown:17 error: content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown: patch does not apply 这一过程无法成功完成，它并未提供任何解决冲突或处理问题的方案。而真正运行 git cherry-pick 时的实际情况却大为不同，我遭遇到了一处合并冲突:$ git cherry-pick 10e96e46 error: could not apply 10e96e46... wip hint: After resolving the conflicts, mark them with hint: "git add/rm <pathspec>", then run hint: "git cherry-pick --continue". 因此，看起来 “Git 正在应用一个补丁”这样的理解方式并不十分准确。但这里的错误信息确实标明了 “无法应用 10e96e46”，这么看来，这种理解又不完全是错的。这到底是怎么回事呢？那么，遴选到底是怎么执行的呢？我深入研究了 Git 的源代码，主要是想了解 cherry-pick 是如何工作的，最终我找到了 这一行代码：res = do_recursive_merge(r, base, next, base_label, next_label, &head, &msgbuf, opts); 所以，遴选实际上就是一种……合并操作？这有些出乎意料。那具体都合并了什么内容？如何执行这个合并操作的呢？我意识到我对 Git 的合并操作并不是特别理解，于是我上网搜索了一下。结果发现 Git 实际上采用了一种被称为 “三路合并” 的合并方式。那这到底是什么含义呢？Git 的合并策略：三路合并假设我要合并下面两个文件，我们将其分别命名为 v1.py 和 v2.py。def greet(): greeting = "hello" name = "julia" return greeting + " " + name def say_hello(): greeting = "hello" name = "aanya" return greeting + " " + name 在这两个文件间，存在两处不同：def greet() 和 def say_helloname = "julia" 和 name = "aanya"我们应该选择哪个呢？看起来好像不可能有答案！不过，如果我告诉你，原始的函数（我们称之为 base.py）是这样的：def say_hello(): greeting = "hello" name = "julia" return greeting + " " + name 一切似乎变得清晰许多！在这个基础上，v1 将函数的名字更改为 greet，v2 将 name = "aanya"。因此，合并时，我们应该同时做出这两处改变：def greet(): greeting = "hello" name = "aanya" return greeting + " " + name 我们可以命令 Git 使用 git merge-file 来完成这次合并，结果正是我们预期的：它选择了 def greet() 和 name = "aanya"。$ git merge-file v1.py base.py v2.py -p def greet(): greeting = "hello" name = "aanya" return greeting + " " + name⏎ 这种将两个文件与其原始版本进行合并的方式，被称为 三路合并。如果你想在线上试一试，我在 jvns.ca/3-way-merge/ 创建了一个小实验场。不过我只是草草制作，所以可能对移动端并不友好。Git 合并的是更改，而非文件我对三路合并的理解是 —— Git 合并的是更改，而不是文件。我们对同一个文件做出两种不同的更改，Git 试图以合理的方式将这两种更改结合到一起。当两个更改都对同一行进行操作时，Git 可能会遇到困难，此时就会产生合并冲突。Git 也可以合并超过两处的更改：你可以对同一文件有多达 8 处不同的更改，Git 会尝试将所有更改协调一致。这被称为八爪鱼合并，但除此之外我对其并不了解，因为我从未执行过这样的操作。Git 如何使用三路合并来应用补丁接下来，让我们进入到一个有些出乎意料的情境！当我们讨论 Git “应用补丁”（如在变基 —— rebase、撤销 —— revert 或遴选 —— cherry-pick 中所做的）时，其实并非是生成一个补丁文件并应用它。相反，实际执行的是一次三路合并。下面是如何将提交 X 作为补丁应用到你当前的提交，并与之前的 v1、v2 和 base 设置相对应：在你当前提交中，文件的版本是 v1。在提交 X 之前，文件的版本是 base。在提交 X 中，文件的版本是 v2。执行 git merge-file v1 base v2 以合并它们（实际上，Git 并不直接执行 git merge-file，而是运行一个实现这个功能的 C 函数）。总的来说，你可以将 base 和 v2 视为“补丁”，它们之间的差异就是你想要应用到 v1 上的更改。遴选如何运作假设我们有如下提交图，并且我们打算在 main 分支上遴选提交 Y：A - B (main) \ \ X - Y - Z 那么，如何将此情景转化为我们前面提过的 v1、v2 和 base 组成的三路合并呢？B 是 v1X 是 base，而 Y 是 v2所以，X 和 Y 共同构成了这个“补丁”。其实，git rebase 无非就是重复多次执行 git cherry-pick 的过程。撤销如何运作现在，假如我们希望在如下的提交图上执行 git revert Y：X - Y - Z - A - B B 是 v1Y 是 base，而 X 是 v2这个过程反映的实际上就是遴选的情况，不过 X 和 Y 的位置颠倒了。我们需要这样做因为我们期望生成一个“反向补丁”。在 Git 中，撤销和遴选关系如此的紧密，它们甚至在同一个文件中实现：revert.c。“三路补丁”是一个非常棒的技巧使用三路合并将提交作为补丁应用的这个技巧非常巧妙且酷炫，我很惊讶之前从未听说过！我并未听过一个特定的名字来描述这种方法，但我更倾向于称之为“三路补丁”。“三路补丁”的理念在于，你可以通过两个文件来定义补丁：在应用补丁前后的文件（在我们这篇文章中称之为 base 和 v2）。因此，总体来看有三个文件被涉及到：一个是原文件，另外两个构成了补丁。最重要的是，与普通补丁相比，三路补丁是一个更加高效的补丁方案，因为在有两个完整文件的情况下，你拥有更丰富的上下文信息来进行合并。以下是我们例子中的常规补丁的大致情况：@@ -1,1 +1,1 @@: - def greet(): + def say_hello(): greeting = "hello" 而下面这就是一个三路补丁。不过，需要提醒的是这个“三路补丁”并不是一个真正的文件格式，这只是我自己提出的一种概念。BEFORE: (the full file) def greet(): greeting = "hello" name = "julia" return greeting + " " + name AFTER: (the full file) def say_hello(): greeting = "hello" name = "julia" return greeting + " " + name 《Building Git》 中提到了这点James Coglan 的书籍 《Building Git》 是我在 Git 源码之外唯一找到的地方，他解释了 git cherry-pick 是如何在底层运用三路合并的（我原以为《Pro Git》可能会提及这个，但我并没能找到此话题的内容）。我购买完这本书后发现，我早在 2019 年时就已经买过了，这对我来说真的是个很好的参考。Git 中的合并实际上比这更复杂在 Git 中，合并不限于三路合并 —— 还有一种我不太理解的叫做“递归合并”，还有许多具体处理文件删除和移动的细节，同时也有多种合并算法。如果想要了解更多相关知识，我最好的建议是阅读《Building Git》，尽管我还未完全阅读这本书。Git 应用到底做了什么？我也参阅了 Git 的源代码，试图理解 git apply 的功能。它似乎（不出意外地）在 apply.c 中实现。这段代码解析了一个补丁文件，并通入目标文件来寻找应该在何处应用补丁。核心逻辑似乎在 这里：思路好像是从补丁建议的行数开始，然后向前向后找寻。 /* * There's probably some smart way to do this, but I'll leave * that to the smart and beautiful people. I'm simple and stupid. */ backwards = current; backwards_lno = line; forwards = current; forwards_lno = line; current_lno = line; for (i = 0; ; i++) { ... 这个处理过程不禁让人觉得非常直白、与之前的期望相符。Git 三路应用的工作方式git apply 命令中也有一个 --3way 参数，可以实现三路合并。因此，我们实际上可以通过如下方式，使用 git apply 来大体实现 git cherry-pick 的功能：$ git show 10e96e46 --patch > out.patch $ git apply out.patch --3way Applied patch to 'content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown' with conflicts. U content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown 但要注意，参数 --3way 并不只用到了补丁文件的内容！补丁文件开始的部分是:index d63ade04..65778fc0 100644 d63ade04 和 65778fc0 是旧/新文件版本在 Git 对象数据库中的 ID，因此 Git 可以用这些 ID 来执行三路补丁操作。但如果有人将补丁文件通过邮件发送给你，而你并没有新/旧版本的文件，就无法执行这个操作：如果你缺少 blob，将会出现如下错误：$ git apply out.patch error: repository lacks the necessary blob to perform 3-way merge. 三路合并有点历史了有一部分人指出，三路合并比 Git 的历史还要久远，它起源于 70 年代末期左右。有一篇 2007 年的 论文 对此进行了讨论。就说这么多！我真的对于我对于 Git 内部应用补丁的核心方法其实理解得并不深入这一点感到非常吃惊——学习这一点真的很酷！虽然我对 Git 用户界面存在 诸多不满，但是这个特定问题并不包含在内。三路合并似乎是统一解决一系列不同问题的优雅方式，它对于人们来说也很直观（“应用一个补丁”这个想法是许多编程者都习以为常的思考模式，而它底层实现为三路合并的细节，实际上没有人真正需要去思考）。我顺便快速推荐一下：我正在写一部有关 Git 的 zine，如果你对它的发布感兴趣，你可以注册我非常不频繁的 公告邮件列表。（题图：MJ/321bc2c9-4363-4661-802a-c74fb6a721b2）via: https://jvns.ca/blog/2023/11/10/how-cherry-pick-and-revert-work/作者：Julia Evans 选题：lujun9972 译者：ChatGPT 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
Linux 系统也将出现 “蓝屏死机”几乎所有主流的 Linux 系统都在使用 systemd 作为启动、初始化和服务管理等系统。systemd 在不断增加各种功能，吞噬各种 Linux 系统功能。而即将发布的 systemd 255 甚至引入了 “蓝屏死机” 功能，用于在 Linux 启动失败时显示重要的错误信息 —— 这不是愚人节恶作剧。消息来源：Phoronix老王点评：果然，这就是 systemd 之父 Lennart Poettering 被微软雇佣后的结果。OpenAI 推出 GPT-4 Turbo 和 AI 应用商店OpenAI 在其首届开发者大会上发布了 GPT-4 Turbo，该公司称其不仅更强大，而且成本更低。GPT-4 Turbo 有两个版本：一个是纯文本分析版本，另一个是可以同时理解文本和图像上下文的版本。GPT-4 Turbo 提供 128k 个令牌的上下文窗口，相当于约 100,000 个单词或 300 页书籍，这个大小是 GPT-4 的四倍，也是所有商用模型中最大的上下文窗口。此外，OpenAI 还 宣布 推出 AI 应用商店，任何人都可以创建这种被称作 “GPT” 的对话式人工智能系统，然后你可以将其上架到 AI 应用商店。OpenAI 称，没有编码经验也可以制作 GPT，只需与 ChatGPT 聊天并描述你想要的东西，就可以制作一个 GPT。消息来源：Tech Crunch老王点评：这个 AI 时代，我们却难以接触最前沿的技术进步。Firefox 的开发将完全转到 GitFirefox 的开发工作一直都在同时使用两个版本控制系统 Mercurial 和 Git。但这对 Mozilla 来说是一个 “沉重的负担”，因此它决定将开发工作完全转移到 Git 上。Mozilla 预计至少需要 6 个月才能开始全面过渡到 Git。此外，Mozilla 将继续使用 Bugzilla 等工具。不过，虽然该项目将会托管在 GitHub，但短时间内不会接受拉取请求，贡献工作流维持不变。消息来源：Phoronix老王点评：还有其它版本控制系统吗？感觉现在 Git 已经将其它版本控制系统都挤压得没有空间了。这应该不是好事。
我一直在慢慢地撰写关于 Git 工作原理的文章。尽管我曾认为自己对 Git 非常了解，但像往常一样，当我尝试解释某事的时候，我又学到一些新东西。现在回想起来，这些事情都不算太令人吃惊，但我以前并没有清楚地思考过它们。事实是:“索引”、“暂存区” 和 -cached 是一回事隐匿文件就是一堆提交并非所有引用都是分支或标签合并提交不是空的下面我们来详细了解这些内容。“索引”、“暂存区” 和 -cached 是一回事当你运行 git add file.txt，然后运行 git status，你会看到类似以下的输出：$ git add content/post/2023-10-20-some-miscellaneous-git-facts.markdown $ git status Changes to be committed: (use "git restore --staged <file>..." to unstage) new file: content/post/2023-10-20-some-miscellaneous-git-facts.markdown 人们通常称这个过程为“暂存文件”或“将文件添加到暂存区”。当你使用 git add 命令来暂存文件时，Git 在后台将文件添加到其对象数据库（在 .git/objects 目录下），并更新一个名为 .git/index 的文件以引用新添加的文件。Git 中的这个“暂存区”事实上有 3 种不同的名称，但它们都指的是同一个东西（即 .git/index 文件）：git diff --cachedgit diff --staged.git/index 文件我觉得我早该早点认识到这一点，但我之前并没有，所以在这里提醒一下。隐匿文件就是一堆提交当我运行 git stash 命令来保存更改时，我一直对这些更改究竟去了哪里感到有些困惑。事实上，当你运行 git stash 命令时，Git 会根据你的更改创建一些提交，并用一个名为 stash 的引用来标记它们（在 .git/refs/stash 目录下）。让我们将此博客文章隐匿起来，然后查看 stash 引用的日志：$ git log stash --oneline 6cb983fe (refs/stash) WIP on main: c6ee55ed wip 2ff2c273 index on main: c6ee55ed wip ... some more stuff 现在我们可以查看提交 2ff2c273 以查看其包含的内容：$ git show 2ff2c273 --stat commit 2ff2c273357c94a0087104f776a8dd28ee467769 Author: Julia Evans <julia@jvns.ca> Date: Fri Oct 20 14:49:20 2023 -0400 index on main: c6ee55ed wip content/post/2023-10-20-some-miscellaneous-git-facts.markdown | 40 ++++++++++++++++++++++++++++++++++++++++ 毫不意外，它包含了这篇博客文章。这很合理！实际上，git stash 会创建两个独立的提交：一个是索引提交，另一个是你尚未暂存的改动提交。这让我感到很振奋，因为我一直在开发一款工具，用于快照和恢复 Git 仓库的状态（也许永远不会发布），而我提出的设计与 Git 的隐匿实现非常相似，所以我对自己的选择感到满意。显然 stash 中的旧提交存储在 reflog 中。并非所有引用都是分支或标签Git 文档中经常泛泛地提到 “引用”，这使得我有时觉得很困惑。就个人而言，我在 Git 中处理 “引用” 的 99% 时间是指分支或 HEAD，而剩下的 1% 时间是指标签。事实上，我以前完全不知道任何不是分支、标签或 HEAD 的引用示例。但现在我知道了一个例子—— stash 是一种引用，而它既不是分支也不是标签！所以这太酷啦！以下是我博客的 Git 仓库中的所有引用（除了 HEAD）：$ find .git/refs -type f .git/refs/heads/main .git/refs/remotes/origin/HEAD .git/refs/remotes/origin/main .git/refs/stash 人们在本帖回复中提到的其他一些参考资料：refs/notes/*，来自 git notesrefs/pull/123/head 和 refs/pull/123/head` 用于 GitHub 拉取请求（可通过 git fetch origin refs/pull/123/merge 获取）refs/bisect/*，来自 git bisect合并提交不是空的这是一个示例 Git 仓库，其中我创建了两个分支 x 和 y，每个分支都有一个文件（x.txt 和 y.txt），然后将它们合并。让我们看看合并提交。$ git log --oneline 96a8afb (HEAD -> y) Merge branch 'x' into y 0931e45 y 1d8bd2d (x) x 如果我运行 git show 96a8afb，合并提交看起来是“空的”：没有差异！git show 96a8afb commit 96a8afbf776c2cebccf8ec0dba7c6c765ea5d987 (HEAD -> y) Merge: 0931e45 1d8bd2d Author: Julia Evans <julia@jvns.ca> Date: Fri Oct 20 14:07:00 2023 -0400 Merge branch 'x' into y 但是，如果我单独比较合并提交与其两个父提交之间的差异，你会发现当然有差异：$ git diff 0931e45 96a8afb --stat x.txt | 1 + 1 file changed, 1 insertion(+) $ git diff 1d8bd2d 96a8afb --stat y.txt | 1 + 1 file changed, 1 insertion(+) 现在回想起来，合并提交并不是实际上“空的”（它们是仓库当前状态的快照，就像任何其他提交一样），这一点似乎很明显，只是我以前从未思考为什么它们看起来为空。显然，这些合并差异为空的原因是合并差异只显示冲突 —— 如果我创建一个带有合并冲突的仓库（一个分支在同一文件中添加了 x，而另一个分支添加了 y），然后查看我解决冲突的合并提交，它看起来会像这样：$ git show HEAD commit 3bfe8311afa4da867426c0bf6343420217486594 Merge: 782b3d5 ac7046d Author: Julia Evans <julia@jvns.ca> Date: Fri Oct 20 15:29:06 2023 -0400 Merge branch 'x' into y diff --cc file.txt index 975fbec,587be6b..b680253 --- a/file.txt +++ b/file.txt @@@ -1,1 -1,1 +1,1 @@@ - y -x ++z 这似乎是在告诉我，一个分支添加了 x，另一个分支添加了 y，合并提交通过将 z 替代冲突解决了它。但在前面的示例中，没有冲突，所以 Git 并未显示任何差异。（感谢 Jordi 告诉我合并差异的工作原理）先这样吧些写到这里吧，也许我将在学到更多 Git 知识时撰写另一篇关于 Git 的知识的博客文章。（题图：MJ/03bfecc3-944e-47a0-a4fd-575293d2ba92）via: https://jvns.ca/blog/2023/10/20/some-miscellaneous-git-facts/作者：Julia Evans 选题：lujun9972 译者：KaguyaQiang 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
大家好！今天我和一个朋友讨论 Git 的工作原理，我们感到奇怪，Git 是如何存储你的文件的？我们知道它存储在 .git 目录中，但具体到 .git 中的哪个位置，各个版本的历史文件又被存储在哪里呢？以这个博客为例，其文件存储在一个 Git 仓库中，其中有一个文件名为 content/post/2019-06-28-brag-doc.markdown。这个文件在我的 .git 文件夹中具体的位置在哪里？过去的文件版本又被存储在哪里？那么，就让我们通过编写一些简短的 Python 代码来探寻答案吧。Git 把文件存储在 .git/objects 之中你的仓库中，每一个文件的历史版本都被储存在 .git/objects 中。比如，对于这个博客，.git/objects 包含了 2700 多个文件。$ find .git/objects/ -type f | wc -l 2761 注意：.git/objects 包含的信息，不仅仅是 “仓库中每一个文件的所有先前版本”，但我们暂不详细讨论这一内容。这里是一个简短的 Python 程序（find-git-object.py），它可以帮助我们定位在 .git/objects 中的特定文件的具体位置。import hashlib import sys def object_path(content): header = f"blob {len(content)}\0" data = header.encode() + content sha1 = hashlib.sha1() sha1.update(data) digest = sha1.hexdigest() return f".git/objects/{digest[:2]}/{digest[2:]}" with open(sys.argv[1], "rb") as f: print(object_path(f.read())) 此程序的主要操作如下：读取文件内容计算一个头部（blob 16673\0），并将其与文件内容合并计算出文件的 sha1 校验和（此处为 e33121a9af82dd99d6d706d037204251d41d54）将这个 sha1 校验和转换为路径（如 .git/objects/e3/3121a9af82dd99d6d706d037204251d41d54）运行的方法如下：$ python3 find-git-object.py content/post/2019-06-28-brag-doc.markdown .git/objects/8a/e33121a9af82dd99d6d706d037204251d41d54 术语解释：“内容寻址存储”这种存储策略的术语为“ 内容寻址存储 content addressed storage ”，它指的是对象在数据库中的文件名与文件内容的哈希值相同。内容寻址存储的有趣之处就是，假设我有两份或许多份内容完全相同的文件，在 Git 的数据库中，并不会因此占用额外空间。如果内容的哈希值是 aabbbbbbbbbbbbbbbbbbbbbbbbb，它们都会被存储在 .git/objects/aa/bbbbbbbbbbbbbbbbbbbbb 中。这些对象是如何进行编码的？如果我尝试在 .git/objects 目录下查看这个文件，显示的内容似乎有一些奇怪：$ cat .git/objects/8a/e33121a9af82dd99d6d706d037204251d41d54 x^A<8D><9B>}s<E3>Ƒ<C6><EF>o|<8A>^Q<9D><EC>ju<92><E8> <9C><9C>*<89>j<FD>^... 这是怎么回事呢？让我们来运行 file 命令检查一下：$ file .git/objects/8a/e33121a9af82dd99d6d706d037204251d41d54 .git/objects/8a/e33121a9af82dd99d6d706d037204251d41d54: zlib compressed data 原来，它是压缩的！我们可以编写一个小巧的 Python 程序—— decompress.py，然后用 zlib 模块去解压这些数据：import zlib import sys with open(sys.argv[1], "rb") as f: content = f.read() print(zlib.decompress(content).decode()) 让我们来解压一下看看结果：$ python3 decompress.py .git/objects/8a/e33121a9af82dd99d6d706d037204251d41d54 blob 16673--- title: "Get your work recognized: write a brag document" date: 2019-06-28T18:46:02Z url: /blog/brag-documents/ categories: [] --- ... the entire blog post ... 结果显示，这些数据的编码方式非常简单：首先有 blob 16673\0 标识，其后就是文件的全部内容。这里并没有差异性数据（diff）这里有一件我第一次知道时让我感到惊讶的事：这里并没有任何差异性数据！那个文件是该篇博客文章的第 9 个版本，但 Git 在 .git/objects 目录中存储的版本是完整文件内容，而并非与前一版本的差异。尽管 Git 实际上有时候会以差异性数据存储文件（例如，当你运行 git gc 时，为了提升效率，它可能会将多个不同的文件封装成 “打包文件”），但在我个人经验中，我从未需要关注这个细节，所以我们不在此深入讨论。然而，关于这种格式如何工作，Aditya Mukerjee 有篇优秀的文章 《拆解 Git 的打包文件》。博客文章的旧版本在哪？你可能会好奇：如果在我修复了一些错别字之前，这篇博文已经存在了 8 个版本，那它们在 .git/objects 目录中的位置是哪里？我们如何找到它们呢？首先，我们来使用 git log 命令来查找改动过这个文件的每一个提交：$ git log --oneline content/post/2019-06-28-brag-doc.markdown c6d4db2d 423cd76a 7e91d7d0 f105905a b6d23643 998a46dd 67a26b04 d9999f17 026c0f52 72442b67 然后，我们选择一个之前的提交，比如 026c0f52。提交也被存储在 .git/objects 中，我们可以尝试在那里找到它。但是失败了！因为 ls .git/objects/02/6c* 没有显示任何内容！如果有人告诉你，“我们知道有时 Git 会打包对象来节省空间，我们并不需过多关心它”，但现在，我们需要去面对这个问题了。那就让我们去解决它吧。让我们开始解包一些对象现在我们需要从打包文件中解包出一些对象。我在 Stack Overflow 上查找了一下，看起来我们可以这样进行操作：$ mv .git/objects/pack/pack-adeb3c14576443e593a3161e7e1b202faba73f54.pack . $ git unpack-objects < pack-adeb3c14576443e593a3161e7e1b202faba73f54.pack 这种直接对库进行手术式的做法让人有些紧张，但如果我误操作了，我还可以从 Github 上重新克隆这个库，所以我并不太担心。解包所有的对象文件后，我们得到了更多的对象：大约有 20000 个，而不是原来的大约 2700 个。看起来很酷。find .git/objects/ -type f | wc -l 20138 我们回头再看看提交现在我们可以继续看看我们的提交 026c0f52。我们之前说过 .git/objects 中并不都是文件，其中一部分是提交！为了弄清楚我们的旧文章 content/post/2019-06-28-brag-doc.markdown 是在哪里被保存的，我们需要深入查看这个提交。首先，我们需要在 .git/objects 中查看这个提交。查看提交的第一步：找到提交经过解包后，我们现在可以在 .git/objects/02/6c0f5208c5ea10608afc9252c4a56c1ac1d7e4 中找到提交 026c0f52，我们可以用下面的方法去查看它：$ python3 decompress.py .git/objects/02/6c0f5208c5ea10608afc9252c4a56c1ac1d7e4 commit 211tree 01832a9109ab738dac78ee4e95024c74b9b71c27 parent 72442b67590ae1fcbfe05883a351d822454e3826 author Julia Evans <julia@jvns.ca> 1561998673 -0400 committer Julia Evans <julia@jvns.ca> 1561998673 -0400 brag doc 我们也可以用 git cat-file -p 026c0f52 命令来获取相同的信息，这个命令能起到相同的作用，但是它在格式化数据时做得更好一些。（-p 选项意味着它能够以更友好的方式进行格式化）查看提交的第二步：找到树这个提交包含一个树。树是什么呢？让我们看一下。树的 ID 是 01832a9109ab738dac78ee4e95024c74b9b71c27，我们可以使用先前的 decompress.py 脚本查看这个 Git 对象，尽管我不得不移除 .decode() 才能避免脚本崩溃。$ python3 decompress.py .git/objects/01/832a9109ab738dac78ee4e95024c74b9b71c27 这个输出的格式有些难以阅读。主要的问题在于，该提交的哈希（\xc3\xf7$8\x9b\x8dO\x19/\x18\xb7}|\xc7\xce\x8e…）是原始字节，而没有进行十六进制的编码，因此我们看到 \xc3\xf7$8\x9b\x8d 而非 c3f76024389b8d。我打算切换至 git cat-file -p 命令，它能以更友好的方式显示数据，我不想自己编写一个解析器。$ git cat-file -p 01832a9109ab738dac78ee4e95024c74b9b71c27 100644 blob c3f76024389b8d4f192f18b77d7cc7ce8e3a68ad .gitignore 100644 blob 7ebaecb311a05e1ca9a43f1eb90f1c6647960bc1 README.md 100644 blob 0f21dc9bf1a73afc89634bac586271384e24b2c9 Rakefile 100644 blob 00b9d54abd71119737d33ee5d29d81ebdcea5a37 config.yaml 040000 tree 61ad34108a327a163cdd66fa1a86342dcef4518e content <-- 这是我们接下来的目标 040000 tree 6d8543e9eeba67748ded7b5f88b781016200db6f layouts 100644 blob 22a321a88157293c81e4ddcfef4844c6c698c26f mystery.rb 040000 tree 8157dc84a37fca4cb13e1257f37a7dd35cfe391e scripts 040000 tree 84fe9c4cb9cef83e78e90a7fbf33a9a799d7be60 static 040000 tree 34fd3aa2625ba784bced4a95db6154806ae1d9ee themes 这是我在这次提交时库的根目录中所有的文件。看起来我曾经不小心提交了一个名为 mystery.rb 的文件，后来我删除了它。我们的文件在 content 目录中，接下来让我们看看那个树：61ad34108a327a163cdd66fa1a86342dcef4518e查看提交的第三步：又一棵树$ git cat-file -p 61ad34108a327a163cdd66fa1a86342dcef4518e 040000 tree 1168078878f9d500ea4e7462a9cd29cbdf4f9a56 about 100644 blob e06d03f28d58982a5b8282a61c4d3cd5ca793005 newsletter.markdown 040000 tree 1f94b8103ca9b6714614614ed79254feb1d9676c post <-- 我们接下来的目标！ 100644 blob 2d7d22581e64ef9077455d834d18c209a8f05302 profiler-project.markdown 040000 tree 06bd3cee1ed46cf403d9d5a201232af5697527bb projects 040000 tree 65e9357973f0cc60bedaa511489a9c2eeab73c29 talks 040000 tree 8a9d561d536b955209def58f5255fc7fe9523efd zines 还未结束……查看提交的第四步：更多的树……我们要寻找的文件位于 post/ 目录，因此我们需要进一步探索：$ git cat-file -p 1f94b8103ca9b6714614614ed79254feb1d9676c .... 省略了大量行 ... 100644 blob 170da7b0e607c4fd6fb4e921d76307397ab89c1e 2019-02-17-organizing-this-blog-into-categories.markdown 100644 blob 7d4f27e9804e3dc80ab3a3912b4f1c890c4d2432 2019-03-15-new-zine--bite-size-networking-.markdown 100644 blob 0d1b9fbc7896e47da6166e9386347f9ff58856aa 2019-03-26-what-are-monoidal-categories.markdown 100644 blob d6949755c3dadbc6fcbdd20cc0d919809d754e56 2019-06-23-a-few-debugging-resources.markdown 100644 blob 3105bdd067f7db16436d2ea85463755c8a772046 2019-06-28-brag-doc.markdown <-- 我们找到了！！！ 在此，2019-06-28-brag-doc.markdown 之所以位于列表最后，是因为在发布时它是最新的博文。查看提交的第五步：我们终于找到它！经过努力，我们找到了博文历史版本所在的对象文件！太棒了！它的哈希值是 3105bdd067f7db16436d2ea85463755c8a772046，因此它位于 git/objects/31/05bdd067f7db16436d2ea85463755c8a772046。我们可以使用 decompress.py 来查看它：$ python3 decompress.py .git/objects/31/05bdd067f7db16436d2ea85463755c8a772046 | head blob 15924--- title: "Get your work recognized: write a brag document" date: 2019-06-28T18:46:02Z url: /blog/brag-documents/ categories: [] --- ... 文件的剩余部分在此 ... 这就是博文的旧版本！如果我执行命令 git checkout 026c0f52 content/post/2019-06-28-brag-doc.markdown 或者 git restore --source 026c0f52 content/post/2019-06-28-brag-doc.markdown，我就会获取到这个版本。这样遍历树就是 git log 的运行机制我们刚刚经历的整个过程（找到提交、逐层遍历目录树、搜索所需文件名）看似繁琐，但实际上当我们执行 git log content/post/2019-06-28-brag-doc.markdown 时，背后就是这样在运行。它需要逐个检查你历史记录中的每一个提交，在每个提交中核查 content/post/2019-06-28-brag-doc.markdown 的版本（例如在这个案例中为 3105bdd067f7db16436d2ea85463755c8a772046），并查看它是否自上一提交以来有所改变。这就是为什么有时 git log FILENAME 会执行的有些缓慢 —— 我的这个仓库中有 3000 个提交，它需要对每个提交做大量的工作，来判断该文件是否在该提交中发生过变化。我有多少个历史版本的文件？目前，我在我的博客仓库中跟踪了 1530 个文件：$ git ls-files | wc -l 1530 但历史文件有多少呢？我们可以列出 .git/objects 中所有的内容，看看有多少对象文件：$ find .git/objects/ -type f | grep -v pack | awk -F/ '{print $3 $4}' | wc -l 20135 但并不是所有这些都代表过去版本的文件 —— 正如我们之前所见，许多都是提交和目录树。不过，我们可以编写一个小小的 Python 脚本 find-blobs.py，遍历所有对象并检查是否以 blob 开头：import zlib import sys for line in sys.stdin: line = line.strip() filename = f".git/objects/{line[0:2]}/{line[2:]}" with open(filename, "rb") as f: contents = zlib.decompress(f.read()) if contents.startswith(b"blob"): print(line) $ find .git/objects/ -type f | grep -v pack | awk -F/ '{print $3 $4}' | python3 find-blobs.py | wc -l 6713 于是，看起来在我的 Git 仓库中存放的旧文件版本有 6713 - 1530 = 5183 个，Git 会为我保存这些文件，以备我想着要恢复它们时使用。太好了！就这些啦！在 这个 gist 中附上了全部的此篇文章所用代码，其实没多少。我以为我已经对 Git 的工作方式了如指掌，但我以前从未真正涉及过打包文件，所以这次探索很有趣。我也很少思考当我让 git log 跟踪一个文件的历史时，它实际上有多大的工作量，因此也很开心能深入研究这个。作为一个有趣的后续：我提交这篇博文后，Git 就警告我仓库中的对象太多（我猜 20,000 太多了！），并运行 git gc 将它们全部压缩成打包文件。所以现在我的 .git/objects 目录已经被压缩得十分小了：$ find .git/objects/ -type f | wc -l 14 （题图：MJ/319a396c-6f3f-4891-b051-261312c8ea9a）via: https://jvns.ca/blog/2023/09/14/in-a-git-repository--where-do-your-files-live-/作者：Julia Evans 选题：lujun9972 译者：ChatGPT 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
跟随这个演示来了解我如何使用 Git 为 Foreman 编写文档。作为 ATIX 的技术作家，我的任务包括为 Foreman 创建和维护存放在 github.com/theforeman/foreman-documentation 的文档。Git 帮助我跟踪内容的版本，并与开源社区进行协作。它是我存储工作成果、共享和讨论改进的重要工具。我主要使用的工具包括浏览器、用 OpenSSH 连接 Foreman 实例、用 Vim 编辑源文件，以及使用 Git 进行版本控制。本文重点介绍在开始使用 Git 和为 Foreman 文档做贡献时经常遇到的挑战。适用于中级 Git 用户。先决条件你已在系统上安装和配置了 Git。你至少需要设置用户名和电子邮件地址。你在 github.com 上拥有一个帐户。GitHub 本身并不是一个开源项目，但它是许多开源 Git 存储库的托管站点（包括 Foreman 的文档）。你已将 foreman-documentation 存储库复刻到你自己的账户或组织（例如，github.com/<My_User_Account>/foreman-documentation，这里 <My_User_Account> 是你的 GitHub 用户名）。有关更多信息，请参阅 Kedar Vijay Kulkarni 的 Kedar Vijay Kulkarni 的 Git 逐步指南。你已将你的 SSH 公钥添加到 GitHub。这是将你的更改推送到 GitHub 所必需的。有关更多信息，请参阅 Nicole C. Baratta 的《GitHub 简单指引》。对 Foreman 文档做出贡献Foreman 是一个开源项目，依靠社区的贡献而发展壮大。该项目欢迎所有人的参与，并且只有一些要求才能做出有意义的贡献。这些要求和惯例在 README.md 和 CONTRIBUTING.md 文件中有详细记录。以下是在处理 Foreman 文档时最常见的一些任务。我想开始贡献 Foreman 文档1、从 github.com 克隆存储库：$ git clone git@github.com:theforeman/foreman-documentation.git $ cd foreman-documentation/ 2、重命名远程存储库：$ git remote rename origin upstream 3、可选：确保你的本地主分支跟踪 theforeman 组织的 foreman-documentation 存储库的 master 分支：$ git status 这将自动将你置于默认分支（本例中为 master）的最新提交上。4、如果你的账户或组织中尚未有该存储库的 复刻 Fork ，请创建一个。前往 github.com/theforeman/foreman-documentation 并点击 “ 复刻 Fork ” 按钮。5、将你的复刻添加到你的存储库中：$ git remote add github git@github.com:<My_User_Account>/foreman-documentation.git 你的本地存储库现在有两个远程存储库：upstream 和 github。我想扩展 Foreman 文档对于简单的更改，比如修正拼写错误，你可以直接创建一个拉取请求（PR）。1、创建一个分支，例如 fix_spelling。git switch 命令用于切换当前所在的分支，-c 参数用于创建分支：$ git switch -c fix_spelling 2、进行你的更改。3、添加你的更改并进行提交：$ git add guides/common/modules/abc.adoc $ git commit -m "Fix spelling of existing" 良好的 Git 提交消息的重要性无需再强调。提交消息告诉贡献者你做了哪些工作，因为它与代码库的其余部分一起保存，所以它在查看代码时起到历史注释的作用，帮助了解代码的演化过程。有关优秀的 Git 提交消息的更多信息，请参阅由 cbeams 撰写的 《创建完美的 Git 提交信息的 7 条规则》。4、可选但建议的操作：查看并验证与默认分支的差异。foreman-documentation 的默认分支称为 master，但其他项目可能有不同的命名（例如 main、dev 或 devel）。$ git diff master 5、将分支推送到 GitHub。这将发布你的更改到你的代码库副本：$ git push --set-upstream github fix_spelling 6、点击终端中 Git 提供的链接来创建一个拉取请求（PR）：remote: Create a pull request for 'fix_spelling' on Github by visiting: remote: https://github.com/_My_User_Account_/foreman-documentation/pull/new/fix_spelling 7、在解释中说明社区为什么应该接受你的更改。对于修正拼写错误等简单 PR，这并不是必需的，但对于重大更改则很重要。我想将我的分支变基到 master1、确保你的本地 master 分支跟踪的是 github.com/theforeman/foreman-documentation 的 master 分支，而不是你自己命名空间下的 foreman-documentation：$ git switch master 此时应该显示 Your branch is up to date with 'upstream/master'，其中 upstream 是指向 github.com/theforeman/foreman-documentation 的远程存储库的名称。你可以通过运行 git remote -v 来查看远程存储库设置情况。2、从远程获取可能的更改。git fetch 命令会从远程下载被跟踪的分支，并且使用 --all 选项可以同时更新所有分支。在使用其他分支时这是必要的。--prune 选项会删除对已不存在的分支的引用。$ git fetch --all --prune 3、将可能的更改从 upstream/master 拉取到你的本地 master 分支。git pull 命令将跟踪的分支上的提交复制到当前分支。这用于将你的本地 master 分支“更新”为远程（在本例中为 GitHub）master 分支的最新状态。$ git pull 4、将你的分支 变基 rebase 到 master。$ git switch my_branch $ git rebase -i master 我在 master 分支上意外地提交了代码1、创建一个分支来保存你的工作：$ git switch -c my_feature 2、切换回 master 分支：$ git switch master 3、回退 master 分支上的最后一次提交：$ git reset --soft HEAD~1 4、切换回 my_feature 分支并继续工作：$ git switch my_feature 我想修改我的提交消息1、如果你的分支只有一次提交，可以使用 git amend 来修改你的最后一次提交：$ git commit --amend 这假设你没有将其他文件添加到暂存区（即，没有运行过 git add My_File，并且没有进行提交）。2、使用 --force 选项将你的 “更改” 推送到 GitHub，因为 Git 提交消息是你现有提交的一部分，所以你正在更改分支上的历史记录：$ git push --force 我想重新整理单个分支上的多个更改1、可选但强烈推荐：从 GitHub 获取更改。$ git switch master $ git fetch $ git pull 这确保你将其他更改按照它们被合并到 master 中的顺序直接合并到你的分支中。2、若要重新整理你的工作，请对你的分支进行变基并根据需要进行更改。对于将分支变基到 master，这意味着你需要更改你的分支上第一个提交的父提交：$ git rebase --interactive master 使用你喜欢的编辑器打开变基交互界面，将第一个单词 pick 替换为你要修改的提交。使用 e 来对你的提交进行实际更改。这会中断你的变基操作！使用 f 将一个提交与其父提交合并。使用 d 完全删除一个提交。移动行以改变你更改的顺序。成功进行变基后，你自己的提交将位于 master 上最后一个提交的顶部。我想从其他分支复制一个提交1、从稳定分支（例如名为 3.3 的分支）获取提交的 ID，请使用 -n 选项限制提交数量：$ git log -n 5 3.3 2、通过挑选提交来复制更改到你的分支。-x 选项将提交的 ID 添加到你的提交消息中。这仅建议在从稳定分支挑选提交时使用：$ git switch My_Branch $ git cherry-pick -x Commit_ID 更多技巧在 ATIX，我们运行一个 GitLab 实例，用于内部共享代码、协作以及自动化测试和构建。对于围绕 Foreman 生态系统的开源社区，我们依赖于 GitHub。我建议你始终将名为 origin 的远程指向你的内部的版本控制系统。这样做可以防止在纯粹凭记忆进行 git push 时向外部服务泄露信息。此外，我建议使用固定的命名方案来命名远程。我总是将指向自己的 GitLab 实例的远程命名为 origin，将指向开源项目的远程命名为 upstream，将指向我在 Github 上的复刻的远程命名为 github。对于 foreman-documentation，该存储库具有相对较平的历史记录。当处理更复杂结构时，我倾向于以非常可视化的方式思考 Git 存储库，其中节点（提交）指向线上的节点（分支），这些分支可以交织在一起。图形化工具如 gitk 或 Git Cola 可以帮助可视化你的 Git 历史记录。一旦你完全掌握了 Git 的工作原理，如果你更喜欢命令行，可以使用别名。在进行具有大量预期合并冲突的大型变基之前，我建议创建一个“备份”分支，以便你可以快速查看差异。请注意，要永久删除提交是相当困难的，因此在进行重大更改之前，请在本地 Git 存储库中进行测试。Git 对技术文档编写者的帮助Git 对技术文档编写者来说是一个巨大的帮助。不仅可以使用 Git 对文档进行版本控制，还可以与他人积极地进行协作。（题图：MJ/1fb1dd43-e460-4e76-9ff6-b6ef76570f7e）via: https://opensource.com/article/22/11/git-tips-technical-writers作者：Maximilian Kolb 选题：lkxed 译者：ChatGPT 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
在遇到合并冲突时，请不要惊慌。通过一些娴熟的技巧协商，你可以解决任何冲突。假设你和我正在共同编辑同一个名称为 index.html 的文件。我对文件进行了修改，进行了提交，并将更改推送到 Git 远程仓库。你也对同一个文件进行了修改，进行了提交，并开始将更改推送到同一个 Git 仓库。然而，Git 检测到一个冲突，因为你所做的更改与我所做的更改冲突。以下是你可以解决冲突的方法：1、从远程仓库获取并合并最新更改：$ git pull 2、识别一个或多个有冲突的文件：$ git status 3、使用文本编辑器打开冲突文件：$ vim index.html 4、解决冲突。冲突的修改会被标记为 <<<<<<< HEAD 和 >>>>>>>。你需要选择要保留和放弃哪些修改，并手动编辑文件以合并冲突的修改。以下是一个示例：<<<<<<< HEAD Sample text 1 ======= Sample text 2 >>>>>>> feature-branch 在这个例子中，我将网站标题更改为 Sample text 1，而你将标题更改为 Sample text 2。两种更改都已添加到文件中。现在你可以决定保留哪一个标题，或者编辑文件以合并更改。在任一情况下，删除指示更改开始和结束的标记，只留下你想要的代码： Sample text 2 5、保存所有更改，并关闭编辑器。6、将文件添加到暂存区：$ git add index.html 7、提交更改：$ git commit -m "Updated h1 in index.html" 此命令使用消息 Resolved merge conflict 提交更改。8、将更改推送到远程仓库：$ git push 结论合并冲突是将注意力集中于代码的好理由。你在文件中进行的更改越多，就越容易产生冲突。你应该进行更多的提交，每个提交更改应该更少。你应该避免进行包含多个特性增强或错误修复的单片巨大更改。你的项目经理也会感谢你，因为具有清晰意图的提交更容易追踪。在最初遇到 Git 合并冲突时可能会感到吓人，但是现在你知道如何解决它，你会发现它很容易解决。（题图：MJ/f432c41a-eb4f-4de0-b2da-f3d8d7a86e26）via: https://opensource.com/article/23/4/resolve-git-merge-conflicts作者：Agil Antony 选题：lkxed 译者：ChatGPT 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出