bigywater.tech
Git · 39

stash 的实现:一次 stash 产生几个 commit

git stash 到底往 .git 里写了什么?一次 stash 产生几个 commit、暂存区的改动存在哪、多个 stash 之间靠什么串起来?这篇把 refs/stash 的结构逐层拆开,再看 pop 为什么能应用到别的提交上。

2 个 commit
普通 git stash 创建的 commit 数:工作区快照加 index commit;-u 时变成 3 个
reflog
git stash list 其实就是 refs/stash 的 reflog,旧 stash 之间没有 parent 链接
stash@{0}^
指向基础提交,而不是上一个 stash

起点

实测 · 本机执行输出
  $ git status --short
   M a.txt
  MM b.txt

一个文件只改了工作区,另一个文件一部分已暂存、一部分没暂存——stash 要保存的是三份状态:HEAD、暂存区、工作区。

refs/stash 的结构

实测 · 本机执行输出
  $ 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。

stash show

实测 · 本机执行输出
  $ 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,只显示基础提交到工作区快照的差异。

-u

实测 · 本机执行输出
  $ 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,未跟踪文件留在工作区。

stash 栈

实测 · 本机执行输出
  $ 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 一篇)。

apply 与 pop

实测 · 本机执行输出
  $ 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 丢弃它,冲突时保留并提示。

分步演示

stash 的实现
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / git 2.54.0(Apple Git-157);每个实验在临时目录里新建仓库,HOME 和全局配置指向实验专用的空目录(不读用户自己的 git 配置),提交的作者与时间固定以便复现;需要网络协议的实验用本机的 file:// 或 git daemon 回环端口(9800 以上),不碰任何远程仓库;计时用 time.perf_counter,数字每次运行会有小幅抖动。作者与时间固定,stash 提交的时间戳与基础提交相同(hash 可复现);对象数 = count + in-pack
    • 没有验证:-a(包含被忽略的文件);stash push 加路径参数(pathspec);git stash branch;stash create / store 的低级命令;pop 时 --index 恢复暂存区的行为;stash 数量很多时 reflog 的大小与过期(stash 的 reflog 不会过期的说法没有验证)
    京ICP备2026067502号-1京公网安备11010502063487号