跳转至

stash 不是剪贴板,是个游离的 merge commit

场景

活干到一半,突然要切去处理别的事。半成品既不能提交(污染历史),也不能丢(白干)。

一句话

git stash 把工作区打包成一个游离的 merge commit,挂在 refs/stash 的 reflog 上;apply / pop 以这个 commit 的第一个 parent 为 base 做三方合并。整个过程 HEAD 不动 —— 所以它不是 merge 状态,冲突时 git merge --abort 直接报错,该用 git checkout HEAD -- <file> 回退。

心智模型

一个 stash 是一个 commit,带 2~3 个 parent

graph LR
    S["stash commit<br/>refs/stash"] --> P1["parent 1<br/>创建时的 HEAD"]
    S --> P2["parent 2<br/>暂存区快照"]
    S --> P3["parent 3<br/>未跟踪文件快照"]
parent 内容 何时存在
1 创建 stash 时的 HEAD 总是(三方合并的 base 就是它
2 暂存区快照 总是
3 未跟踪文件快照 只有用了 -u / -a

实测(git 2.34.1)一个 stash -u 的 commit:

$ git log --format='%h %p %s' -1 refs/stash
cf62bf4 1da5053 a83366f d4dfbf6 On main: demo

三个 parent 的 message 分别是 baseindex on main: ...untracked files on main: ...。这解释了另一件事:git fsck --unreachable 里一个 stash 会留下 2~3 个游离对象,不是 1 个。

stash@{0} 就是 reflog 语法

$ git rev-parse 'stash@{0}' refs/stash
cf62bf4c9214ca3f5a2f0c9f1dfdf08a3ae17a4c
cf62bf4c9214ca3f5a2f0c9f1dfdf08a3ae17a4c

两者同值 —— stash 栈的本质就是 refs/stash 这个 ref 的 reflog。由此推出三件事,不用背:

  • stash@{0} 是最新存的,不是最早的 —— 所以不带参数的 git stash pop 弹的是刚存的那个
  • drop 之后编号会重排 —— reflog 条目被删,后面的往前挪。别写 while ... git stash drop stash@{1} 这种循环
  • 空仓库不能 stash —— 没有 HEAD 就没有 parent 1,没有 base 就无从比较。报错原文:You do not have the initial commit yet

apply 是三方合并,不是打补丁

这两种语义差别很大,决定了 apply 安不安全:

  • patch 语义git apply):「在第 4 行插入一行」是一串操作,执行两遍就插两遍
  • merge 语义git stash apply):比的是目标状态,状态已经达到就没事可做

所以 apply 是幂等的,手抖敲两次不会把改动叠加两遍。实测三种情形:

第一次 apply 后的工作区 再 apply 一次的结果
还脏着(没提交) 拒绝local changes would be overwritten by merge,退出码 1,文件一个字节没动
已提交,工作区干净 成功但零变化nothing to commit, working tree clean
stash 里含未跟踪文件 部分失败xxx already exists, no checkout

第二行最能说明 merge 语义:命令成功了,但什么都没发生。

第一行那句报错措辞容易误读 —— would be overwritten by merge 听着像 git 想覆盖你的东西,其实它是提前中止来保护你

操作

git stash push -m "改到一半的说明"   # 带描述,栈里堆多个时能认出来
git stash -u -m "..."               # 连未跟踪文件一起收(日常默认用这个)
git stash -p                        # 交互式,只挑一部分改动收起来

git stashgit stash push 的简写,只是没描述。git 没有重命名 stash 的命令,真要改只能取出来重存。

看(下手前先看清,全是只读)

git stash list
git stash show -p -u stash@{0}      # -p 看完整 diff,-u 才显示未跟踪文件

git stash apply stash@{0}           # 恢复,stash 留在栈里
git stash pop                       # 恢复 + 删掉 stash
git stash branch <name> stash@{0}   # 在 stash 的原 base 上开分支恢复,零冲突

默认用 apply,确认结果对了再 git stash drop 多敲一条命令,换掉一整类事故 —— pop 在无冲突成功时立刻删 stash,这时若发现弹错了(最常见是弹到了错误的分支上),改动已经和工作区搅在一起,而备份没了。

git stash branch 是 stash 太老、base 已经跑远、pop 必然大面积冲突时的救命招:它直接回到当初那个 base 上开分支,所以不可能冲突。

冲突了怎么收拾

pop 遇到冲突会保留 stash 不删The stash entry is kept in case you need it again.)。冲突标记的两边是:

  • <<<<<<< Updated upstream → 当前 HEAD
  • >>>>>>> Stashed changes → stash 里的内容

三条出路:

# A. 手动改完
git add <file> && git stash drop        # drop 不会自动做,必须手动

# B. 直接选一边(ours = 当前 HEAD,theirs = stash,容易记反)
git checkout --ours <file>
git checkout --theirs <file>

# C. 放弃,回到 pop 之前
git checkout HEAD -- <file>             # 只回退这个文件;stash 还在,随时重试

出路 C 不要用 git reset --hard 代替 —— 它会连带丢掉工作区里所有不相关的改动,范围比你想要的大得多。

排查复杂冲突时把标记换成三段式,多出共同祖先那一段:

git checkout --conflict=diff3 -- <file>

默认不收未跟踪文件。 这是最常踩的一个。stash 的本质是「相对 HEAD 的差异」,而未跟踪文件既不在 HEAD 也不在索引里,不构成差异,于是被无视。危害不止「少收一个文件」:

  • 你以为工作区干净了,切分支去跑构建 —— 残留的新文件参与了编译,排查半天找不到原因
  • 更狠的:stash 之后顺手 git clean -fd,那个文件永久删除,stash 里根本没有它的备份

三档收纳范围:

命令 已跟踪的改动 未跟踪文件 .gitignore 忽略的
git stash
git stash -u
git stash -a

-a 要谨慎 —— 它会把 node_modules/build/.env 一股脑吞进去,又慢又容易出事。日常用 -u

git merge --abort 在 stash 冲突里用不了。 普通 merge 冲突养成的肌肉记忆在这里失效,很多人卡在这一步。实测(git 2.34.1):

检查项 结果
.git/MERGE_HEAD 不存在
git merge --abort fatal: There is no merge to abort (MERGE_HEAD missing)
git stash pop --abort error: unknown option 'abort' —— 没这个选项

原因:stash apply 借用了合并算法,但故意不写 MERGE_HEAD —— 语义上它不是在合并两条历史,只是搬运改动,HEAD 全程没动。merge --abort 完全依赖 MERGE_HEAD 才知道退回哪儿。

所以结论要反过来说:不是「撤不回」,而是压根不需要撤销一个 merge,只需要把文件恢复原样。

解决完冲突要手动 git stash drop git 不会替你做。忘了就在栈里留一个已经用过的 stash,下次 git stash list 看到它一头雾水。

git stash show 默认既不显示未跟踪文件,也只给 diffstat。 一个纯 -u 存下的 stash(里面只有未跟踪文件),git stash show stash@{0} 输出是空的,看着像个空 stash —— 别急着 drop。顺手的检视命令固定用 git stash show -p -u stash@{n}

drop / pop 掉的 stash 能救回来,但有时效。 git 在删的时候把 hash 打给你了,那就是留的后路:

$ git stash pop
...
Dropped stash@{0} (c38ce147a99f2d1f09e00f9e9cd94be21a864bd4)

拿 hash 当 stash 用即可:git stash apply c38ce147a。hash 已经滚屏丢了就捞游离对象:

git fsck --unreachable | awk '/commit/ {print $3}' | xargs -I% git log -1 --format='% %s' %

stash 的 commit message 有固定格式(WIP on ... / On ...),据此认出要的那个。

但这条路会被 git gc 断掉 —— 不可达对象默认宽限 90 天,git gc --prune=now 立刻就没了。它是急救手段,不是保险箱。这也正是「默认用 apply 而不是 pop」的理由。

顺带撞到的:文本文件结尾留一个换行符

读 diff 时如果出现「这行我明明没动」的困惑,先看有没有 \ No newline at end of file功能2(无 \n,文件到此为止)和 功能2\n 在 git 眼里是两个不同的行,字节不同就匹配不上,只能一删一增。

补上结尾换行符后,那行立刻被认成没动过的上下文。echo 会自动加,很多编辑器默认不加。不加会让最后一行的每次改动都显示成「删一行加一行」。

另一个成因是 最短编辑脚本可能不止一个,git 挑哪个不由你决定 —— 两种改法代价相同时纯看算法选择,偶尔就会看到反直觉的对齐。stash pop 的冲突标记里也会出现同样的困惑,八成是同一个原因。

-p 挑一部分:hunk 是什么

git stash -p 逐个问你要不要收某处改动,单位是 hunk —— 改动行 + 周围 3 行上下文构成的一块。上下文重叠的两处改动会被合并成一个 hunk,于是没得挑。

交互界面里按 s 能把一个 hunk 拆开,只要两处改动之间有没被改过的行。实测(git 2.34.1)两处改动之间隔 3 行未改动时,s 拆得开:

(1/1) Stage this hunk [y,n,q,a,d,s,e,?]? s
Split into 2 hunks.

默认 diff 输出的自动分块要求严一些:两处改动之间超过 6 行未改动才会分成两个 @@ 块。所以「git diff 只看到一个 hunk」不代表 -p 里挑不动。

hunk 这个概念在 git add -pgit checkout -p 里通用,s / y / n / q 那套按键也一样。

练法:AI 只设场景,命令全自己敲

这篇是在一个空仓库里从零练出来的,分工卡得很死:AI 负责造现场、埋坑、当提示官;每一条命令我自己敲。 AI 一次只说三件事 —— 现在是什么情况、你该做什么、预期看到什么 —— 然后等我贴输出。它不代敲,也不提前把答案讲完。

四条真正起作用的做法:

  • 先撞墙,再解释。第一步不是建仓库,是在空仓库里直接 git stash 吃一个 You do not have the initial commit yet。报错自己会解释"stash 记录的是相对某个 commit 的差异",比先讲一遍原理记得牢。同样的还有:stash 之后故意让我发现未跟踪文件还赖在原地、git stash show 对纯 -u 的 stash 输出空的。坑是安排好的,但要我自己踩到。
  • 输出先自己读。每步 AI 只说"重点看第二条的输出,你自己找找哪里不对劲",不直接点破。这一步逼出的问题最有价值 —— 「为什么会出现 +功能2-功能2?这一行我明明没动」就是这么问出来的,答案是结尾换行符(见上面那条 tip)。
  • 行为靠实测,不靠记忆。凡是"会怎样"的问题,AI 都在临时仓库里跑对照实验再回答,不凭印象。今天这么验过三处:连续 apply 两次会不会叠加、有没有 MERGE_HEAD、缺换行符时 diff 怎么对齐。文章里凡标"实测 git 2.34.1"的都是这么来的。
  • 手滑了不重来。我手快直接 pop 掉了本该 apply 的 stash,AI 没让我重置回去,而是就着这个残局讲了 Dropped stash@{0} (c38ce14...) 那行 hash 的用处和 git fsck --unreachable 捞游离对象。计划外的失误比设计好的步骤更接近真实事故。

这套练法不限于 stash —— 任何"命令记得住、模型说不清"的 git 子命令都能这么练:让 AI 先把现场搭到能出错的状态,自己敲,撞了再问为什么。