Git Notes, Archives, and Bundles
Move what Git stores somewhere else: notes for metadata that cannot change a hash, archive for clean tarballs, bundle for a repository in one file, and custom subcommands.
Move what Git stores somewhere else: notes for metadata that cannot change a hash, archive for clean tarballs, bundle for a repository in one file, and custom subcommands.
Everything in this course so far has worked inside one repository — rewriting its history, splitting its objects, signing its commits. The last three tools point outward. Each takes something Git already stores and moves it somewhere else: a fact attached to a commit that must not change, a tree lifted into a tarball, a whole repository folded into one file you can carry on a USB stick.
By the end of this lesson you can annotate a commit without altering its hash, ship a release archive with none of your development files in it, move a repository across a network that does not exist, and teach Git verbs of your own.
A commit's identity is a hash over its tree, its parents, its author and committer lines, and its message. Nothing else. That guarantee has an awkward consequence: every fact you learn after the commit exists — which build produced it, which review approved it, how fast it ran — has nowhere to go. Put it in the message and you have rewritten the commit and everything after it.
Notes are Git's answer: text attached to an object by hash, stored outside the object it describes and editable as often as you like. Think of a museum placard. The artifact behind the glass is sealed; the card beside it is rewritten whenever somebody learns something new about it.
git notes add -m 'build 4821, deployed 2026-07-14' HEAD
git notes show HEADThe mechanism explains every quirk that follows. Notes live in an
ordinary ref, refs/notes/commits, pointing at an ordinary
commit, whose tree uses the annotated commit's hash as the
filename:
git cat-file -p refs/notes/commits # a normal commit object
git ls-tree -r refs/notes/commits
# 100644 blob 548a16e0… 3eafa25dbd7d63f6a734025e85787d98f4c72692That blob is your note text; that path is the commit it belongs to. No new object type, no new storage engine: notes are commits, trees, and blobs, arranged so a lookup by commit hash is a path lookup.
The rest of the verbs hold few surprises.
git notes append -m 'rolled back 15:20 UTC' HEAD # after a blank line
git notes add -f -m 'v2' HEAD # -f: overwrite what is there
git notes edit HEAD # opens $GIT_EDITOR
git notes remove HEAD # detach; the commit is untouched
git notes list # <note-blob> <annotated-object>One namespace for everything gets crowded fast, so --ref gives
each kind of fact its own ref under refs/notes/. A deploy
pipeline on refs/notes/deploys never collides with a benchmark
runner on refs/notes/benchmarks, and either can be dropped
wholesale by deleting one ref. git log shows only the default
namespace unless you ask for more:
git notes --ref=benchmarks add -m 'p99 41ms, n=10k' HEAD
git log --notes=benchmarks # one extra namespace
git log --notes='*' # every namespace
git config notes.displayRef 'refs/notes/*' # always show allThe commit comes back verbatim — the human record of intent — with everything the machinery learned appended underneath:
commit 3eafa25dbd7d63f6a734025e85787d98f4c72692
Author: A Developer <dev@example.com>
Date: Sun Jul 26 21:29:18 2026 +0000
Cache the tenant lookup
Notes:
build 4821, deployed 2026-07-14
Notes (benchmarks):
p99 41ms, n=10kHere is the part that costs people an afternoon.
refs/heads and refs/tags.
Branches and tags, by default, with no configuration.
refs/notes, and nothing else touches it.
A colleague cloning the repository gets every commit and none of your annotations, and nothing anywhere reports that.
git push origin 'refs/notes/*' # push all notes
git fetch origin 'refs/notes/*:refs/notes/*' # and fetch them
git config --add remote.origin.fetch \
'+refs/notes/*:refs/notes/*' # stop thinkingQuote the refspecs so your shell leaves the asterisk alone, and
note that --add is not optional: without it you replace the
branch refspec and quietly stop fetching branches.
A notes ref is a branch in every meaningful sense, so two people
annotating the same commit produce diverging histories and the
second push is rejected. git notes merge resolves it; for
append-only facts -s cat_sort_uniq concatenates both notes,
sorts the lines, and drops duplicates.
# notes.rewrite.amend and .rebase are already true; this is not
git config notes.rewriteRef 'refs/notes/*'Now the opposite problem. Someone wants your code, not your
repository: a distribution packaging a release, a build container
that should not carry .git, an auditor who asked for the source
as of v1.4.0.
The lazy answer is to tar the working directory, and it is wrong
in a way you will not notice until it bites: a working directory
holds whatever is lying around — node_modules, a stray
.env.local, editor swap files. git archive reads a tree
object instead, so what comes out is exactly what is committed
at that reference and nothing else.
git archive --list # tar, tgz, tar.gz, zip
git archive --format=tar.gz --prefix=app-1.4.0/ \
-o app-1.4.0.tar.gz v1.4.0--prefix puts every path inside one top-level directory, so the
archive unpacks tidily instead of spraying forty files across
whatever directory the recipient was standing in. The trailing
slash is required, and when -o ends in a recognised extension
the format is inferred.
Archive a tag, not a branch. git archive main means
"whatever main pointed at the instant I ran this", which is not
reproducible and not what a release is. A tag is a fixed commit,
so two people running the command a year apart get identical
bytes.
With no -o the archive goes to stdout — a deployment that never
clones anything:
git archive HEAD | tar -x -C /srv/app # unpack straight outThe CI config, the test suite, the fixture data — all of it
belongs in the repository and none of it needs to reach a
packager. .gitattributes carries the switch, the same machinery
you met in the intermediate course for diff drivers.
/.github export-ignore
/tests export-ignore
/docs/internal export-ignore
.gitattributes export-ignoreEvery path marked export-ignore is skipped by git archive and
left untouched everywhere else. Ignoring .gitattributes itself
is traditional: the recipient has no use for your rules.
The catch is which copy of the file Git reads. Attributes come
from the tree being archived, not your working directory, so
adding an export-ignore line today does nothing for a tag you
cut last week. Add the rules, commit them, then tag.
Notes move metadata and archives move a tree. git bundle moves
the repository itself — history, branches, tags, every object —
into one file Git treats as a legitimate remote.
You need this more often than you think. A machine on an air-gapped network that will never see your server. A colleague whose 4 GB clone dies at 80% for the third time. An offline backup that is one file.
git bundle create repo.bundle --all # every ref, plus HEAD
git bundle list-heads repo.bundle # what is inside
git bundle verify repo.bundle # intact? applicable?On the far side, a bundle is a URL. Clone it and you get a
working repository whose origin is the bundle file, which is
rarely what you want:
git clone repo.bundle restored-repo
git -C restored-repo remote set-url origin https://ex.com/app.gitBundles earn their keep on the second delivery, because
git bundle create accepts any revision range git log accepts.
The commits inside the range become the bundle's contents; the
commits it excludes become its prerequisites, a list of
hashes the receiving repository must already have. Bundle
v1.0.0..main and you ship only what came after v1.0.0, with a
note attached saying "this applies only if you already have
v1.0.0".
git bundle create update.bundle v1.0.0..main
git bundle create update.bundle main ^"$LAST_SYNCED" # same idea
# on the receiver — a bundle fetches like any other remote
git fetch ../update.bundle main:refs/remotes/offline/main
git switch -c review offline/mainIf a prerequisite is missing, Git refuses the whole file and names the commit it wanted:
error: Repository lacks these prerequisite commits:
error: 32c02ec4b5b5ae95701068e199abde4026e61d51So keep a marker of the last successful sync: tag the tip you bundled, ship the range from the previous tag, move the tag once the far side confirms. That is an offline mirror that stays in step over months, each delta small enough to email.
Run git something and, before giving up, Git searches your
PATH for an executable called
git-something. If it finds one it runs it, forwarding your
arguments. That is the whole mechanism: no plugin registry, no
manifest, and any script you write becomes a subcommand.
Here is one worth having: the branches you were actually working
on, not the ninety git branch lists alphabetically.
#!/bin/sh
# git-recent [N] — branches by how recently they were committed to
fmt='%(align:22,left)%(refname:short)%(end)'
fmt="$fmt %(align:15,left)%(committerdate:relative)%(end)"
git for-each-ref --count="${1:-10}" --sort=-committerdate \
--format="$fmt %(subject)" refs/heads/Save it anywhere on your PATH:
chmod +x ~/bin/git-recent
git recent 5
# feature-x 4 minutes ago wip on the tenant cache
# main 2 days ago Cache the tenant lookupThe first lesson of this course drew the line between
porcelain — status, log, diff, built for a person at a
keyboard — and plumbing — rev-parse, cat-file,
for-each-ref, built for other programs. git-recent sits on
the plumbing side, which is why it has nothing to parse and
nothing that shifts when Git changes.
Carry the rule out of this course: if a human reads the output,
use porcelain; if a program reads it, use plumbing or an explicit
--format. Porcelain is a user interface Git reserves the right
to improve. Plumbing is a contract.
# Notes — metadata without changing the commit
git notes add -m 'build 4821' HEAD # annotate; hash unchanged
git notes append -m 'more' HEAD # add to an existing note
git notes show HEAD # print it
git notes edit HEAD # open in $GIT_EDITOR
git notes remove HEAD # detach; commit survives
git notes list # note-blob + annotated obj
git notes --ref=benchmarks add -m 'p99 41ms' HEAD
git log --notes='*' # show every namespace
git config notes.displayRef 'refs/notes/*'
git config notes.rewriteRef 'refs/notes/*' # survive a rebase
# Notes are not synced by default
git push origin 'refs/notes/*'
git fetch origin 'refs/notes/*:refs/notes/*'
git config --add remote.origin.fetch '+refs/notes/*:refs/notes/*'
git notes merge -s cat_sort_uniq other-notes-ref
# Archive — a clean tree, no .git, no untracked junk
git archive --list # tar, tgz, tar.gz, zip
git archive --format=tar.gz --prefix=app-1.4.0/ \
-o app-1.4.0.tar.gz v1.4.0 # a tag, never a branch
git archive HEAD | tar -x -C /srv/app # straight to a directory
git archive --remote=<url> v1.4.0 # if the server allows it
# .gitattributes: "/tests export-ignore" keeps paths out
# Bundle — a repository in one file
git bundle create repo.bundle --all # everything, plus HEAD
git bundle create up.bundle v1.0.0..main # incremental delta
git bundle list-heads repo.bundle # what is inside
git bundle verify repo.bundle # run this on the receiver
git clone repo.bundle restored-repo # a bundle is a valid URL
git fetch ../up.bundle main:refs/remotes/offline/main
# Your own subcommands
# any executable named git-foo on PATH becomes: git foo
git help -a # lists external commandsThat is the end of the track. You started by making a commit and you are finishing by moving repositories through files, attaching facts to immutable objects, and extending the command itself. Somewhere in there Git stopped being a tool you run and became a tool you build on.
There is no next lesson, so let the sources take over.
git help <command> is unusually good documentation — read the page for
something you use every day and you will find flags no tutorial
mentions. The Pro Git book, free at git-scm.com/book, is the
best long-form treatment there is. Then read git log in a large
project you admire: how its maintainers write messages and
structure releases teaches more than any guide. Send them a
patch, too — nothing exposes a gap in your model faster than a
maintainer asking you to rebase.
Above all, keep the habit this course has been building. When Git
surprises you, do not go hunting for a command to copy. Drop to
plumbing and look: git cat-file, git rev-parse, and
git for-each-ref answer nearly anything, because there is no
magic underneath — only commits, trees, blobs, and refs pointing
at them. Pick one thing from this lesson, use it on a real
repository this week, and let it teach you the rest.