I needed to move commits between two machines with no network route between them, so: git bundle. Write the bundle, copy it across, clone from it.
I checked my work the way you're supposed to:
$ git bundle verify transfer.bundle
9c824386e56b3c496c45823acc195283c024bccc refs/heads/main
The bundle records a complete history.
Complete history. Then:
$ git clone transfer.bundle restored
Cloning into 'restored'...
warning: remote HEAD refers to nonexistent ref, unable to checkout.
$ echo $?
0
$ ls restored
$
Exit status 0. A .git directory. No branches. No files.
What the bundle was missing
A HEAD. That's it.
$ git bundle list-heads transfer.bundle
9c824386e... refs/heads/main
One ref. git clone needs to know which ref to check out, and it resolves that through the bundle's HEAD. There isn't one, so it falls back to a default that the bundle doesn't contain — you can see the wreckage afterwards:
$ git -C restored symbolic-ref HEAD
refs/heads/master
$ git -C restored branch
$
HEAD points at refs/heads/master, which does not exist, because the bundle carried main. The objects are all there. Nothing is corrupt. git log main inside that clone works fine. There is simply no branch and no checkout.
What I assumed, and why it was wrong
I had this filed in my head as "don't build bundles from refs/remotes" — I'd made the bundle from refs/remotes/origin/main the first time, and blamed that.
Wrong. I tested both:
| bundle created from | verify |
clone exit |
files | branch |
|---|---|---|---|---|
refs/remotes/origin/main |
complete history | 0 | 0 | none |
main (i.e. refs/heads/main) |
complete history | 0 | 0 | none |
--all HEAD |
complete history | 0 | 1 | main |
The local ref behaves exactly as badly. The refs/remotes detail was a coincidence I'd turned into a rule. The actual requirement is the HEAD ref, and nothing in verify's output is sensitive to it.
The incantation
git bundle create transfer.bundle --all HEAD
--all takes every ref; HEAD adds the one that makes it clonable. That bundle lists four heads including HEAD, and the clone checks out a working tree with a branch on it.
If you want one branch rather than everything:
git bundle create transfer.bundle main HEAD
Why this is worse than a failure
Every signal available to a script says success.
git bundle verify exits 0 and prints a sentence containing the word complete. git clone exits 0. Under set -e, nothing stops. The only evidence is a warning: on stderr, and a transfer script that logs stdout — or pipes through tail, or runs under a CI step that only surfaces failures — will never show it to anyone.
The failure lands later, somewhere unrelated, as "the repo on the other machine is empty."
The check that costs nothing
verify answers "are the objects intact". It does not answer "is this clonable". So assert the thing you actually want, after cloning:
set -euo pipefail
git bundle create "$B" --all HEAD
git bundle verify "$B"
git bundle list-heads "$B" | grep -qw HEAD \
|| { echo "bundle has no HEAD ref, clone would produce an empty tree" >&2; exit 1; }
git clone -q "$B" "$DEST"
git -C "$DEST" rev-parse --verify HEAD >/dev/null \
|| { echo "cloned repo has no resolvable HEAD" >&2; exit 1; }
Two lines of assertion, and they're the two questions verify doesn't answer: is there a HEAD in the bundle, and did the clone end up on a commit.
The general shape, which is the part worth keeping: a verify step that checks integrity is not a verify step that checks usability. Anything you move between machines wants an assertion on the property you actually need at the destination — the objects being intact is necessary and nowhere near sufficient.
Tested on git 2.34.1. If your version prints something friendlier than a stderr warning, good — mine didn't, and the exit code is 0 either way.
Top comments (0)