ADR-024: A migration is a file, discovered by diffing the template's own history¶
Status: accepted
Relates to: ADR-002, ADR-005, ADR-008, ADR-019
Context¶
ADR-019 gave a template a way to address the user — note/note_file — and scoped migrations out deliberately:
Shown on
initonly.updatestaying a ref-only operation is most of its value, and a note tied to a version boundary is a different feature — migrations — deliberately not this one.
Issue #63 opened that feature. Two things it asks for, independent of each other:
- A message an
updatecan show, tied to a boundary the template crossed — "0.4 movedsrc/config.rs; your overrides need moving too." - A path move applied to the rendered ref, so a rename produces a rename in the user's own history rather than a delete and an add that orphans their edits on the old path.
Neither exists today.
Config/Manifest have no notion of a version at all, and there is no mechanism to say "this update carries
something the last one didn't."
The rejected shape: a declared version¶
The obvious design — a manifest declares version = "0.4.0" and an array of [[migrations]] keyed to it — was
rejected before being built.
Two problems, both practical:
- The moment a migration is authored is not the moment its version is known. A fix that needs a migration is written against a template whose next release number has not been decided, and is very often backported to more than one. A version field invites writing the wrong one.
- Every other fact this design tracks is already a Git fact.
The template is a Git repository; "has it moved since I last rendered" is a question Git already answers, and
ops::updatealready asks a form of it — the previous rendered commit'sTemplate-Committrailer (ADR-008) names the exact template commit the project was last rendered from.
Re-deriving a parallel numbering scheme on top of a system that already has one is exactly the kind of thing the project's own premise argues against.
Decision¶
A migration is a file in migrations/, at the template repository root, sibling to template.toml. No template
declares a version, and no project records one.
Discovery is a tree diff, not a version comparison¶
ops::update already reads the previous rendered commit's provenance back, to print "updated from A to B".
That gives it, for free, the exact commit the project was last rendered from.
update now also asks: which files under migrations/ exist in the tree at the newly resolved revision that did
not exist in the tree at that previous one?
let old_tree = template.commit_tree(old_commit)?;
let discovered = migration::discover_new(&template, old_tree, new_tree)?;
That is the whole mechanism.
discover_new is diff_trees scoped to migrations/, filtered to Added.
No version is compared because none exists; the template's own history between the two commits is the boundary.
A migration is "new" — and is shown, and is applied — at whichever update first makes that diff non-empty,
regardless of how many template commits separate the two renderings.
A project that jumps from a very old revision straight to the newest one still discovers every migration in
between, in one pass, because a diff between two trees does not care how many commits produced the difference.
This is also what keeps the noise down without any new state.
A migration cannot resurface once discovered: the next update's "old tree" is the one that already contains it.
The one rule this shape depends on: migration files are never deleted¶
A migration is discovered by its presence, not its history.
A file removed from the template repository disappears from every future diff, not just the ones after its
removal — a project that skipped both the commit that added it and the commit that removed it would never see it
at all.
migrations/ is therefore append-only by convention, the same discipline Rails, Django and Alembic migration
directories already enforce, for the same reason.
What a migration file declares¶
# migrations/2026-08-config-move.toml
message = "0.4 split `config.rs` into a module; your overrides moved with it."
[[moves]]
from = "src/config.rs"
to = "src/config/mod.rs"
message/message_file are Manifest::note/note_file again, one file down: mutually exclusive, resolved the
same way — message_file is repository-root-relative and rendered only if it ends in .jinja — and shown through
the identical note::sanitise and attributed block.
A migration is still only ever addressing the user, never acting; ADR-019's closure rule is untouched.
moves is new.
Each entry names a rendered output path — the same namespace git tpl diff reports — not a template source
path, and both sides are literal strings.
A move whose destination depends on an answer cannot yet be expressed; see "Explicitly out of scope" below.
Moves are applied as a content-identical commit, ahead of the ordinary one¶
The project's premise (docs/concepts/git-model.md) is that the user merges with plain git merge — never one
git-tpl runs or configures (ADR-002).
Git's own rename detection is a similarity heuristic over blob content; it is reliable only when the old and new
blob are still close to identical.
A move that lands in the same commit as an unrelated content rewrite of the same file risks Git seeing an ordinary
delete and an unrelated add, which loses the user's edits on the old path during the merge rather than carrying
them across the rename.
So update builds the rename as its own step, before the ordinary rendered commit:
- Take the ref's current tip tree.
For every declared move, drop the entry at
fromand re-insert it atto, keeping its bloboiduntouched. That is the entire mechanism — no newGitBackendprimitive, because a move is nothing but a different flat list passed to the tree builder that already exists. - If that tree is exactly what the fresh rendering already produces, it is the final commit: no rename step is needed, because the ordinary commit already is a pure rename.
- Otherwise — a move landed alongside some other change in the same update — commit the renamed tree first, as a child of the previous tip. Content is byte-identical to its parent except for the moved paths, so Git's default similarity detection sees the closest thing to certainty a heuristic can see. The ordinary rendered commit, with whatever else changed, follows as its child.
The intermediate commit carries no provenance trailers — it is superseded within the same update call, and no
future update ever reads it as a ref tip.
Both commits are ordinary, unremarkable objects; a plain git log on the ref shows one more commit than usual, and
nothing else distinguishes it as special.
A migration forces a commit, even when nothing else changed¶
update's determinism guarantee — identical rendering, no commit — stands for everything except this one case.
A migration with a message and no content effect on the rendering (a text-only note) would otherwise never advance
the previous rendered commit's Template-Commit trailer, and the same migration would surface again on the very
next update.
Discovering a migration therefore always produces a commit, so the trailer advances past it and the diff in the
next update is empty again.
This is the one piece of extra state the whole design needs, and it is not new: it reuses the trailer ADR-008
already writes on every commit.
Explicitly out of scope¶
from/toare literal, not expressions. A template whose output path varies by answer cannot yet declare a move for it. Extendingmovesto Jinja is a small, additive change if it turns out to matter; it is left out now because nothing in the surveyed cases needed it, and a literal path is whatapply_movescan validate without a project at lint time.- No
git tpl testassertion for a migration. ADR-016 keeps the test vocabulary closed and free of anything that needs two resolved revisions at once; asserting "this migration moves X to Y" would need exactly that. Left for a future ADR if it is wanted. git tpl status/diffgain no rename awareness. They report a move as the delete-and-add it structurally is, exactly asupdate's own change list does today. Only the ref's own commits change shape; nothing about how git-tpl reports a diff does.
Consequences¶
migrations/ is a new, optional convention in a template repository.
No existing manifest is affected: a template with no such directory behaves exactly as before, because
discover_new's diff is then always empty.
.config/git.tpl.toml and template.toml are both untouched.
Nothing was added to either, which is the point: this is the feature that could have needed a schema change to
both, and needs one to neither.
update can now write two commits instead of one or zero.
tests/update.rs gains a case for it; the single most important existing test —
update_does_not_touch_head_the_index_or_the_worktree — is unaffected, because both commits are built the same way
every other rendered commit is: as a Git object, with one ref moved at the end.
Invariant 1 does not distinguish "how many objects were written" from "was the worktree touched."
git tpl lint validates a migration file's shape — TOML, message/message_file not both set, a message_file
that names something, no move to an empty or self-identical path — without a project, the same guarantee
missing_note_file already gives note_file.
What it cannot check — whether a declared from exists in some project's previously rendered tree — is refused by
update itself, loudly, before any commit is written.