大家好！我一直在慢慢摸索如何解释 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中国 荣誉推出
最近，我首次尝试了 Mac。直至现在，我注意到的最大缺点是其软件包管理比 Linux 差很多。一段时间以来，我对于 homebrew 感到相当不满，因为每次我安装新的软件包时，它大部分时间都花在了升级上。于是，我萌生了试试 nix 包管理器的想法！公认的，nix 的使用存在一定困惑性（甚至它有自己单独的编程语言！），因此，我一直在努力以最简洁的方式掌握使用 nix，避开复杂的配置文件管理和新编程语言学习。以下是我至今为止学习到的内容, 敬请期待如何进行：使用 nix 安装软件包为一个名为 paperjam 的 C++ 程序构建一个自定义的 nix 包用 nix 安装五年前的 hugo 版本如同以往，由于我对 nix 的了解还停留在入门阶段，本篇文章可能存在一些表述不准确的地方。甚至我自己也对于我是否真的喜欢上 nix 感到模棱两可 —— 它的使用真的让人相当困惑！但是，它帮我成功编译了一些以前总是难以编译的软件，并且通常来说，它比 homebrew 的安装速度要快。nix 为何引人关注？通常，人们把 nix 定义为一种“声明式的包管理”。尽管我对此并不太感兴趣，但以下是我对 nix 的两个主要欣赏之处：它提供了二进制包（托管在 https://cache.nixos.org/ 上），你可以迅速下载并安装对于那些没有二进制包的软件，nix 使编译它们变得更容易我认为 nix 之所以擅长于编译软件，主要有以下两个原因：在你的系统中，可以安装同一库或程序的多个版本（例如，你可能有两个不同版本的 libc）。举个例子，我当前的计算机上就存在两个版本的 node，一个位于 /nix/store/4ykq0lpvmskdlhrvz1j3kwslgc6c7pnv-nodejs-16.17.1，另一个位于 /nix/store/5y4bd2r99zhdbir95w5pf51bwfg37bwa-nodejs-18.9.1。除此之外，nix 在构建包时是在隔离的环境下进行的，只使用你明确声明的依赖项的特定版本。因此，你无需担心这个包可能依赖于你的系统里的其它你并不了解的包，再也不用与 LD_LIBRARY_PATH 战斗了！许多人投入了大量工作，来列出所有包的依赖项。在本文后面，我将给出两个例子，展示 nix 如何使我在编译软件时遇到了更小的困难。我是如何开始使用 nix 的下面是我开始使用 nix 的步骤：安装 nix。我忘记了我当时是如何做到这一点，但看起来有一个官方安装程序 和一个来自 zero-to-nix.com 的 非官方安装程序。在 MacOS 上使用标准的多用户安装卸载 nix 的 教程 有点复杂，所以选择一个卸载教程更为简单的安装方法可能值得。把 ~/.nix-profile/bin 添加到我的 PATH用 nix-env -iA nixpkgs.NAME 命令安装包就是这样。基本上，是把 nix-env -iA 当作 brew install 或者 apt-get install。例如，如果我想安装 fish，我可以这样做：nix-env -iA nixpkgs.fish 这看起来就像是从 https://cache.nixos.org 下载一些二进制文件 - 非常简单。有些人使用 nix 来安装他们的 Node 和 Python 和 Ruby 包，但我并没有那样做 —— 我仍然像我以前一样使用 npm install 和 pip install。一些我没有使用的 nix 功能有一些 nix 功能/工具我并没有使用，但我要提及一下。我最初认为你必须使用这些功能才能使用 nix，因为我读过的大部分 nix 教程都讨论了它们。但事实证明，你并不一定要使用它们。NixOS（一个 Linux 发行版）nix-shellnix flakeshome-managerdevenv.sh我不去深入讨论它们，因为我并没真正使用过它们，而且网上已经有很多详解。安装软件包nix 包在哪里定义的？我认为 nix 包主仓库中的包是定义在 https://github.com/NixOS/nixpkgs/。你可以在 https://search.nixos.org/packages 查找包。似乎有两种官方推荐的查找包的方式：nix-env -qaP NAME，但这非常缓慢，并且我并没有得到期望的结果nix --extra-experimental-features 'nix-command flakes' search nixpkgs NAME，这倒是管用，但显得有点儿冗长。并且，无论何种原因，它输出的所有包都以 legacyPackages 开头我找到了一种我更喜欢的从命令行搜索 nix 包的方式：运行 nix-env -qa '*' > nix-packages.txt 获取 Nix 仓库中所有包的列表编写一个简洁的 nix-search 脚本，仅在 packages.txt 中进行 grep 操作（cat ~/bin/nix-packages.txt | awk '{print $1}' | rg "$1"）所有的东西都是通过符号链接来安装的nix 的一个主要设计是，没有一个单一的 bin 文件夹来存放所有的包，而是使用了符号链接。有许多层的符号链接。比如，以下就是一些符号链接的例子：我机器上的 ~/.nix-profile 最终是一个到 /nix/var/nix/profiles/per-user/bork/profile-111-link/ 的链接~/.nix-profile/bin/fish 是到 /nix/store/afkwn6k8p8g97jiqgx9nd26503s35mgi-fish-3.5.1/bin/fish 的链接当我安装某样东西的时候，它会创建一个新的 profile-112-link 目录并建立新的链接，并且更新我的 ~/.nix-profile 使其指向那个目录。我认为，这意味着如果我安装了新版本的 fish 但我并不满意，我可以很容易地退回先前的版本，只需运行 nix-env --rollback，这样就可以让我回到之前的配置文件目录了。卸载包并不意味着删除它们如果我像这样卸载 nix 包，实际上并不会释放任何硬盘空间，而仅仅是移除了符号链接：$ nix-env --uninstall oil 我尚不清楚如何彻底删除包 - 我试着运行了如下的垃圾收集命令，这似乎删除了一些项目：$ nix-collect-garbage ... 85 store paths deleted, 74.90 MiB freed 然而，我系统上仍然存在 oil 包，在 /nix/store/8pjnk6jr54z77jiq5g2dbx8887dnxbda-oil-0.14.0。nix-collect-garbage 有一个更具攻击性的版本，它也会删除你配置文件的旧版本（这样你就不能回滚了）。$ nix-collect-garbage -d --delete-old 尽管如此，上述命令仍无法删除 /nix/store/8pjnk6jr54z77jiq5g2dbx8887dnxbda-oil-0.14.0，我不明白原因。升级过程你可以通过以下的方式升级 nix 包：nix-channel --update nix-env --upgrade （这与 apt-get update && apt-get upgrade 类似。）我还没真正尝试升级任何东西。我推测，如果升级过程中出现任何问题，我可以通过以下方式轻松地回滚（因为在 nix 中，所有事物都是不可变的！）：nix-env --rollback 有人向我推荐了 Ian Henry 的 这篇文章，该文章讨论了 nix-env --upgrade 的一些令人困惑的问题 - 也许它并不总是如我们所料？因此，我会对升级保持警惕。下一个目标：创建名为 paperjam 的自定义包经过几个月使用现有的 nix 包后，我开始考虑制作自定义包，对象是一个名为 paperjam 的程序，它还没有被打包封装。实际上，因为我系统上的 libiconv 版本不正确，我甚至在没有 nix 的情况下也遇到了编译 paperjam 的困难。我认为，尽管我还不懂如何制作 nix 包，但使用 nix 来编译它可能会更为简单。结果证明我的想法是对的！然而，理清如何实现这个目标的过程相当复杂，因此我在这里写下了一些我实现它的方式和步骤。构建示例包的步骤在我着手制作 paperjam 自定义包之前，我想先试手构建一个已存在的示例包，以便确保我已经理解了构建包的整个流程。这个任务曾令我头痛不已，但在我在 Discord 提问之后，有人向我阐述了如何从 https://github.com/NixOS/nixpkgs/ 获取一个可执行的包并进行构建。以下是操作步骤：步骤 1： 从 GitHub 的 nixpkgs 下载任意一个包，以 dash 包为例：wget https://raw.githubusercontent.com/NixOS/nixpkgs/47993510dcb7713a29591517cb6ce682cc40f0ca/pkgs/shells/dash/default.nix -O dash.nix 步骤 2： 用 with import <nixpkgs> {}; 替换开头的声明（{ lib , stdenv , buildPackages , autoreconfHook , pkg-config , fetchurl , fetchpatch , libedit , runCommand , dash }:）。我不清楚为何需要这样做，但事实证明这么做是有效的。步骤 3： 运行 nix-build dash.nix这将开始编译该包。步骤 4： 运行 nix-env -i -f dash.nix这会将该包安装到我的 ~/.nix-profile 目录下。就这么简单！一旦我完成了这些步骤，我便感觉自己能够逐步修改 dash 包，进一步创建属于我自己的包了。制作自定义包的过程因为 paperjam 依赖于 libpaper，而 libpaper 还没有打包，所以我首先需要构建 libpaper 包。以下是 libpaper.nix，我基本上是从 nixpkgs 仓库中其他包的源码中复制粘贴得到的。我猜测这里的原理是，nix 对如何编译 C 包有一些默认规则，例如 “运行 make install”，所以 make install 实际上是默认执行的，并且我并不需要明确地去配置它。with import <nixpkgs> {}; stdenv.mkDerivation rec { pname = "libpaper"; version = "0.1"; src = fetchFromGitHub { owner = "naota"; repo = "libpaper"; rev = "51ca11ec543f2828672d15e4e77b92619b497ccd"; hash = "sha256-S1pzVQ/ceNsx0vGmzdDWw2TjPVLiRgzR4edFblWsekY="; }; buildInputs = [ ]; meta = with lib; { homepage = "https://github.com/naota/libpaper"; description = "libpaper"; platforms = platforms.unix; license = with licenses; [ bsd3 gpl2 ]; }; } 这个脚本基本上告诉 nix 如何从 GitHub 下载源代码。我通过运行 nix-build libpaper.nix 来构建它。接下来，我需要编译 paperjam。我制作的 nix 包 的链接在这里。除了告诉它从哪里下载源码外，我需要做的主要事情有：添加一些额外的构建依赖项（像 asciidoc）在安装过程中设置一些环境变量（installFlags = [ "PREFIX=$(out)" ];），这样它就会被安装在正确的目录，而不是 /usr/local/bin。我首先从散列值为空开始，然后运行 nix-build 以获取一个关于散列值不匹配的错误信息。然后我从错误信息中复制出正确的散列值。我只是在 nixpkgs 仓库中运行 rg PREFIX 来找出如何设置 installFlags 的 —— 我认为设置 PREFIX 应该是很常见的操作，可能之前已经有人做过了，事实证明我的想法是对的。所以我只是从其他包中复制粘贴了那部分代码。然后我执行了：nix-build paperjam.nix nix-env -i -f paperjam.nix 然后所有的东西都开始工作了，我成功地安装了 paperjam！耶！下一个目标：安装一个五年前的 Hugo 版本当前，我使用的是 2018 年的 Hugo 0.40 版本来构建我的博客。由于我并不需要任何的新功能，因此我并没有感到有升级的必要。对于在 Linux 上操作，这个过程非常简单：Hugo 的发行版本是静态二进制文件，这意味着我可以直接从 发布页面 下载五年前的二进制文件并运行。真的很方便！但在我的 Mac 电脑上，我遇到了一些复杂的情况。过去五年中，Mac 的硬件已经发生了一些变化，因此我下载的 Mac 版 Hugo 二进制文件并不能运行。同时，我尝试使用 go build 从源代码编译，但由于在过去的五年内 Go 的构建规则也有所改变，因此没有成功。我曾试图通过在 Linux docker 容器中运行 Hugo 来解决这个问题，但我并不太喜欢这个方法：尽管可以工作，但它运行得有些慢，而且我个人感觉这样做有些多余。毕竟，编译一个 Go 程序不应该那么麻烦！幸好，Nix 来救援！接下来，我将介绍我是如何使用 nix 来安装旧版本的 Hugo。使用 nix 安装 Hugo 0.40 版本我的目标是安装 Hugo 0.40，并将其添加到我的 PATH 中，以 hugo-0.40 作为命名。以下是我实现此目标的步骤。尽管我采取了一种相对特殊的方式进行操作，但是效果不错（可以参考 搜索和安装旧版本的 Nix 包 来找到可能更常规的方法）。步骤 1： 在 nixpkgs 仓库中搜索找到 Hugo 0.40。我在此链接中找到了相应的 .nix 文件 https://github.com/NixOS/nixpkgs/blob/17b2ef2/pkgs/applications/misc/hugo/default.nix。步骤 2： 下载该文件并进行构建。我下载了带有 .nix 扩展名的文件（以及同一目录下的另一个名为 deps.nix 的文件），将文件的首行替换为 with import <nixpkgs> {};，然后使用 nix-build hugo.nix 进行构建。虽然这个过程几乎无需进行修改就能成功运行，但我仍然做了两处小调整：把 with stdenv.lib 替换为 with lib。为避免与我已安装的其他版本的 hugo 冲突，我把包名改为了 hugo040。步骤 3： 将 hugo 重命名为 hugo-0.40。我编写了一个简短的后安装脚本，用以重命名 Hugo 二进制文件。postInstall = '' mv $out/bin/hugo $out/bin/hugo-0.40 ''; 我是通过在 nixpkgs 仓库中运行 rg 'mv ' 命令，然后复制和修改一条看似相关的代码片段来找到如何实施此步骤。步骤 4： 安装。我通过运行 nix-env -i -f hugo.nix 命令，将 Hugo 安装到了 ~/.nix-profile/bin 目录中。所有的步骤都顺利运行了！我把最终的 .nix 文件存放到了我自己的 nixpkgs 仓库 中，这样我以后如果需要，就能再次使用它了。可重复的构建过程并非神秘，其实它们极其复杂我觉得值得一提的是，这个 hugo.nix 文件并不是什么魔法——我之所以能在今天轻易地编译 Hugo 0.40，完全归功于许多人长期以来的付出，他们让 Hugo 的这个版本得以以可重复的方式打包。总结安装 paperjam 和这个五年前的 Hugo 版本过程惊人地顺利，实际上比没有 nix 来编译它们更简单。这是因为 nix 极大地方便了我使用正确的 libiconv 版本来编译 paperjam 包，而且五年前就已经有人辛苦地列出了 Hugo 的确切依赖关系。我并无计划详细深入地使用 nix（真的，我很可能对它感到困扰，然后最后选择回归使用 homebrew！），但我们将拭目以待！我发现，简单入手然后按需逐步掌握更多功能，远比一开始就全面接触一堆复杂功能更容易掌握。我可能不会在 Linux 上使用 nix —— 我一直都对 Debian 基础发行版的 apt 和 Arch 基础发行版的 pacman 感到满意，它们策略明晰且少有混淆。而在 Mac 上，使用 nix 似乎会有所得。不过，谁知道呢！也许三个月后，我可能会对 nix 感到不满然后再次选择回归使用 homebrew。（题图：MJ/f68aaf37-4a34-4643-b3a1-8728d49cf887）via: https://jvns.ca/blog/2023/02/28/some-notes-on-using-nix/作者：Julia Evans 选题：lkxed 译者：ChatGPT 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出
我一直在慢慢地撰写关于 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中国 荣誉推出
大家好！前段时间我写了一篇关于“如何用 Go 语言建立一个简易的 DNS 解析器”的帖子。那篇帖子里我没写有关“如何生成以及解析 DNS 查询请求”的内容，因为我觉得这很无聊，不过一些伙计指出他们不知道如何解析和生成 DNS 查询请求，并且对此很感兴趣。我开始好奇了——解析 DNS 能 花多大功夫？事实证明，编写一段 120 行精巧的 Ruby 语言代码组成的程序就可以做到，这并不是很困难。所以，在这里有一个如何生成 DNS 查询请求，以及如何解析 DNS 响应报文的速成教学！我们会用 Ruby 语言完成这项任务，主要是因为不久以后我将在一场 Ruby 语言大会上发表观点，而这篇博客帖的部分内容是为了那场演讲做准备的。?（我尽量让不懂 Ruby 的人也能读懂，我只使用了非常基础的 Ruby 语言代码。）最后，我们就能制作一个非常简易的 Ruby 版本的 dig 工具，能够查找域名，就像这样：$ ruby dig.rb example.com example.com 20314 A 93.184.216.34 整个程序大概 120 行左右，所以 并不 算多。（如果你想略过讲解，单纯想去读代码的话，最终程序在这里：dig.rb。）我们不会去实现之前帖中所说的“一个 DNS 解析器是如何运作的？”，因为我们已经做过了。那么我们开始吧！如果你想从头开始弄明白 DNS 查询是如何格式化的，我将尝试解释如何自己弄明白其中的一些东西。大多数情况下的答案是“用 Wireshark 去解包”和“阅读 RFC 1035，即 DNS 的规范”。生成 DNS 查询请求步骤一：打开一个 UDP 套接字我们需要实际发送我们的 DNS 查询，因此我们就需要打开一个 UDP 套接字。我们会将我们的 DNS 查询发送至 8.8.8.8，即谷歌的服务器。下面是用于建立与 8.8.8.8 的 UDP 连接，端口为 53（DNS 端口）的代码。require 'socket' sock = UDPSocket.new sock.bind('0.0.0.0', 12345) sock.connect('8.8.8.8', 53) 关于 UDP 的说明关于 UDP，我不想说太多，但是我要说的是，计算机网络的基础单位是“ 数据包 packet ”（即一串字节），而在这个程序中，我们要做的是计算机网络中最简单的事情：发送 1 个数据包，并接收 1 个数据包作为响应。所以 UDP 是一个传递数据包的最简单的方法。它是发送 DNS 查询最常用的方法，不过你还可以用 TCP 或者 DNS-over-HTTPS。步骤二：从 Wireshark 复制一个 DNS 查询下一步：假设我们都不知道 DNS 是如何运作的，但我们还是想尽快发送一个能运行的 DNS 查询。获取 DNS 查询并确保 UDP 连接正常工作的最简单方法就是复制一个已经正常工作的 DNS 查询！所以这就是我们接下来要做的，使用 Wireshark （一个绝赞的数据包分析工具）。我的操作大致如下：打开 Wireshark，点击 “ 捕获 capture ” 按钮。在搜索栏输入 udp.port == 53 作为筛选条件，然后按下回车。在我的终端运行 ping example.com（用来生成一个 DNS 查询）。点击 DNS 查询（显示 “Standard query A example.com”）。 （“A”：查询类型；“example.com”：域名；“Standard query”：查询类型描述）右键点击位于左下角面板上的 “ 域名系统（查询） Domain Name System (query) ”。点击 “ 复制 Copy ” ——> “ 作为十六进制流 as a hex stream ”。现在 b96201000001000000000000076578616d706c6503636f6d0000010001 就放到了我的剪贴板上，之后会用在我的 Ruby 程序里。好欸！步骤三：解析 16 进制数据流并发送 DNS 查询现在我们能够发送我们的 DNS 查询到 8.8.8.8 了！就像这样，我们只需要再加 5 行代码：hex_string = "b96201000001000000000000076578616d706c6503636f6d0000010001" bytes = [hex_string].pack('H*') sock.send(bytes, 0) # get the reply reply, _ = sock.recvfrom(1024) puts reply.unpack('H*') [hex_string].pack('H*') 意思就是将我们的 16 位字符串转译成一个字节串。此时我们不知道这组数据到底是什么意思，但是很快我们就会知道了。我们还可以借此机会运用 tcpdump ，确认程序是否正常进行以及发送有效数据。我是这么做的：在一个终端选项卡下执行 sudo tcpdump -ni any port 53 and host 8.8.8.8 命令在另一个不同的终端指标卡下，运行 这个程序（ruby dns-1.rb）以下是输出结果：$ sudo tcpdump -ni any port 53 and host 8.8.8.8 08:50:28.287440 IP 192.168.1.174.12345 > 8.8.8.8.53: 47458+ A? example.com. (29) 08:50:28.312043 IP 8.8.8.8.53 > 192.168.1.174.12345: 47458 1/0/0 A 93.184.216.34 (45) 非常棒 —— 我们可以看到 DNS 请求（”这个 example.com 的 IP 地址在哪里？“）以及响应（“在93.184.216.34”）。所以一切运行正常。现在只需要（你懂的）—— 搞清我们是如何生成并解析这组数据的。步骤四：学一点点 DNS 查询的格式现在我们有一个关于 example.com 的 DNS 查询，让我们了解它的含义。下方是我们的查询（16 位进制格式）：b96201000001000000000000076578616d706c6503636f6d0000010001 如果你在 Wireshark 上搜索，你就能看见这个查询它由两部分组成：请求头：b96201000001000000000000语句本身：076578616d706c6503636f6d0000010001步骤五：制作请求头我们这一步的目标就是制作字节串 b96201000001000000000000（借助一个 Ruby 函数，而不是把它硬编码出来）。（LCTT 译注： 硬编码 hardcode 指在软件实现上，将输出或输入的相关参数（例如：路径、输出的形式或格式）直接以常量的方式撰写在源代码中，而非在运行期间由外界指定的设置、资源、数据或格式做出适当回应。）那么：请求头是 12 个字节。那些个 12 字节到底意味着什么呢？如果你在 Wireshark 里看看（亦或者阅读 RFC-1035），你就能理解：它是由 6 个 2 字节大小的数字串联在一起组成的。这六个数字分别对应查询 ID、标志，以及数据包内的问题计数、回答资源记录数、权威名称服务器记录数、附加资源记录数。我们还不需要在意这些都是些什么东西 —— 我们只需要把这六个数字输进去就行。但所幸我们知道该输哪六位数，因为我们就是为了直观地生成字符串 b96201000001000000000000。所以这里有一个制作请求头的函数（注意：这里没有 return，因为在 Ruby 语言里，如果处在函数最后一行是不需要写 return 语句的）：def make_question_header(query_id) # id, flags, num questions, num answers, num auth, num additional [query_id, 0x0100, 0x0001, 0x0000, 0x0000, 0x0000].pack('nnnnnn') end 上面内容非常的短，主要因为除了查询 ID ，其余所有内容都由我们硬编码写了出来。什么是 nnnnnn?可能能想知道 .pack('nnnnnn') 中的 nnnnnn 是个什么意思。那是一个向 .pack() 函数解释如何将那个 6 个数字组成的数据转换成一个字节串的一个格式字符串。.pack 的文档在 这里，其中描述了 n 的含义其实是“将其表示为” 16 位无符号、网络（大端序）字节序’”。（LCTT 译注： 大端序 Big-endian ：指将高位字节存储在低地址,低位字节存储在高地址的方式。）16 个位等同于 2 字节，同时我们需要用网络字节序，因为这属于计算机网络范畴。我不会再去解释什么是字节序了（尽管我确实有 一幅自制漫画尝试去描述它）。测试请求头代码让我们快速检测一下我们的 make_question_header 函数运行情况。puts make_question_header(0xb962) == ["b96201000001000000000000"].pack("H*") 这里运行后输出 true 的话，我们就成功了。好了我们接着继续。步骤六：为域名进行编码下一步我们需要生成 问题本身（“example.com 的 IP 是什么？”）。这里有三个部分：域名（比如说 example.com）查询类型（比如说 A 代表 “IPv4 Address”）查询类（总是一样的，1 代表 INternet）最麻烦的就是域名，让我们写个函数对付这个。example.com 以 16 进制被编码进一个 DNS 查询中，如 076578616d706c6503636f6d00。这有什么含义吗？如果我们把这些字节以 ASCII 值翻译出来，结果会是这样：076578616d706c6503636f6d00 7 e x a m p l e 3 c o m 0 因此，每个段（如 example）的前面都会显示它的长度（7）。下面是有关将 example.com 翻译成 7 e x a m p l e 3 c o m 0 的 Ruby 代码：def encode_domain_name(domain) domain .split(".") .map { |x| x.length.chr + x } .join + "\0" end 除此之外，，要完成问题部分的生成，我们只需要在域名结尾追加上（查询）的类型和类。步骤七：编写 make\_dns\_query下面是制作一个 DNS 查询的最终函数：def make_dns_query(domain, type) query_id = rand(65535) header = make_question_header(query_id) question = encode_domain_name(domain) + [type, 1].pack('nn') header + question end 这是目前我们写的所有代码 dns-2.rb —— 目前仅 29 行。接下来是解析的阶段现在我尝试去解析一个 DNS 查询，我们到了硬核的部分：解析。同样的，我们会将其分成不同部分：解析一个 DNS 的请求头解析一个 DNS 的名称解析一个 DNS 的记录这几个部分中最难的（可能跟你想的不一样）就是：“解析一个 DNS 的名称”。步骤八：解析 DNS 的请求头让我们先从最简单的部分开始：DNS 的请求头。我们之前已经讲过关于它那六个数字是如何串联在一起的了。那么我们现在要做的就是：读其首部 12 个字节将其转换成一个由 6 个数字组成的数组为方便起见，将这些数字放入一个类中以下是具体进行工作的 Ruby 代码：class DNSHeader attr_reader :id, :flags, :num_questions, :num_answers, :num_auth, :num_additional def initialize(buf) hdr = buf.read(12) @id, @flags, @num_questions, @num_answers, @num_auth, @num_additional = hdr.unpack('nnnnnn') end end 注： attr_reader 是 Ruby 的一种说法，意思是“使这些实例变量可以作为方法使用”。所以我们可以调用 header.flags 来查看@flags变量。我们也可以借助 DNSheader(buf) 调用这个，也不差。让我们往最难的那一步挪挪：解析一个域名。步骤九：解析一个域名首先，让我们写其中的一部分：def read_domain_name_wrong(buf) domain = [] loop do len = buf.read(1).unpack('C')[0] break if len == 0 domain << buf.read(len) end domain.join('.') end 这里会反复读取一个字节的数据，然后将该长度读入字符串，直到读取的长度为 0。这里运行正常的话，我们在我们的 DNS 响应头第一次看见了域名（example.com）。关于域名方面的麻烦：压缩！但当 example.com 第二次出现的时候，我们遇到了麻烦 —— 在 Wireshark 中，它报告上显示输出的域的值为含糊不清的 2 个字节的 c00c。这种情况就是所谓的 DNS 域名压缩，如果我们想解析任何 DNS 响应我们就要先把这个实现完。幸运的是，这没那么难。这里 c00c 的含义就是：前两个比特（0b11.....）意思是“前面有 DNS 域名压缩！”而余下的 14 比特是一个整数。这种情况下这个整数是 12（0x0c），意思是“返回至数据包中的第 12 个字节处，使用在那里找的域名”如果你想阅读更多有关 DNS 域名压缩之类的内容。我找到了相关更容易让你理解这方面内容的文章： 关于 DNS RFC 的释义。步骤十：实现 DNS 域名压缩因此，我们需要一个更复杂的 read_domain_name 函数。如下所示：domain = [] loop do len = buf.read(1).unpack('C')[0] break if len == 0 if len & 0b11000000 == 0b11000000 # weird case: DNS compression! second_byte = buf.read(1).unpack('C')[0] offset = ((len & 0x3f) << 8) + second_byte old_pos = buf.pos buf.pos = offset domain << read_domain_name(buf) buf.pos = old_pos break else # normal case domain << buf.read(len) end end domain.join('.') 这里具体是：如果前两个位为 0b11，那么我们就需要做 DNS 域名压缩。那么：读取第二个字节并用一点儿运算将其转化为偏移量。在缓冲区保存当前位置。在我们计算偏移量的位置上读取域名在缓冲区存储我们的位置。可能看起来很乱，但是这是解析 DNS 响应的部分中最难的一处了，我们快搞定了！一个关于 DNS 压缩的漏洞有些人可能会说，有恶意行为者可以借助这个代码，通过一个带 DNS 压缩条目的 DNS 响应指向这个响应本身，这样 read_domain_name 就会陷入无限循环。我才不会改进它（这个代码已经够复杂了好吗！）但一个真正的 DNS 解析器确实会更巧妙地处理它。比如，这里有个 能够避免在 miekg/dns 中陷入无限循环的代码。如果这是一个真正的 DNS 解析器，可能还有其他一些边缘情况会造成问题。步骤十一：解析一个 DNS 查询你可能在想：“为什么我们需要解析一个 DNS 查询？这是一个响应啊！”但每一个 DNS 响应包含它自己的原始查询，所以我们有必要去解析它。这是解析 DNS 查询的代码：class DNSQuery attr_reader :domain, :type, :cls def initialize(buf) @domain = read_domain_name(buf) @type, @cls = buf.read(4).unpack('nn') end end 内容不是太多：类型和类各占 2 个字节。步骤十二：解析一个 DNS 记录最让人兴奋的部分 —— DNS 记录是我们的查询数据存放的地方！即这个 “rdata 区域”（“记录数据字段”）就是我们会在 DNS 查询对应的响应中获得的 IP 地址所驻留的地方。代码如下：class DNSRecord attr_reader :name, :type, :class, :ttl, :rdlength, :rdata def initialize(buf) @name = read_domain_name(buf) @type, @class, @ttl, @rdlength = buf.read(10).unpack('nnNn') @rdata = buf.read(@rdlength) end 我们还需要让这个 rdata 区域更加可读。记录数据字段的实际用途取决于记录类型 —— 比如一个“A” 记录就是一个四个字节的 IP 地址，而一个 “CNAME” 记录则是一个域名。所以下面的代码可以让请求数据更可读：def read_rdata(buf, length) @type_name = TYPES[@type] || @type if @type_name == "CNAME" or @type_name == "NS" read_domain_name(buf) elsif @type_name == "A" buf.read(length).unpack('C*').join('.') else buf.read(length) end end 这个函数使用了 TYPES 这个哈希表将一个记录类型映射为一个更可读的名称：TYPES = { 1 => "A", 2 => "NS", 5 => "CNAME", # there are a lot more but we don't need them for this example } read.rdata 中最有趣的一部分可能就是这一行 buf.read(length).unpack('C*').join('.') —— 像是在说：“嘿！一个 IP 地址有 4 个字节，就将它转换成一组四个数字组成的数组，然后数字互相之间用 ‘.’ 联个谊吧。”步骤十三：解析 DNS 响应的收尾工作现在我们正式准备好解析 DNS 响应了！工作代码如下所示：class DNSResponse attr_reader :header, :queries, :answers, :authorities, :additionals def initialize(bytes) buf = StringIO.new(bytes) @header = DNSHeader.new(buf) @queries = (1..@header.num_questions).map { DNSQuery.new(buf) } @answers = (1..@header.num_answers).map { DNSRecord.new(buf) } @authorities = (1..@header.num_auth).map { DNSRecord.new(buf) } @additionals = (1..@header.num_additional).map { DNSRecord.new(buf) } end end 这里大部分内容就是在调用之前我们写过的其他函数来协助解析 DNS 响应。如果 @header.num_answers 的值为 2，代码会使用了 (1..@header.num_answers).map 这个巧妙的结构创建一个包含两个 DNS 记录的数组。（这可能有点像 Ruby 魔法，但我就是觉得有趣，但愿不会影响可读性。）我们可以把这段代码整合进我们的主函数中，就像这样：sock.send(make_dns_query("example.com", 1), 0) # 1 is "A", for IP address reply, _ = sock.recvfrom(1024) response = DNSResponse.new(reply) # parse the response!!! puts response.answers[0] 尽管输出结果看起来有点辣眼睛（类似于 #<DNSRecord:0x00000001368e3118>），所以我们需要编写一些好看的输出代码，提升它的可读性。步骤十四：对于我们输出的 DNS 记录进行美化我们需要向 DNS 记录增加一个 .to_s 字段，从而让它有一个更良好的字符串展示方式。而者只是做为一行方法的代码在 DNSRecord 中存在。def to_s "#{@name}\t\t#{@ttl}\t#{@type_name}\t#{@parsed_rdata}" end 你可能也注意到了我忽略了 DNS 记录中的 class 区域。那是因为它总是相同的（IN 表示 “internet”），所以我觉得它是个多余的。虽然很多 DNS 工具（像真正的 dig）会输出 class。大功告成！这是我们最终的主函数：def main # connect to google dns sock = UDPSocket.new sock.bind('0.0.0.0', 12345) sock.connect('8.8.8.8', 53) # send query domain = ARGV[0] sock.send(make_dns_query(domain, 1), 0) # receive & parse response reply, _ = sock.recvfrom(1024) response = DNSResponse.new(reply) response.answers.each do |record| puts record end 我不觉得我们还能再补充什么 —— 我们建立连接、发送一个查询、输出每一个回答，然后退出。完事儿！$ ruby dig.rb example.com example.com 18608 A 93.184.216.34 你可以在这里查看最终程序：dig.rb。可以根据你的喜好给它增加更多特性，就比如说：为其他查询类型添加美化输出。输出 DNS 响应时增加“授权”和“可追加”的选项重试查询确保我们看到的 DNS 响应匹配我们的查询（ID 信息必须是对的上的！）另外如果我在这篇文章中出现了什么错误，就 在推特和我聊聊吧。（我写的比较赶所以可能还是会有些错误）（题图：MJ/449d049d-6bdd-448b-a61d-17138f8551bc）via: https://jvns.ca/blog/2022/11/06/making-a-dns-query-in-ruby-from-scratch/作者：Julia Evans 选题：lujun9972 译者：Drwhooooo 校对：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中国 荣誉推出
今天我在想 —— 当你在 Linux 上运行一个简单的 “Hello World” Python 程序时，发生了什么，就像下面这个？print("hello world") 这就是在命令行下的情况：$ python3 hello.py hello world 但是在幕后，实际上有更多的事情在发生。我将描述一些发生的情况，并且（更重要的是）解释一些你可以用来查看幕后情况的工具。我们将用 readelf、strace、ldd、debugfs、/proc、ltrace、dd 和 stat。我不会讨论任何只针对 Python 的部分 —— 只研究一下当你运行任何动态链接的可执行文件时发生的事情。0、在执行 execve 之前要启动 Python 解释器，很多步骤都需要先行完成。那么，我们究竟在运行哪一个可执行文件呢？它在何处呢？1、解析 python3 hello.pyShell 将 python3 hello.py 解析成一条命令和一组参数：python3 和 ['hello.py']。在此过程中，可能会进行一些如全局扩展等操作。举例来说，如果你执行 python3 *.py ，Shell 会将其扩展到 python3 hello.py。2、确认 python3 的完整路径现在，我们了解到需要执行 python3。但是，这个二进制文件的完整路径是什么呢？解决办法是使用一个名为 PATH 的特殊环境变量。自行验证：在你的 Shell 中执行 echo $PATH。对我来说，它的输出如下：$ echo $PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 当执行一个命令时，Shell 将会依序在 PATH 列表中的每个目录里搜索匹配的文件。对于 fish（我的 Shell），你可以在 这里 查看路径解析的逻辑。它使用 stat 系统调用去检验是否存在文件。自行验证：执行 strace -e stat bash，然后运行像 python3 这样的命令。你应该会看到如下输出：stat("/usr/local/sbin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory) stat("/usr/local/bin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory) stat("/usr/sbin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory) stat("/usr/bin/python3", {st_mode=S_IFREG|0755, st_size=5479736, ...}) = 0 你可以观察到，一旦在 /usr/bin/python3 找到了二进制文件，搜索就会立即终止：它不会继续去 /sbin 或 /bin 中查找。对 execvp 的补充说明如果你想要不用自己重新实现，而运行和 Shell 同样的 PATH 搜索逻辑，你可以使用 libc 函数 execvp（或其它一些函数名中含有 p 的 exec* 函数）。3、stat 的背后运作机制你可能在思考，Julia，stat 到底做了什么？当你的操作系统要打开一个文件时，主要分为两个步骤：它将 文件名 映射到一个包含该文件元数据的 inode它利用这个 inode 来获取文件的实际内容stat 系统调用只是返回文件的 inode 内容 —— 它并不读取任何的文件内容。好处在于这样做速度非常快。接下来让我们一起来快速了解一下 inode。（在 Dmitry Mazin 的这篇精彩文章 《磁盘就是一堆比特》中有更多的详细内容）$ stat /usr/bin/python3 File: /usr/bin/python3 -> python3.9 Size: 9 Blocks: 0 IO Block: 4096 symbolic link Device: fe01h/65025d Inode: 6206 Links: 1 Access: (0777/lrwxrwxrwx) Uid: ( 0/ root) Gid: ( 0/ root) Access: 2023-08-03 14:17:28.890364214 +0000 Modify: 2021-04-05 12:00:48.000000000 +0000 Change: 2021-06-22 04:22:50.936969560 +0000 Birth: 2021-06-22 04:22:50.924969237 +0000 自行验证：我们来实际查看一下硬盘上 inode 的确切位置。首先，我们需要找出硬盘的设备名称：$ df ... tmpfs 100016 604 99412 1% /run /dev/vda1 25630792 14488736 10062712 60% / ... 看起来它是 /dev/vda1。接着，让我们寻找 /usr/bin/python3 的 inode 在我们硬盘上的确切位置（在 debugfs 提示符下输入 imap 命令）：$ sudo debugfs /dev/vda1 debugfs 1.46.2 (28-Feb-2021) debugfs: imap /usr/bin/python3 Inode 6206 is part of block group 0 located at block 658, offset 0x0d00 我不清楚 debugfs 是如何确定文件名对应的 inode 的位置，但我们暂时不需要深入研究这个。现在，我们需要计算硬盘中 “块 658，偏移量 0x0d00” 处是多少个字节，这个大的字节数组就是你的硬盘。每个块有 4096 个字节，所以我们需要到 4096 * 658 + 0x0d00 字节。使用计算器可以得到，这个值是 2698496。$ sudo dd if=/dev/vda1 bs=1 skip=2698496 count=256 2>/dev/null | hexdump -C 00000000 ff a1 00 00 09 00 00 00 f8 b6 cb 64 9a 65 d1 60 |...........d.e.`| 00000010 f0 fb 6a 60 00 00 00 00 00 00 01 00 00 00 00 00 |..j`............| 00000020 00 00 00 00 01 00 00 00 70 79 74 68 6f 6e 33 2e |........python3.| 00000030 39 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |9...............| 00000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00000060 00 00 00 00 12 4a 95 8c 00 00 00 00 00 00 00 00 |.....J..........| 00000070 00 00 00 00 00 00 00 00 00 00 00 00 2d cb 00 00 |............-...| 00000080 20 00 bd e7 60 15 64 df 00 00 00 00 d8 84 47 d4 | ...`.d.......G.| 00000090 9a 65 d1 60 54 a4 87 dc 00 00 00 00 00 00 00 00 |.e.`T...........| 000000a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| 好极了！我们找到了 inode！你可以在里面看到 python3，这是一个很好的迹象。我们并不打算深入了解所有这些，但是 Linux 内核的 ext4 inode 结构 指出，前 16 位是 “模式”，即权限。所以现在我们将看一下 ffa1 如何对应到文件权限。ffa1 对应的数字是 0xa1ff，或者 41471（因为 x86 是小端表示）41471 用八进制表示就是 0120777这有些奇怪 - 那个文件的权限肯定可以是 777，但前三位是什么呢？我以前没见过这些！你可以在 inode 手册页 中找到 012 的含义（向下滚动到“文件类型和模式”）。这里有一个小的表格说 012 表示 “符号链接”。我们查看一下这个文件，确实是一个权限为 777 的符号链接：$ ls -l /usr/bin/python3 lrwxrwxrwx 1 root root 9 Apr 5 2021 /usr/bin/python3 -> python3.9 它确实是！耶，我们正确地解码了它。4、准备复刻我们尚未准备好启动 python3。首先，Shell 需要创建一个新的子进程来进行运行。在 Unix 上，新的进程启动的方式有些特殊 - 首先进程克隆自己，然后运行 execve，这会将克隆的进程替换为新的进程。自行验证： 运行 strace -e clone bash，然后运行 python3。你应该会看到类似下面的输出：clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f03788f1a10) = 3708100 3708100 是新进程的 PID，这是 Shell 进程的子进程。这里有些工具可以查看进程的相关信息：pstree 会展示你的系统中所有进程的树状图cat /proc/PID/stat 会显示一些关于该进程的信息。你可以在 man proc 中找到这个文件的内容说明。例如，第四个字段是父进程的PID。新进程的继承新的进程（即将变为 python3 的）从 Shell 中继承了很多内容。例如，它继承了：环境变量：你可以通过 cat /proc/PID/environ | tr '\0' '\n' 查看标准输出和标准错误的文件描述符：通过 ls -l /proc/PID/fd 查看工作目录（也就是当前目录）命名空间和控制组（如果它在一个容器内）运行它的用户以及群组还有可能是我此刻未能列举出来的更多东西5、Shell 调用 execve现在我们准备好启动 Python 解释器了！自行验证：运行 strace -f -e execve bash，接着运行 python3。其中的 -f 参数非常重要，因为我们想要跟踪任何可能产生的子进程。你应该可以看到如下的输出：[pid 3708381] execve("/usr/bin/python3", ["python3"], 0x560397748300 /* 21 vars */) = 0 第一个参数是这个二进制文件，而第二个参数是命令行参数列表。这些命令行参数被放置在程序内存的特定位置，以便在运行时可以访问。那么，execve 内部到底发生了什么呢？6、获取该二进制文件的内容我们首先需要打开 python3 的二进制文件并读取其内容。直到目前为止，我们只使用了 stat 系统调用来获取其元数据，但现在我们需要获取它的内容。让我们再次查看 stat 的输出：$ stat /usr/bin/python3 File: /usr/bin/python3 -> python3.9 Size: 9 Blocks: 0 IO Block: 4096 symbolic link Device: fe01h/65025d Inode: 6206 Links: 1 ... 该文件在磁盘上占用 0 个块的空间。这是因为符号链接（python3.9）的内容实际上是存储在 inode 自身中：在下面显示你可以看到（来自上述 inode 的二进制内容，以 hexdump 格式分为两行输出）。00000020 00 00 00 00 01 00 00 00 70 79 74 68 6f 6e 33 2e |........python3.| 00000030 39 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |9...............| 因此，我们将需要打开 /usr/bin/python3.9 。所有这些操作都在内核内部进行，所以你并不会看到其他的系统调用。每个文件都由硬盘上的一系列的 块 构成。我知道我系统中的每个块是 4096 字节，所以一个文件的最小大小是 4096 字节 —— 甚至如果文件只有 5 字节，它在磁盘上仍然占用 4KB。自行验证：我们可以通过 debugfs 找到块号，如下所示：（再次说明，我从 Dmitry Mazin 的《磁盘就是一堆比特》文章中得知这些步骤）。$ debugfs /dev/vda1 debugfs: blocks /usr/bin/python3.9 145408 145409 145410 145411 145412 145413 145414 145415 145416 145417 145418 145419 145420 145421 145422 145423 145424 145425 145426 145427 145428 145429 145430 145431 145432 145433 145434 145435 145436 145437 接下来，我们可以使用 dd 来读取文件的第一个块。我们将块大小设定为 4096 字节，跳过 145408 个块，然后读取 1 个块。$ dd if=/dev/vda1 bs=4096 skip=145408 count=1 2>/dev/null | hexdump -C | head 00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............| 00000010 02 00 3e 00 01 00 00 00 c0 a5 5e 00 00 00 00 00 |..>.......^.....| 00000020 40 00 00 00 00 00 00 00 b8 95 53 00 00 00 00 00 |@.........S.....| 00000030 00 00 00 00 40 00 38 00 0b 00 40 00 1e 00 1d 00 |....@.8...@.....| 00000040 06 00 00 00 04 00 00 00 40 00 00 00 00 00 00 00 |........@.......| 00000050 40 00 40 00 00 00 00 00 40 00 40 00 00 00 00 00 |@.@.....@.@.....| 00000060 68 02 00 00 00 00 00 00 68 02 00 00 00 00 00 00 |h.......h.......| 00000070 08 00 00 00 00 00 00 00 03 00 00 00 04 00 00 00 |................| 00000080 a8 02 00 00 00 00 00 00 a8 02 40 00 00 00 00 00 |..........@.....| 00000090 a8 02 40 00 00 00 00 00 1c 00 00 00 00 00 00 00 |..@.............| 你会发现，这样我们得到的输出结果与直接使用 cat 读取文件所获得的结果完全一致。$ cat /usr/bin/python3.9 | hexdump -C | head 00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............| 00000010 02 00 3e 00 01 00 00 00 c0 a5 5e 00 00 00 00 00 |..>.......^.....| 00000020 40 00 00 00 00 00 00 00 b8 95 53 00 00 00 00 00 |@.........S.....| 00000030 00 00 00 00 40 00 38 00 0b 00 40 00 1e 00 1d 00 |....@.8...@.....| 00000040 06 00 00 00 04 00 00 00 40 00 00 00 00 00 00 00 |........@.......| 00000050 40 00 40 00 00 00 00 00 40 00 40 00 00 00 00 00 |@.@.....@.@.....| 00000060 68 02 00 00 00 00 00 00 68 02 00 00 00 00 00 00 |h.......h.......| 00000070 08 00 00 00 00 00 00 00 03 00 00 00 04 00 00 00 |................| 00000080 a8 02 00 00 00 00 00 00 a8 02 40 00 00 00 00 00 |..........@.....| 00000090 a8 02 40 00 00 00 00 00 1c 00 00 00 00 00 00 00 |..@.............| 关于魔术数字的额外说明这个文件以 ELF 开头，这是一个被称为“ 魔术数字 magic number ”的标识符，它是一种字节序列，告诉我们这是一个 ELF 文件。在 Linux 上，ELF 是二进制文件的格式。不同的文件格式有不同的魔术数字。例如，gzip 的魔数是 1f8b。文件开头的魔术数字就是 file blah.gz 如何识别出它是一个 gzip 文件的方式。我认为 file 命令使用了各种启发式方法来确定文件的类型，而其中，魔术数字是一个重要的特征。7、寻找解释器我们来解析这个 ELF 文件，看看里面都有什么内容。自行验证：运行 readelf -a /usr/bin/python3.9。我得到的结果是这样的（但是我删减了大量的内容）：$ readelf -a /usr/bin/python3.9 ELF Header: Class: ELF64 Machine: Advanced Micro Devices X86-64 ... -> Entry point address: 0x5ea5c0 ... Program Headers: Type Offset VirtAddr PhysAddr INTERP 0x00000000000002a8 0x00000000004002a8 0x00000000004002a8 0x000000000000001c 0x000000000000001c R 0x1 -> [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] ... -> 1238: 00000000005ea5c0 43 FUNC GLOBAL DEFAULT 13 _start 从这段内容中，我理解到：请求内核运行 /lib64/ld-linux-x86-64.so.2 来启动这个程序。这就是所谓的动态链接器，我们将在随后的部分对其进行讨论。该程序制定了一个入口点（位于 0x5ea5c0），那里是这个程序代码开始的地方。接下来，让我们一起来聊聊动态链接器。8、动态链接好的！我们已从磁盘读取了字节数据，并启动了这个“解释器”。那么，接下来会发生什么呢？如果你执行 strace -o out.strace python3，你会在 execve 系统调用之后观察到一系列的信息：execve("/usr/bin/python3", ["python3"], 0x560af13472f0 /* 21 vars */) = 0 brk(NULL) = 0xfcc000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 fstat(3, {st_mode=S_IFREG|0644, st_size=32091, ...}) = 0 mmap(NULL, 32091, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f718a1e3000 close(3) = 0 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0 l\0\0\0\0\0\0"..., 832) = 832 fstat(3, {st_mode=S_IFREG|0755, st_size=149520, ...}) = 0 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f718a1e1000 ... close(3) = 0 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3 这些内容初看可能让人望而生畏，但我希望你能重点关注这一部分：openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpthread.so.0" ...。这里正在打开一个被称为 pthread 的 C 语言线程库，运行 Python 解释器时需要这个库。自行验证：如果你想知道一个二进制文件在运行时需要加载哪些库，你可以使用 ldd 命令。下面展示的是我运行后的效果：$ ldd /usr/bin/python3.9 linux-vdso.so.1 (0x00007ffc2aad7000) libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f2fd6554000) libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f2fd654e000) libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f2fd6549000) libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f2fd6405000) libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007f2fd63d6000) libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f2fd63b9000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2fd61e3000) /lib64/ld-linux-x86-64.so.2 (0x00007f2fd6580000) 你可以看到，第一个列出的库就是 /lib/x86_64-linux-gnu/libpthread.so.0，这就是它被第一个加载的原因。关于 LD\_LIBRARY\_PATH说实话，我关于动态链接的理解还有些模糊，以下是我所了解的一些内容：动态链接发生在用户空间，我的系统上的动态链接器位于 /lib64/ld-linux-x86-64.so.2. 如果你缺少动态链接器，可能会遇到一些奇怪的问题，比如这种 奇怪的“文件未找到”错误动态链接器使用 LD_LIBRARY_PATH 环境变量来查找库动态链接器也会使用 LD_PRELOAD 环境变量来覆盖你想要的任何动态链接函数（你可以使用它来进行 有趣的魔改，或者使用像 jemalloc 这样的替代品来替换默认内存分配器）strace 的输出中有一些 mprotect，因为安全原因将库代码标记为只读在 Mac 上，不是使用 LD_LIBRARY_PATH（Linux），而是 DYLD_LIBRARY_PATH你可能会有疑问，如果动态链接发生在用户空间，我们为什么没有看到大量的 stat 系统调用在 LD_LIBRARY_PATH 中搜索这些库，就像 Bash 在 PATH 中搜索那样？这是因为 ld 在 /etc/ld.so.cache 中有一个缓存，因此所有之前已经找到的库都会被记录在这里。你可以在 strace 的输出中看到它正在打开缓存 - openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3。在 完整的 strace 输出 中，我仍然对动态链接之后出现的一些系统调用感到困惑（什么是 prlimit64？本地环境的内容是如何介入的？gconv-modules.cache 是什么？rt_sigaction 做了什么？arch_prctl 是什么？以及 set_tid_address 和 set_robust_list 是什么？）。尽管如此，我觉得已经有了一个不错的开头。旁注：ldd 实际上是一个简单的 Shell 脚本！在 Mastodon 上，有人 指出，ldd 实际上是一个 Shell 脚本，它设置了 LD_TRACE_LOADED_OBJECTS=1 环境变量，然后启动程序。因此，你也可以通过以下方式实现相同的功能：$ LD_TRACE_LOADED_OBJECTS=1 python3 linux-vdso.so.1 (0x00007ffe13b0a000) libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f01a5a47000) libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f01a5a41000) libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f2fd6549000) libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f2fd6405000) libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007f2fd63d6000) libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f2fd63b9000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2fd61e3000) /lib64/ld-linux-x86-64.so.2 (0x00007f2fd6580000) 事实上，ld 也是一个可以直接运行的二进制文件，所以你也可以通过 /lib64/ld-linux-x86-64.so.2 --list /usr/bin/python3.9 来达到相同的效果。关于 init 和 fini让我们来谈谈这行 strace 输出中的内容：set_tid_address(0x7f58880dca10) = 3709103 这似乎与线程有关，我认为这可能是因为 pthread 库（以及所有其他动态加载的库）在加载时得以运行初始化代码。在库加载时运行的代码位于 init 区域（或者也可能是 .ctors 区域）。自行验证：让我们使用 readelf 来看看这个：$ readelf -a /lib/x86_64-linux-gnu/libpthread.so.0 ... [10] .rela.plt RELA 00000000000051f0 000051f0 00000000000007f8 0000000000000018 AI 4 26 8 [11] .init PROGBITS 0000000000006000 00006000 000000000000000e 0000000000000000 AX 0 0 4 [12] .plt PROGBITS 0000000000006010 00006010 0000000000000560 0000000000000010 AX 0 0 16 ... 这个库没有 .ctors 区域，只有一个 .init。但是，.init 区域都有些什么内容呢？我们可以使用 objdump 来反汇编这段代码：$ objdump -d /lib/x86_64-linux-gnu/libpthread.so.0 Disassembly of section .init: 0000000000006000 <_init>: 6000: 48 83 ec 08 sub $0x8,%rsp 6004: e8 57 08 00 00 callq 6860 <__pthread_initialize_minimal> 6009: 48 83 c4 08 add $0x8,%rsp 600d: c3 所以它在调用 __pthread_initialize_minimal。我在 glibc 中找到了 这个函数的代码，尽管我不得不找到一个较早版本的 glibc，因为在更近的版本中，libpthread 不再是一个独立的库。我不确定这个 set_tid_address 系统调用是否实际上来自 __pthread_initialize_minimal，但至少我们知道了库可以通过 .init 区域在启动时运行代码。这里有一份关于 .init 区域的 elf 手册的笔记：$ man elf .init 这个区域保存着对进程初始化代码有贡献的可执行指令。当程序开始运行时，系统会安排在调用主程序入口点之前执行该区域中的代码。在 ELF 文件中也有一个在结束时运行的 .fini 区域，以及其他可以存在的区域 .ctors / .dtors（构造器和析构器）。好的，关于动态链接就说这么多。9、转到 \_start在动态链接完成后，我们进入到 Python 解释器中的 _start。然后，它将执行所有正常的 Python 解析器会做的事情。我不打算深入讨论这个，因为我在这里关心的是关于如何在 Linux 上运行二进制文件的一般性知识，而不是特别针对 Python 解释器。10、写入字符串不过，我们仍然需要打印出 “hello world”。在底层，Python 的 print 函数调用了 libc 中的某个函数。但是，它调用了哪一个呢？让我们来找出答案！自行验证：运行 ltrace -o out python3 hello.py：$ ltrace -o out python3 hello.py $ grep hello out write(1, "hello world\n", 12) = 12 看起来它确实在调用 write 函数。我必须承认，我对 ltrace 总是有一些疑虑 —— 与我深信不疑的 strace 不同，我总是不完全确定 ltrace 是否准确地报告了库调用。但在这个情况下，它似乎有效。并且，如果我们查阅 cpython 的源代码，它似乎在一些地方确实调用了 write() 函数，所以我倾向于相信这个结果。什么是 libc？我们刚刚提到，Python 调用了 libc 中的 write 函数。那么，libc 是什么呢？它是 C 的标准库，负责许多基本操作，例如：用 malloc 分配内存文件 I/O（打开/关闭文件）执行程序（像我们之前提到的 execvp）使用 getaddrinfo 查找 DNS 记录使用 pthread 管理线程在 Linux 上，程序不一定需要使用 libc（例如 Go 就广为人知地未使用它，而是直接调用了 Linux 系统调用），但是我常用的大多数其他编程语言（如 node、Python、Ruby、Rust）都使用了 libc。我不确定 Java 是否也使用了。你能通过在你的二进制文件上执行 ldd 命令，检查你是否正在使用 libc：如果你看到了 libc.so.6 这样的信息，那么你就在使用 libc。为什么 libc 重要？你也许在思考 —— 为何重要的是 Python 调用 libc 的 write 函数，然后 libc 再调用 write 系统调用？为何我要着重提及 libc 是调用过程的一环？我认为，在这个案例中，这并不真的很重要（根据我所知，libc 的 write 函数与 write 系统调用的映射相当直接）。然而，存在不同的 libc 实现，有时它们的行为会有所不同。两个主要的实现是 glibc（GNU libc）和 musl libc。例如，直到最近，musl 的 getaddrinfo 并不支持 TCP DNS，这是一篇关于这个问题引发的错误的博客文章。关于 stdout 和终端的小插曲在我们的程序中，stdout（1 文件描述符）是一个终端。你可以在终端上做一些有趣的事情！例如：在终端中运行 ls -l /proc/self/fd/1。我得到了 /dev/pts/2 的结果。在另一个终端窗口中，运行 echo hello > /dev/pts/2。返回到原始终端窗口。你应会看到 hello 被打印出来了！暂时就到这儿吧!希望通过上文，你对 hello world 是如何打印出来的有了更深的了解！我暂时不再添加更多的细节，因为这篇文章已经足够长了，但显然还有更多的细节可以探讨，如果大家能提供更多的细节，我可能会添加更多的内容。如果你有关于我在这里没提到的程序内部调用过程的任何工具推荐，我会特别高兴。我很期待看到一份 Mac 版的解析我对 Mac OS 的一个懊恼是，我不知道如何在这个级别上解读我的系统——当我打印 “hello world”，我无法像在 Linux 上那样，窥视背后的运作机制。我很希望看到一个深度的解析。我所知道的一些在 Mac 下的对应工具：ldd -> otool -Lreadelf -> otool有人说你可以在 Mac 上使用 dtruss 或 dtrace 来代替 strace，但我尚未有足够的勇气关闭系统完整性保护来让它工作。strace -> sc_usage 似乎能够收集关于系统调用使用情况的统计信息，fs_usage 则可以收集文件使用情况的信息。延伸阅读一些附加的链接：快速教程：如何在 Linux 上创建超小型 ELF 可执行文件在 FreeBSD 上探索 “hello world”微观视角下的 Windows 中 “Hello World”来自 LWN 的文章：如何运行程序 （以及第二部分）详尽介绍了 execve 的内部机制Lexi Mattick 的文章，赋予 CPU “你” 的存在从零开始在 6502 上实现 “Hello, World” （来自 Ben Eater 的视频）（题图：MJ/b87ed0a2-80d6-49cd-b2bf-1ef822485e3f）via: https://jvns.ca/blog/2023/08/03/behind--hello-world/作者：Julia Evans 选题：lujun9972 译者：ChatGPT 校对：wxy本文由 LCTT 原创编译，Linux中国 荣誉推出