第 13 天:暂存工作目录与索引的变更状态

有没有遇过这种情境,某个系统开发写到一半,结果被老板或客户「插单」,被要求紧急修正一个现有系统的 Bug 或添加一个功能,眼前的程式即将完成,老板的「急件」又不能拖,一个未完成的软体开发状态外加紧急调整的需求,这简直是软体品质的一大考验。如果你有这种困扰,那麽 Git 可以漂亮的帮你完成任务。

认识 git stash 指令

我们知道使用 Git 版控的时候,有区分「工作目录」与「索引」。工作目录裡面会有你改到一半还没改完的档案(尚未加入索引),也有新增档案但还没加入的档案(尚未加入索引)。而放在索引的资料,则是你打算透过 git commit 建立版本 (建立 commit 物件) 的内容。

当你功能开发到一半,被紧急插单一定手忙脚乱,尤其是手边正改写到一半的那些程式码不知该如何是好。在 Git 裡有个 git stash 指令,可以自动帮你把改写到一半的那些档案建立一个「特殊的版本」(也是一个 commit 物件),我们称这些版本为 stash 版本,或你可以直接称他为「暂存版」。

建立暂存版本

我们手边改到一半的档案,可能会有以下状态:

  • 新增档案 (尚未列入追踪的档案) (untracked files)
  • 新增档案 (已经加入索引的档案) (tracked/staged files)
  • 修改档案 (尚未加入索引的档案) (tracked/unstaged files)
  • 修改档案 (已经加入索引的档案) (tracked/staged files)
  • 删除档案 (尚未加入索引的档案) (tracked/unstaged files)
  • 删除档案 (已经加入索引的档案) (tracked/staged files)

若要将这些开发到一半的档案建立一个「暂存版」,你有两个选择:

  • git stash 会将所有已列入追踪(tracked)的档案建立暂存版
  • git stash -u 会包括所有已追踪或未追踪的档案,全部都建立成暂存版

: git stash 也可以写成 git stash save,两个指令的结果是一样的,只是 save 参数可以忽略不打而已。

我们来看看一个简单的例子。我们先透过以下指令快速建立一个拥有两个版本的 Git 储存库与工作目录:

mkdir git-stash-demo
cd git-stash-demo
git init

echo. > a.txt
git add .
git commit -m "Initial commit"

echo 1 > a.txt
git add .
git commit -m "a.txt: set 1 as content"

目前的「工作目录」是乾淨的,没有任何更新到一半的档案:

C:\git-stash-demo>git log
commit 95eff6b19a9494667985ed5da37427bb08b8cdd7
Author: Will <doggy.huang@gmail.com>
Date:   Fri Oct 11 08:17:15 2013 +0800

    a.txt: set 1 as content

commit 346fadefdd6ed2c562201b5ca37d1e4d26b26d54
Author: Will <doggy.huang@gmail.com>
Date:   Fri Oct 11 08:17:14 2013 +0800

    Initial commit

C:\git-stash-demo>git status
# On branch master
nothing to commit, working directory clean

C:\git-stash-demo>dir
 磁碟区 C 中的磁碟是 System
 磁碟区序号:  361C-6BD6

 C:\git-stash-demo 的目录

2013/10/11  上午 08:17    <DIR>          .
2013/10/11  上午 08:17    <DIR>          ..
2013/10/11  上午 08:17                 4 a.txt
               1 个档案               4 位元组
               2 个目录   9,800,470,528 位元组可用

接著我新增一个 b.txt,再将 a.txt 的内容改成 2,如下:

C:\git-stash-demo>type a.txt
1

C:\git-stash-demo>echo 2 > a.txt

C:\git-stash-demo>type a.txt
2

C:\git-stash-demo>echo TEST > b.txt

C:\git-stash-demo>dir
 磁碟区 C 中的磁碟是 System
 磁碟区序号:  361C-6BD6

 C:\git-stash-demo 的目录

2013/10/11  上午 08:55    <DIR>          .
2013/10/11  上午 08:55    <DIR>          ..
2013/10/11  上午 08:54                 4 a.txt
2013/10/11  上午 08:55                 7 b.txt
               2 个档案              11 位元组
               2 个目录   9,704,288,256 位元组可用

C:\git-stash-demo>git status
# On branch master
# Changes not staged for commit:
#   (use "git add <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
#
#       modified:   a.txt
#
# Untracked files:
#   (use "git add <file>..." to include in what will be committed)
#
#       b.txt
no changes added to commit (use "git add" and/or "git commit -a")

现在我们用 git status 得出我们有两个档案有变更,一个是 a.txt 处于 "not staged" 状态,而 b.txt 则是 "untracked" 状态。

这时,我们利用 git stash -u 即可将目前这些变更全部储存起来 (包含 untracked 档案),储存完毕后,这些变更全部都会被重置,新增的档案会被删除、修改的档案会被还原、删除的档案会被加回去,让我们目前在工作目录中所做的变更全部回复到 HEAD 状态。这就是 Stash 帮我们做的事。如下所示:

C:\git-stash-demo>git stash -u
Saved working directory and index state WIP on master: 95eff6b a.txt: set 1 as c
ontent
HEAD is now at 95eff6b a.txt: set 1 as content

C:\git-stash-demo>git status
# On branch master
nothing to commit, working directory clean

在建立完成「暂存版」之后,Git 会顺便帮我们建立一个暂存版的「参考名称」,而且是「一般参考」,在 .git\refs\stash 储存的是一个 commit 物件的「绝对名称」:

C:\git-stash-demo>dir .git\refs\
 磁碟区 C 中的磁碟是 System
 磁碟区序号:  361C-6BD6

 C:\git-stash-demo\.git\refs 的目录

2013/10/11  上午 08:57    <DIR>          .
2013/10/11  上午 08:57    <DIR>          ..
2013/10/11  上午 08:57    <DIR>          heads
2013/10/11  上午 08:57                41 stash
2013/10/11  上午 08:17    <DIR>          tags
               1 个档案              41 位元组
               4 个目录   9,701,650,432 位元组可用

我们用 git cat-file -p stash 即可查出该物件的内容,这时你可以发现它其实就是个具有三个 parent (上层 commit 物件) 的 commit 物件:

C:\git-stash-demo>git cat-file -p stash
tree 86cf41ab650d8d0ce5fdd003bb7b722a917438a2
parent 95eff6b19a9494667985ed5da37427bb08b8cdd7
parent b79c4650e72ad4627d691a2d6cfb192626e24e94
parent 9b4e4a100776783dc76d16c3872235e6314d15e3
author Will <doggy.huang@gmail.com> 1381453062 +0800
committer Will <doggy.huang@gmail.com> 1381453062 +0800

WIP on master: 95eff6b a.txt: set 1 as content

有三个 parent commit 物件的意义就在于,这个特殊的暂存版是从另外三个版本合併进来的,然而这三个版本的内容,我们一样可以透过相同的指令显示其内容:

C:\git-stash-demo>git cat-file -p 95ef
tree eba2ef4205738a5015fc47d9cfe634d7d5eae466
parent 346fadefdd6ed2c562201b5ca37d1e4d26b26d54
author Will <doggy.huang@gmail.com> 1381450635 +0800
committer Will <doggy.huang@gmail.com> 1381450635 +0800

a.txt: set 1 as content

C:\git-stash-demo>git cat-file -p b79c
tree eba2ef4205738a5015fc47d9cfe634d7d5eae466
parent 95eff6b19a9494667985ed5da37427bb08b8cdd7
author Will <doggy.huang@gmail.com> 1381453061 +0800
committer Will <doggy.huang@gmail.com> 1381453061 +0800

index on master: 95eff6b a.txt: set 1 as content

C:\git-stash-demo>git cat-file -p 9b4e
tree b583bfe854b66756dd0f8ee96cab0c898193b5fd
author Will <doggy.huang@gmail.com> 1381453062 +0800
committer Will <doggy.huang@gmail.com> 1381453062 +0800

untracked files on master: 95eff6b a.txt: set 1 as content

从上述执行结果你应该可以从「讯息纪录」的地方清楚看出这三个版本分别代表那些内容:

  1. 原本工作目录的 HEAD 版本
  2. 原本工作目录裡所有追踪中的内容 (在索引中的内容)
  3. 原本工作目录裡所有未追踪的内容 (不在索引中的内容)

也就是说,他把「原本工作目录的 HEAD 版本」先建立两个暂时的分支,这两个分支分别就是「原本工作目录裡所有追踪中的内容」与「原本工作目录裡所有未追踪的内容」之用,并在个别分支建立了一个版本以产生 commit 物件并且给予预设的 log 内容。最后把这三个分支,合併到一个「参照名称」为 stash 的版本 (这也是个 commit 物件)。不仅如此,他还把整个「工作目录」强迫重置为 HEAD 版本,把这些变更与新增的档案都给还原,多的档案也会被移除。

取回暂存版本

由于「工作目录」已经被重置,所以变更都储存到 stash 这裡,哪天如果你想要把这个暂存档案取回,就可以透过 git stash pop 重新「合併」回来。如下所示:

C:\git-stash-demo>git status
# On branch master
nothing to commit, working directory clean

C:\git-stash-demo>git stash pop
# On branch master
# Changes not staged for commit:
#   (use "git add <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
#
#       modified:   a.txt
#
# Untracked files:
#   (use "git add <file>..." to include in what will be committed)
#
#       b.txt
no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (0e5b72c96fcf693e0402c40cd58f980bb3ff7efd)

执行完毕后,所有当初的工作目录状态与索引状态都会被还原。事实上 Git 骨子裡是透过「合併」的功能把这个名为 stash 的版本给合併回目前分支而已。最后,它还会自动将这个 stash 分支给删除,所以称它为【暂存版】非常贴切!

建立多重暂存版

Git 的 stash 暂存版可以不只一份,你也可以建立多份暂存档,以供后续使用。不过,在正常的开发情境下,通常不会有太多暂存版才对,会有这种情况发生,主要有两种可能:

  1. 你的开发习惯太差,导致累积一堆可能用不到的暂存版。
  2. 你老板或客户「插单」的问题十分严重,经常改到一半就被迫插单。(这就是身为 IT 人的 BI 啊~~~XD) (BI = Business Intelligence 或另一层意思... Well, you know....)

我们延续上一个例子,目前工作目录的状态应该是有两个档案有变化,我们用 git status -s 取得工作目录的状态(其中 -s 代表显示精简版的状态):

C:\git-stash-demo>git status -s
 M a.txt
?? b.txt

现在,我们先建立第一个 stash 暂存版:

C:\git-stash-demo>git stash save -u
Saved working directory and index state WIP on master: 95eff6b a.txt: set 1 as content
HEAD is now at 95eff6b a.txt: set 1 as content

然后透过 git stash list 列出目前所有的 stash 清单,目前仅一份暂存版:

C:\git-stash-demo>git stash list
stash@{0}: WIP on master: 95eff6b a.txt: set 1 as content

而且你可以看到建立暂存版之后,工作目录是乾淨的。此时我们在建立另一个 new.txt 档案,并且再次建立暂存版:

C:\git-stash-demo>git status -s

C:\git-stash-demo>echo 1 > new.txt

C:\git-stash-demo>git status -s
?? new.txt

C:\git-stash-demo>git stash save -u
Saved working directory and index state WIP on master: 95eff6b a.txt: set 1 as c
ontent
HEAD is now at 95eff6b a.txt: set 1 as content

我们在再一次 git stash list 就可以看到目前有两个版本:

C:\git-stash-demo>git stash list
stash@{0}: WIP on master: 95eff6b a.txt: set 1 as content
stash@{1}: WIP on master: 95eff6b a.txt: set 1 as content

你应该会很纳闷,都没有自订的注解,过了几天不就忘记这两个暂存档各自的修改项目吗?没错,所以你可以自订「暂存版」的纪录讯息。我们透过 git stash save -u <message> 指令,就可自订暂存版的注解:

C:\git-stash-demo>git stash -h
usage: git core\git-stash list [<options>]
   or: git core\git-stash show [<stash>]
   or: git core\git-stash drop [-q|--quiet] [<stash>]
   or: git core\git-stash ( pop | apply ) [--index] [-q|--quiet] [<stash>]
   or: git core\git-stash branch <branchname> [<stash>]
   or: git core\git-stash [save [--patch] [-k|--[no-]keep-index] [-q|--quiet]
                       [-u|--include-untracked] [-a|--all] [<message>]]
   or: git core\git-stash clear

C:\git-stash-demo>git stash pop
Already up-to-date!
# On branch master
# Untracked files:
#   (use "git add <file>..." to include in what will be committed)
#
#       new.txt
nothing added to commit but untracked files present (use "git add" to track)
Dropped refs/stash@{0} (5800f37937aea5fb6a1aba0d5a1598a940e70c96)

C:\git-stash-demo>git stash save -u "新增 new.txt 档案"
Saved working directory and index state On master: 新增 new.txt 档案
HEAD is now at 95eff6b a.txt: set 1 as content

C:\git-stash-demo>git stash list
stash@{0}: On master: 新增 new.txt 档案
stash@{1}: WIP on master: 95eff6b a.txt: set 1 as content

这时,如果你直接执行 git stash pop 的话,他会取回最近的一笔暂存版,也就是上述例子的 stash@{0} 这一项,并且把这一笔删除。另一种取回暂存版的方法是透过 git stash apply 指令,唯一差别则是取回该版本 (其实是执行合併动作) 后,该暂存版还会留在 stash 清单上。

如果你想取回「特定一个暂存版」,你就必须在最后指名 stash id,例如 stash@{1} 这样的格式。例如如下范例,我使用 git stash apply "stash@{1}" 取回前一个暂存版,但保留这版在 stash 清单裡:

C:\git-stash-demo>git stash list
stash@{0}: On master: 新增 new.txt 档案
stash@{1}: WIP on master: 95eff6b a.txt: set 1 as content

C:\git-stash-demo>git stash apply "stash@{1}"
# On branch master
# Changes not staged for commit:
#   (use "git add <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
#
#       modified:   a.txt
#
# Untracked files:
#   (use "git add <file>..." to include in what will be committed)
#
#       b.txt
no changes added to commit (use "git add" and/or "git commit -a")

C:\git-stash-demo>git stash list
stash@{0}: On master: 新增 new.txt 档案
stash@{1}: WIP on master: 95eff6b a.txt: set 1 as content

如果确定合併正确,你想删除 stash@{1} 的话,可以透过 git stash drop "stash@{1}" 将特定暂存版删除。

C:\git-stash-demo>git stash drop "stash@{1}"
Dropped stash@{1} (118cb8a7c0b763c1343599027d79f7b20df57ebf)

C:\git-stash-demo>git stash list
stash@{0}: On master: 新增 new.txt 档案

如果想清理掉所有的暂存版,直接下达 git stash clear 即可全部删除。

C:\git-stash-demo>git stash list
stash@{0}: On master: 新增 new.txt 档案

C:\git-stash-demo>git stash clear

C:\git-stash-demo>git stash list

今日小结

Git 的 stash (暂存版) 机制非常非常的实用,尤其是在 IT 业界插单严重的工作环境下 (不只台湾这样,世界各地的 IT 业界应该也差不多),这功能完全为我们量身打造,非常的贴心。在 Subversion 裡就没有像 Git 这麽简单,一个指令就可以把工作目录与索引的状态全部存起来。

本篇文章也试图透过指令了解 stash 的核心机制,其实就是简单的「分支」与「合併」而已,由此可知,整套 Git 版本控管机制,大多是以「分支」与「合併」的架构在运作。

我重新整理一下本日学到的 Git 指令与参数:

  • git stash
  • git stash -u
  • git stash save
  • git stash save -u
  • git stash list
  • git stash pop
  • git stash apply
  • git stash pop "stash@{id}"
  • git stash apply "stash@{id}"
  • git stash drop "stash@{id}"
  • git stash clear

参考链接

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8