无需放弃变更、关闭占用程序!用暂存区和 git底层命令实现 Git 变更备份
前言
在日常开发中,git stash
虽为我们备份数据的常用命令,但其操作过程需要放弃当前的变更,这对于 win
平台下编辑二进制资源时会造成很多麻烦,由于主程序会对修改的二进制资源进行占用,此时执行储藏大概率会报错,如果想要储藏成功,就需要关闭主程序,然后再操作储藏,才能完成数据的备份,如果主程序比较大比如游戏引擎,那么整个操作流程将相当繁重耗时。
而本文则将介绍一种更灵活轻便的 git仓库变更数据的备份方案:通过暂存区(Index) 与git create-commit
等底层 git 命令,手动构建出stash
的提交,从而在不放弃任何变更,也不需要关闭占用程序前提下,完成工作区变更数据的备份,从而提高研发效率。
问题
使用 git 进行版本控制时,通常会在工作区产生变更,而某些情况我们不想要提交而仅想要备份一下修改的数据,常规的做法是git stash
,它会自动打包这些状态并自动将工作区的状态清空,但是这个过程无法干预,并且如果出现变更文件被其他程序占用(例如打开并修改的 excel 文件),就会导致储藏失败。
如下,当一个 hello.xlsx 的文件,并用 excel 打开修改,此时执行 git stash -u 会报错:

其根本原因是 git stash
包含的是备份数据+放弃变更的连贯过程,但由于 xlsx 文件被 excel 程序占用,导致 stash 不能完成放弃变更而最终报错退出。
而我们有时的数据备份,通常只需要将当前工作区的变更读取出来,进行备份操作,无需放弃变更,而本文则提供这样一种数据备份的方法。
如果你不想了解实现机制,则可以直接跳转最后查看如何具体实现。
相关知识
Git object
理解 Git 对象的工作原理,有利于了解 Git 的暂存区、提交机制以及接下来要介绍的git create-commit
操作。
在 Git 的世界里,一切操作都围绕着 Git 对象(Git object)展开,版本数据库内存储的是k-v数据(<hash_ID, object>),通过hash ID 可以直接获取到对象值,其中object主要分为四种类型:
-
blob
(文本对象)。blob
对象用于存储文件的内容,比如文本文件的具体代码、图片文件的二进制数据等,它不包含任何文件名或目录结构信息。 -
tree
(树对象)。tree
对象就像文件系统中的目录,用于组织和管理blob
对象以及其他tree
对象,记录了文件的名称、权限和指向blob
或子tree
的指针,从而构建出完整的目录树结构。 -
commit
(提交对象)。commit
对象则是版本控制的核心,每个提交都包含了指向了根目录的tree
对象的指针 ,用于记录项目在某一时刻的状态;同时还包含了作者信息、提交者信息、提交时间以及指向父提交的指针,通过这些指针可以串联起整个项目的版本演进历史,形成一个有向无环图。 -
tag
(标签对象)。tag
对象主要用于给特定的commit
添加标签,常用于标记发布版本、重要里程碑等,分为轻量级标签(仅保存提交对象的 SHA-1 哈希值)和附注标签(包含标签名、标签说明、标签创建者等详细信息)。本文【不放弃变更完成储藏】功能的实现,本质上是将创建 git 对象,将其存储到git 数据库下,并操作这些 Git 对象的创建、引用关系。
暂存区中的 tree object
暂存区(也称为索引,Index)是 Git 用于存储下次提交内容的区域,它在 Git 对象模型中扮演着关键角色,作为提交前置状态的暂存区,暂存区实际上可以作为 tree object 创建的"工具"。
当执行git add
命令将修改的文件添加到暂存区时,Git 会创建一个tree object(树对象) 来记录这些文件的状态。Tree object 本质上是一个文件和子目录的目录树结构,它包含了文件的元数据信息,如文件名、文件模式(可执行、普通文件等)以及指向 blob object(存储文件实际内容的对象)的指针。如果暂存区中存在多个目录层级的文件修改,Git 会递归地构建多层 tree object,形成一个完整的目录树结构,从而完整记录整个项目在暂存区的状态。
详细参考此文Git - Git 对象,文中详细介绍了如何基于 git 的 plumbing commands 构造一个提交(对象)。
Git Stash 的底层结构
最终备份的数据还是会以储藏形式呈现,所以我们需要了解储藏(提交)的结构,方便指引我们后续去手动创建这样的储藏。
每个储藏其实也是一笔提交。

要实现自定义储藏,首先需明确:常规的git stash
本质是创建一个 "特殊提交"。在储藏了未跟踪文件的情况下,该提交包含三个父节点;默认情况下则包含两个父节点,它们分别对应储藏前的不同状态:
-
父提交 1(
stash@{0}^1
) :即git log
能看到的最后一个 commit,这是储藏时当前分支的最新提交(HEAD
); -
父提交 2(
stash@{0}^2
) :包含了储藏时暂存区(Index,又名 staging area)中已通过git add
操作、但尚未提交的变更集; -
父提交 3(
stash@{0}^3
) :仅在使用git stash -u
或git stash -a
时存在,包含了储藏时工作树中未跟踪的文件(-u
)以及被忽略的文件(-a
) 。
git stash 底层实现参考:what-is-in-a-stash,其详细介绍了 stash 各个parent 的含义及作用。
二、实现关键:从变更文件到暂存区(index)创建 commit
存储 blob对象------git add foo.txt
git add foo.txt
用于将工作区 foo.txt
的变更添加到暂存区。执行该命令时,会先创建变更文件对应的 blob object
。git add
实际上是一个多步骤操作,blob object
的创建只是其中一环。当变更被添加到暂存区后,新生成的 blob object
便会被暂存区的 tree object
所引用。
git 还提供了纯粹的的数据库操作方法------ git hash-object ,来让你 仅 将某些 blob 对象存到git 数据库下,具体可以参考:git-scm.com/docs/git-ha...
暂存区中获取tree 对象------write-tree
git-write-tree 命令会扫描当前暂存区(index)的内容,将文件的状态、权限等信息组织成一个树状结构,构造一个指向树根节点的对象,并返回其对应的 hash_ID,该 tree对象代表了暂存区的当前状态。
使用示例:执行 git write-tree 后,会输出一个 40 位的哈希值,该哈希值对应 index 内部保存的 tree object。可通过 git cat-file -p <hash> 查看树对象的具体内容。
tree对象创建commit对象------commit-tree
从前置的知识可以推得:要想将工作区的修改储藏起来,需要先构造出储藏的3个 parent 提交,而提交对象的创建,除了直接调用 git commit 命令,还有一个较为底层的 git 命令 commit-tree
。
git commit-tree
的作用是可以从已有的 tree 对象创建新的 commit 对象:
其基本语法为 git commit-tree <tree> [-p <parent>] [-m <message>]
,其中 <tree>
是必填的树对象哈希值,-p
用于指定父提交(可指定多次以创建合并提交),-m
用于添加提交信息。以下是几种简单的使用示例:
-
创建基础提交:
假设已通过
git write-tree
命令获取到树对象哈希值abc123
,创建一个无父提交(适用于初始提交场景)的提交:
sql
git commit-tree abc123 -m "Initial commit"
-
创建合并提交:
假设有两个父提交
ghi789
和jkl012
,以及对应的树对象mno345
,创建一个合并提交:
css
git commit-tree mno345 -p ghi789 -p jkl012 -m "Merge two branches"
具体参考官方文档: Git - git-commit-tree Documentation
因此,我们知道了如何基于工作区变更创建 blob 对象,并如何获取 tree object,最后创建 commit object,而上述的步骤仅设计文件读取,不会产生程序占用导致的报错。那么接下来我们便可以将所有的环节串联,形成最终实现。
三、具体实现
假设当前场景:你在main
分支开发,有部分变更是未追踪的(新增而且没有加到暂存区内的)、有部分变更文件是受追踪(修改的),你想要把当前所有修改、新增的文件都备份,所谓备份就是不放弃变更,也不希望受制于占用程序影响导致储藏失败。
步骤 0 备份现有 index
因为我们的核心思路时操作暂存区,为了防止中间步骤失败导致最开始的暂存区状态丢失,需要先备份当前的 index
(暂存区)状态备份到 index_backup
下:
bash
cp .git/index .git/index_backup
后续若出现问题,可根据备份文件恢复暂存区。
步骤 1 git reset HEAD
执行 git reset HEAD
命令,它的作用是将暂存区重置为与 HEAD 所指向的提交一致。这一步会重置清空暂存区,将所有变更文件移除暂存区。由此,我们将工作目录与旧暂存区解耦,为后续精准处理变更文件做好准备。
bash
git reset HEAD
步骤 2 git status
获取所有变更文件路径及变更状态
使用 git status
命令查看当前工作目录的状态,它会清晰列出所有变更文件的路径,以及每个文件的变更状态,如 modified
(已修改)、deleted
(已删除)、untracked
(未追踪)等。这些文件路径和变更信息 是后续对不同类型文件进行差异化处理的依据。我们可以将输出内容复制到文本编辑器中,便于后续操作时快速查看和使用。
其中 git
提供了一个适合脚本处理的参数获取工作区的变更
bash
git status --porcelain
步骤 3 git add
所有未追踪的文件,并基于 write tree + commit+tree
获取 untracked
文件的 parent
提交
执行 git add .
或指定未追踪文件路径,将所有未追踪的文件添加到暂存区。随后,通过 git write-tree
命令创建一个新的树对象,该对象代表当前暂存区的内容状态。接着使用 git commit-tree
命令创建一个新的提交对象,此提交对象包含了刚刚创建的树对象。为了明确提交的父子关系,指定当前 HEAD 提交作为父提交,这样就得到了未追踪文件的 parent
提交。具体命令如下:
bash
git add <untracked_files> # 添加所有未追踪文件
tree_untracked=$(git write-tree) # 获取树对象哈希值
commit_untracked=$(git commit-tree $tree_untracked -p HEAD -m "Commit for untracked files") # 创建提交对象
步骤 4 git add
所有追踪文件,并基于 write tree
+ commit tree
创建追踪文件的 parent
提交
与步骤 3 类似,先执行 git add
命令,将所有已追踪且发生变更的文件添加到暂存区。然后同样通过 git write-tree
创建树对象,再使用 git commit-tree
创建提交对象,并指定合适的父提交(可以是 HEAD 提交或其他相关提交),从而得到追踪文件的 parent
提交。具体操作如下:
bash
git add <tracked_changed_files> # 添加已追踪的变更文件
tree_tracked=$(git write-tree)
commit_tracked=$(git commit-tree $tree_tracked -p HEAD -m "Commit for tracked changed files")
步骤 5 基于 git commit-tree -p [p1] -p [p2] -p [head] -m 'on main: xx'
这里的 [p1]
为步骤 3 中未追踪文件提交的哈希值,[p2]
为步骤 4 中追踪文件提交的哈希值,[head]
为当前分支 HEAD 提交的哈希值。使用 git commit-tree
命令,通过 -p
参数指定上述三个父提交,创建一个新的合并提交。新提交整合了所有变更内容,并且保留了清晰的提交历史脉络。命令示例:
bash
git commit-tree -p $commit_untracked -p $commit_tracked -p HEAD -m "on main: Consolidated changes"
步骤 6 恢复 index
根据步骤 0 中备份的 index_backup
文件,恢复暂存区状态。
bash
cp .git/index_backup .git/index
四、验证与回滚:确保 Stash 有效且可恢复
1. 验证 Stash 是否创建成功
执行git stash list
可看到我们手动创建的 Stash,与常规 Stash 格式一致:
bash
git stash list
# 输出示例:stash@{0}: WIP on main: a1b2c3d 初始化项目文档
2. 回滚 Stash(恢复变更)
由于我们的 Stash 完全符合 Git 原生格式,可直接用常规命令恢复:
bash
# 恢复Stash并删除Stash记录(同git stash pop)
git stash pop stash@{0}
# 或仅恢复不删除(同git stash apply)
# git stash apply stash@{0}
四、注意事项
1. 版本要求
git create-commit
是 Git 2.23.0 + 新增命令,若版本过低需先升级:
bash
# 查看Git版本
git --version
# 升级(以Ubuntu为例,其他系统参考官方文档)
sudo apt update && sudo apt install git -y
2. 避免数据丢失的关键
-
步骤 0 中 "备份索引(.git/index)" 必须执行,否则会覆盖已暂存的变更;
-
操作前可以执行
git diff
和git diff --staged
记录变更,便于异常时核对。
五、总结
本文方案的核心价值在于:不依赖 Git 自动打包,而是手动拆解 Stash 的底层结构 ,通过暂存区生成 tree object、并利用 git write-tree
git create-commit
构建父提交,最终实现 "不放弃变更、不用关闭占用程序" 的储藏。这种方式非常适合在游戏开发中引擎占用二进制资源时,进行数据备份操作。