Push a Specific Ref Safely with git send-pack
git send-pack pushes an exact ref when plain git push is not precise enough, giving you direct control over receive-pack and the protocol. Git's own manual says git push is normally the higher-level command to use instead, so reach for this one deliberately. This guide pushes one local branch to a bare repository, previews the change first, and verifies the remote ref afterwards.
The route
Jump straight to the step you need, or tick off Done means at the end.
Allow about 10 minutes for the first run. You need Git 2.43.0 or newer, a source repository with at least one commit, and write access to the destination. The examples use a local bare repository, so no elevated privileges are needed. Do not put sudo in front of these commands: changing ownership of a repository can create a separate problem.
1. Check the installed command and repository state
Run these ordinary, read-only checks from the source repository. The installed manpage identifies this machine's command as Git 2.43.0. Replace the example branch name only after checking which ref you actually intend to publish.
$ git --version
git version 2.43.0
$ git status --short
$ git show-ref --verify refs/heads/main
An empty git status --short means there are no uncommitted changes to distract you. If your branch is not called main, list local heads with git for-each-ref --format='%(refname)' refs/heads/ and use the full name that actually exists. Stop here if the source ref is wrong: a refspec cannot push an object that is not present locally.
2. Prepare a harmless local destination
For a reproducible test, create a bare repository outside the working tree. A bare repository has no checked-out files; its refs are the state a server would receive. This step changes only the new directory under /tmp.
$ DEST=$(mktemp -d /tmp/git-send-pack-demo.XXXXXX)
$ git init --bare "$DEST/remote.git"
Initialized empty Git repository in /tmp/git-send-pack-demo.XXXXXX/remote.git/
$ git -C "$DEST/remote.git" show-ref
The final command prints nothing because the destination has no refs yet. In a real deployment, the destination is commonly written as [email protected]:/srv/git/project.git; the host prefix makes Git invoke git-receive-pack through SSH. Confirm the path and account before doing a real push.
3. Preview one explicit ref update
Use a source-to-destination pair to make the operation unambiguous. The destination below is a local path, so no SSH service is involved.
$ git send-pack --dry-run --verbose \
"$DEST/remote.git" \
refs/heads/main:refs/heads/main
The dry run performs the negotiation without actually updating the destination. On an empty destination, Git reports a new branch, though the exact progress wording can vary with terminal and Git settings. Verify the destination is still empty:
$ git -C "$DEST/remote.git" show-ref --verify refs/heads/main
fatal: 'refs/heads/main' - not a valid ref
A single name such as main is shorthand for main:main. Full ref names are easier to audit in scripts. The source side must resolve to exactly one local ref, and a new destination ref must start with refs/, which is why the example spells it out.
Checkpoint
Before removing --dry-run, confirm three things: the source commit is the one you expect, the destination path is correct, and the destination ref is the name you intend to update. If any of that is uncertain, stop and inspect with git log --oneline --decorate -5 refs/heads/main.
4. Send the ref and verify the result
Remove only the preview flag. This is the first command in the guide that changes remote state.
$ git send-pack --verbose \
"$DEST/remote.git" \
refs/heads/main:refs/heads/main
$ git -C "$DEST/remote.git" show-ref --verify refs/heads/main
<commit-id> refs/heads/main
Use this verification command with the actual object ID if you need a strict check:
$ test "$(git -C "$DEST/remote.git" rev-parse refs/heads/main)" = \
"$(git rev-parse refs/heads/main)" && echo "remote matches local main"
remote matches local main
5. Map other refs without guessing
The part before the colon is the local source; the part after it is the remote destination. This publishes a local topic branch under a different remote name:
$ git send-pack --dry-run \
[email protected]:/srv/git/project.git \
refs/heads/topic:refs/heads/review/topic
Without --all and without explicit refs, the command updates heads that exist on both sides, a default that is easy to overlook, so automation should normally name every ref it means to change. With --all, every locally existing head is transferred and explicit refs cannot be supplied at the same time. Do not confuse --all with --mirror: mirroring is a broader operation and deserves its own review of every ref it may affect.
6. Handle rejected updates safely
By default, the destination must be empty or an ancestor of the source. This fast-forward check protects commits that exist only on the remote: if another contributor has advanced the branch, the send fails rather than overwriting it. Fetch or inspect the remote, reconcile the histories in your normal workflow, and retry the explicit refspec.
Warning
--force disables that check for every ref in this invocation. A leading plus disables it for only one ref, for example +refs/heads/main:refs/heads/main. Both forms can make commits disappear from the branch name, so use them only with an agreed recovery plan, and record the old object ID first:
$ git -C "$DEST/remote.git" rev-parse refs/heads/main
<old-commit-id>
$ git send-pack --dry-run "$DEST/remote.git" \
+refs/heads/main:refs/heads/main
If a force update has already happened, the old commit may still be recoverable from another clone or a reflog, but that is not guaranteed. Prefer the dry run and the narrow plus-prefixed refspec. For several refs, --atomic asks the receiver to update them as one transaction, so if one update fails, none should change; the remote must support atomic transactions.
7. Finish and clean up the test
Once the verification passes, remove the temporary demonstration directory. This is destructive, but it only contains the bare repository created in step 2:
$ rm -rf -- "$DEST"
$ test ! -e "$DEST" && echo "temporary repository removed"
temporary repository removed
For a real remote, there is no local undo command for a successful push. The recovery action is another carefully reviewed ref update, usually after preserving the current remote object ID and agreeing whether that update should be a normal fast-forward or a force update.
Done means
- The source ref was checked and resolved to the intended commit.
- --dry-run showed the expected destination before any update.
- The explicit source and destination refs were sent successfully.
- The destination ref resolves to the expected object ID.
- No force update was used without preserving the old object ID and planning recovery.