git 技巧(1): 存檔時,自動執行 git stash 保存檔案變更,避免誤刪
你我可能都有過一種經驗,就是好不容易寫了一大堆 code,但還沒準備好要commit,結果不小心手殘,把所有變更都 discard 了...這時候真的是欲哭無淚,因為 git 必須要透過 commit 才能留下紀錄,而 commit 之前的所有變更都不存在git 版本控管理面,當然也就無法救回了.....難道就沒有一種辦法可以不需要 commit 又能在git中留下紀錄?有的!以下就來介紹這個方法....
俗話說「預防勝於治療」,既然事情已經發生了無法挽回,總要想辦法避免再次發生類似情況。既然我們都用 git 做版本控管了,於是我想,能不能在每次存檔時,都用 git 對所有檔案變更做一次「快照」呢?答案是可以的,以下我將利用 git stash 指令,配合 VSCode 擴充套件 Run on Save 來達成這個功能。
首先,在 VSCode 的擴充套件商店搜尋 run on save,找到作者是 emeraldwalk 的版本,進行安裝:

按下快捷鍵 ctrl+shift+p 跳出指令輸入欄,輸入 settings json 後,選第一個 Open settings (JSON) 按下 enter:

在開啟的檔案中,加入以下設定欄位:

"emeraldwalk.runonsave": {
"shell": "bash",
"commands": [
{
"match": ".*",
"cmd": "echo '${fileBasename} saved at ${fileDirname}'"
},
{
"match": ".*",
"cmd": "cd \"${fileDirname}\" && lock=\"$(git rev-parse --git-dir)\"/runonsave.lock && if [ -n \"$(git status -s)\" ]; then if [ ! -f \"$lock\" ]; then touch \"$lock\" && git stash push -u && git stash apply --index --quiet && if [ -z \"$(diff <(git rev-parse stash@{1}~) <(git rev-parse stash@{0}~))\" ] && [ \"$(git diff --word-diff=porcelain stash@{1} stash@{0} -- . ':(exclude)*.ipynb' | grep -e '^+[^+]\\|^-[^-]' | wc -m)\" -lt 500 ] && [ \"$(git diff --word-diff=porcelain stash@{1}^3 stash@{0}^3 | grep -e '^+[^+]\\|^-[^-]' | wc -m)\" -lt 500 ];then git stash drop stash@{1} && rm \"$lock\"; else echo \"don't drop\" && rm \"$lock\"; fi; else echo \"locked!\"; fi; else echo \"No local changes to save\"; fi;"
}
]
}2021/05/27 Update 5:
"cd \"${fileDirname}\" && lock=\"$(git rev-parse --git-dir)\"/runonsave.lock && if [ -n \"$(git status -s)\" ]; then if [ ! -f \"$lock\" ]; then touch \"$lock\" && git stash push -u && git stash apply --index --quiet && if [ -z \"$(diff <(git rev-parse stash@{1}~) <(git rev-parse stash@{0}~))\" ] && [ \"$(git diff --word-diff=porcelain stash@{1} stash@{0} -- . ':(exclude)*.ipynb' | grep -e '^+[^+]\\|^-[^-]' | wc -m)\" -lt 3000 ] && [ \"$(git diff --word-diff=porcelain stash@{1}^3 stash@{0}^3 | grep -e '^+[^+]\\|^-[^-]' | wc -m)\" -lt 3000 ];then git stash drop stash@{1} && rm \"$lock\"; else echo \"don't drop\" && rm \"$lock\"; fi; else echo \"locked!\"; fi; else echo \"No local changes to save\"; fi;"文字差異變化量增加為 3000,因為原本的 500 太小了,有點小修改就一直保留stash。
2021/05/27 Update 4:
"cd \"${fileDirname}\" && lock=\"$(git rev-parse --git-dir)\"/runonsave.lock && if [ -n \"$(git status -s)\" ]; then if [ ! -f \"$lock\" ]; then touch \"$lock\" && git stash push -u && git stash apply --index --quiet && if [ -z \"$(diff <(git rev-parse stash@{1}~) <(git rev-parse stash@{0}~))\" ] && [ \"$(git diff --word-diff=porcelain stash@{1} stash@{0} -- . ':(exclude)*.ipynb' | grep -e '^+[^+]\\|^-[^-]' | wc -m)\" -lt 500 ] && [ \"$(git diff --word-diff=porcelain stash@{1}^3 stash@{0}^3 | grep -e '^+[^+]\\|^-[^-]' | wc -m)\" -lt 500 ];then git stash drop stash@{1} && rm \"$lock\"; else echo \"don't drop\" && rm \"$lock\"; fi; else echo \"locked!\"; fi; else echo \"No local changes to save\"; fi;"在 git diff 計算字數差異時排除 .ipynb 檔案,因為 .ipynb 每次執行會把 output 記錄下來,導致很容易超過 500 字門檻,變成每次只要 ipynb 執行產生不同的 output,就不會把舊的 stash drop 掉,結果是累積越來越多 stash 紀錄,但實際上根本沒有改到甚麼 code。
如果是想記錄每次執行 ipynb 的 output 結果呢?那麼應該在 jupyter notebook 的 save hook 中呼叫 stash ,或者在 VSCode 中另外再加上一個 runonsave command,這個 command 只有在 .ipynb 檔案 save 時才會執行,這樣就可以用在當使用 VSCode 開啟 jupyter notebook 時。
參考:Want to exclude file from "git diff" - Stack Overflow
2021/05/26 Update 3:
"cd \"${fileDirname}\" && lock=\"$(git rev-parse --git-dir)\"/runonsave.lock && if [ -n \"$(git status -s)\" ]; then if [ ! -f \"$lock\" ]; then touch \"$lock\" && git stash push -u && git stash apply --index --quiet && if [ -z \"$(diff <(git rev-parse stash@{1}~) <(git rev-parse stash@{0}~))\" ] && [ \"$(git diff --word-diff=porcelain stash@{1} stash@{0} | grep -e '^+[^+]\\|^-[^-]' | wc -m)\" -lt 500 ] && [ \"$(git diff --word-diff=porcelain stash@{1}^3 stash@{0}^3 | grep -e '^+[^+]\\|^-[^-]' | wc -m)\" -lt 500 ];then git stash drop stash@{1} && rm \"$lock\"; else echo \"don't drop\" && rm \"$lock\"; fi; else echo \"locked!\"; fi; else echo \"No local changes to save\"; fi;"三項改進:
- 在進入 lock 執行 stash 之前,先檢查是否有新增或修改的檔案。
因為有發現到一種情況,當沒有任何修改時,若執行 save,雖然git stash執行後沒有儲存任何東西,但仍會執行git stash apply,這時候反而會回復上一次 stash 的改變,這不是我們希望的。
解決辦法:用if檢查git status -s的結果,如果是空的,表示目前沒有任何變動,就不需要執行 stash。 - 檢查兩次 stash 是否相同祖先。
如果兩次 stash 的祖先的 SHA 不同,表示是 base on 不同的 commit ,因此不應該drop 較舊的stash。
這裡用到diff <(git rev-parse stash@{1}~) <(git rev-parse stash@{0})這個指令,其中波浪符(~) 為指向祖先,而git rev-parse則為解析該祖先的實際名稱(SHA),再利用diff比較兩次stash 的祖先 SHA。
參考:version control - How can I see the changes in a Git commit? - Stack Overflow - 使用改變字數為基礎的
git diff條件。
先前的 git diff 只是簡單的比較兩次 stash 之間是否有變化,但是這會造成只要有一點小改動 save 後就會產生一個 stash 紀錄,結果變成非常多 stash 紀錄,反而很難找。因此,改成比較兩次 stash 之間的文字變化量,如果增加及減少的字數總和小於500,則 drop 較舊的 stash;否則不要 drop,保留較舊的stash。
這裡用到指令git diff --word-diff=porcelain stash@{1} stash@{0} | grep -e '^+[^+]\|^-[^-]' | wc -m,參考: word count - Quantifying the amount of change in a git diff? - Stack Overflow
2021/05/26 Update 2:
"cd \"${fileDirname}\" && lock=\"$(git rev-parse --git-dir)\"/runonsave.lock && if [ ! -f \"$lock\" ]; then touch \"$lock\" && git stash push -u && git stash apply --index --quiet && if [ -z \"$(git diff stash@{1} stash@{0})\" ] && [ -z "$(git diff stash@{1}^3 stash@{0}^3)" ];then git stash drop stash@{1} && rm \"$lock\"; else echo \"don't drop\" && rm \"$lock\"; fi; else echo \"locked!\";fi"最近又發現一個 bug,當我用 VSCode 的 diff 視窗比較兩個檔案時,當按下ctrl+s,它會同時 trigger 兩組 runonsave command。這會導致 race condition,以至於有時候上一個 stash apply 回復太慢,下一個 stash push 又來,結果記錄到沒有檔案的情況,然後又 stash drop 掉前一個紀錄,導致記錄遺失。
解決辦法:引入 lock 檔案。找到 .git 資料夾,檢查底下是否存在 runonsave.lock 檔案,如果沒有,就 touch 產生一個,直到操作完畢才 rm 掉;如果有,表示目前已經有其他 runonsave command 正在運行,就直接回復 locked ,不進行任何操作。
並且,在比較前後兩次 stash 的差異方面,改用了 git diff stash@{1} stash@{0} ,比起比較 commit message 來的更準確。
限制:git diff 沒有辦法比較 stash 中 untracked 的檔案,這是因為 stash 的untracked 檔案的記錄方式與一般的檔案不同。若要顯示 stash 中的 untracked 檔案,可以執行以下指令:
git show stash@{0}^3因此,在 if 條件中再加上 && [ -z "$(git diff stash@{1}^3 stash@{0}^3)" ] 判斷是否兩次 stash 之間的 untracked file 沒有任何改動,如果有的話,就要執行 stash。
參考:
如何找到 .git 資料夾(支援submodule)?
version control - Is there a way to get the git root directory in one command? - Stack Overflow
git rev-parse --git-dirgit 的 index.lock 檔案
Understanding and Using Git's index.lock File | Pluralsight
顯示 git stash 中的 untracked 檔案
In git, is there a way to show untracked stashed files without applying the stash? - Stack Overflow
2021/05/14 Update 1:
"cd ${fileDirname} && oldstashmsg=$(git stash list -1 | cut -d: -f2-3) && git stash push -u && git stash apply --index --quiet && newstashmsg=$(git stash list -1 | cut -d: -f2-3) && if [ \"$newstashmsg\" = \"$oldstashmsg\" ];then git stash drop stash@{1}; else echo \"don't drop\"; fi"
之前的做法發現如果儲存的檔案是在 submodule 中,並不會 stash 到 submodule,而只會在 parent module 執行 stash,如此就無法記錄到 submodule 下的變更。因此,需要在 stash 之前,cd 到儲存檔案所在的資料夾下,這樣執行 git stash 才會正確暫存到 submodule 的 repo 中。
上面這段設定主要分成兩個指令:
第一個指令是一個簡單印出一句話的指令:
"echo 'ruu on file save...'"第二個指令才是我們主要的 git stash 指令:
"oldstashmsg=$(git stash list -1 | cut -d: -f2-3) && git stash push -u && git stash apply --index --quiet && newstashmsg=$(git stash list -1 | cut -d: -f2-3) && if [ \"$newstashmsg\" = \"$oldstashmsg\" ];then git stash drop stash@{1}; else echo \"don't drop\"; fi"我把它攤平來解釋:
# step1: 將最近一次 git stash 的 commit message 暫存到變數 oldstashmsg
oldstashmsg=$(git stash list -1 | cut -d: -f2-3)
# step 2: 執行 git stash push -u,將所有修改的檔案暫存,其中 -u 是包含所有 untrack 的檔案(新增的還沒有加到版本控管的檔案)
git stash push -u
# step 3: 由於 git stash 暫存會將所有變更清除,若要恢復原狀,則須執行 git stash apply --index --quiet ,其中 --index 是回覆原來暫存變更的狀態, --quiet 是不要輸出訊息
git stash apply --index --quiet
# step 4: 將最近一次 git stash 的 commit message 暫存到變數 newstashmsg,如果step 2執行成功,就會得到本次新增的 commit message
newstashmsg=$(git stash list -1 | cut -d: -f2-3)
# step 5: 比較 newstashmsg 跟 oldstashmsg 是否相同,如果相同,表示前後兩次 stash 都是在同一個 commit 上所作的修改,所以可以把之前的 stash 版本(stash@{1})移除;如果不相同,表示這次stash 跟之前的版本是基於不同的 commit,所以應該把之前的版本保留下來
if [ "$newstashmsg" = "$oldstashmsg" ];
then
git stash drop stash@{1};
else
echo "don't drop";
fi注意到由於 runonsave 的 cmd 設定是單行的,所以我用 && 把每一個 step 接起來,表示只要中間有一個步驟出錯,就不會繼續執行;並且用双引號 " 將整串括起來,但是記得 if [...] 中間的變數必須要有双引號才能正確表示字串,這裡的双引號就必須要加上跳出符號 \" 才能正確執行
另外,shell 需要設定為 bash,在windows 底下才可以用git bash 正常執行,否則預設會用 cmd 執行,那就會出錯了。
參考:
bash - How can I escape a double quote inside double quotes? - Stack Overflow
Is there any way to save my current change without Commit or Stash in Git? - Stack Overflow
設定好後儲存,以後每當按下 ctrl+s 儲存時,就會自動產生一個 stash 快照紀錄:

然後在下方輸出欄,可以看到執行結果的紀錄:

其中會紀錄下移除的 stash 的 hash:
Dropped stash@{1} (52d57c84b17cf4f3cd190c808b776aff3f61581e)在某些情況下,你可能需要取回被移除掉的 stash,這時可以執行:
git branch recovered $stash_hash
這會建立一個新 branch 名為 recovered 在已經移除的 stash 上,切換到該branch 就可將檔案取回來。

參考: recovery - How to recover a dropped stash in Git? - Stack Overflow
類似地,如果要回復的 stash 版本還存在,只要執行
git branch recovered stash@{version}其中 recovered 可以換成任意名稱, version 可以是 0~n 看你要哪一版。
因此,之要曾經有快照過的檔案,理論上這些檔案變更都會存在 git 當中,只要知道 commit hash 就能夠輕易將檔案復原。