包包baobaolin.com
date
entry
040
topic
tooling
rev
—

git push 印出 Everything up-to-date,遠端其實少了一個 commit

fatal: the remote end hung up unexpectedly 的下一行是 Everything up-to-date,遠端少了一個 commit。這句話印出的時候,最外層的 git push 還沒把失敗併進結果;前面幾行和結束碼都說失敗了,是我用 | tail 把它們截掉、換成了 0。

git push 印出「Everything up-to-date」,正常情況下的意思是:這次要推的 ref,遠端都不需要更新。這行字很容易被讀成「推送成功」。

但 10 月 2 日,一個網站的 repo 推一個 1,473 個檔案變更的 commit,輸出最後兩行是 fatal: the remote end hung up unexpectedly 和 Everything up-to-date,之後的紀錄顯示遠端的 main 還停在前一個 commit。10 月 8 日另一個 repo 印出同樣兩行,背景工作回報的結束碼是 0。這篇要談的是「連線斷了」和「全部是最新的」為什麼會一起出現。依序回答五件事:兩次的紀錄、本機重現、git 原始碼裡的原因、我為什麼看到 0,以及推完之後怎麼確認。

兩次的紀錄

兩次都是 HTTPS 推到 GitHub,第一次推送的指令裡都是 git push origin main 2>&1 | tail -2,只留最後兩行。

10 月 2 日那次,第一次推送的輸出就是上面那兩行。我接著試了兩種常見的建議:-c http.postBuffer=524288000,以及再加上 -c http.version=HTTP/1.1。重試時改留最後三行,才看到 send-pack: unexpected disconnect while reading sideband packet(第一次有沒有這行,截短的紀錄看不出來),最後一行仍是 Everything up-to-date,git rev-list --left-right --count HEAD...origin/main 都是 1 0,也就是本機比遠端追蹤分支多一個 commit。改用 SSH 推,輸出是遠端 main 從舊的 tip 更新到新的 tip,再 git fetch 之後比對變成 0 0,這才確定前面幾次都沒推上去。那個 commit 的物件事後用 git pack-objects --revs --thin 打包,大約 24 MB(實際推送時協商出的 pack 不一定完全一樣)。

10 月 8 日那次是另一個 repo,指令放在背景執行,完成通知寫著 exit code 0。我讀輸出才看到那兩行,git status -sb 顯示 [ahead 1];再推一次(這次加了 110 秒的 timeout、留最後三行)就成功了。同一個工作階段在那段時間也出現過連不上 API 伺服器的 ENOTFOUND,表示當時的連線可能不穩,但這不足以確定 GitHub 推送失敗的原因。

兩次斷線的根因,這些紀錄都沒有確認,那也不是這篇的重點。重點是兩次的最後一行都在說「沒事」。

本機重現:讓伺服器收到部分資料就掛斷

我在本機建了一個 bare repo,前面放一支三十行左右的 Python 伺服器:GET 請求交給 git http-backend 處理;對沒有 Content-Length 的 git-receive-pack POST(這次實測就是 chunked 的那一個),從原始輸入讀最多 4,096 個位元組就把連線關掉。工作目錄裡有一個 3 MB 隨機檔案的 commit,產生的請求大於 http.postBuffer 的預設值 1 MiB,-v 的輸出也確認 git 用的是 chunked 傳輸。用 Apple Git 2.50.1(下面省略了測試時加的 timeout):

$ git push http main
error: RPC failed; curl 56 Recv failure: Connection reset by peer
send-pack: unexpected disconnect while reading sideband packet
fatal: the remote end hung up unexpectedly
Everything up-to-date
$ echo $?
1

後三行和 10 月 2 日重試時的輸出一字不差(curl 的錯誤碼和訊息在幾次重跑之間是 55 或 56)。git push -v 多印 POST git-receive-pack (chunked),結尾一樣是 Everything up-to-date。--porcelain 給腳本讀的格式更直接,最後一行印 Done,結束碼同樣是 1。遠端的 main 停在舊的 commit。

對照組是不經過 HTTP 的本機傳輸:用 head -c 2000 把送給 git-receive-pack 的輸入截斷,git 印的是 ! [remote rejected] main -> main (unpacker error) 和 error: failed to push some refs,沒有 Everything up-to-date。兩組截斷的方式不同,所以我只能說:這次的 HTTP 重現,走的是下面這條 helper 狀態回報的路徑。

原因:helper 回報了一份空的結果

這種 smart HTTPS 推送,git push 不自己連線,而是啟動 git-remote-https 這個 remote helper(遠端輔助程式),再由它執行 send-pack。我讀了 git v2.50.1 的原始碼,四段接起來是這樣:

  1. remote-curl.c 的 rpc_service():RPC 出錯時,把 send-pack 剩下的輸出讀掉丟棄,不放進結果,所以這次沒有任何 ref 的狀態被轉送出去。
  2. 同一個檔案的 parse_push():推送結束後先 printf("\n"),然後才看結果,失敗就 exit(128)。那個空行在 helper 協定裡代表「ref 狀態回報結束」。
  3. transport-helper.c 的 push_update_refs_status():一行行讀 helper 的回報,讀到空行就結束迴圈、回傳 0。它把空行當成回報結束,沒有因為少了應有的 ref 狀態而報錯。
  4. transport.c 的 transport_push():push_ret 是 0,push_had_errors() 也不把「沒有狀態」算成錯誤,所以 ret = push_ret | err 是 0;最後一段 else if (!quiet && !ret && !transport_refs_pushed(remote_refs)) 就印 Everything up-to-date。transport_refs_pushed() 把「沒有狀態」和「已是最新」都當成沒推任何東西。

結束碼是後來才補上的。builtin/push.c 在推送之後呼叫 transport_disconnect(),它在 disconnect_helper() 裡等 helper 結束,拿到那個 128,git push 因此回 1。換句話說,send-pack 和 helper 都已經印了錯誤,但印 Everything up-to-date 的時候,最外層的 git push 還沒把 helper 的失敗結束碼併進結果。--porcelain 的 Done 也是同一個順序:它只看 push_ret,那時候還是 0。

我為什麼看到 0:tail 拿走了結束碼

本機重現裡 git push 本身回 1(原始兩次紀錄沒有保存 git 自己的結束碼)。沒開 pipefail 時,git push … 2>&1 | tail -2 這條 pipeline 的結束碼是 tail 的,而這裡 tail 正常結束,回 0。同一個重現接上 tail:

$ git push http main 2>&1 | tail -2
fatal: the remote end hung up unexpectedly
Everything up-to-date
$ echo "pipe-exit=$? pipestatus=${pipestatus[*]}"     # zsh
pipe-exit=0 pipestatus=1 0

在重現裡,tail -2 還順便切掉了 error: RPC failed; curl 56 … 和 send-pack 那兩行。剩下的兩行,一行說斷了,一行說沒事,最後那行很容易被讀成成功。10 月 8 日那次背景工作回報的 0,照那條 pipeline 的寫法(紀錄裡看不到有開 pipefail),應該就是 tail 的 0。

至於 http.postBuffer:git help config 的說明是,調大這個值只對「關掉 chunked 傳輸」有效,只該用在伺服器或 proxy 只支援 HTTP/1.0 或不符合 HTTP 標準的情況,「一般來說不是解決多數推送問題的有效方法」,而且整塊 buffer 連小推送都會配置,會多吃記憶體。10 月 2 日那次調大之後照樣斷,跟這段說明一致。

推完之後怎麼確認

直接問遠端比較可靠:git ls-remote 會向伺服器要它現在的 ref,不靠本機的 remote-tracking ref(遠端追蹤分支)。重現環境裡,推送失敗後遠端是 8ba0f15,本機 HEAD 是 d1737b7,一比就知道。推送後可以接這幾行:

set -o pipefail                       # bash 與 zsh 都有;不想開,就讀 PIPESTATUS / pipestatus
branch=$(git symbolic-ref --quiet --short HEAD) || { echo "不在任何分支上" >&2; exit 1; }
expected=$(git rev-parse HEAD)        # 推送前先記下要推的 commit
git push origin "$branch" 2>&1 | tail -5 || { echo "push 失敗" >&2; exit 1; }
remote=$(git ls-remote --exit-code origin "refs/heads/$branch" | cut -f1) || { echo "查詢遠端失敗,或遠端沒有 $branch" >&2; exit 1; }
[ "$remote" = "$expected" ] || { echo "遠端是 $remote,預期 $expected" >&2; exit 1; }

這段邏輯我在重現環境用 bash 和 zsh 各跑過(遠端換成假伺服器和一個本機 bare repo):推送中斷時兩者都在 tail 的輸出後印「push 失敗」、結束碼 1;推送成功時比對通過、結束碼 0。它假設 origin 的 fetch 和 push 指向同一個 repo;設了 pushurl 的話,要查的是實際推送的那一端。遠端的 tip 不等於預期時,也可能是別人剛好又推了新的 commit,要再看一眼。

結束碼和 ls-remote 兩道檢查只要一道就能抓到這次的情況,我兩道都留:前者擋住 git 自己知道的失敗,後者擋住「遠端不是我以為的那個 commit」。輸出還是可以截短,只是不要只看最後一行。

如果只記得一件事

在這條失敗路徑上,「Everything up-to-date」只表示 git 沒收到任何 ref 被更新的回報,不表示遠端有你的 commit。本機重現裡,前面幾行和 git 的結束碼都說了失敗(原始兩次紀錄沒有保存 git 自己的結束碼);開頭那兩次,是 | tail 把錯誤行截掉,沒開 pipefail 的 pipeline 又只回報了 tail 的 0。

修訂紀錄

  1. 首次發布