git tpl backport¶
Emit a patch that carries a local fix back to the template it came from.
You fixed something in a generated project.
The same fix belongs in the template, so the next project gets it and every existing one gets it on the next
update.
backport finds which .jinja produced the file you edited, works out the corresponding change to that source,
and gives you a patch.
The loop¶
Start with a fix in the project.
Here ci.yml is a file the template copies byte-for-byte, and CI should run on pull requests too:
$ sed -i 's/^on: push/on: [push, pull_request]/' ci.yml
$ git commit -qam "fix: run CI on pull requests"
Ask for the patch:
$ git tpl backport
backport main (e754104)
template/ci.yml <- ci.yml
1 file changed, 1 insertion(+), 1 deletion(-)
apply: git tpl backport | git -C ../my-template am
The summary went to stderr; the patch itself went to stdout. So the command in that last line is exactly what you run next:
The template now has the fix. Back in the project, it arrives the ordinary way:
$ git tpl update
Template: ../my-template
Revision: main (e754104) → main (937573e)
Updated refs/tpl/my-template
modified ci.yml
Your working tree was not modified.
$ git tpl merge
Merged refs/tpl/my-template into the current branch
Merge commit e91261a.
Git merges your change with the identical change now coming from upstream, and there is nothing left to send:
That is the whole feature.
Applying it¶
git am reads a mailbox on stdin, so the pipe needs nothing from git-tpl:
To read the patch before applying it — recommended, since you are about to change something every project shares — write it out first:
git tpl backport -o backport.mbox
less backport.mbox
git -C ../my-template am ../my-service/backport.mbox
Either way it is git am that applies the patch, in your clone, where git am --abort, git am -3 and
git am --skip all work as usual.
git-tpl never applies the patch, and never writes to the template. There is no --to flag and there will not
be one.
Two reasons, both structural:
- A template resolved from a remote is a throwaway clone in a temporary directory. Writing into it writes into a directory about to be deleted.
- Applying a patch is reconciliation, and git-tpl contributes none of its own — see
ADR-002.
git amalready does it, better, in the repository where you can review the result.
git-tpl does print the exact command it declines to run, built from the source in your
configuration.
If the template is a URL rather than a path on this machine, there is no clone to name and the hint says
<your-template-clone> instead.
What it compares¶
Not the project against the template — one is .jinja sources and the other is output, so there is nothing to
compare.
backport compares two rendered trees:
refs/tpl/<id> the tree the template produces at the recorded revision
↓
your worktree the same tree, plus whatever you changed
↓
the difference your local divergence — the thing worth sending upstream
The baseline is the revision the project actually rendered, which is why there is no --ref.
Diffing against any other revision would fold the template's own movement into the patch, and you would send
upstream a revert of upstream.
For the same reason there is no --answer: the rendering exists to reproduce the tree you were given, so it uses
the answers in .config/git.tpl.toml.
Both flags are refused by the parser rather than accepted and ignored.
Uncommitted work is included. You have just made the fix, and requiring a commit before you can see the patch would be a step for its own sake.
Paths in the patch¶
Patch paths are relative to the template repository root, so they carry the render root as a prefix —
template/ci.yml, not ci.yml:
That is what makes a plain git am correct: its default -p1 strips the a/, leaving template/ci.yml, which
is where the file lives.
You need --directory only if your clone is rooted somewhere below the template repository root, which is
unusual.
The .jinja suffix is restored too, so a patch to README.md in your project edits template/README.md.jinja
in the template.
Selecting what to send¶
Positional arguments are Git pathspecs, matched against the paths as you see them in the project:
--exclude removes paths, and is repeatable:
A single * does not cross a /; ** does.
A bare name like --exclude Cargo.lock matches at any depth.
Three kinds of change are handled specially:
| Change | Default | Why |
|---|---|---|
| You edited a file the template produces | backported | The case the command exists for. |
| You deleted a file the template produces | reported, not backported | Removing a file from a template removes it from every project that renders it. Far too blunt to infer from one project's worktree. |
| You added a file the template does not produce | ignored, unless you name it | Otherwise every file your project owns would be a candidate. Named explicitly, it becomes a new template file — and not a .jinja, since nothing was substituted into a file the template has never seen. |
Choosing hunks¶
Pathspecs select whole files.
-p selects within one:
$ git tpl backport -p
README.md → template/README.md.jinja
1 @@ -1,4 +1,5 @@
# acme
+
+A generated project.
Run the tests before pushing.
2 @@ -8,3 +9,3 @@
## Releasing
-Tag it.
+Tag it, then push the tag.
Send which hunks of `README.md`? (Space toggles, Enter confirms.)
> [x] 1 @@ -1,4 +1,5 @@ +2 −0
[ ] 2 @@ -8,3 +9,3 @@ +1 −1
The hunks are your own edits, as git add -p shows them — not the template
patch.
Everything starts selected, because -p is for taking things out.
The selection happens before the patch is built, not after. What you keep is assembled into a candidate — your file, with only those hunks — and that is what the template source is patched to produce and then checked against. A change that round-tripped as a whole does not necessarily round-trip with half its hunks dropped, so the proof is made against the patch you are actually sending. See ADR-023.
That is also why a selection can be refused where the whole change would not have been. When it is, the refusal names the hunk:
$ git tpl backport -p
tpl::backport::hunk_refused
x hunk 2 of `README.md` cannot be backported
help: the hunk at `@@ -8,3 +9,3 @@` is the one that failed. Run
`git tpl backport -p` again and leave it out to send the rest, or
edit `README.md.jinja` by hand to carry it.
Deselecting every hunk of a file simply leaves that file out.
Pressing Escape abandons the command with tpl::backport::cancelled, and no patch is emitted — a cancelled
prompt is not read as "send nothing", because it is just as likely to have been "wait, start again".
Choosing hunks without a terminal¶
-p needs somewhere to show the hunks, so under --json, through a pipe, or with tpl.interactive false it is
refused rather than ignored:
This is the opposite of how --unsubstitute behaves, and deliberately so.
Un-substitution is something git-tpl offers; not offering it on a CI runner is a decision it can take for you.
-p is something you asked for, and the one answer it cannot mean is "send everything".
To select without a prompt, name pathspecs or use --exclude.
Changing a line that holds a placeholder¶
Backporting works per line, not per file.
A .jinja backports fine as long as your change lands on lines it copies verbatim — which most prose and most
configuration is:
$ git tpl backport
backport main (937573e)
template/README.md.jinja <- README.md
1 file changed, 1 insertion(+), 1 deletion(-)
--- a/template/README.md.jinja
+++ b/template/README.md.jinja
@@ -2,4 +2,4 @@
A generated service.
-Run the tests before pushing.
+Run the tests and the linter before pushing.
The {{ project_name }} heading is untouched and still a placeholder.
Often, though, the fix is on a line with a {{ }} in it — a heading you want to reword, a flag you want to add
to a command.
backport carries those too, keeping the placeholder and sending only the change around it.
Because that is the one thing it does which cannot be proved right for anyone but you, it asks first:
$ git tpl backport
`README.md` line 1 was changed around a value the template substitutes.
rendered # acme — a service
yours # acme — a web service
upstream # {{ project_name }} — a web service
It keeps `{{ project_name }}` and sends the rest of the line to
`README.md.jinja`.
Send this line upstream? [yes/no]
Answer no and the file refuses with substituted_region, exactly as it would have without the offer.
Why it asks¶
Every patch is proved to render back to your file (see
below).
That is not the same as being right for everyone else's answers, and reversing a substitution is the first thing
backport does where the two come apart:
source version = "{{ version }}" with version = "1.0"
rendered version = "1.0"
project version = "1.0.0"
The .0 you added sits right against the value.
Attributing it to the text around the placeholder gives version = "{{ version }}.0" — which renders back to
your file perfectly, and appends .0 to every other project's version.
You meant to change your answer.
backport refuses that particular one, because the edit could equally have been an edit to the value and there
is nothing in the bytes to choose between them.
But the class as a whole is not decidable, so the last word is yours.
Nothing here searches your file for an answer's value, which is why a value that happens to coincide with ordinary text is not a problem:
With author = "June", editing the month carries cleanly and the placeholder survives; editing the author is
refused.
The two "June"s are simply different ranges of the line — one produced by the expression, one copied from the
source.
ADR-022 has the whole argument.
Without a terminal¶
With nobody to ask — under --json, in a script, on CI, or with tpl.interactive set to false — no
substitution is reversed and the line refuses as it always did.
Pass --unsubstitute to take every reversal without asking:
Under --json the payload's unsubstituted array names every line that was reversed, so a reviewer can gate on
it.
See JSON output.
When it refuses¶
A backport that guesses ships a broken template to every downstream project at once.
That is strictly worse than editing the template by hand, which is what you would have done anyway — so
backport refuses rather than guessing, and every refusal names that fallback.
The change is on a line the template renders¶
The commonest one, and what you get whenever the reversal above is declined or not available. Here the heading comes from a question:
If you rename the project by editing the rendered README.md, there is no change to send: you changed an
answer, not the template.
Reversing acme back into {{ project_name }} would rename the heading for everyone.
$ git tpl backport
tpl::backport::substituted_region
x `README.md` was changed where the template substitutes a value
help: line 1 of `README.md` is produced by an expression in
`README.md.jinja`, not copied from it, so there is no one-to-one
change to send upstream. Edit `README.md.jinja` by hand, or restrict
the backport with a pathspec.
The same code covers every line whose provenance is not exact enough to reverse:
- a change that replaces the whole value, or any part of it
- a change whose placement is ambiguous, as in the
versioncase above - a line holding a
{% … %}block tag or a{# … #}comment - a line using whitespace control,
{{- … }}or{{ … -}} - a line inside a
{% for %}body, or any line whose expressions do not reassemble into exactly what the template produced - a line where an expression rendered to nothing
The patch does not render back to your file¶
Every patch is checked before it is emitted: the patched template source is rendered, and the result must equal
your file exactly.
Because rendering is deterministic, a successful check is a proof rather than a
guess.
tpl::backport::round_trip means the check failed — most often because the change landed against a region a
{% if %} collapsed — and sending it would change what the template produces for everyone.
Other refusals¶
| Code | When |
|---|---|
tpl::backport::binary |
A changed file is binary. A text patch cannot carry it; copy it into the template by hand. |
tpl::backport::stale_rendering |
.config/git.tpl.toml was edited without re-rendering, so refs/tpl/<id> is not what the answers produce and every line would be measured against the wrong file. Run git tpl update first. |
tpl::backport::unknown_path |
A named path is neither produced by the template nor present in the project. |
tpl::backport::hunk_refused |
A hunk selected with -p cannot be backported. The refusal it wraps keeps its own code; this one names the hunk to leave out. |
tpl::backport::not_interactive |
-p was asked for where no prompt can run. |
tpl::backport::cancelled |
The hunk picker was cancelled. Nothing was emitted. |
The full list is in Diagnostic codes, and the reasoning behind all of it is ADR-020.
Options¶
| Option | Meaning |
|---|---|
<pathspec>... |
Limit the backport to these paths. A file the template does not produce is only considered when named here. |
--exclude <glob> |
Leave these paths out. Repeatable. * does not cross a /, ** does. |
-o, --output <file> |
Write the patch here instead of to stdout. |
--trust |
Fetch the template's remote data sources without confirming. Per invocation; nothing is recorded. |
-p, --patch |
Choose which hunks to send, one file at a time. Needs a terminal; refused under --json or with tpl.interactive false. See Choosing hunks. |
--unsubstitute |
Reverse changed template expressions without confirming. Also the only way to reverse one at all with nobody to ask — under --json, or on CI. See Changing a line that holds a placeholder. |
There is deliberately no --ref and no --answer: both would change the baseline the patch is measured against.
See What it compares.
Machine-readable output¶
git tpl --json backport emits its outcome on stdout as a single JSON object, with the prose on stderr.
The patch travels in the payload as patch, rather than beside it — stdout under --json is one JSON object,
always.
The payload is described in JSON output.