git stash 到底往 .git 里写了什么?一次 stash 产生几个 commit、暂存区的改动存在哪、多个 stash 之间靠什么串起来?这篇把 refs/stash 的结构逐层拆开,再看 pop 为什么能应用到别的提交上。
$ git status --short M a.txt MM b.txt
一个文件只改了工作区,另一个文件一部分已暂存、一部分没暂存——stash 要保存的是三份状态:HEAD、暂存区、工作区。
$ git status --short; echo "(工作区干净了)"; git stash list
(工作区干净了)
stash@{0}: WIP on main: b1ce1bc 起点
对象数 5 → 11(新增 6 个:2 个 commit、2 个 tree、2 个 blob——工作区里的 a.txt 和 b.txt;暂存的 b.txt 在 git add 时就已经写入了)
$ git cat-file -p refs/stash | sed -E 's/[0-9a-f]{40}/<sha40>/g'
tree <sha40>
parent <sha40>
parent <sha40>
author Alice <alice@example.com> 1767261600 +0000
committer Alice <alice@example.com> 1767261600 +0000
WIP on main: b1ce1bc 起点
^1 是基础提交(True),^2 的说明是: 'index on main: b1ce1bc 起点'
三个 tree 各自装着什么:
基础 HEAD a.txt: 'a1' b.txt 末行: 'b1'
^2(index commit,暂存区) a.txt: 'a1' b.txt 末行: 'b2 暂存的'
refs/stash(工作区快照) a.txt: 'a2 工作区改的' b.txt 末行: 'b3 又改了没暂存'refs/stash 是一个 commit:它的 tree 是工作区(已跟踪文件)的快照,第一个 parent 是基础提交,第二个 parent 是“index on …”那个 commit,tree 是暂存区。所以 git stash 新增 2 个 commit、2 个 tree 和相应的 blob。
$ git stash show -p | grep -E "^[+-][^+-]" +a2 工作区改的 +b2 暂存的 +b3 又改了没暂存 等价: git diff refs/stash^1 refs/stash → 完全相同: True
git stash show -p 等价于 git diff stash^1 stash,只显示基础提交到工作区快照的差异。
$ git stash -u; git cat-file -p refs/stash | sed -E 's/[0-9a-f]{40}/<sha40>/g'
Saved working directory and index state WIP on main: b1ce1bc 起点
tree <sha40>
parent <sha40>
parent <sha40>
parent <sha40>
author Alice <alice@example.com> 1767261600 +0000
committer Alice <alice@example.com> 1767261600 +0000
WIP on main: b1ce1bc 起点
^3 的说明: 'untracked files on main: b1ce1bc 起点'
$ git ls-tree -r refs/stash^3 --name-only
new.txt
对比:不加 -u 时 new.txt 不会进 stash(留在工作区):
$ git stash -q; ls; git cat-file -p refs/stash | grep -c "^parent"
a.txt
b.txt
new.txt
2-u 在 refs/stash 上再加第 3 个 parent,说明是“untracked files on …”,tree 里是未跟踪文件。不加 -u,未跟踪文件留在工作区。
$ git stash list
stash@{0}: On main: 第 3 个
stash@{1}: On main: 第 2 个
stash@{2}: On main: 第 1 个
$ cat .git/logs/refs/stash | sed -E 's/^[0-9a-f]{40} [0-9a-f]{40} /<旧> <新> /; s/ [0-9]{10} [+-][0-9]{4}//'
<旧> <新> Alice <alice@example.com> On main: 第 1 个
<旧> <新> Alice <alice@example.com> On main: 第 2 个
<旧> <新> Alice <alice@example.com> On main: 第 3 个
$ git rev-parse stash@{0} stash@{1} | cut -c1-7 | tr "\n" " "; echo; git rev-parse stash@{0}^ | cut -c1-7; echo "(stash@{0} 的 ^1 是基础提交,不是 stash@{1})"
924ac98 4720c05
b1ce1bc
(stash@{0} 的 ^1 是基础提交,不是 stash@{1})
$ git stash drop -q stash@{1}; git stash list; echo "(drop 的是 reflog 里的一行)"
stash@{0}: On main: 第 3 个
stash@{1}: On main: 第 1 个
(drop 的是 reflog 里的一行)多个 stash 不是靠 parent 链接成栈的,而是靠 refs/stash 的 reflog:git stash list 就是 git reflog show stash。stash@{0}^ 是基础提交。drop 删的是 reflog 的一行;被删掉的 stash commit 仍是对象库里的不可达对象(见 fsck 一篇)。
$ git stash pop 2>&1 | head -3; git status --short; git stash list | wc -l
On branch main
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
M a.txt
0
$ git stash pop 2>&1 | grep -E "CONFLICT|kept|conflict" ; git status --short; git stash list | wc -l
CONFLICT (content): Merge conflict in a.txt
The stash entry is kept in case you need it again.
UU a.txt
1应用 stash 是一次三方合并:基础是 stash^1,一方是当前 HEAD,另一方是 stash 提交,所以能应用到之后的新提交上。成功后 pop 丢弃它,冲突时保留并提示。