Template format¶
A template is a normal Git repository. Nothing registers it, nothing packages it, and any Git URL that libgit2 can clone will do — including a local path.
Layout¶
rust-library-template/
├── template.toml ← the manifest
├── data/ ← optional structured data
│ ├── licenses.toml
│ └── defaults.toml
├── migrations/ ← optional file moves to apply across a version jump
│ └── 2026-08-config-move.toml
├── macros.jinja ← a shared partial, not rendered
├── macros/
│ └── rust.jinja ← likewise
├── template/ ← everything here is rendered
│ ├── Cargo.toml.jinja
│ ├── README.md.jinja
│ ├── src/
│ │ └── lib.rs.jinja
│ └── .github/
│ └── workflows/
│ └── ci.yml
├── README.md ← the template's own README, not rendered
└── LICENSE ← likewise
Only template/ is rendered.
The rest of the repository — the template's own README, its CI, its license — is invisible to the projects that
use it.
The exception, and the only one, is a .jinja file outside template/: it is still never rendered into a
project, but it is importable as a shared partial.
migrations/ is likewise never rendered — it declares file moves for update to apply when a project crosses
them. See Migrations.
The rendered subdirectory is configurable:
The manifest¶
template.toml at the repository root.
name = "rust-library"
description = "A small Rust library"
root = "template"
[data.licenses]
source = "data/licenses.toml"
[questions.project_name]
type = "string"
prompt = "Project name"
[computed]
package_name = "{{ project_name | lower | replace(' ', '-') }}"
line_length = 100
Top level¶
| Key | Type | Default | Meaning |
|---|---|---|---|
name |
string | required | The template's name. Used in output, and in the rendered commit subject. |
description |
string | — | One line, shown when prompting. |
root |
string | "template" |
The subdirectory that gets rendered. |
strict |
bool | false |
Fail on an undeclared name in a rendered file, rather than rendering it to an empty string. git tpl lint reports the same names as warnings. |
note |
string | — | A note shown after init. May be an expression. Mutually exclusive with note_file. |
note_file |
string | — | A path in the template repository, relative to its root, whose content is shown after init. Rendered if it ends in .jinja. The path may be an expression. |
All of these must be written above the first table header.
In TOML a bare key belongs to the table that most recently opened, so a note_file written below [computed] is
a computed value and the note never appears.
The manifest stays valid and nothing fails, which is why
git tpl lint reports it as tpl::lint::absorbed_key.
The template id — which determines the ref name — is derived from the source the project records, not from
name.
See Configuration.
Each entry in [computed] is an expression or a literal value: a string containing {{ or {% is evaluated,
anything else is kept as written.
[questions], [computed] and [data] are covered in Questions, Computed values
and Data sources.
[data.<name>]¶
| Key | Type | Default | Meaning |
|---|---|---|---|
source |
string | required | Where the data comes from. May be an expression. |
kind |
string | inferred | template, local, remote or git. Required when source only becomes a URL after interpolation. |
ref |
string | — | The revision a git source is read at — branch, tag or SHA. May be an expression. |
path |
string | — | The path inside a git source's repository. May be an expression. |
format |
string | inferred | toml, json or yaml. Inferred from the extension. |
sha256 |
string | — | The expected digest of the content, as 64 hex characters. A mismatch stops the render. |
ref and path go together: a half-declared triple is refused at load time.
See Git data sources.
[remotes]¶
Git remotes to add on init, as <name> = "<url>".
The URL may be an expression, which is the point — a remote a template can usefully declare is one derived from
the answers.
Added in declaration order, on init only, and never fetched or pushed.
If the repository already has a remote of that name pointing somewhere else, it is left alone and a warning names
both URLs.
git-tpl does not repoint an existing origin: the one in the repository was put there by a person, and a
template that could redirect it could redirect a push.
These are Git remotes.
A remote data source under [data] is an unrelated thing — an HTTP URL the loader reads.
[extends]¶
A template may declare one parent, pinned to a tag or a commit:
[extends]
source = "https://github.com/org/base-template"
rev = "v3.1.0"
remove = ["template/.github/workflows/ci.yml.jinja"]
| Key | Type | Default | Meaning |
|---|---|---|---|
source |
string | required | Any Git URL, or a path on this machine — the same shape as git tpl init's own template argument. |
rev |
string | required | The revision the parent is pinned to. Must be a tag or a commit SHA, never a branch — an unpinned parent would make the same child revision render two different trees on two different days. |
remove |
array of strings | [] |
Paths to drop from the merge, relative to the parent's own repository root — including its root prefix, so a partial can be removed too. Naming a path the parent does not have is an error. |
A child renders on top of its parent: it may add questions, data sources, computed values and remotes, override any of them by name, add files, and replace files the parent renders. The rules, applied uniformly:
- The unit of override is a whole
[questions.<name>],[computed]entry,[data.<name>]or[remotes]entry, not a field within one. A child redeclaring[questions.license]replaces the parent's entirely. - Order: the parent's own entries come first, in the parent's own declaration order, then the child's new ones. An entry the child overrides keeps the parent's position — overriding a question does not move it in the prompt sequence.
name,description,root,strict,noteandnote_fileare never inherited. Each layer's own manifest is authoritative for these; a child that wants its parent's note copies it.- Files are merged by their pre-render path: a child's
template/README.md.jinjareplaces the parent's file at the same path entirely; a parent's file the child does not mention is included unchanged. - A child may add no files of its own at all — one that only overrides a question or a data source has
nothing under its own
root, which is fine: only a template with no[extends]must have a non-emptyroot. - Partials merge into one namespace by name: the nearer layer's own declaration wins for a bare
{% import "macros.jinja" %}, and{% import "parent:macros.jinja" %}reaches the next declaration of that same name, one layer further out — the value a bare reference would have resolved to had the nearer layer not overridden it. This is the<prefix>:loader namespace shared partials already reserves.
Extending a shared base across the chain¶
{% extends %}/{% block %} compose with the chain exactly like shared partials do, because
they are shared partials, merged into the same namespace by the same rule.
The everyday case needs nothing special: a parent declares a base with blocks, and a child with no partial of its own by that name just extends it bare — the parent's declaration is the only, and therefore nearest, one:
{# the parent's base.html.jinja #}
{% block title %}Default title{% endblock %}
{% block content %}Default content{% endblock %}
{# the child's template/page.html.jinja #}
{% extends "base.html.jinja" %}
{% block title %}Overridden title{% endblock %}
{% block content %}{{ super() }} + more{% endblock %}
renders to:
The <prefix>: form matters only once the child declares its own base.html.jinja too, shadowing the
parent's for a bare reference — the same trap ADR-012 built the prefix for.
From inside that shadow, parent:base.html.jinja reaches the parent's original — one layer further out from
wherever the reference is written, not from the leaf that is actually rendering:
{# the child's own base.html.jinja -- shadows the parent's for a bare reference #}
{% extends "parent:base.html.jinja" %}
{% block content %}{{ super() }} + child layer{% endblock %}
{# the child's template/page.html.jinja -- extends its own base.html.jinja, bare #}
{% extends "base.html.jinja" %}
{% block content %}{{ super() }} + page layer{% endblock %}
renders content to Default content + child layer + page layer: the parent's original, extended once by the
child's own shadow, extended again by the child's page.
A chain may be several templates deep (a base, extended by a language-specific template, extended by a
project's own), up to a small depth limit, but each template names exactly one parent — no multiple inheritance,
no diamonds. A chain that revisits a template it has already resolved is rejected before anything renders.
A remote (non-local) source needs the same confirmation a [data] kind = "git" source does — --trust, a
[trust] entry, or an interactive confirmation — because it is chosen by the template's author, not typed on the
command line, exactly like a git-kind data source is. See Confirmation. Unlike
[data], which is confirmed all at once before evaluation, an ancestor is confirmed as it is discovered — the
chain is only readable one clone at a time. git tpl lint, git tpl questions and git tpl status resolve this
chain too and need the same confirmation, even though none of them ever touch a [data] source.
git tpl status and the rendered commit's trailers record the whole chain, not just the directly-configured
template — see What is in the commit. Full reasoning:
ADR-034.
Talking to the user¶
A template can show one note after init, and only after init.
Two forms, mutually exclusive:
# A literal, for a line or two.
note = "Next: run scripts/bootstrap.sh"
# A file in the template repository, for more than fits in a TOML string.
note_file = "NEXT-STEPS.md"
The key is note and not message because [questions.<name>].message already exists — it explains a
pattern.
TOML would fold a top-level message = written after any table into that question, silently.
note_file¶
The path is relative to the template repository root, not to the render root.
A note beside template.toml is "NEXT-STEPS.md", never "template/NEXT-STEPS.md".
This is the same namespace partials live in.
The file is read from the template and never rendered into the project. A note is guidance, not an artifact — if you want a file the user keeps, render one and let the note say to read it.
It is rendered if and only if the path ends in .jinja, exactly as a file is:
note_file = "NEXT-STEPS.md" # shown verbatim, braces and all
note_file = "NEXT-STEPS.md.jinja" # rendered with the answers
The path itself may be an expression, so a template can choose its note:
note_file = "notes/{{ language }}.md"
note_file = "{% if ci %}notes/ci.md{% endif %}" # renders empty: no note
A path that renders to nothing means no note.
A non-empty path naming nothing is an error, and init refuses before it writes anything — git tpl lint
reports the same thing without a repository.
What a note cannot do¶
Nothing here runs.
git-tpl executes no command a note names, and a note saying "run curl … | sh" is exactly as dangerous as a
README.md saying it — which is to say the user has to do it themselves.
See ADR-019.
The note is shown in a block attributed to the template, and is sanitised first: colour and https links
survive, and everything else a terminal could act on does not.
Under --json, when piped, or under NO_COLOR, it is plain text.
Rendering rules¶
.jinja files are templates¶
Cargo.toml.jinja is rendered and written as Cargo.toml.
The suffix is stripped, and only that suffix — a.jinja.jinja becomes a.jinja.
Everything else is copied byte-for-byte¶
.github/workflows/ci.yml lands unchanged.
This matters more than it sounds: GitHub Actions files are full of ${{ }}, and a tool that rendered every file
would mangle them.
If you want to template a workflow, name it ci.yml.jinja and escape the Actions syntax with {% raw %}.
Paths are templates too¶
Every path segment is itself rendered:
A segment that renders to the empty string causes that entry — and, for a directory, everything beneath it — to be skipped:
That is how you make a whole subtree conditional.
A directory segment that renders to . is different: it is transparent, not skipped.
It contributes nothing to the output path, but — unlike an empty segment — nothing beneath it is skipped either;
the rest of the path renders one level up, exactly as if that segment were never there:
renders to {{ package_name }}/mod.rs when use_src is false, and to src/{{ package_name }}/mod.rs when it is
true — one subtree, not two copies kept in sync by hand. Each level is independent, so several such segments
compose: a template with both a src/ toggle and a namespace toggle needs one physical copy of the subtree, not
one per combination.
. only means this on a directory segment. On the file's own name — the last segment of the path — it is still
an error; a file has no "above" to promote its content to.
A rendered value may also contain / itself, and it is split the same way: each piece between the separators
becomes its own real directory level, exactly as if the template had that many nested directories.
with package_path a computed value of "{{ 'src/' if use_src else '' }}" renders to pkg/__init__.py or
src/pkg/__init__.py depending on use_src — one expression, one subtree, no {% if %} needed at all. This is
the same mechanism as the .-transparent form above, generalized: a chain of {% if %}...{% else %}.{% endif
%} segments and one fanning-out expression build the same variable-depth path. Use whichever reads more clearly
for the template — several independent booleans usually read better one level at a time, while a single computed
path reads better as one expression.
A rendered path may not escape the tree
A piece may not be .., or contain a backslash — unconditionally, at any position. That is the actual
danger: .. is a request to write outside the render root, and a backslash is a separator Git itself treats
differently across platforms. Neither is about / itself, which is why / inside a rendered value is safe
to allow: it only ever produces more pieces, each checked the same way.
A piece that disappears — empty or . — is otherwise dropped in place, except as the file's own name, which
has no "above" to promote its content to and so still rejects both there.
The rendered tree is built directly as a Git tree, so all of this is caught before anything is written, but
it is rejected explicitly rather than left to chance.
Shared partials¶
A .jinja file outside the rendered subdirectory is a partial.
It is never written into a project, and it can be imported by name from any file that is:
A partial is named by its path relative to the repository root, not to the rendered subdirectory, so a partial in a directory is:
{% include %} follows the same path rule but takes no alias — it inlines the partial's output directly, e.g.
{% include "macros/rust.jinja" %}.
Being outside the rendered subdirectory is the whole rule, and it is what keeps a macro definition from landing in every generated project. There is no manifest key and no reserved filename.
Two consequences worth stating:
- A
.jinjafile inside the rendered subdirectory is an output file and is not importable. One file, one meaning. - Only
.jinjafiles are loadable.{% include "data/licenses.toml" %}does not work; declare a data source instead, which knows how to parse it.
Partials are read from the same pinned revision as everything else, so editing one changes the rendered tree and
advances the ref.
They are available to manifest expressions too — a computed value may {% import %} the same macro a file
does.
A partial can also be the base of a {% extends %} chain. {% block %}, {% extends %} and {{ super() }} are
plain Jinja, and the loader that serves {% import %} and {% include %} serves these exactly the same way, by
name, from the same partial namespace — there is nothing extends-specific to configure:
{# base.html.jinja #}
{% block title %}Default title{% endblock %}
{% block content %}Default content{% endblock %}
{# template/page.html.jinja #}
{% extends "base.html.jinja" %}
{% block title %}{{ project_name }}{% endblock %}
{% block content %}{{ super() }} + more{% endblock %}
renders to:
title is replaced outright; content extends the base's own content with {{ super() }} rather than
replacing it. The overall shape — which block comes first, and the newline between them — comes from the base,
since a file extending another contributes only its blocks, nothing else in its own body.
Getting the name wrong
A failed import lists the partials that do exist.
The usual cause is a path written relative to template/ instead of the repository root.
Binary files¶
A file with a NUL byte in its first 8 KiB is treated as binary and copied verbatim, even if it is named .jinja.
Rendering a PNG would corrupt it, and the failure would be silent.
Permissions¶
The executable bit is preserved. Git records nothing else about a file's mode, so nothing else can be.
Determinism¶
Byte-identical output for identical inputs, always. See Determinism for what that rules out.
The template context¶
Inside a .jinja file, these names are available:
| Name | What it is |
|---|---|
| (answers, by name) | Every answered question, at the top level. {{ project_name }}. |
| (computed, by name) | Every computed value, at the top level. {{ package_name }}. |
data |
Loaded data sources. {{ data.licenses }}. |
template |
Template metadata: template.name, template.description. |
Answers and computed values are at the top level because that is what templates actually read, and
{{ answers.project_name }} is noise.
They share one namespace, and a computed value may not reuse an answer's name — that is an error at load time,
not a silent shadow.
Full detail: Template context.
Publishing¶
There is nothing to package and nowhere to register.
Publishing a template is git push, and sharing it is sharing the URL someone passes to git tpl init.
What a visitor lands on is the repository itself, so the template's own README.md and LICENSE — the files
outside template/ that are never rendered — are the ones worth writing.
They are the only description anyone gets before cloning.
On GitHub, add the git-tpl repository topic:
or Settings → About → Topics. The template then appears at github.com/topics/git-tpl, which is how templates are found.
The topic is a discovery convention between humans, nothing more.
git-tpl never queries GitHub, has no registry and resolves no names: git tpl init takes a Git URL and clones it.
A template without the topic works exactly as well — it is just harder to come across.