无需放弃变更、关闭占用程序!用暂存区和 git底层命令实现 Git 变更备份

无需放弃变更、关闭占用程序!用暂存区和 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. 父提交 1( stash@{0}^1 :即git log能看到的最后一个 commit,这是储藏时当前分支的最新提交(HEAD);

  2. 父提交 2( stash@{0}^2 :包含了储藏时暂存区(Index,又名 staging area)中已通过git add操作、但尚未提交的变更集;

  3. 父提交 3( stash@{0}^3 :仅在使用git stash -ugit 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 objectgit 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> 查看树对象的具体内容。

具体参考: Git - git-write-tree Documentation

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 用于添加提交信息。以下是几种简单的使用示例:

  1. 创建基础提交

    假设已通过 git write-tree 命令获取到树对象哈希值 abc123,创建一个无父提交(适用于初始提交场景)的提交:

sql 复制代码
git commit-tree abc123 -m "Initial commit"
  1. 创建合并提交

    假设有两个父提交 ghi789jkl012,以及对应的树对象 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 diffgit diff --staged记录变更,便于异常时核对。

五、总结

本文方案的核心价值在于:不依赖 Git 自动打包,而是手动拆解 Stash 的底层结构 ,通过暂存区生成 tree object、并利用 git write-tree git create-commit构建父提交,最终实现 "不放弃变更、不用关闭占用程序" 的储藏。这种方式非常适合在游戏开发中引擎占用二进制资源时,进行数据备份操作。

相关推荐
_poplar_5 小时前
15 【C++11 新特性】统一的列表初始化和变量类型推导
开发语言·数据结构·c++·git·算法
北城笑笑5 小时前
Git 10 ,使用 SSH 提升 Git 操作速度实践指南( Git 拉取推送响应慢 )
前端·git·ssh
蓁蓁啊11 小时前
GIT使用SSH 多账户配置
运维·git·ssh
相与还16 小时前
IDEA和GIT实现cherry pick拣选部分变更到新分支
git·elasticsearch·intellij-idea
刘志辉1 天前
git指令
git
2501_916766541 天前
【Git学习】初识git:简单介绍及安装流程
git·学习
vortex51 天前
Shell脚本技巧:去除文件中字符串两端空白
linux·bash·shell·sed·awk
孤独的追光者1 天前
Git 完整流程:从暂存到推送
git