Assets

How photos, scans, audio, and video stay tracked by git without their bytes living in git's object database.

What it is

Status: implemented. The current asset model and commands are documented below. Dated local and production conversion evidence is preserved in the 2026-08-01 conversion report. That report is history, not a claim about current production health.

The model

Assets are tracked by git-annex. Git stores a small pointer; git-annex stores the bytes under .git/annex/objects/, keyed by their SHA-256.

_content/trip.attach/photo-001.jpg      ← ordinary file, readable and writable
                                          (git records a pointer for it)

Three consequences worth holding onto:

Adding an asset

Nothing special:

cp photo.jpg _content/trip.attach/
git add -A && git commit -m "Add trip photo"

annex.largefiles decides what goes into the annex, so plain git add routes assets to git-annex and everything else to git. Do not use git annex add — it is a needless special case, and it locks files by default, which breaks in-place editing.

What counts as an asset

An extension allowlist: src/lib/asset-extensions.ts holds the list and renders it into the annex.largefiles expression.

git-annex replaces Git LFS. Boxes previously ran LFS over this same extension list, and the migration removes the filter=lfs rules. The allowlist is therefore unscoped — it matches a binary anywhere, not only inside .attach/ — because LFS was unscoped and its content (legacy captures under _content/inbox/, 154 files on one box) would otherwise be left behind with no mechanism at all. Where both filters were configured, annex took precedence; that was verified rather than assumed.

It is an allowlist rather than "everything in .attach/" because attach scopes hold committed non-assets too — capture writes child .card files into the parent scope, email bodies land as .txt, and manifest.json used to live there. On one production box, 1,844 tracked files sit inside .attach/ scopes. A path glob would have turned every one of those cards into a pointer.

Adding a new binary type means adding its extension to that list. An omission means the bytes get committed to git directly, permanently. That has happened before (41 MB page.frozen snapshots, 2026-07), so a commit-time guard now blocks any file over 1 MB in an attach scope whose extension is not listed. The pre-commit hook checks the staged blobs (inside bbx validate --pre-commit), so it fires at exactly the commit that would embed the bytes and also catches an allowlisted extension staged raw through a broken annex filter; bbx attachments check-unlisted walks the whole box for the same problem on demand.

Two deliberate exceptions:

Absent content

A file whose content is not present locally holds a ~100-byte pointer:

/annex/objects/SHA256E-s300000--2ee2c7d4….jpg

Two different situations produce that, and they are identical on disk: content never fetched, and a checkout made without git-annex installed. Fetch it with:

git annex get _content/trip.attach/photo-001.jpg

Every code path that reads asset bytes goes through src/lib/asset-content.ts, which distinguishes "these are the bytes" from "this is a stand-in for them". The webapp answers 409 (not 404 — the file exists and is tracked; its content is elsewhere) with the expected size and hash. Transcription and publishing refuse rather than shipping pointer text to an API or embedding it in a page.

Detection is backend-independent: a SHA512E, WORM, or URL pointer is just as much a stand-in as a SHA256E one.

Configuration

settingvaluewherepropagates to clones?
annex.largefilesrendered from ASSET_EXTENSIONSgit annex configyes
annex.thinfalsegit configno
numcopies1git annex numcopiesyes
.git/info/attributesrendered from ASSET_EXTENSIONS + the bulk-batch scoperepository fileno

.git/info/attributes is which paths git hands to the git-annex filter-process. git annex init writes * filter=annex there — the whole repository — so a commit of two text cards pays the filter's startup cost for nothing (measured: 15 git add + git commit pairs of one-line cards took 3.7s unscoped and 1.4s scoped). Since annex.largefiles is purely extension-based, the filter is narrowed to the same extensions and text-only commits skip it entirely.

The narrowing is only safe while every already-annexed path has an extension on the list: one that does not keeps its pointer in git but loses the smudge filter, so the next checkout writes /annex/objects/… text where the bytes were. bbx doctor annex checks that (annexed-coverage) before it writes the scoped file, and refuses to scope while any path is uncovered. The attribute lines use case-insensitive character classes (*.[hH][eE][iI][cC]), and so does the annex.largefiles expression — the two are rendered from one function so neither can match a path the other misses. Getting that wrong is not symmetric but both directions are real: a path largefiles annexes that the filter never sees commits as raw bytes, and a path the annex holds that the filter no longer covers strands a pointer.

git annex init reinstates its unscoped default, including in every fresh clone, so this drift recurs; bbx doctor annex (and therefore bbx init) repairs it.

annex.thin=false is load-bearing. With thin mode the working-tree file is a hardlink to its annex object, so an in-place edit silently corrupts the object and fsck does not detect it. Non-thin costs a second copy on disk and buys back the integrity check.

annex.thin is plain git config, so it does not propagate — every fresh clone silently inherits git-annex's default. bbx doctor annex repairs it, and repairing takes both git config annex.thin false and git annex fix (the config alone leaves existing files hardlinked).

Commands

commandwhat
bbx doctor annexCheck and repair the box's annex configuration. Most checks self-heal. --check for read-only.
bbx doctor annex-fsckVerify content against keys, incrementally. Read-only; run from a schedule.
bbx attachments check-unlistedBlock unlisted large binaries, box-wide. (The pre-commit hook runs the staged-blob equivalent inside bbx validate --pre-commit.)
bbx attachments largefiles-exprPrint the annex.largefiles expression.
bbx attachments annex-attributesPrint the scoped .git/info/attributes contents.
bbx attachments unignoreRestore the un-ignore block on a box whose .gitignore was hand-edited back to hiding assets.

bbx init runs the doctor's repair pass, so an ordinary init brings a box up to spec rather than leaving it to a command someone must remember.

bbx init also rewrites the box's .gitignore and .gitattributes on every run. There is one form of each. A box is annex-shaped from its first commit, so assets are always un-ignored — visible to git add, which is how they reach the annex.

There used to be two forms, selected by a probe for .git/annex/, and that probe is the reason this paragraph exists: until 2026-08 a single bbx init silently de-annexed a converted box, re-ignoring every asset while .git/annex/ sat there looking healthy, with nothing reporting it until a later commit or asset write failed. The branch is gone because the alternative it selected is gone.

.gitattributes carries no filter=lfs rules: as of 2026-08-04 the template dropped them, since Git LFS is retired and the rules' only live effect was the risk of re-LFS-ifying a box's new media.

Anything that writes asset bytes asserts the shape first, via assertAnnexBox() (src/core/annex/assert-annex-box.ts). It is an assertion rather than a graceful refusal because a box that fails it is a broken invariant: the only way to reach the state is to hand-edit the .gitignore back to hiding assets. The scan-upload routes still answer a retryable 503 instead, because a client that deletes its only copy on accepted needs an answer it can act on.

There is no migration

Every box is annex-shaped at creation, and every box that existed before that was converted and verified in 2026-09. bbx attachments to-annex and the manifest scheme it migrated from are both deleted.

The verification that migration did is worth recording, because it is the bar for any future change to this seam. A box was not called converted because .git/annex/ existed — three checks over that one seam once reported success at the same time while a production box held 536 assets in neither git nor the annex (c47fd2be1). What settled it was running git check-ignore against every real asset file in every box.

Rollback is git annex uninit. There is no reverse migration and no scheme to roll back to.

Failure modes

SymptomCauseFix
Image renders as alt text; file is ~100 bytes of /annex/objects/… textContent not fetched, or git-annex not installedgit annex get <path>, or install git-annex
Commit fails: "git-annex is not installed"Missing binaryapt install git-annex / brew install git-annex
Commit blocked on a large unlisted fileNew binary type not in ASSET_EXTENSIONSAdd the extension, or confirm the file belongs in git
bbx health reports annex-content-present failingBytes are gone and there is no remote to fetch fromRestore from backup
fsck quarantines to .git/annex/bad/Content no longer matches its keyRestore from backup

Not yet

There is no remote. numcopies is 1 because there is nowhere to copy to. This iteration buys integrity, not durability — a box's assets still live on exactly one disk, and "on git-annex" does not mean "backed up". The R2 remote is the next iteration; its cost and API analysis is in plans/asset-offbox-storage.md.

Dropping local content (git annex drop) also waits for that, since there is nothing to drop to.