Git stash 记录选定的未提交工作,让检出可以暂时回到较早的已提交状态。它适合短暂中断,但保存和恢复都不是对目录中所有文件的无条件承诺。真正需要确认的是包含了什么、工作原来基于哪个版本、目标现在处于什么状态,以及保存条目何时才能移除。只看到命令报告成功,还不足以证明所有想保留的数据都已经纳入快照。

明确快照包含的范围

普通保存操作记录受跟踪文件的工作树变化与索引,再把这些修改退回到 HEAD 对应状态。未跟踪文件默认留下,只有加入相应选项才会一起保存;被忽略文件在包含未跟踪文件时仍然留下,需要更大的包含范围才会纳入。选择之前先检查,因为忽略路径里可能有凭据、大型构建输出或不适合进入 Git 对象的本地数据,不能为了追求“全部收起来”就不看内容。

选定路径、只处理已暂存内容、保留索引等选项,会产生不同结果。不要习惯性组合参数,再假定它们代表同一份快照。应先检查状态与预期差异,为条目写明含义,并查看实际保存的内容,再把原编辑视为已经能够恢复。尤其要区分磁盘文本和索引文本,它们可能在同一路径上分别代表两阶段尚未完成的工作。

stash 使用 Git 提交对象表示,不是对象库之外的特殊文件。最新条目由专用引用指向,更旧的条目通过该引用的日志保留,所以普通分支历史不一定显示它们。忘记使用并不自动等于丢失,但删除保留引用之后,对象最终可能无法恢复。本地分支或 stash 也不会仅凭存在,就提供一份独立的异地备份;保存形式与备份位置是不同问题。

应用与弹出不同

应用命令尝试恢复,同时保留保存条目,适合先审阅恢复结果,再决定是否删除副本。默认不能期待原来的已暂存与未暂存区别自动重建;索引选项会额外尝试恢复这一状态,但如果冲突或目标变化妨碍恢复,仍可能失败。应检查索引和磁盘各自的结果,而不只看最终文件里是否出现了某段文字。

弹出操作只在应用成功后才从列表移除条目。发生冲突时,保存项保留,但能够干净应用的路径可能已经改变。因此,非零返回不代表整个工作树完全没动。再次操作前应查看索引和文件,不能在部分恢复的目录上反复弹出同一条目,指望重复执行自动修复第一次冲突。

条目不属于某个分支或关联工作树私有。描述中出现的原分支名称是背景,不是强制只能在那里恢复的规则。随着新增和删除,数字选择器还会变化,所以敏感操作前要检查并记录准确对象。在 Shell 中传入带花括号的选择器时应引用,避免外层语法干扰。需要删除的究竟是哪份内容,应由对象证据确认,而不是仅凭它此刻排在第一位。

坏掉的样子

在变化后的基线上恢复可能冲突;即便文本没有冲突,也可能需要业务适配。应依据最终代码意图解决,暂存具体路径并验证结果,不能反复向半恢复状态应用同一份变化。stash 应用没有一个通用中止命令,能够自动撤销任何已经产生的部分效果,因此“先试试,不行就一键回去”不是可靠的恢复设计。

如果适合回到原始基线继续,可以使用建立分支的恢复命令:它从记录的起点创建分支,再应用保存工作。通过 stash 日志指定条目且应用成功时,条目也会被移除。这是与记录基线有关的恢复流程,不要求必须有两份 stash,也不存在超过某个时间就必须采用的固定期限。应按当前基线与恢复意图判断,而不是按存放时长机械选择。

移除应发生在验证之后,不能代替验证。清空整个列表可能丢弃无关工作,已经删除的对象以后还可能被回收。需要持续评审的工作,应在适合的分支形成有意提交,并遵守项目的备份与共享规则。stash 也不承诺捕获编辑器未保存缓冲区、任意文件系统元数据或构建使用的全部外部资源,不能把它当作整套开发环境的恢复镜像。

快速验证

在 POSIX 系统上使用 Python 3 和 Git 2.28 或更新版本运行下面实验。全部修改与移除都发生在可丢弃仓库,使用示例身份,关闭钩子及签名,排除个人 Git 配置和路由变量,没有远端。样例故意让索引与工作文件包含不同值,再检查保存范围、索引恢复、冲突后的条目保留,以及验证后才删除的顺序。

import os
import subprocess
import tempfile
from pathlib import Path

env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
with tempfile.TemporaryDirectory(prefix="git-stash-demo-") as directory:
    root = Path(directory)
    def git(*args, check=True):
        return subprocess.run(
            ["git", "-c", "user.name=Stash Example",
             "-c", "user.email=stash@example.invalid",
             "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args],
            cwd=root, env=env, text=True, capture_output=True, check=check,
        )
    git("init", "-q", "-b", "main")
    (root / ".gitignore").write_text("ignored.txt\n")
    (root / "note.txt").write_text("base\n")
    git("add", ".gitignore", "note.txt")
    git("commit", "-qm", "base")
    (root / "note.txt").write_text("staged\n")
    git("add", "note.txt")
    (root / "note.txt").write_text("working\n")
    (root / "new.txt").write_text("untracked\n")
    (root / "ignored.txt").write_text("ignored\n")
    git("stash", "push", "-qm", "tracked only")
    saved = git("rev-parse", "refs/stash").stdout.strip()
    print("default-leaves-untracked:", (root / "new.txt").exists()
          and (root / "ignored.txt").exists()
          and (root / "note.txt").read_text() == "base\n")
    git("stash", "apply", "--index", saved)
    restored = (git("show", ":note.txt").stdout == "staged\n"
                and (root / "note.txt").read_text() == "working\n")
    assert restored
    print("index-and-work-restored:", restored)
    print("apply-keeps-entry:", git("rev-parse", "stash@{0}").stdout.strip() == saved)
    assert git("rev-parse", "stash@{0}").stdout.strip() == saved
    git("stash", "drop", "-q", "stash@{0}")
    git("stash", "push", "-qu", "-m", "tracked and untracked")
    saved = git("rev-parse", "stash@{0}").stdout.strip()
    print("saved-untracked-content:",
          git("show", f"{saved}^3:new.txt").stdout == "untracked\n"
          and "ignored.txt" not in git("ls-tree", "-r", "--name-only", f"{saved}^3").stdout.splitlines())
    print("include-untracked-not-ignored:", not (root / "new.txt").exists()
          and (root / "ignored.txt").exists())
    (root / "note.txt").write_text("upstream\n")
    git("add", "note.txt")
    git("commit", "-qm", "new base")
    conflict = git("stash", "pop", "stash@{0}", check=False)
    print("conflicting-pop-keeps-entry:", conflict.returncode != 0
          and bool(git("ls-files", "-u").stdout)
          and git("rev-parse", "stash@{0}").stdout.strip() == saved)
    (root / "note.txt").write_text("upstream\nworking\n")
    git("add", "note.txt", "new.txt")
    assert not git("ls-files", "-u").stdout
    assert git("show", ":note.txt").stdout == "upstream\nworking\n"
    assert (root / "new.txt").read_text() == "untracked\n"
    git("commit", "-qm", "retain both fixture values")
    assert git("rev-parse", "stash@{0}").stdout.strip() == saved
    git("stash", "drop", "-q", "stash@{0}")
    print("resolved-and-verified:", not git("status", "--porcelain").stdout
          and not git("stash", "list").stdout)

七个判断都应为真。默认保存留下未跟踪和忽略文件;带索引应用恢复两个受跟踪状态,同时保留条目。包含未跟踪文件仍不会包含忽略文件。检查保存对象第三父级的目录树,还能确认未跟踪文本确实进入保存内容;文件从目录消失,本身不能证明它已经得到保留。这里使用对象结构只是为了独立验证样例,不要求日常使用者手动编辑内部对象。后续冲突弹出保留对应条目;样例明确决定保留两项预期值,提交并验证结果之后,才删除已经核对的那个保存项。最后由 Python 清理包括忽略文件在内的全部演示数据,不操作真实项目的保存列表。

来源