↓快轉到主要內容
  1. 教學文章/

Git bundle 離線交接:完整封裝、增量更新與還原驗收

·8 分鐘· loading · loading · ·
Git Git Bundle Offline Backup Prerequisites Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
版本控制: Git - 本文屬於一個選集。
§ 21: 本文

一. 前言:不能 Push,也不想只寄一份 ZIP
#

拍拍醬要把專案交給一台無法連 Git server 的機器。

寄工作目錄的 ZIP 很直覺,但對方還想看 commit、切 branch、比較上一版,怎麼辦?

Git bundle 可以把指定 refs 與相關 Git objects 裝進單一檔案,讓另一端用 clone 或 fetch 接收。

它不是另一種遠端伺服器;你不能對 bundle 做 git push。

它更不是「整台開發機的備份」。未提交修改、外部大檔與本機設定,都要另外交代。

今天跟著拍拍君完成一條小管線:完整包啟動、增量包更新、故意漏掉前置歷史,再做內容驗收。

先前的 Partial Clone 教學問「物件在不在本機」;本篇則問「這包交接檔能不能在另一端還原成約定版本」。

二. 先訂契約:你要交接哪些歷史?
#

三種常見交付物,保證的東西不同:

方式 主要交付 接收端需要什麼
原始碼快照 某個版本的檔案 解壓縮工具;沒有完整 Git 歷史
完整 bundle 所選 refs 的完整可達物件 相容的 Git;不需既有共同歷史
增量 bundle 排除基準歷史後的更新 必須已具備 prerequisites 及其完整連結

這裡的「完整」永遠有範圍:只選 main,就不是把所有其他 branch 都打包了。

本文使用完整、非 shallow、非 partial 的小 repo,物件格式採 SHA-1,方便隔離傳輸語意。

真實專案若使用 SHA-256,兩端都要支援相同 object format;bundle 不會替你轉換雜湊格式。

Shallow/partial repo 請先另外取得完整來源,別看到副檔名是 .bundle 就當成自足封存。

三. 安裝與實驗環境:只在新目錄操作
#

Bundle 是 Git 內建指令,不需要 pip 套件。

macOS、Linux、Windows 安裝 Git 後,可在 Bash/Git Bash 執行以下範例:

git --version

本文以 Git 2.50.1 實測,採同一個 Bash session,變數會沿用;預期失敗已包在 if 中,不必關閉錯誤檢查。

先建立拋棄式實驗,不要切到正在工作的專案:

set -eu
lab_dir=$(mktemp -d)
mkdir "$lab_dir/handoff"
cd "$lab_dir"
git init --object-format=sha1 -b main sender
cd sender
git config user.name '拍拍君'
git config user.email 'pypy@example.invalid'
git config commit.gpgsign false
git config tag.gpgsign false

printf '# Pypy delivery\n' > README.md
printf 'version one\n' > report.txt
git add README.md report.txt
git commit -m 'Start offline delivery'
git branch review
git tag -a v1 -m 'First handoff'
git rev-parse main > "$lab_dir/handoff/base-main.oid"

現在有兩條 branch 與一個 annotated tag。後面不硬編 commit ID,因為提交時間不同,OID 就可能不同。

四. 完整包:把範圍與 Ref 清單一起交付
#

先明確選 branch 與 tag,不把所有本機 ref 都默默外送:

git for-each-ref --format='%(objectname) %(refname)' \
  refs/heads refs/tags | LC_ALL=C sort \
  > "$lab_dir/handoff/full.refs"
git bundle create "$lab_dir/handoff/full.bundle" --branches --tags
git bundle list-heads "$lab_dir/handoff/full.bundle" \
  | LC_ALL=C sort > "$lab_dir/handoff/advertised.refs"
diff -u "$lab_dir/handoff/full.refs" "$lab_dir/handoff/advertised.refs"

git init --bare --object-format=sha1 "$lab_dir/empty-check.git"
git -C "$lab_dir/empty-check.git" bundle verify \
  "$lab_dir/handoff/full.bundle"

list-heads 列的是 bundle 廣告的 refs,不是所有物件清單,也不是接收端一定建立好的 branch。

verify 要在 repository 情境中執行。刻意用空 repo 驗證完整包,才能確認它沒有要求既有基準。

但別把這個成功訊息當成逐一檢查全部內容;後面仍會真正解包、fsck 與比較檔案。

Annotated tag 的 ref 指向 tag object,不是直接指向 commit;清單保留原始 ref OID,不擅自改成 peeled commit。

--all 範圍更廣,可能含 remote-tracking refs、自訂 refs,甚至不打算交接的歷史。先審核再選,不是越多越安全。

Bundle 會攜帶所選歷史中的作者資訊與舊檔案;刪掉目前版本的敏感檔,不等於歷史裡沒有它。

五. 還原演練:不要只在原 Repo 裡自我安慰
#

把來源改名,讓封裝時的原路徑消失,再從交接檔啟動兩種還原:

cd "$lab_dir"
mv sender sender-away
git clone --mirror "$lab_dir/handoff/full.bundle" restored.git
git -C restored.git for-each-ref --format='%(objectname) %(refname)' \
  refs/heads refs/tags | LC_ALL=C sort > "$lab_dir/restored.refs"
diff -u "$lab_dir/handoff/full.refs" "$lab_dir/restored.refs"
git -C restored.git symbolic-ref HEAD refs/heads/main
git -C restored.git fsck --full

git clone -b main "$lab_dir/handoff/full.bundle" receiver
test "$(git -C receiver rev-parse HEAD)" = \
  "$(cat "$lab_dir/handoff/base-main.oid")"
test "$(git -C receiver show HEAD:report.txt)" = 'version one'
test ! -e "$lab_dir/receiver/.git/objects/info/alternates"
git -C receiver status --short

Mirror 用來核對保存的 refs;一般 clone 用來模擬拍拍醬真正開發的工作目錄。

兩者不要混淆:一般 clone 通常只建立被 checkout 的本機 branch,其他 branch 變成 remote-tracking refs。

本範例的 mirror 也有自己的 config,不是還原來源的所有本機設定。

這包沒有另廣告 HEAD,因此 mirror 的預設 HEAD 可能指向不存在的預設分支;我們依交接規格明確指回 main,不把 refs 清單相同誤當成所有 metadata 相同。

所有 clone/fetch 來源都是本機交接檔,沒有使用 HTTP/SSH server;這是本機傳輸演練,不宣稱整台主機已斷網。

origin 指向 bundle 檔案,而不是原來的 GitHub repo。日後不會自己上網同步,也不能 push 回這個檔案。

實際交接時,把 handoff/ 複製到接收機,再使用接收機上的絕對路徑即可。

六. 增量包:省下的內容,變成接收端的責任
#

現在來源多一個 commit,只想交付基準之後的更新:

cd "$lab_dir/sender-away"
printf 'version two\n' > report.txt
git commit -am 'Update offline report'
base_oid=$(cat "$lab_dir/handoff/base-main.oid")
git merge-base --is-ancestor "$base_oid" refs/heads/main
git bundle create "$lab_dir/handoff/update-01.bundle" \
  "$base_oid..refs/heads/main"
git rev-parse main > "$lab_dir/handoff/target-main.oid"
git rev-parse 'main^{tree}' > "$lab_dir/handoff/target-main.tree"
git show main:report.txt > "$lab_dir/handoff/target-report.txt"
git bundle list-heads "$lab_dir/handoff/update-01.bundle"

base..main 不是「把兩個版本的檔案 diff 寄出去」。它選取 main 可達、但 base 不可達的歷史與必要新增物件。

排除掉的歷史,形成接收端要先具備的 prerequisites。複雜拓樸可能列出不只一個前置 commit。

右側使用可命名的 refs/heads/main;不要任意把兩側都換成裸 OID,就期待 bundle 自動知道要廣告哪個 ref。

我們先檢查 base 是 main 的祖先,將本篇限制在可 fast-forward 的交接,不處理偷偷重寫歷史的情況。

此增量包只廣告 main。新 tag、其他 branch、刪除 ref 都不是這條指令順便完整同步的東西。

七. 故意失敗:空 Repo 不能拿增量包開局
#

先在剛才的空 repo 嘗試驗證,再測試 clone:

if git -C "$lab_dir/empty-check.git" bundle verify \
  "$lab_dir/handoff/update-01.bundle"; then
  printf '錯誤:空 repo 不應具備基準\n' >&2
  exit 1
else
  printf '預期失敗:接收端缺 prerequisites\n'
fi

if git clone -b main "$lab_dir/handoff/update-01.bundle" \
  "$lab_dir/invalid-restore"; then
  printf '錯誤:增量包不應能獨立 clone\n' >&2
  exit 1
else
  printf '預期失敗:先還原完整包,再套增量\n'
fi

看到缺少 prerequisite 的 OID,應該找回相應完整包或之前的增量鏈。

不要直接把檔案叫作 full.bundle 再試一次;檔名不會改變包內的前置條件。

也不要只在寄件端跑 verify。寄件端本來就有舊歷史,成功不代表接收端也有。

若增量鏈長到難管理,可以重新做一份已驗收的完整包,降低還原時需要依序保存的檔案數量。

八. 正常更新:先 Fetch 到交接 Ref,再決定合併
#

回到已從完整包啟動的 receiver:

cd "$lab_dir/receiver"
test -z "$(git status --porcelain)"
git bundle verify "$lab_dir/handoff/update-01.bundle"
git fetch "$lab_dir/handoff/update-01.bundle" \
  refs/heads/main:refs/remotes/delivery/main
git log --oneline HEAD..refs/remotes/delivery/main
git merge --ff-only refs/remotes/delivery/main

test "$(git rev-parse HEAD)" = \
  "$(cat "$lab_dir/handoff/target-main.oid")"
test "$(git rev-parse 'HEAD^{tree}')" = \
  "$(cat "$lab_dir/handoff/target-main.tree")"
git show HEAD:report.txt > "$lab_dir/received-report.txt"
cmp "$lab_dir/handoff/target-report.txt" "$lab_dir/received-report.txt"
git fsck --full
test -z "$(git status --porcelain)"

這裡刻意不把 fetch 直接寫入正在 checkout 的 main。

先放入 delivery/main,就能審核更新,再用 --ff-only 拒絕意外的分岔;它不是自動替你解衝突。

接收端如果有自己的 commit,應先停下來另做整合決策,而不是順手加 --force 或 reset --hard。

我們分三層核對:commit OID、tree OID、具體檔案位元組。

OID 一樣已提供很強的內容關聯;明列 tree 與檔案檢查,是為了讓交接報告能指出「期待的產物」到底是什麼。

fsck 檢查物件關係;若交付要求能 build 或 test,仍要在接收環境執行專案自己的驗收。

九. 基準要等 ACK,不是「寄出去就算收到了」
#

下一次增量要從哪裡開始?從對方已確認具備的版本開始。

先重送同一包,確認不會多長出一顆 commit,再寫入接收確認:

git fetch "$lab_dir/handoff/update-01.bundle" \
  refs/heads/main:refs/remotes/delivery/main
git merge --ff-only refs/remotes/delivery/main
git rev-parse HEAD > "$lab_dir/handoff/ack-main.oid"

cd "$lab_dir/sender-away"
cmp "$lab_dir/handoff/target-main.oid" "$lab_dir/handoff/ack-main.oid"
git tag handoff/receiver-main "$(cat "$lab_dir/handoff/ack-main.oid")"

這個 tag 是寄件端的交接 checkpoint,不代表軟體 release,也不等於數位簽章。

真實環境由接收端回傳 ACK 與驗收結果;本機範例只是用同一個交接目錄模擬訊息往返。

下一輪可以以 handoff/receiver-main..refs/heads/main 為範圍,但仍須先確認祖先關係與新的目標 OID。

若還沒有任何新 commit,Git 可能拒絕建立空增量包;這不是傳輸壞掉,而是沒有更新可寄。

不要在收件方還沒驗收時就推進 checkpoint,否則第二包可能依賴一個對方根本沒收到的第一包。

Ref 刪除也要另外列在交接規格:某個 branch 沒出現在新 bundle,不等於接收端應自動刪掉它。

十. 邊界實驗:有髒檔案,Bundle 還是能成功
#

讓來源同時帶著已 staged、未 staged、未追蹤的資料,再打一次 main:

cd "$lab_dir/sender-away"
printf 'local change\n' >> README.md
printf 'staged only\n' > staged.txt
git add staged.txt
printf 'private scratch\n' > scratch.txt
git config pypy.lab-marker sender-only
printf 'local hook marker\n' > .git/hooks/pypy-marker
git status --porcelain > "$lab_dir/status-before.txt"

git bundle create "$lab_dir/handoff/committed-only.bundle" main
git status --porcelain > "$lab_dir/status-after.txt"
cmp "$lab_dir/status-before.txt" "$lab_dir/status-after.txt"
git clone -b main "$lab_dir/handoff/committed-only.bundle" \
  "$lab_dir/committed-only"
test ! -f "$lab_dir/committed-only/staged.txt"
test ! -f "$lab_dir/committed-only/scratch.txt"
test "$(git -C "$lab_dir/committed-only" show HEAD:README.md)" = \
  '# Pypy delivery'
test ! -f "$lab_dir/committed-only/.git/hooks/pypy-marker"
test -z "$(git -C "$lab_dir/committed-only" config --local \
  --get pypy.lab-marker || true)"

成功打包不會幫你 commit,也不會替你清乾淨來源;它只保存選定 commit 可達的內容。

所以發送前要先盤點 git status,再決定把修改正式提交,或另用合適方式保存工作現場。

不應假設會自動交付 正確處理方向
Index、未提交/未追蹤檔案 明確提交或另外保存,不靠 bundle 猜需求
Reflog 與不可達 commit 先建立需要的 refs;原始 reflog 另行處理
Hooks、repo config、憑證 分開管理;不要直接搬運私人憑證
Git LFS 真正的大檔內容 另行交付 LFS objects,Git 中可能只有 pointer
Submodule repository 主 repo 只有 gitlink;子 repo 要逐個交接
套件、映像與建置工具 按離線環境另外準備與驗收

--all 也不會保存完整 stash 操作棧。它可能納入 refs/stash 最新位置可達的物件,但不等於把 stash reflog 的每一筆都搬走。

若要救回已失去 ref 的 commit,先依 reflog 救援流程建立保護 branch;不要期待打包工具幫你尋寶。

十一. 真正交付時,至少留下這份清單
#

拍拍君會把 bundle、ref manifest、完整基準/目標 OID、Git 版本與驗收結果放在同一個交接批次。

檔案 checksum 可偵測複製損壞,但「包和 checksum 一起被換掉」仍可能通過;來源可信度要靠另外受信任的傳遞或簽章流程。

對陌生或不受信任來源,也應先在隔離環境用更新過的 Git 檢查,不要直接匯入重要工作目錄。

交付前確認:

  1. refs 範圍清楚,沒有不該外送的 branch 或歷史。
  2. 完整包能在空 repo 驗證、獨立 clone,並通過物件檢查。
  3. 增量包在真正接收端驗證 prerequisites,而不是只在來源自測。
  4. Fetch 到獨立交接 ref,審核後才 fast-forward。
  5. 目標 commit、tree、關鍵內容與專案測試符合預期。
  6. ACK 完成才推進下一輪基準;保留能重建的完整包與增量鏈。

封存完成後的索引整理是另一件事;Git maintenance不會代替交接驗收。

結語:收到檔案,不等於收到可用版本
#

Bundle 最實用的地方,是讓 Git 歷史能離開持續連線的 server,卻仍能用熟悉的 clone/fetch 接回來。

記住三句話:完整包負責開局,增量包依賴基準,還原演練才證明交接成功。

下一次拍拍醬說「我不能連遠端」,就不用在 ZIP 和整包工作目錄之間二選一;把 refs、prerequisites、驗收與 ACK 一起設計好,才是真的交到手上。

延伸閱讀
#

版本控制: Git - 本文屬於一個選集。
§ 21: 本文

相關文章

Git Partial Clone 實戰:blob:none、按需下載與離線驗證
·8 分鐘· loading · loading
Git Partial Clone Blob:none Promisor Remote Lazy Fetch Offline Developer-Tools
Git attributes 實戰:LF/CRLF、Binary 與可審核正規化
·7 分鐘· loading · loading
Git Gitattributes LF CRLF Binary Renormalize Developer-Tools
Git maintenance 實戰:用 Commit-Graph、MIDX 與排程加速大型 Repo
·10 分鐘· loading · loading
Git Git Maintenance Commit-Graph Multi-Pack-Index Performance Developer-Tools
Git sparse-checkout 實戰:大型 Monorepo 的部分取出與安全切換
·7 分鐘· loading · loading
Git Sparse-Checkout Monorepo Cone Mode Partial Clone Worktree Developer-Tools
Git Changelog 自動化:從 Commit、PR 到 Release Notes
·5 分鐘· loading · loading
Git GitHub Changelog Release Notes Automation GitHub Actions Developer-Tools
Git reset、restore、revert 實戰:選對復原工具
·9 分鐘· loading · loading
Git Reset Restore Revert Undo Version-Control Developer-Tools