Skip to content

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:

root = "src"

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.

[remotes]
origin = "git@github.com:{{ github_org }}/{{ project_name }}.git"

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, note and note_file are 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.jinja replaces 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-empty root.
  • 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:

Overridden title
Default content + more

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 parent's base.html.jinja #}
{% block content %}Default content{% endblock %}
{# 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:

template/src/{{ package_name }}/mod.rs.jinja

A segment that renders to the empty string causes that entry — and, for a directory, everything beneath it — to be skipped:

template/{% if ci %}.github{% endif %}/workflows/ci.yml

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:

template/{% if use_src %}src{% else %}.{% endif %}/{{ package_name }}/mod.rs

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.

template/{{ package_path }}pkg/__init__.py

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:

macros.jinja                             ← the partial
template/README.md.jinja                 ← imports it
{% import "macros.jinja" as m %}
{{ m.badge(project_name) }}

A partial is named by its path relative to the repository root, not to the rendered subdirectory, so a partial in a directory is:

{% import "macros/rust.jinja" as rust %}

{% 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 .jinja file inside the rendered subdirectory is an output file and is not importable. One file, one meaning.
  • Only .jinja files 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                          ← the partial, declares the blocks
template/page.html.jinja                 ← extends it
{# 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:

demo
Default content + more

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:

gh repo edit --add-topic git-tpl

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.