pyfr-cli 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyfr_cli/render.py ADDED
@@ -0,0 +1,119 @@
1
+ """The template at the target version, rendered with the recorded answers.
2
+
3
+ A shallow clone into a temporary directory, rendered by path -- never by
4
+ URL, so cookiecutter's own clone cache and its re-clone prompt are never
5
+ involved, and the clone's updates/ and CHANGELOG.md are on disk for the
6
+ later steps (spec section 4.4).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import yaml
17
+
18
+ from pyfr_cli import answers
19
+ from pyfr_cli.errors import UpdateError
20
+ from pyfr_cli.git import Git
21
+ from pyfr_cli.versions import Version
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Render:
26
+ clone: Path
27
+ project: Path
28
+ # Prompts the target added since the project was generated, and the
29
+ # default each one took.
30
+ defaulted: dict[str, str]
31
+
32
+
33
+ def clone_template(template: str, version: Version, into: Path, git: Git) -> Path:
34
+ # `template` comes from .pyfr-answers.yml or --template, so it is not
35
+ # trusted input. `--` stops git from reading a value starting with `-`
36
+ # (say `--upload-pack=...`) as an option instead of the repository.
37
+ result = git.run(
38
+ "clone", "--quiet", "--depth", "1", "--branch", str(version),
39
+ "--", template, str(into), check=False,
40
+ ) # fmt: skip
41
+ if result.returncode != 0:
42
+ raise UpdateError(
43
+ f"could not clone {template} at {version}: {result.stderr.strip()}",
44
+ "check the _template URL in .pyfr-answers.yml (or --template), "
45
+ "and that you are online",
46
+ )
47
+ return into
48
+
49
+
50
+ def prompts(clone: Path) -> dict[str, object]:
51
+ """The target's prompts: cookiecutter.json without the `_` keys."""
52
+ data = json.loads((clone / "cookiecutter.json").read_text())
53
+ if not isinstance(data, dict):
54
+ raise UpdateError(
55
+ f"{clone / 'cookiecutter.json'} is not a JSON object",
56
+ "the template is broken at this version; pick another --to",
57
+ )
58
+ return {str(key): value for key, value in data.items() if not key.startswith("_")}
59
+
60
+
61
+ def render(
62
+ clone: Path, version: Version, recorded: answers.Answers, output_dir: Path
63
+ ) -> Render:
64
+ # Imported here so the other modules' unit tests need no cookiecutter.
65
+ from cookiecutter.main import cookiecutter
66
+
67
+ declared = prompts(clone)
68
+ extra, defaulted = answers.context(recorded, declared)
69
+ # PYFR_REGEN: the target's post-generation hook prunes and stops -- no
70
+ # git init, no uv sync (the hook's own contract, scripts/regen.py sets
71
+ # it the same way). Restored afterwards, whatever happens.
72
+ previous = os.environ.get("PYFR_REGEN")
73
+ os.environ["PYFR_REGEN"] = "1"
74
+ try:
75
+ project = Path(
76
+ cookiecutter(
77
+ str(clone),
78
+ no_input=True,
79
+ extra_context=extra,
80
+ output_dir=str(output_dir),
81
+ # Never read ~/.cookiecutterrc: a contributor's defaults
82
+ # must not reach a project's update.
83
+ default_config=True,
84
+ )
85
+ )
86
+ except Exception as exc:
87
+ # cookiecutter's own hierarchy, and ValueError for a recorded
88
+ # choice the target no longer offers; the hook's message went to
89
+ # stderr already.
90
+ raise UpdateError(
91
+ f"the template at {version} could not be rendered: {exc}",
92
+ "the message names the recorded answer the template refused; "
93
+ f"change it in {answers.FILE}, or pick another --to",
94
+ ) from exc
95
+ finally:
96
+ if previous is None:
97
+ os.environ.pop("PYFR_REGEN", None)
98
+ else:
99
+ os.environ["PYFR_REGEN"] = previous
100
+ return Render(clone, project, _defaults(project, declared, defaulted))
101
+
102
+
103
+ def _defaults(
104
+ project: Path, declared: dict[str, object], names: list[str]
105
+ ) -> dict[str, str]:
106
+ """What each defaulted prompt became: read from the render's answers
107
+ file, or cookiecutter.json's raw default when that file lacks it."""
108
+ written: dict[str, str] = {}
109
+ file = project / answers.FILE
110
+ if file.is_file():
111
+ data = yaml.safe_load(file.read_text())
112
+ if isinstance(data, dict):
113
+ written = {str(key): str(value) for key, value in data.items()}
114
+ defaults: dict[str, str] = {}
115
+ for name in names:
116
+ raw = declared[name]
117
+ fallback = raw[0] if isinstance(raw, list) and raw else raw
118
+ defaults[name] = written.get(name, str(fallback))
119
+ return defaults
pyfr_cli/state.py ADDED
@@ -0,0 +1,70 @@
1
+ """A paused update, recorded in .git/ so a re-run of the same command resumes.
2
+
3
+ Written when a merge stops on conflicts and before the after-scripts run;
4
+ deleted when the update completes. Inside .git/, so never committed
5
+ (spec section 4.8).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from pyfr_cli.errors import UpdateError
16
+ from pyfr_cli.git import Git
17
+ from pyfr_cli.versions import Version
18
+
19
+ FILE = "pyfr-update.json"
20
+ Phase = Literal["merging", "after-scripts"]
21
+ PHASES: tuple[Phase, ...] = ("merging", "after-scripts")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class State:
26
+ from_version: Version
27
+ to_version: Version
28
+ phase: Phase
29
+ # The sha of the HEAD the tool grafted an extra parent onto for the
30
+ # merge (spec section 4.7), so a run that died before deleting the
31
+ # graft is cleaned up by the next one.
32
+ graft: str | None = None
33
+
34
+
35
+ def path(git: Git) -> Path:
36
+ return git.git_dir() / FILE
37
+
38
+
39
+ def load(git: Git) -> State | None:
40
+ file = path(git)
41
+ if not file.exists():
42
+ return None
43
+ fix = f"delete {file} if no update is in progress, then run again"
44
+ try:
45
+ data = json.loads(file.read_text())
46
+ phase = data["phase"]
47
+ if phase not in PHASES:
48
+ raise ValueError(f"unknown phase {phase!r}")
49
+ return State(
50
+ Version.parse(data["from"]),
51
+ Version.parse(data["to"]),
52
+ "merging" if phase == "merging" else "after-scripts",
53
+ data.get("graft"),
54
+ )
55
+ except (KeyError, ValueError, TypeError) as exc:
56
+ raise UpdateError(f"{FILE} is unreadable: {exc}", fix) from exc
57
+
58
+
59
+ def save(git: Git, state: State) -> None:
60
+ record = {
61
+ "from": str(state.from_version),
62
+ "to": str(state.to_version),
63
+ "phase": state.phase,
64
+ "graft": state.graft,
65
+ }
66
+ path(git).write_text(json.dumps(record, indent=2) + "\n")
67
+
68
+
69
+ def clear(git: Git) -> None:
70
+ path(git).unlink(missing_ok=True)
pyfr_cli/update.py ADDED
@@ -0,0 +1,443 @@
1
+ """`pyfr update` and `pyfr update-check`: spec section 4, step by step."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import TextIO
10
+
11
+ from pyfr_cli import (
12
+ answers,
13
+ changelog,
14
+ ignore,
15
+ migrate,
16
+ render,
17
+ state,
18
+ vendor,
19
+ versions,
20
+ )
21
+ from pyfr_cli.errors import UpdateError
22
+ from pyfr_cli.git import Git, require_tools
23
+ from pyfr_cli.versions import Version
24
+
25
+ # `--no-edit`: the prepared message holds the changelog's `## [vX]` and
26
+ # `### Feat` headings, and the editor's default clean-up (`--cleanup=strip`)
27
+ # would delete every line that starts with `#`.
28
+ CONFLICT_HELP = """\
29
+ merge: conflicts in the files above
30
+ 1. resolve them, then stage them: git add <the files>
31
+ 2. commit the merge: git commit --no-edit (the message is prepared)
32
+ 3. run the same command again: pyfr update (runs what is left)
33
+ """
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Options:
38
+ to: str | None = None
39
+ push: bool = True
40
+ template: str | None = None
41
+
42
+
43
+ def check(
44
+ project: Path, options: Options, out: TextIO, *, as_json: bool = False
45
+ ) -> int:
46
+ """`pyfr update-check`: 0 when current, 1 when behind, 2 on error."""
47
+ require_tools("git")
48
+ recorded = answers.load(project)
49
+ template = options.template or recorded.template
50
+ newest = versions.remote_versions(template, Git(project))[-1]
51
+ behind = recorded.version < newest
52
+ if as_json:
53
+ record = {
54
+ "recorded": str(recorded.version),
55
+ "newest": str(newest),
56
+ "behind": behind,
57
+ "template": template,
58
+ }
59
+ out.write(json.dumps(record) + "\n")
60
+ else:
61
+ out.write(f"recorded {recorded.version}, newest {newest}\n")
62
+ return 1 if behind else 0
63
+
64
+
65
+ def update(project: Path, options: Options, out: TextIO) -> int:
66
+ """`pyfr update`: 0 when updated or current, 1 when a merge waits for
67
+ the user, 2 on error (raised as UpdateError)."""
68
+ require_tools("git", "uv")
69
+ git = Git(project)
70
+ _preconditions(git, project)
71
+ recorded = answers.load(project)
72
+ template = options.template or recorded.template
73
+
74
+ pending = state.load(git)
75
+ if pending is not None:
76
+ _ungraft(git, pending)
77
+ resumed = _resume(git, project, recorded, template, pending, out)
78
+ if resumed is not None:
79
+ return resumed
80
+
81
+ operation = git.operation_in_progress()
82
+ if operation is not None:
83
+ raise UpdateError(
84
+ f"a {operation} is in progress",
85
+ f"finish it, or abort it with git {operation} --abort, then run again",
86
+ )
87
+ _require_clean(git)
88
+
89
+ available = versions.remote_versions(template, git)
90
+ target = versions.resolve_target(options.to, available)
91
+ if target == recorded.version:
92
+ # A --no-push run promised that the next run pushes the branch --
93
+ # this run too, although it has nothing else to do (spec 3.2).
94
+ if options.push and vendor.push_if_ahead(git):
95
+ out.write(f"template: pushed to {vendor.REMOTE}\n")
96
+ out.write(f"already current at {target}\n")
97
+ return 0
98
+ if target < recorded.version:
99
+ raise UpdateError(
100
+ f"{target} is older than the recorded {recorded.version}",
101
+ "downgrades are not supported; pass a newer --to, or none for the newest",
102
+ )
103
+
104
+ # --no-push says the network may be missing: an unreachable origin then
105
+ # means "work from the local branch", not "stop".
106
+ branch = vendor.ensure(git, recorded.version, target, offline=not options.push)
107
+ for note in branch.notes:
108
+ out.write(f"{note}\n")
109
+ # The merge base: the template commit that renders the recorded version.
110
+ previous = vendor.commit_for(git, recorded.version, branch.base)
111
+
112
+ with tempfile.TemporaryDirectory(prefix="pyfr-update-") as scratch:
113
+ tmp = Path(scratch)
114
+ clone = render.clone_template(template, target, tmp / "template", git)
115
+ # Always rendered: step 10's answers file comes from it. The sync,
116
+ # commit and push are skipped when the branch is already there.
117
+ rendered = render.render(clone, target, recorded, tmp / "render")
118
+ for name, value in rendered.defaulted.items():
119
+ out.write(f"render: new prompt {name} defaulted to {value}\n")
120
+
121
+ if branch.version == target:
122
+ out.write(f"template: already at {target}\n")
123
+ # A --no-push run got the branch here; this run carries it.
124
+ if options.push and vendor.push_if_ahead(git):
125
+ out.write(f"template: pushed to {vendor.REMOTE}\n")
126
+ else:
127
+ spec, from_file = ignore.load(project, recorded)
128
+ if not from_file:
129
+ out.write(f"ignore: no {ignore.FILE}; using the built-in default\n")
130
+ with vendor.worktree(git, tmp / "worktree") as worktree:
131
+ vendor.sync(rendered.project, worktree, spec)
132
+ sha = vendor.commit(worktree, branch.version, target)
133
+ out.write(
134
+ f"template: committed {branch.version} -> {target} ({sha[:12]})\n"
135
+ )
136
+ if options.push:
137
+ vendor.push(git)
138
+ out.write(f"template: pushed to {vendor.REMOTE}\n")
139
+ else:
140
+ out.write(
141
+ "template: not pushed (--no-push); push it before this "
142
+ f"update is merged: git push {vendor.REMOTE} {vendor.BRANCH}\n"
143
+ )
144
+
145
+ migrations = migrate.discover(clone, recorded.version, target)
146
+ migrate.run_before(migrations, project, recorded.version, target, out)
147
+ _commit_changes(git, f"chore: prepare for template {target}", out)
148
+
149
+ body = _changelog(clone, recorded.version, target, template)
150
+ # Recorded before the merge starts: a run killed between here and the
151
+ # merge commit (Ctrl-C, or a broken pipe under `pyfr update | head`)
152
+ # must still leave a state file behind. Otherwise the next run either
153
+ # sees a committed merge with no state and silently skips the
154
+ # after-scripts, or sees an uncommitted tool merge and reports it as
155
+ # a foreign one.
156
+ state.save(git, state.State(recorded.version, target, "merging"))
157
+ outcome = _merge(git, previous, body, recorded.version, target, out)
158
+ # Step 10, clean or not: the answers file is an ignored path, so the
159
+ # merge never touched it, and staged here it rides in the merge commit.
160
+ # The recorded URL, not --template: that flag is a one-off override
161
+ # (a maintainer testing an unreleased template from a local clone),
162
+ # and a recorded local path would break the weekly workflow's
163
+ # ls-remote in CI.
164
+ answers.install(rendered.project, project, recorded.template)
165
+ git.run("add", answers.FILE)
166
+ # Same for the ignore file, when the project has none yet: the
167
+ # built-in default ignores it, so the merge could not bring it.
168
+ if ignore.install(rendered.project, project):
169
+ git.run("add", ignore.FILE)
170
+ out.write(f"ignore: installed {ignore.FILE} from the template\n")
171
+ if outcome.graft_error is not None:
172
+ # Reported only now, with the merge message written and the
173
+ # answers file staged: a `git commit --no-edit` after this must
174
+ # still produce a proper merge commit, not git's own default
175
+ # message and a stale answers file. The graft entry is
176
+ # re-recorded (it was already there from inside _merge) so the
177
+ # next run's _ungraft retries the delete before anything else.
178
+ state.save(
179
+ git,
180
+ state.State(recorded.version, target, "merging", graft=outcome.grafted),
181
+ )
182
+ out.write(
183
+ "merge: the merge is ready but the temporary graft is still "
184
+ "in place; remove it, then run pyfr update again\n"
185
+ )
186
+ raise outcome.graft_error
187
+ if not outcome.clean:
188
+ out.write(CONFLICT_HELP)
189
+ return 1
190
+ # No merge in progress after a clean exit: git said "Already up to
191
+ # date" because the template commit is in HEAD's history already
192
+ # (merged by hand, say). Only the answers file changes then, and
193
+ # the commit is a plain one, not a merge.
194
+ merged = git.operation_in_progress() == "merge"
195
+ git.run(
196
+ "commit", "--quiet", "--no-verify",
197
+ "--file", str(git.git_dir() / "MERGE_MSG"),
198
+ ) # fmt: skip
199
+ if merged:
200
+ sha = git.out("rev-parse", "--short=12", "HEAD")
201
+ out.write(f"merge: clean, committed as {sha}\n")
202
+ else:
203
+ out.write(f"merge: nothing to merge; recording {target}\n")
204
+ state.save(git, state.State(recorded.version, target, "after-scripts"))
205
+ _finish(git, project, migrations, recorded.version, target, out)
206
+
207
+ out.write(f"recorded: {target}\n")
208
+ return 0
209
+
210
+
211
+ def _preconditions(git: Git, project: Path) -> None:
212
+ if not git.ok("rev-parse", "--git-dir"):
213
+ raise UpdateError(
214
+ f"{project} is not inside a git repository",
215
+ "run pyfr update at the root of the generated project",
216
+ )
217
+ if git.toplevel() != project.resolve():
218
+ raise UpdateError(
219
+ f"{project} is not the repository root ({git.toplevel()} is)",
220
+ "cd to the root and run again",
221
+ )
222
+ # Before the branch and root checks: both read HEAD, and a repository
223
+ # with no commits has none to read.
224
+ if not git.ok("rev-parse", "--verify", "--quiet", "HEAD"):
225
+ raise UpdateError(
226
+ "the repository has no commits",
227
+ "commit the generated project first: git add -A && "
228
+ "git commit -m 'chore: generate the project from pyfr'",
229
+ )
230
+ if git.current_branch() is None:
231
+ raise UpdateError(
232
+ "HEAD is detached", "switch to a branch first: git switch main"
233
+ )
234
+ if not git.has_identity():
235
+ raise UpdateError(
236
+ "git has no user.name or user.email configured",
237
+ 'git config user.name "Your Name" && git config user.email you@example.com',
238
+ )
239
+
240
+
241
+ def _require_clean(git: Git) -> None:
242
+ if git.has_tracked_changes():
243
+ raise UpdateError(
244
+ "the working tree has uncommitted changes",
245
+ "commit or discard them first: a clean tree is what makes every "
246
+ "step of the update reversible with git reset --hard HEAD",
247
+ )
248
+
249
+
250
+ def _resume(
251
+ git: Git,
252
+ project: Path,
253
+ recorded: answers.Answers,
254
+ template: str,
255
+ pending: state.State,
256
+ out: TextIO,
257
+ ) -> int | None:
258
+ """A previous run stopped. Either the merge still waits (1), the merge is
259
+ committed and the after-scripts remain (run them, 0), or the merge was
260
+ aborted (clear the state and start over: None)."""
261
+ if git.operation_in_progress() == "merge":
262
+ conflicts = _report_conflicts(git, out)
263
+ if conflicts:
264
+ out.write(CONFLICT_HELP)
265
+ else:
266
+ out.write(
267
+ "merge: an uncommitted merge is waiting; commit it with git "
268
+ "commit --no-edit, then run pyfr update again\n"
269
+ )
270
+ return 1
271
+ if recorded.version == pending.to_version:
272
+ _require_clean(git)
273
+ out.write(f"resume: finishing the update to {pending.to_version}\n")
274
+ with tempfile.TemporaryDirectory(prefix="pyfr-update-") as scratch:
275
+ clone = render.clone_template(
276
+ template, pending.to_version, Path(scratch) / "template", git
277
+ )
278
+ migrations = migrate.discover(
279
+ clone, pending.from_version, pending.to_version
280
+ )
281
+ _finish(
282
+ git, project, migrations, pending.from_version, pending.to_version, out
283
+ )
284
+ out.write(f"recorded: {pending.to_version}\n")
285
+ return 0
286
+ state.clear(git)
287
+ out.write("resume: the previous merge was aborted; starting over\n")
288
+ return None
289
+
290
+
291
+ def _ungraft(git: Git, pending: state.State) -> None:
292
+ """A run that died between grafting and un-grafting left a replacement
293
+ ref behind; drop it before anything reads history."""
294
+ if pending.graft is None:
295
+ return
296
+ deleted = git.run("replace", "--delete", pending.graft, check=False)
297
+ if deleted.returncode != 0 and _has_replacement(git, pending.graft):
298
+ # The common case is the ref is already gone and only the state
299
+ # entry was stale (the previous run deleted it) -- that fails here
300
+ # too, but harmlessly, so only a ref that is still really there is
301
+ # worth stopping for.
302
+ raise UpdateError(
303
+ f"the temporary graft on {pending.graft[:12]} could not be "
304
+ f"removed: {deleted.stderr.strip()}",
305
+ f"remove it by hand: git replace -d {pending.graft[:12]}, "
306
+ "then run pyfr update again",
307
+ )
308
+
309
+
310
+ def _report_conflicts(git: Git, out: TextIO) -> list[str]:
311
+ """The unmerged paths of the merge in progress, printed as `conflict:`
312
+ lines and returned -- empty when a killed run left a clean merge
313
+ uncommitted rather than a real conflict."""
314
+ paths = git.out("diff", "--name-only", "--diff-filter=U").splitlines()
315
+ for path in paths:
316
+ out.write(f"conflict: {path}\n")
317
+ return paths
318
+
319
+
320
+ @dataclass(frozen=True)
321
+ class MergeOutcome:
322
+ """What `_merge` did. `graft_error` is set, not raised, so the caller
323
+ can finish preparing the merge (the message, the staged answers file)
324
+ before reporting it -- see the comment where `_merge` builds it."""
325
+
326
+ clean: bool
327
+ grafted: str | None
328
+ graft_error: UpdateError | None
329
+
330
+
331
+ def _merge(
332
+ git: Git,
333
+ previous: str,
334
+ body: str,
335
+ recorded: Version,
336
+ target: Version,
337
+ out: TextIO,
338
+ ) -> MergeOutcome:
339
+ """`git merge --no-ff --no-commit template` with its base pinned to
340
+ `previous`, the template commit at the recorded version (spec 4.7).
341
+
342
+ Git finds that base by itself only when `previous` is in HEAD's
343
+ history. After a squash-merged update pull request it is not, so a
344
+ temporary graft -- an extra parent, seen by git but not written into
345
+ the commit -- makes it the base; the graft is deleted right after the
346
+ merge, whatever happened.
347
+ """
348
+ grafted: str | None = None
349
+ if not git.ok("merge-base", "--is-ancestor", previous, "HEAD"):
350
+ head = git.out("rev-parse", "HEAD")
351
+ if _has_replacement(git, head):
352
+ # The graft would replace the user's ref, and the state file
353
+ # would then send the next run's clean-up to delete it. Nothing
354
+ # has started, so the state saved for this merge goes too.
355
+ state.clear(git)
356
+ raise UpdateError(
357
+ "HEAD already has a replacement ref (git replace), which the "
358
+ "merge would have to change",
359
+ f"remove it with git replace -d {head[:12]}, or update from a "
360
+ "commit without one",
361
+ )
362
+ parents = git.out("rev-parse", f"{head}^@").split()
363
+ state.save(git, state.State(recorded, target, "merging", graft=head))
364
+ git.run("replace", "--graft", head, *parents, previous)
365
+ grafted = head
366
+ out.write(
367
+ f"merge: base pinned to template commit {previous[:12]} "
368
+ "(the last update was squash-merged)\n"
369
+ )
370
+ graft_error: UpdateError | None = None
371
+ try:
372
+ result = git.run("merge", "--no-ff", "--no-commit", vendor.BRANCH, check=False)
373
+ finally:
374
+ # Recorded here, not raised: raising in `finally` would run before
375
+ # the merge message is written and the answers file is staged
376
+ # below, in `update()`, and skip both -- a `git commit --no-edit`
377
+ # afterwards (the merge itself is real and still needs finishing)
378
+ # would then get git's own default message and miss the answers
379
+ # file. The caller raises `graft_error` once that is done. Either
380
+ # way, the state file's graft entry (saved above) is left in
381
+ # place, so the next run's _ungraft retries the delete.
382
+ if grafted is not None:
383
+ deleted = git.run("replace", "--delete", grafted, check=False)
384
+ if deleted.returncode != 0:
385
+ graft_error = UpdateError(
386
+ f"the temporary graft on {grafted[:12]} could not be "
387
+ f"removed: {deleted.stderr.strip()}",
388
+ f"remove it by hand: git replace -d {grafted[:12]}, "
389
+ "then run pyfr update again",
390
+ )
391
+ if result.returncode != 0 and git.operation_in_progress() != "merge":
392
+ # Refused before it started: untracked files in the way, typically.
393
+ # Nothing is in progress, so the state this run just saved is stale
394
+ # and would otherwise be mistaken for a real paused merge.
395
+ state.clear(git)
396
+ raise UpdateError(
397
+ f"git merge could not start: {result.stderr.strip()}",
398
+ "move the files it names out of the way, then run again",
399
+ )
400
+ # Written only once the merge is actually in progress (or done): a
401
+ # could-not-start merge above must leave no prepared message behind for
402
+ # git to pre-fill into the user's next manual commit.
403
+ message = f"chore: update template {recorded} -> {target}\n"
404
+ if body:
405
+ message += f"\n{body}"
406
+ (git.git_dir() / "MERGE_MSG").write_text(message)
407
+ if result.returncode == 0:
408
+ return MergeOutcome(True, grafted, graft_error)
409
+ _report_conflicts(git, out)
410
+ return MergeOutcome(False, grafted, graft_error)
411
+
412
+
413
+ def _has_replacement(git: Git, sha: str) -> bool:
414
+ """Whether a `git replace` ref exists for the commit `sha`."""
415
+ return bool(git.out("replace", "--list", sha))
416
+
417
+
418
+ def _commit_changes(git: Git, message: str, out: TextIO) -> None:
419
+ """Commit what the migration scripts changed in tracked files, if anything."""
420
+ git.run("add", "--update")
421
+ if git.has_staged_changes():
422
+ sha = git.commit(message)
423
+ out.write(f"commit: {message} ({sha[:12]})\n")
424
+
425
+
426
+ def _finish(
427
+ git: Git,
428
+ project: Path,
429
+ migrations: list[migrate.Migration],
430
+ recorded: Version,
431
+ target: Version,
432
+ out: TextIO,
433
+ ) -> None:
434
+ migrate.run_after(migrations, project, recorded, target, out)
435
+ _commit_changes(git, f"chore: finish template {target}", out)
436
+ state.clear(git)
437
+
438
+
439
+ def _changelog(clone: Path, after: Version, up_to: Version, template: str) -> str:
440
+ file = clone / "CHANGELOG.md"
441
+ if not file.is_file():
442
+ return ""
443
+ return changelog.entries(file.read_text(), after, up_to, template)