JSON output¶
--json is global.
Every command accepts it and every failure emits the same envelope — including the commands that have no success
payload of their own.
Almost every command emits its payload to stdout; the three whose stdout is already the payload are
show, completion and man.
Human output goes to stderr, so a piped --json stream stays parseable even when the command is chatty:
The global flags¶
Four flags are accepted by every command, before or after the subcommand:
| Flag | Default | Meaning |
|---|---|---|
--json |
off | The payload on stdout as one object, as described here. Silences the prose narration, as --quiet does. |
-q, --quiet |
off | Suppress everything but errors and warnings. The exit code still says what happened. |
-v, --verbose |
off | More detail. Repeatable — -vv for more again. |
--color <auto\|always\|never> |
auto |
auto colours only when stderr is a terminal. |
A warning is deliberately louder than the rest: neither --quiet nor --json suppresses one, because a warning
names something the caller is getting wrong right now.
Warnings go to stderr, so a JSON payload on stdout stays parseable.
Failure¶
One shape, from every command:
{
"ok": false,
"error": {
"code": "tpl::render::collision",
"message": "`a.jinja` and `b.jinja` both render to `x`",
"help": "two template files cannot produce the same output file",
"causes": [{ "code": "tpl::eval::expression", "message": "…", "help": "…" }],
"labels": [{ "offset": 412, "length": 9, "label": "…" }]
}
}
Branch on error.code, never on message.
causes carries the chain: the outer error names the file, the one beneath names the reason, and only the pair is
actionable.
The exit code is unchanged — non-zero on failure, 2 from status when a template update is pending.
Reading the code to decide what went wrong and the exit status to decide whether it did are different jobs.
Success¶
Every payload carries "ok": true, so a caller can check one field without first knowing which command it ran.
render¶
{ "ok": true,
"template": { "name": "rust", "description": "…" },
"revision": { "reference": "main", "commit": "a17b0b2…", "dirty": false },
"output": "/tmp/out",
"files": [{ "path": "Cargo.toml", "bytes": 412, "executable": false, "templated": true }],
"ignoredAnswers": [],
"skippedByGitignore": [] }
templated says whether the file went through MiniJinja or was copied byte-for-byte.
It is the only way to tell, from the output, that a workflow full of ${{ }} was copied rather than
rendered-and-survived.
skippedByGitignore names the working-tree files a .gitignore kept out of a --dirty render — always empty for
a committed revision.
Only paths a render reads are listed: those under root, the .jinja partials outside it, and the files named by
declared data sources.
A path that could never have been rendered is not reported, however it is ignored.
It is a report, not an error: the render succeeded, and a caller comparing the file list against its expectations
needs to know why one is absent.
See the authoring loop.
lint¶
{ "ok": true,
"template": "rust",
"diagnostics": [{ "severity": "warning", "code": "tpl::lint::undeclared",
"message": "…", "help": "…", "path": "Cargo.toml.jinja",
"denied": false }],
"errors": 0, "warnings": 1, "denied": 0 }
ok is about the command, not the template: warnings do not fail it unless
--deny says so.
Check the exit code, or errors and denied together.
severity is the rule's, denied is this run's policy.
A --deny never rewrites the severity, so "severity": "warning", "denied": true remains distinguishable from a
native error.
errors and warnings count by severity; denied counts the warnings promoted, and is what makes the exit code
1 when errors is 0.
An --allowed finding appears nowhere and is counted nowhere.
questions¶
{ "ok": true,
"template": { "name": "rust", "description": "…", "root": "template" },
"questions": [{ "name": "crate", "order": 0, "type": "string", "prompt": "Crate name",
"help": null, "default": null, "defaultIsExpression": false,
"when": null, "defaultWhenSkipped": false, "pattern": "^[a-z0-9-]+$",
"message": "…", "defaultFrom": null }],
"computed": ["lib_name"],
"data": [{ "name": "targets", "source": "data/targets.toml", "kind": "template",
"format": "toml", "sha256": null }] }
Questions come in resolution order, which is the order they must be answered in when a when or a default
references an earlier answer.
defaultIsExpression distinguishes "{{ crate }}" from a literal.
defaultFrom is the machine-seeded default, which pre-fills
a prompt and is never the answer.
defaultWhenSkipped mirrors the manifest's
default_when_skipped: when true, default reaches
the context even while the question is skipped, though it is still never the answer.
Three keys appear only when the question declares them: choices, an array of { value, label, help };
choicesFrom, the reference a choices_from names; and choicesResolved, that reference's values, present only
when it points at a data file inside the template — which saves the caller fetching and parsing it, and is why a
remote source has no resolved form here.
context¶
{ "ok": true,
"answers": {…}, "computed": {…}, "gatedDefaults": {…}, "data": {…}, "template": {…}, "flat": {…},
"extends": { "chain": [], "questions": {…}, "data": {…} } }
flat is what a template body sees: answers, computed values and gatedDefaults merged into one table.
gatedDefaults holds the values injected for skipped
default_when_skipped questions — kept apart from
answers because they are not answers.
data and template are not in flat — they are siblings of it here, and namespaces of their own in a template.
extends is documented in context: the [extends] chain, and which
layer of it declared each inherited question or data source — []/{} for a template with no chain.
With --eval:
status¶
Documented in status.
--json is the only spelling: the --format json it replaced was removed in 0.7.
diff¶
{ "ok": true,
"conflicts": ["mise.toml"],
"changes": [{ "path": "mise.toml", "kind": "modified", "insertions": 9,
"deletions": 3, "binary": false }],
"insertions": 9, "deletions": 3 }
conflicts names the paths a merge could not resolve on its own.
They are still reported as changes — the preview contains them with conflict markers, which is what a merge would
leave in the worktree.
init¶
{ "ok": true,
"id": "rust", "ref": "refs/tpl/rust",
"template": "https://github.com/noirbizarre/rust.tpl",
"revision": { "reference": "main", "commit": "a17b0b2…", "dirty": false },
"commit": "f4c9e21…",
"changes": [{ "path": "Cargo.toml", "kind": "added" }],
"merge": { "result": "merged", "commit": "…" },
"configPath": ".config/git.tpl.toml", "configCommitted": true,
"ignoredAnswers": [],
"note": "Next: run scripts/bootstrap.sh",
"remotes": [{ "name": "origin", "url": "git@github.com:acme/demo.git",
"status": "added", "existing": null }] }
revision describes the template — the same shape render reports. commit is the different, project-side
commit init created on the rendered ref; the two are never the same repository's history.
template is the expanded URL, never the mine: shortcut that may have been typed: it is what was recorded in
the project, and a shortcut means nothing on anyone else's machine.
merge is null under --no-merge, which is a different thing from a merge that ran and found nothing to do
({"result": "upToDate"}).
note is the template's own note, null when it declares none.
It is unsanitised — escape sequences are a terminal's problem and this stream reaches no terminal, so strip
them yourself if you are going to print it.
Branch on its presence, never on its text: note prose is not a contract.
remotes lists the [remotes] a template declared, in declaration order, with what became of each:
status |
Meaning |
|---|---|
added |
It was not configured, and now is. |
unchanged |
It was already configured with this URL. |
skipped |
A remote of that name exists with a different URL, and was left alone. existing names it. |
url is always what the template asked for, including when it was refused — which is the case you most need it
in.
existing is null unless the two disagree, so its presence is the signal.
Both are init-only: update neither adds a remote nor shows an init-time note.
It has its own, narrower mechanism for the same purpose — see migrations under update, below.
With --dry-run the payload is a different shape entirely, because nothing was created and there is no ref,
commit or merge to report:
{ "ok": true, "dryRun": true,
"template": "https://github.com/noirbizarre/rust.tpl",
"revision": { "reference": "main", "commit": "a17b0b2…", "dirty": false },
"questions": [{ "name": "crate", "kind": "question", "supplied": false },
{ "name": "lib_name", "kind": "computed", "supplied": false }],
"files": ["Cargo.toml"],
"ignoredAnswers": [] }
questions is in resolution order and includes the computed and data nodes the graph resolves alongside them,
which kind distinguishes.
files is null, not [], unless --defaults was passed: without it the list was never computed, and producing
it would mean asking the whole questionnaire — which is what a dry run is avoiding.
An empty array would claim the template renders nothing.
update¶
{ "ok": true, "result": "upToDate",
"id": "rust", "ref": "refs/tpl/rust",
"template": "https://github.com/noirbizarre/rust.tpl",
"revision": { "reference": "main", "commit": "a17b0b2…", "dirty": false },
"ignoredAnswers": [] }
{ "ok": true, "result": "updated",
"id": "rust", "ref": "refs/tpl/rust", "template": "…",
"commit": "f4c9e21…",
"previousRevision": { "reference": "main", "commit": "a1b2c3d…", "dirty": false },
"revision": { "reference": "main", "commit": "a17b0b2…", "dirty": false },
"changes": [{ "path": "Cargo.toml", "kind": "modified" }],
"answersChanged": false, "startedNewHistory": false,
"migrations": [{ "path": "migrations/2026-08-config-move.toml",
"message": "0.4 split `config.rs` …",
"moves": [{ "from": "src/config.rs", "to": "src/config/mod.rs" }] }],
"movedCommit": "9c1f4a0…",
"ignoredAnswers": [], "pushed": null }
Branch on result: upToDate or updated.
It is the one thing the exit code does not say — both succeed.
result is always updated, never upToDate, when migrations is non-empty: discovering a migration always
produces a commit, so the same one is never discovered again on a later update.
See ADR-024.
migrations lists every migration newly discovered by this update, in application order; empty on almost every
update.
message is null when the migration declares none, and — like note above — is unsanitised: strip escape
sequences yourself before printing it.
moves is [] when the migration moved nothing.
movedCommit is the intermediate, content-identical rename commit's id, or null when none was needed — which is
most of the time, including most updates that do carry a migration.
See Migrations.
startedNewHistory is true when there was no refs/tpl/<id> to descend from, so the new commit is an orphan
sharing no ancestry with anything the branch has merged.
Two causes, both legitimate: the configuration's source or id was edited, or the project was cloned without
refs/tpl/* and never fetched.
Not an error, but a git tpl merge from here has no merge base and can conflict on every file — fetch first if
the ref exists on a remote.
previousRevision is null on the first rendering.
When present, its reference and commit are independently null if the recorded provenance the previous
rendering left could not supply one — an older trailer format, or a hand-edited commit on the ref.
pushed names the remote when tpl.autoPush pushed automatically, null otherwise — the
push still happens under --json, only its prose is silenced.
With --dry-run, the same shape plus "dryRun": true, and result is upToDate or wouldUpdate.
merge¶
result is one of upToDate, fastForward, merged, staged, conflicted or aborted.
commit accompanies fastForward and merged; conflicts accompanies conflicted.
This is the same object init reports under merge, so a caller handles both with one function.
A conflicted merge is a success, not a failure: the index is left as Git leaves it, for the user to resolve.
result is how a caller finds out.
backport¶
{ "ok": true, "result": "patched",
"template": "../my-template",
"revision": { "reference": "main", "commit": "937573e…", "dirty": false },
"patch": "From 0000000…\nSubject: [PATCH] tpl: backport from my-service\n…",
"output": null,
"applyCommand": "git tpl backport | git -C ../my-template am",
"files": [ { "rendered": "README.md", "source": "template/README.md.jinja",
"insertions": 1, "deletions": 1, "added": false } ],
"skipped": [], "unsubstituted": [], "insertions": 1, "deletions": 1 }
result is patched, nothingToBackport, or plan (with --list-hunks, below).
revision.reference is null when a commit was recorded but no reference was — a hand-edited commit on the ref, or
an older trailer format — and backport declines to guess which reference it might have come from.
revision.commit/revision.dirty are never null: when neither was recorded, they fall back to what the fresh
render underneath the patch actually resolved.
patch carries the mailbox itself, rather than it going to stdout beside the payload: under --json, stdout is
one JSON object, always.
It is "" when there is nothing to backport.
output is the --output path, or null when the patch went to stdout.
files[].source is the path in the template repository, render root and .jinja suffix included — the path
the patch edits.
files[].rendered is the path in the project.
added marks a file the template did not previously produce.
skipped carries paths that were considered and deliberately not backported, each with a reason — currently
only files deleted locally, which would remove them from every project rendering the template.
Files the template never produced are not listed: they are out of scope rather than skipped.
unsubstituted names every line whose template expression was reversed — path, source, line, the
rendered and project forms, the patched source line, and the expressions it kept.
It is empty unless --unsubstitute was passed, since under --json there is nobody to confirm a reversal with.
Worth branching on: a reversed substitution changes what the template produces for every project, and is the one
part of a patch that should not be merged unread (ADR-022).
applyCommand is the git am invocation git-tpl declines to run (ADR-020),
built from the configured source.
When the template is a URL it contains the literal <your-template-clone>, because there is no local clone to
name.
-p is refused under --json with tpl::backport::not_interactive: there is nobody to show hunks to, and a flag
that silently sent everything instead would be the one answer that was not asked for.
Choose hunks without a prompt with --list-hunks and --hunk instead, or limit the backport with pathspecs or
--exclude.
With --list-hunks, result is plan, patch is "", files is empty, and the payload gains a plan array
(ADR-037):
{ "ok": true, "result": "plan", "patch": "", "files": [],
"plan": [ { "rendered": "README.md", "source": "template/README.md.jinja", "added": false,
"hunks": [ { "id": "3f9a1c0be2d4", "spec": "README.md:3f9a1c0be2d4",
"header": "@@ -1,4 +1,5 @@", "insertions": 2, "deletions": 0,
"lines": [ "+# Acme", "+", " # Notes", " " ] } ] } ] }
spec is exactly what --hunk takes.
id is derived from the hunk's content and the file's path, so it is stable while the file is unchanged and stops
matching when it changes.
lines is the hunk body, each line prefixed with a space, - or +.
A file that the template would refuse to patch is still listed, because the listing is produced before the proof;
and a binary file appears in skipped instead.
A refusal is a failure, with a tpl::backport::* code.
Branch on the code: substituted_region is routine and means "edit the template by hand", while
stale_rendering means "run update first".
fetch¶
{ "ok": true, "remote": "origin", "ref": "refs/tpl/rust",
"state": "behind",
"relation": { "ahead": 0, "behind": 2, "synced": false, "diverged": false } }
state is one of absent, synced, diverged, behind or ahead, and relation is null exactly when
state is absent — the remote has no copy of the ref, which is a different thing from a copy level with yours.
Fetching never moves the local ref, so behind is a report, not an action.
With --dry-run nothing is fetched and the payload says only what would be:
{ "ok": true, "dryRun": true, "remote": "origin",
"refspec": "+refs/tpl/*:refs/remotes/origin/tpl/*" }
push¶
With --dry-run nothing is pushed:
fetch names a refspec and push a ref, because a fetch brings every template ref and a push moves the one
this project has.
test¶
Documented with the command, at git tpl test.
The payload carries summary and a cases array, each with its failures.
show, completion and man¶
No success envelope, with or without --json.
Their stdout is already the payload — a rendered file's bytes, a shell script, troff — and wrapping it in JSON
would only mean nothing could read, source or render it.
Failures still carry the usual envelope.