Development setup¶
Prerequisites¶
mise install reads mise.toml and mise.lock, so you get the same tool versions CI does. prek install wires
up the Git hooks.
Rust itself is deliberately not managed by mise: CI needs per-job components (rustfmt, clippy,
llvm-tools-preview) and cross-compilation targets, which dtolnay/rust-toolchain handles better.
rust-toolchain.toml pins the channel for both.
Tasks¶
mise run # fmt, lint, lint:actions, build, test — the default
mise run build
mise run test # accepts nextest selectors: mise run test render
mise run fmt
mise run lint
mise run spell
mise run cover
mise run check # everything that does not modify the tree
mise run ci # check + docs:build
mise tasks lists them all.
Layout¶
src/
├── lib.rs the library surface
├── main.rs the git-tpl binary
├── exit.rs exit codes, defined once
├── config.rs .config/git.tpl.toml
├── gitconfig.rs tpl.* preferences and their precedence
├── refs.rs template id → refs/tpl/<id>
├── provenance.rs commit trailers
├── template/ manifest, questions, the Value type
├── context.rs the shared evaluation context
├── graph.rs the dependency DAG
├── eval.rs expression evaluation and prompting
├── render.rs the tree walk
├── answers.rs --answers-from files
├── userconfig.rs ~/.config/git-tpl/config.toml
├── seed.rs the machine-seeded prompt defaults (ADR-018)
├── remote.rs remote URL parts, for seeding
├── lint.rs static template analysis
├── note.rs terminal-safe rendering of a template's note (ADR-019)
├── migration.rs discovering and applying template migrations (ADR-024)
├── suggest.rs "did you mean?"
├── data/ data sources
├── git/ the Git abstraction
│ ├── mod.rs GitBackend — our types, never git2's
│ ├── ignore.rs .gitignore evaluation, ours not libgit2's (ADR-017)
│ └── libgit2.rs the only implementation
├── ops/ orchestration, one function per command
│ ├── mod.rs init, update, status, diff, merge, fetch, push, and the rest
│ ├── resolve.rs fetching a template to a revision, and its `[extends]` chain (ADR-034)
│ ├── extends.rs merging an `[extends]` chain's manifests into one (ADR-034)
│ ├── backport.rs the patch that carries a fix upstream (ADR-020)
│ ├── hunks.rs hunk selection, by prompt or by name (ADR-023, ADR-037)
│ ├── unsubstitute.rs reversing a substitution in a change (ADR-022)
│ └── testing.rs running a template's own tests (ADR-016)
├── cli.rs argument types only
├── report.rs the --json envelope, success and failure
├── theme.rs formatting helpers that return String
├── prompt.rs the demand-based prompter
└── commands/ one module per subcommand
ops/ holds one function per command in mod.rs — init, update, status, diff, merge, fetch, push,
lint, questions and the rest — and a file of its own for each command too large to be one: backport,
testing, hunks, unsubstitute, plus resolve for fetching a template and extends for merging an [extends]
chain. commands/ is the directory with one module per subcommand; that is where argument handling and output
formatting live, and nothing else.
Dependencies point inward. ops uses render, graph, git; nothing in template/ or render.rs knows a
command exists.
Invariants¶
These are enforced, not merely intended. Breaking one fails a hook or a test.
git2 appears only in src/git/libgit2.rs. The GitBackend trait is the boundary; a use git2::Oid anywhere
else makes it decorative. The git-backend-isolation prek hook is what actually stops that. If you need a Git
capability the trait lacks, add it to the trait — not a git2 import.
update does not modify HEAD, the index, or render output in the worktree. An integration test asserts all
three are unchanged, or change only in the one expected way: .config/git.tpl.toml itself, rewritten in place when
a template question gains a newly recorded answer. The renderer writes to a Git tree builder and never to the
filesystem, so a rendered file changing is structural — the test exists to keep it that way.
Rendering is deterministic. A test renders twice and compares trees. See Determinism.
Template refs are append-only. The parent is always the previous tip — push never forces. Rewriting a
template ref would destroy the merge base a user's branch depends on.
No code execution from templates. No subprocess, no shell, no eval, no HTTP data-source access outside
src/data/; git-backed network access (cloning or fetching a template) is confined to src/git/libgit2.rs by the
git2-isolation invariant above.
Tests¶
Unit tests live at the bottom of the module they test. Integration tests are in tests/ and build real Git
repositories in temporary directories — nothing about Git is mocked, because the entire premise of the project is
that Git's behaviour is the behaviour.
Test names are sentences: an_unchanged_template_produces_no_commit, a_cycle_is_reported_before_any_prompt.
Snapshots use insta:
They live in tests/snapshots.rs, and they pin the transcripts quoted by docs/usage/ and the envelopes described
in docs/reference/json.md. A failing snapshot there is not necessarily a bug: it means documented output changed,
and accepting it commits you to the matching documentation edit in the same pull request.
Git identity¶
The integration tests commit, and libgit2 refuses to build a signature without one:
Style¶
Follow what is there. The one convention worth stating explicitly: every non-obvious line carries a comment saying why, ideally naming the failure it prevents. A comment that restates the code is worse than none; a comment that records the bug that motivated the line saves the next person an afternoon.