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/__init__.py +16 -0
- pyfr_cli/__main__.py +86 -0
- pyfr_cli/answers.py +118 -0
- pyfr_cli/changelog.py +67 -0
- pyfr_cli/errors.py +12 -0
- pyfr_cli/git.py +107 -0
- pyfr_cli/ignore.py +103 -0
- pyfr_cli/migrate.py +114 -0
- pyfr_cli/render.py +119 -0
- pyfr_cli/state.py +70 -0
- pyfr_cli/update.py +443 -0
- pyfr_cli/vendor.py +372 -0
- pyfr_cli/versions.py +97 -0
- pyfr_cli-0.11.0.dist-info/METADATA +573 -0
- pyfr_cli-0.11.0.dist-info/RECORD +18 -0
- pyfr_cli-0.11.0.dist-info/WHEEL +4 -0
- pyfr_cli-0.11.0.dist-info/entry_points.txt +2 -0
- pyfr_cli-0.11.0.dist-info/licenses/LICENSE +373 -0
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)
|