jrl-cmakemodules-scripts 2.0.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.
- jrl_cmakemodules_scripts-2.0.0.dist-info/METADATA +99 -0
- jrl_cmakemodules_scripts-2.0.0.dist-info/RECORD +6 -0
- jrl_cmakemodules_scripts-2.0.0.dist-info/WHEEL +5 -0
- jrl_cmakemodules_scripts-2.0.0.dist-info/entry_points.txt +2 -0
- jrl_cmakemodules_scripts-2.0.0.dist-info/top_level.txt +1 -0
- jrl_release.py +1433 -0
jrl_release.py
ADDED
|
@@ -0,0 +1,1433 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "tomlkit",
|
|
6
|
+
# "ruamel.yaml",
|
|
7
|
+
# "rich",
|
|
8
|
+
# "packaging",
|
|
9
|
+
# "cmake-parser",
|
|
10
|
+
# ]
|
|
11
|
+
# ///
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
# jrl_release.py
|
|
15
|
+
|
|
16
|
+
Version management script for multi-format projects. Keeps version strings in sync across all tracked files and automates the release process.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
> Requires [`uv`](https://docs.astral.sh/uv/) — it auto-installs dependencies via PEP 723 inline metadata.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv run --no-project jrl_release.py [OPTIONS]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Common Commands
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Check that all files agree on the current version
|
|
30
|
+
uv run --no-project jrl_release.py --check-version
|
|
31
|
+
|
|
32
|
+
# Bump version
|
|
33
|
+
uv run --no-project jrl_release.py --bump patch # 1.0.0 -> 1.0.1
|
|
34
|
+
uv run --no-project jrl_release.py --bump minor # 1.0.0 -> 1.1.0
|
|
35
|
+
uv run --no-project jrl_release.py --bump major # 1.0.0 -> 2.0.0
|
|
36
|
+
|
|
37
|
+
# Set a specific version
|
|
38
|
+
uv run --no-project jrl_release.py --update-version 1.2.3
|
|
39
|
+
|
|
40
|
+
# Bump, commit and tag in one step
|
|
41
|
+
uv run --no-project jrl_release.py --bump patch --git-commit --git-tag
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Options
|
|
45
|
+
|
|
46
|
+
| Option | Description |
|
|
47
|
+
| :--- | :--- |
|
|
48
|
+
| `--root <PATH>` | Project root (default: cwd). |
|
|
49
|
+
| `--bump <major|minor|patch>` | Bump version component. |
|
|
50
|
+
| `--update-version <X.Y.Z>` | Set a specific version. |
|
|
51
|
+
| `--dry-run` | Show changes without writing files. |
|
|
52
|
+
| `--short` | Print only the version string. |
|
|
53
|
+
| `--output-format <text|json>` | Output format (default: text). |
|
|
54
|
+
| `--confirm` | Skip interactive prompts. |
|
|
55
|
+
| `--list-files` | List tracked files. |
|
|
56
|
+
| `--git-commit [MSG]` | Commit changes. Optional message (`{version}` placeholder). |
|
|
57
|
+
| `--git-tag [NAME]` | Create a tag. Optional name (`{version}` placeholder). |
|
|
58
|
+
| `--git-tag-message <MSG>` | Tag annotation (`{version}` placeholder). |
|
|
59
|
+
|
|
60
|
+
**Git defaults**: commit `chore: bump version to {version}`, tag `v{version}`, tag message `Release version {version}`.
|
|
61
|
+
|
|
62
|
+
## Supported Files
|
|
63
|
+
|
|
64
|
+
| File | Key |
|
|
65
|
+
| :--- | :--- |
|
|
66
|
+
| `package.xml` | `<version>` tag |
|
|
67
|
+
| `pyproject.toml` | `project.version` |
|
|
68
|
+
| `CHANGELOG.md` | First `## [X.Y.Z]` section (not Unreleased) |
|
|
69
|
+
| `pixi.toml` | `[workspace] version` |
|
|
70
|
+
| `pixi.lock` | Regenerated via `pixi list` |
|
|
71
|
+
| `CITATION.cff` | `version` key |
|
|
72
|
+
| `CMakeLists.txt` | `project(... VERSION X.Y.Z ...)` |
|
|
73
|
+
|
|
74
|
+
> Requires `pixi` CLI if `pixi.lock` exists in the project root.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
import sys
|
|
78
|
+
import re
|
|
79
|
+
import argparse
|
|
80
|
+
import datetime
|
|
81
|
+
import json
|
|
82
|
+
import subprocess
|
|
83
|
+
import shutil
|
|
84
|
+
import tempfile
|
|
85
|
+
from pathlib import Path
|
|
86
|
+
from abc import ABC, abstractmethod
|
|
87
|
+
from typing import List, Optional, Tuple, Dict
|
|
88
|
+
|
|
89
|
+
import tomlkit
|
|
90
|
+
import cmake_parser
|
|
91
|
+
from ruamel.yaml import YAML
|
|
92
|
+
from rich.console import Console
|
|
93
|
+
from rich.table import Table
|
|
94
|
+
from rich import box
|
|
95
|
+
from rich.prompt import Confirm
|
|
96
|
+
from rich.panel import Panel
|
|
97
|
+
from rich.markdown import Markdown
|
|
98
|
+
from rich.text import Text
|
|
99
|
+
from packaging.version import parse as parse_version, InvalidVersion
|
|
100
|
+
|
|
101
|
+
# Ensure UTF-8 output so rich box-drawing and ✓/✗ symbols render on Windows.
|
|
102
|
+
# Prevents UnicodeEncodeError: 'charmap' codec can't encode characters.
|
|
103
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
104
|
+
try:
|
|
105
|
+
_stream.reconfigure(encoding="utf-8")
|
|
106
|
+
except (AttributeError, ValueError):
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
console = Console()
|
|
110
|
+
|
|
111
|
+
STYLE_INFO = "bold blue"
|
|
112
|
+
STYLE_SUCCESS = "green"
|
|
113
|
+
STYLE_SUCCESS_STRONG = "bold green"
|
|
114
|
+
STYLE_WARNING = "yellow"
|
|
115
|
+
STYLE_WARNING_STRONG = "bold yellow"
|
|
116
|
+
STYLE_ERROR = "red"
|
|
117
|
+
STYLE_ERROR_STRONG = "bold red"
|
|
118
|
+
STYLE_MUTED = "dim"
|
|
119
|
+
STYLE_OLD_VALUE = "red"
|
|
120
|
+
STYLE_NEW_VALUE = "green"
|
|
121
|
+
STYLE_UNCHANGED_VALUE = "dim"
|
|
122
|
+
STYLE_HIGHLIGHT = "cyan"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class VersionNotPresent(Exception):
|
|
126
|
+
"""Raised when a file exists but has no version field configured."""
|
|
127
|
+
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class VersionExtractor(ABC):
|
|
132
|
+
def __init__(self, file_path: Path):
|
|
133
|
+
self.file_path = file_path
|
|
134
|
+
|
|
135
|
+
@abstractmethod
|
|
136
|
+
def get_version(self) -> str:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
def update_version(self, new_version: str) -> None:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
def check_file_exists(self) -> bool:
|
|
144
|
+
return self.file_path.exists()
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def name(self) -> str:
|
|
148
|
+
return self.file_path.name
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def path(self) -> str:
|
|
152
|
+
return str(self.file_path)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class XmlVersionExtractor(VersionExtractor):
|
|
156
|
+
def get_version(self) -> str:
|
|
157
|
+
# Simple regex for package.xml to avoid parsing namespaces or losing comments
|
|
158
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
159
|
+
content = f.read()
|
|
160
|
+
match = re.search(r"<version>(.*?)</version>", content)
|
|
161
|
+
if match:
|
|
162
|
+
return match.group(1).strip()
|
|
163
|
+
raise VersionNotPresent(f"No <version> tag found in {self.name}")
|
|
164
|
+
|
|
165
|
+
def update_version(self, new_version: str) -> None:
|
|
166
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
167
|
+
content = f.read()
|
|
168
|
+
|
|
169
|
+
# Replace only the first occurrence which is standard for the package version
|
|
170
|
+
new_content = re.sub(
|
|
171
|
+
r"<version>(.*?)</version>",
|
|
172
|
+
f"<version>{new_version}</version>",
|
|
173
|
+
content,
|
|
174
|
+
count=1,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
178
|
+
f.write(new_content)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TomlVersionExtractor(VersionExtractor):
|
|
182
|
+
def __init__(self, file_path: Path, keys: List[str]):
|
|
183
|
+
super().__init__(file_path)
|
|
184
|
+
self.keys = keys
|
|
185
|
+
|
|
186
|
+
def get_version(self) -> str:
|
|
187
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
188
|
+
data = tomlkit.load(f)
|
|
189
|
+
|
|
190
|
+
value = data
|
|
191
|
+
for key in self.keys:
|
|
192
|
+
if key in value:
|
|
193
|
+
value = value[key]
|
|
194
|
+
else:
|
|
195
|
+
raise VersionNotPresent(
|
|
196
|
+
f"Key '{'.'.join(self.keys)}' not found in {self.name}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return str(value)
|
|
200
|
+
|
|
201
|
+
def update_version(self, new_version: str) -> None:
|
|
202
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
203
|
+
data = tomlkit.load(f)
|
|
204
|
+
|
|
205
|
+
# Navigate to the key
|
|
206
|
+
container = data
|
|
207
|
+
for key in self.keys[:-1]:
|
|
208
|
+
if key in container:
|
|
209
|
+
container = container[key]
|
|
210
|
+
else:
|
|
211
|
+
raise ValueError(f"Key '{key}' not found in {self.name}")
|
|
212
|
+
|
|
213
|
+
container[self.keys[-1]] = new_version
|
|
214
|
+
|
|
215
|
+
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
216
|
+
tomlkit.dump(data, f)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class YamlVersionExtractor(VersionExtractor):
|
|
220
|
+
def __init__(self, file_path: Path, keys: List[str]):
|
|
221
|
+
super().__init__(file_path)
|
|
222
|
+
self.keys = keys
|
|
223
|
+
self.yaml = YAML()
|
|
224
|
+
self.yaml.preserve_quotes = True
|
|
225
|
+
|
|
226
|
+
def get_version(self) -> str:
|
|
227
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
228
|
+
data = self.yaml.load(f)
|
|
229
|
+
|
|
230
|
+
value = data
|
|
231
|
+
for key in self.keys:
|
|
232
|
+
if key in value:
|
|
233
|
+
value = value[key]
|
|
234
|
+
else:
|
|
235
|
+
raise VersionNotPresent(
|
|
236
|
+
f"Key '{'.'.join(self.keys)}' not found in {self.name}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return str(value)
|
|
240
|
+
|
|
241
|
+
def update_version(self, new_version: str) -> None:
|
|
242
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
243
|
+
data = self.yaml.load(f)
|
|
244
|
+
|
|
245
|
+
container = data
|
|
246
|
+
for key in self.keys[:-1]:
|
|
247
|
+
container = container[key]
|
|
248
|
+
|
|
249
|
+
container[self.keys[-1]] = new_version
|
|
250
|
+
|
|
251
|
+
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
252
|
+
self.yaml.dump(data, f)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class CMakeListsVersionExtractor(VersionExtractor):
|
|
256
|
+
"""Specialized extractor for CMakeLists.txt that uses cmake-parser
|
|
257
|
+
and handles both direct VERSION and variables (e.g., from package.xml)."""
|
|
258
|
+
|
|
259
|
+
def get_version(self) -> str:
|
|
260
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
261
|
+
content = f.read()
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
# Parse the CMakeLists.txt file
|
|
265
|
+
tree = cmake_parser.parse(content)
|
|
266
|
+
|
|
267
|
+
fallback_version = None
|
|
268
|
+
project_version = None
|
|
269
|
+
|
|
270
|
+
# Walk through all commands
|
|
271
|
+
for node in tree:
|
|
272
|
+
if hasattr(node, "name"):
|
|
273
|
+
# Look for set(PROJECT_VERSION "...")
|
|
274
|
+
if node.name.lower() == "set":
|
|
275
|
+
args = self._get_command_args(node)
|
|
276
|
+
if len(args) >= 2 and args[0] == "PROJECT_VERSION":
|
|
277
|
+
# Remove quotes from version string
|
|
278
|
+
fallback_version = args[1].strip('"')
|
|
279
|
+
|
|
280
|
+
# Look for project(...VERSION ...)
|
|
281
|
+
elif node.name.lower() == "project":
|
|
282
|
+
args = self._get_command_args(node)
|
|
283
|
+
# Find VERSION keyword
|
|
284
|
+
try:
|
|
285
|
+
version_idx = args.index("VERSION")
|
|
286
|
+
if version_idx + 1 < len(args):
|
|
287
|
+
ver = args[version_idx + 1]
|
|
288
|
+
# Check if it's a variable reference
|
|
289
|
+
if not ver.startswith("${"):
|
|
290
|
+
project_version = ver
|
|
291
|
+
except ValueError:
|
|
292
|
+
pass
|
|
293
|
+
|
|
294
|
+
# If project() uses a variable, return fallback
|
|
295
|
+
if fallback_version and not project_version:
|
|
296
|
+
return fallback_version
|
|
297
|
+
|
|
298
|
+
# If project() has a literal version, use that
|
|
299
|
+
if project_version:
|
|
300
|
+
return project_version
|
|
301
|
+
|
|
302
|
+
except Exception:
|
|
303
|
+
pass # cmake-parser failed, fall back to regex
|
|
304
|
+
|
|
305
|
+
return self._get_version_regex(content)
|
|
306
|
+
|
|
307
|
+
def _get_command_args(self, node) -> List[str]:
|
|
308
|
+
"""Extract arguments from a cmake command node."""
|
|
309
|
+
args = []
|
|
310
|
+
if hasattr(node, "body"):
|
|
311
|
+
for item in node.body:
|
|
312
|
+
if hasattr(item, "contents"):
|
|
313
|
+
args.append(item.contents)
|
|
314
|
+
return args
|
|
315
|
+
|
|
316
|
+
def _get_version_regex(self, content: str) -> str:
|
|
317
|
+
"""Fallback regex-based version extraction."""
|
|
318
|
+
# First try to find set(PROJECT_VERSION "X.Y.Z")
|
|
319
|
+
fallback_pattern = re.compile(
|
|
320
|
+
r'set\s*\(\s*PROJECT_VERSION\s+"([0-9]+\.[0-9]+\.[0-9]+)"',
|
|
321
|
+
re.MULTILINE,
|
|
322
|
+
)
|
|
323
|
+
fallback_match = fallback_pattern.search(content)
|
|
324
|
+
|
|
325
|
+
# Also check if project() uses a literal version or variable
|
|
326
|
+
project_pattern = re.compile(
|
|
327
|
+
r"project\s*\([^)]*VERSION\s+([\d.]+|\$\{[^}]+\})", re.MULTILINE
|
|
328
|
+
)
|
|
329
|
+
project_match = project_pattern.search(content)
|
|
330
|
+
|
|
331
|
+
# If project() uses a variable, use the fallback version
|
|
332
|
+
if project_match and project_match.group(1).startswith("${"):
|
|
333
|
+
if fallback_match:
|
|
334
|
+
return fallback_match.group(1)
|
|
335
|
+
raise VersionNotPresent(
|
|
336
|
+
f"{self.name} reads version from variable {project_match.group(1)}, no fallback found"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# If project() uses a literal, return it
|
|
340
|
+
if project_match and not project_match.group(1).startswith("${"):
|
|
341
|
+
return project_match.group(1)
|
|
342
|
+
|
|
343
|
+
raise VersionNotPresent(f"No version found in {self.name}")
|
|
344
|
+
|
|
345
|
+
def update_version(self, new_version: str) -> None:
|
|
346
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
347
|
+
content = f.read()
|
|
348
|
+
|
|
349
|
+
# Update the fallback version in set(PROJECT_VERSION "...")
|
|
350
|
+
fallback_pattern = re.compile(
|
|
351
|
+
r'(set\s*\(\s*PROJECT_VERSION\s+)"([0-9]+\.[0-9]+\.[0-9]+)"',
|
|
352
|
+
re.MULTILINE,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def repl_fallback(match):
|
|
356
|
+
return f'{match.group(1)}"{new_version}"'
|
|
357
|
+
|
|
358
|
+
content = fallback_pattern.sub(repl_fallback, content, count=1)
|
|
359
|
+
|
|
360
|
+
# Also update literal version in project() if present
|
|
361
|
+
project_pattern = re.compile(
|
|
362
|
+
r"(project\s*\([^)]*VERSION\s+)([\d.]+)", re.MULTILINE
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def repl_project(match):
|
|
366
|
+
return f"{match.group(1)}{new_version}"
|
|
367
|
+
|
|
368
|
+
content = project_pattern.sub(repl_project, content, count=1)
|
|
369
|
+
|
|
370
|
+
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
371
|
+
f.write(content)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class ChangelogVersionExtractor(VersionExtractor):
|
|
375
|
+
def __init__(self, file_path: Path, pattern: str = ""):
|
|
376
|
+
super().__init__(file_path)
|
|
377
|
+
|
|
378
|
+
def get_version(self) -> str:
|
|
379
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
380
|
+
content = f.read()
|
|
381
|
+
|
|
382
|
+
# Look for ## [Version]
|
|
383
|
+
matches = re.findall(r"^## \[(.*?)\]", content, re.MULTILINE)
|
|
384
|
+
for version in matches:
|
|
385
|
+
if version.lower() != "unreleased":
|
|
386
|
+
return version
|
|
387
|
+
raise VersionNotPresent(f"No released version found in {self.name}")
|
|
388
|
+
|
|
389
|
+
def update_version(self, new_version: str) -> None:
|
|
390
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
391
|
+
content = f.read()
|
|
392
|
+
|
|
393
|
+
today = datetime.date.today().isoformat()
|
|
394
|
+
|
|
395
|
+
pattern = r"^## \[Unreleased\]"
|
|
396
|
+
if not re.search(pattern, content, re.MULTILINE):
|
|
397
|
+
console.print(
|
|
398
|
+
f"[{STYLE_WARNING}]Warning: Could not find '## [Unreleased]' in CHANGELOG.md. Skipping update.[/{STYLE_WARNING}]"
|
|
399
|
+
)
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
replacement = f"## [Unreleased]\n\n## [{new_version}] - {today}"
|
|
403
|
+
|
|
404
|
+
new_content = re.sub(pattern, replacement, content, count=1, flags=re.MULTILINE)
|
|
405
|
+
|
|
406
|
+
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
407
|
+
f.write(new_content)
|
|
408
|
+
|
|
409
|
+
console.print(
|
|
410
|
+
f"[{STYLE_INFO}]Updated CHANGELOG.md header. Note: Link definitions at the bottom were not updated automatically.[/{STYLE_INFO}]"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def validate_semver(version: str) -> str:
|
|
415
|
+
try:
|
|
416
|
+
parsed = parse_version(version)
|
|
417
|
+
return str(parsed)
|
|
418
|
+
except InvalidVersion:
|
|
419
|
+
raise argparse.ArgumentTypeError(
|
|
420
|
+
f"'{version}' is not a valid Semantic Version."
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def parse_semver(version: str) -> Tuple[int, int, int]:
|
|
425
|
+
"""Parse a semantic version string into major, minor, patch components."""
|
|
426
|
+
match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version.strip())
|
|
427
|
+
if not match:
|
|
428
|
+
raise ValueError(f"Invalid semver format: {version}")
|
|
429
|
+
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def bump_version(version: str, bump_type: str) -> str:
|
|
433
|
+
"""Bump a semantic version by major, minor, or patch."""
|
|
434
|
+
major, minor, patch = parse_semver(version)
|
|
435
|
+
|
|
436
|
+
if bump_type == "major":
|
|
437
|
+
return f"{major + 1}.0.0"
|
|
438
|
+
elif bump_type == "minor":
|
|
439
|
+
return f"{major}.{minor + 1}.0"
|
|
440
|
+
elif bump_type == "patch":
|
|
441
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
442
|
+
else:
|
|
443
|
+
raise ValueError(f"Invalid bump type: {bump_type}")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def get_current_version(checks: List[VersionExtractor]) -> Optional[str]:
|
|
447
|
+
"""Get the current consensus version from all files."""
|
|
448
|
+
versions_found = set()
|
|
449
|
+
errors = []
|
|
450
|
+
|
|
451
|
+
for check in checks:
|
|
452
|
+
if check.check_file_exists():
|
|
453
|
+
try:
|
|
454
|
+
version = check.get_version()
|
|
455
|
+
versions_found.add(version)
|
|
456
|
+
except VersionNotPresent:
|
|
457
|
+
pass # file exists but has no version configured; skip
|
|
458
|
+
except Exception as e:
|
|
459
|
+
errors.append(f"{check.name}: {e}")
|
|
460
|
+
|
|
461
|
+
# Report parsing errors
|
|
462
|
+
if errors:
|
|
463
|
+
console.print(
|
|
464
|
+
f"[{STYLE_WARNING}]Warning: Failed to parse version from some files:[/{STYLE_WARNING}]"
|
|
465
|
+
)
|
|
466
|
+
for error in errors:
|
|
467
|
+
console.print(f" [{STYLE_MUTED}]• {error}[/{STYLE_MUTED}]")
|
|
468
|
+
|
|
469
|
+
if len(versions_found) == 1:
|
|
470
|
+
return list(versions_found)[0]
|
|
471
|
+
elif len(versions_found) > 1:
|
|
472
|
+
console.print(
|
|
473
|
+
f"[{STYLE_ERROR}]Error: Multiple versions found: {', '.join(sorted(versions_found))}[/{STYLE_ERROR}]"
|
|
474
|
+
)
|
|
475
|
+
console.print(
|
|
476
|
+
f"[{STYLE_WARNING}]Please run --check-version first to resolve conflicts.[/{STYLE_WARNING}]"
|
|
477
|
+
)
|
|
478
|
+
return None
|
|
479
|
+
else:
|
|
480
|
+
console.print(
|
|
481
|
+
f"[{STYLE_ERROR}]Error: No version found in any files.[/{STYLE_ERROR}]"
|
|
482
|
+
)
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def infer_change_type(
|
|
487
|
+
old_version: str, new_version: str, bump_type: Optional[str] = None
|
|
488
|
+
) -> str:
|
|
489
|
+
"""Infer change type label (major/minor/patch/custom)."""
|
|
490
|
+
if bump_type in {"major", "minor", "patch"}:
|
|
491
|
+
return bump_type
|
|
492
|
+
|
|
493
|
+
try:
|
|
494
|
+
old_major, old_minor, old_patch = parse_semver(old_version)
|
|
495
|
+
new_major, new_minor, new_patch = parse_semver(new_version)
|
|
496
|
+
except ValueError:
|
|
497
|
+
return "custom"
|
|
498
|
+
|
|
499
|
+
if new_major != old_major:
|
|
500
|
+
return "major"
|
|
501
|
+
if new_minor != old_minor:
|
|
502
|
+
return "minor"
|
|
503
|
+
if new_patch != old_patch:
|
|
504
|
+
return "patch"
|
|
505
|
+
return "no-change"
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def show_version_diff(
|
|
509
|
+
old_version: str, new_version: str, bump_type: Optional[str] = None
|
|
510
|
+
) -> None:
|
|
511
|
+
"""Display a visual diff between old and new versions."""
|
|
512
|
+
old_parts = old_version.split(".")
|
|
513
|
+
new_parts = new_version.split(".")
|
|
514
|
+
|
|
515
|
+
# Build colored versions with highlights on changed parts
|
|
516
|
+
old_colored_parts = []
|
|
517
|
+
new_colored_parts = []
|
|
518
|
+
|
|
519
|
+
for old, new in zip(old_parts, new_parts):
|
|
520
|
+
if old != new:
|
|
521
|
+
old_colored_parts.append(f"[{STYLE_OLD_VALUE}]{old}[/{STYLE_OLD_VALUE}]")
|
|
522
|
+
new_colored_parts.append(f"[{STYLE_NEW_VALUE}]{new}[/{STYLE_NEW_VALUE}]")
|
|
523
|
+
else:
|
|
524
|
+
old_colored_parts.append(
|
|
525
|
+
f"[{STYLE_UNCHANGED_VALUE}]{old}[/{STYLE_UNCHANGED_VALUE}]"
|
|
526
|
+
)
|
|
527
|
+
new_colored_parts.append(
|
|
528
|
+
f"[{STYLE_UNCHANGED_VALUE}]{new}[/{STYLE_UNCHANGED_VALUE}]"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
old_colored = ".".join(old_colored_parts)
|
|
532
|
+
new_colored = ".".join(new_colored_parts)
|
|
533
|
+
|
|
534
|
+
change_type = infer_change_type(old_version, new_version, bump_type)
|
|
535
|
+
|
|
536
|
+
panel = Panel(
|
|
537
|
+
f"[bold]{old_colored} → {new_colored}[/bold]",
|
|
538
|
+
title=f"[{STYLE_WARNING_STRONG}]Version Change ({change_type})[/{STYLE_WARNING_STRONG}]",
|
|
539
|
+
border_style=STYLE_WARNING,
|
|
540
|
+
expand=False,
|
|
541
|
+
)
|
|
542
|
+
console.print(panel)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def validate_version_progression(
|
|
546
|
+
old_version: str, new_version: str, bump_type: str
|
|
547
|
+
) -> None:
|
|
548
|
+
"""Validate and warn about unusual version progressions."""
|
|
549
|
+
try:
|
|
550
|
+
old_major, old_minor, old_patch = parse_semver(old_version)
|
|
551
|
+
new_major, new_minor, new_patch = parse_semver(new_version)
|
|
552
|
+
except ValueError:
|
|
553
|
+
return # Can't validate non-semver
|
|
554
|
+
|
|
555
|
+
warnings = []
|
|
556
|
+
|
|
557
|
+
# Check for skipped versions
|
|
558
|
+
if bump_type == "major":
|
|
559
|
+
if new_major != old_major + 1:
|
|
560
|
+
warnings.append(
|
|
561
|
+
f"Major version jump: {old_major} → {new_major} (skipping versions)"
|
|
562
|
+
)
|
|
563
|
+
elif bump_type == "minor":
|
|
564
|
+
if new_major != old_major:
|
|
565
|
+
warnings.append(
|
|
566
|
+
f"Major version changed during minor bump: {old_major} → {new_major}"
|
|
567
|
+
)
|
|
568
|
+
elif new_minor != old_minor + 1:
|
|
569
|
+
warnings.append(
|
|
570
|
+
f"Minor version jump: {old_minor} → {new_minor} (skipping versions)"
|
|
571
|
+
)
|
|
572
|
+
elif bump_type == "patch":
|
|
573
|
+
if new_major != old_major:
|
|
574
|
+
warnings.append(
|
|
575
|
+
f"Major version changed during patch bump: {old_major} → {new_major}"
|
|
576
|
+
)
|
|
577
|
+
elif new_minor != old_minor:
|
|
578
|
+
warnings.append(
|
|
579
|
+
f"Minor version changed during patch bump: {old_minor} → {new_minor}"
|
|
580
|
+
)
|
|
581
|
+
elif new_patch != old_patch + 1:
|
|
582
|
+
warnings.append(
|
|
583
|
+
f"Patch version jump: {old_patch} → {new_patch} (skipping versions)"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
# Check for backward version
|
|
587
|
+
if (new_major, new_minor, new_patch) <= (old_major, old_minor, old_patch):
|
|
588
|
+
warnings.append("New version is not greater than old version")
|
|
589
|
+
|
|
590
|
+
if warnings:
|
|
591
|
+
console.print(
|
|
592
|
+
f"[{STYLE_WARNING_STRONG}]⚠ Version Progression Warnings:[/{STYLE_WARNING_STRONG}]"
|
|
593
|
+
)
|
|
594
|
+
for warning in warnings:
|
|
595
|
+
console.print(f" [{STYLE_WARNING}]• {warning}[/{STYLE_WARNING}]")
|
|
596
|
+
console.print()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def run_git_command(args: List[str], cwd: Path) -> Tuple[bool, str]:
|
|
600
|
+
"""Run a git command and return (success, output)."""
|
|
601
|
+
try:
|
|
602
|
+
result = subprocess.run(
|
|
603
|
+
["git"] + args,
|
|
604
|
+
cwd=cwd,
|
|
605
|
+
capture_output=True,
|
|
606
|
+
text=True,
|
|
607
|
+
)
|
|
608
|
+
if result.returncode != 0:
|
|
609
|
+
return False, result.stderr.strip()
|
|
610
|
+
return True, result.stdout.strip()
|
|
611
|
+
except subprocess.CalledProcessError as e:
|
|
612
|
+
return False, e.stderr or ""
|
|
613
|
+
except FileNotFoundError:
|
|
614
|
+
return False, "git command not found"
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def git_commit_version(
|
|
618
|
+
root_dir: Path,
|
|
619
|
+
version: str,
|
|
620
|
+
auto_confirm: bool,
|
|
621
|
+
custom_message: Optional[str] = None,
|
|
622
|
+
files_to_stage: Optional[List[str]] = None,
|
|
623
|
+
) -> bool:
|
|
624
|
+
"""Commit version changes to git."""
|
|
625
|
+
success, _ = run_git_command(["rev-parse", "--git-dir"], root_dir)
|
|
626
|
+
if not success:
|
|
627
|
+
console.print(
|
|
628
|
+
f"[{STYLE_WARNING}]Not a git repository, skipping git commit.[/{STYLE_WARNING}]"
|
|
629
|
+
)
|
|
630
|
+
return False
|
|
631
|
+
|
|
632
|
+
_, status_output = run_git_command(["status", "--porcelain"], root_dir)
|
|
633
|
+
if not status_output:
|
|
634
|
+
console.print(f"[{STYLE_WARNING}]No changes to commit.[/{STYLE_WARNING}]")
|
|
635
|
+
return False
|
|
636
|
+
|
|
637
|
+
commit_message = (
|
|
638
|
+
custom_message.format(version=version)
|
|
639
|
+
if custom_message
|
|
640
|
+
else f"chore: bump version to {version}"
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
if not auto_confirm:
|
|
644
|
+
confirmed = Confirm.ask(
|
|
645
|
+
f"[bold]Commit changes with message: '{commit_message}'?[/bold]",
|
|
646
|
+
default=True,
|
|
647
|
+
)
|
|
648
|
+
if not confirmed:
|
|
649
|
+
console.print(f"[{STYLE_WARNING}]Git commit skipped.[/{STYLE_WARNING}]")
|
|
650
|
+
return False
|
|
651
|
+
|
|
652
|
+
if files_to_stage:
|
|
653
|
+
rel_paths = [str(Path(p).relative_to(root_dir)) for p in files_to_stage]
|
|
654
|
+
console.print(f"[{STYLE_MUTED}]$ git add {' '.join(rel_paths)}[/{STYLE_MUTED}]")
|
|
655
|
+
run_git_command(["add"] + files_to_stage, root_dir)
|
|
656
|
+
else:
|
|
657
|
+
console.print(f"[{STYLE_MUTED}]$ git add -u[/{STYLE_MUTED}]")
|
|
658
|
+
run_git_command(["add", "-u"], root_dir)
|
|
659
|
+
|
|
660
|
+
console.print(f"[{STYLE_MUTED}]$ git commit -m '{commit_message}'[/{STYLE_MUTED}]")
|
|
661
|
+
success, output = run_git_command(["commit", "-m", commit_message], root_dir)
|
|
662
|
+
if success:
|
|
663
|
+
console.print(
|
|
664
|
+
f"[{STYLE_SUCCESS}]✓ Committed changes: {commit_message}[/{STYLE_SUCCESS}]"
|
|
665
|
+
)
|
|
666
|
+
return True
|
|
667
|
+
else:
|
|
668
|
+
console.print(f"[{STYLE_ERROR}]Failed to commit: {output}[/{STYLE_ERROR}]")
|
|
669
|
+
return False
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def git_tag_version(
|
|
673
|
+
root_dir: Path,
|
|
674
|
+
version: str,
|
|
675
|
+
auto_confirm: bool,
|
|
676
|
+
custom_tag_name: Optional[str] = None,
|
|
677
|
+
custom_tag_message: Optional[str] = None,
|
|
678
|
+
) -> bool:
|
|
679
|
+
"""Create a git tag for the version."""
|
|
680
|
+
success, _ = run_git_command(["rev-parse", "--git-dir"], root_dir)
|
|
681
|
+
if not success:
|
|
682
|
+
console.print(
|
|
683
|
+
f"[{STYLE_WARNING}]Not a git repository, skipping git tag.[/{STYLE_WARNING}]"
|
|
684
|
+
)
|
|
685
|
+
return False
|
|
686
|
+
|
|
687
|
+
tag_name = (
|
|
688
|
+
custom_tag_name.format(version=version) if custom_tag_name else f"v{version}"
|
|
689
|
+
)
|
|
690
|
+
tag_message = (
|
|
691
|
+
custom_tag_message.format(version=version)
|
|
692
|
+
if custom_tag_message
|
|
693
|
+
else f"Release version {version}"
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
success, _ = run_git_command(["rev-parse", tag_name], root_dir)
|
|
697
|
+
if success:
|
|
698
|
+
console.print(
|
|
699
|
+
f"[{STYLE_WARNING}]Tag {tag_name} already exists.[/{STYLE_WARNING}]"
|
|
700
|
+
)
|
|
701
|
+
return False
|
|
702
|
+
|
|
703
|
+
if not auto_confirm:
|
|
704
|
+
confirmed = Confirm.ask(
|
|
705
|
+
f"[bold]Create git tag '{tag_name}'?[/bold]", default=True
|
|
706
|
+
)
|
|
707
|
+
if not confirmed:
|
|
708
|
+
console.print(f"[{STYLE_WARNING}]Git tag skipped.[/{STYLE_WARNING}]")
|
|
709
|
+
return False
|
|
710
|
+
|
|
711
|
+
console.print(
|
|
712
|
+
f"[{STYLE_MUTED}]$ git tag -a {tag_name} -m '{tag_message}'[/{STYLE_MUTED}]"
|
|
713
|
+
)
|
|
714
|
+
success, output = run_git_command(
|
|
715
|
+
["tag", "-a", tag_name, "-m", tag_message], root_dir
|
|
716
|
+
)
|
|
717
|
+
if success:
|
|
718
|
+
console.print(f"[{STYLE_SUCCESS}]✓ Created tag: {tag_name}[/{STYLE_SUCCESS}]")
|
|
719
|
+
console.print(
|
|
720
|
+
f"[{STYLE_MUTED}] To push: git push origin {tag_name}[/{STYLE_MUTED}]"
|
|
721
|
+
)
|
|
722
|
+
return True
|
|
723
|
+
else:
|
|
724
|
+
console.print(f"[{STYLE_ERROR}]Failed to create tag: {output}[/{STYLE_ERROR}]")
|
|
725
|
+
return False
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def update_pixi_lock(root_dir: Path, dry_run: bool = False) -> Optional[str]:
|
|
729
|
+
"""Update pixi.lock file by running 'pixi list'.
|
|
730
|
+
|
|
731
|
+
Returns the path to pixi.lock if updated, None otherwise.
|
|
732
|
+
"""
|
|
733
|
+
pixi_lock_path = root_dir / "pixi.lock"
|
|
734
|
+
|
|
735
|
+
if not pixi_lock_path.exists():
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
if dry_run:
|
|
739
|
+
return None
|
|
740
|
+
|
|
741
|
+
console.print(
|
|
742
|
+
f"[{STYLE_INFO}]Running 'pixi list' to update pixi.lock...[/{STYLE_INFO}]"
|
|
743
|
+
)
|
|
744
|
+
try:
|
|
745
|
+
result = subprocess.run(
|
|
746
|
+
["pixi", "list"],
|
|
747
|
+
cwd=root_dir,
|
|
748
|
+
timeout=60,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
if result.returncode != 0:
|
|
752
|
+
console.print(
|
|
753
|
+
f"[{STYLE_ERROR}]Error: 'pixi list' returned non-zero exit code: {result.returncode}[/{STYLE_ERROR}]"
|
|
754
|
+
)
|
|
755
|
+
console.print(
|
|
756
|
+
f"[{STYLE_ERROR}]Failed to update pixi.lock. Please ensure 'pixi' is installed.[/{STYLE_ERROR}]"
|
|
757
|
+
)
|
|
758
|
+
raise RuntimeError(f"'pixi list' failed with exit code {result.returncode}")
|
|
759
|
+
|
|
760
|
+
except subprocess.TimeoutExpired:
|
|
761
|
+
console.print(
|
|
762
|
+
f"[{STYLE_ERROR}]Error: 'pixi list' command timed out[/{STYLE_ERROR}]"
|
|
763
|
+
)
|
|
764
|
+
raise RuntimeError("'pixi list' command timed out after 30 seconds")
|
|
765
|
+
except FileNotFoundError:
|
|
766
|
+
console.print(f"[{STYLE_ERROR}]Error: 'pixi' command not found[/{STYLE_ERROR}]")
|
|
767
|
+
console.print(
|
|
768
|
+
f"[{STYLE_ERROR}]pixi.lock exists but 'pixi' executable is not available.[/{STYLE_ERROR}]"
|
|
769
|
+
)
|
|
770
|
+
console.print(
|
|
771
|
+
f"[{STYLE_INFO}]Please install pixi: https://pixi.sh[/{STYLE_INFO}]"
|
|
772
|
+
)
|
|
773
|
+
raise RuntimeError("'pixi' executable not found. Install from https://pixi.sh")
|
|
774
|
+
except Exception as e:
|
|
775
|
+
console.print(
|
|
776
|
+
f"[{STYLE_ERROR}]Error: Failed to run 'pixi list': {e}[/{STYLE_ERROR}]"
|
|
777
|
+
)
|
|
778
|
+
raise RuntimeError(f"Failed to run 'pixi list': {e}") from e
|
|
779
|
+
|
|
780
|
+
console.print(
|
|
781
|
+
f"[{STYLE_SUCCESS}]✓ Updated pixi.lock via 'pixi list'[/{STYLE_SUCCESS}]"
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
return str(pixi_lock_path)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def create_backups(file_paths: List[Path]) -> Dict[Path, Path]:
|
|
788
|
+
"""Create backup copies of files in a temporary directory.
|
|
789
|
+
|
|
790
|
+
Returns a mapping of original paths to backup paths.
|
|
791
|
+
"""
|
|
792
|
+
backups = {}
|
|
793
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="release_backup_"))
|
|
794
|
+
|
|
795
|
+
for file_path in file_paths:
|
|
796
|
+
if file_path.exists():
|
|
797
|
+
backup_path = temp_dir / file_path.name
|
|
798
|
+
shutil.copy2(file_path, backup_path)
|
|
799
|
+
backups[file_path] = backup_path
|
|
800
|
+
|
|
801
|
+
return backups
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def restore_backups(backups: Dict[Path, Path]) -> None:
|
|
805
|
+
"""Restore files from backups and cleanup temporary directory."""
|
|
806
|
+
if not backups:
|
|
807
|
+
return
|
|
808
|
+
|
|
809
|
+
# Get temp directory from first backup
|
|
810
|
+
temp_dir = None
|
|
811
|
+
for original_path, backup_path in backups.items():
|
|
812
|
+
if backup_path.exists():
|
|
813
|
+
shutil.copy2(backup_path, original_path)
|
|
814
|
+
temp_dir = backup_path.parent
|
|
815
|
+
|
|
816
|
+
# Clean up temp directory
|
|
817
|
+
if temp_dir and temp_dir.exists():
|
|
818
|
+
shutil.rmtree(temp_dir)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def cleanup_backups(backups: Dict[Path, Path]) -> None:
|
|
822
|
+
"""Remove backup files without restoring."""
|
|
823
|
+
if not backups:
|
|
824
|
+
return
|
|
825
|
+
|
|
826
|
+
# Get temp directory from first backup
|
|
827
|
+
temp_dir = None
|
|
828
|
+
for backup_path in backups.values():
|
|
829
|
+
temp_dir = backup_path.parent
|
|
830
|
+
break
|
|
831
|
+
|
|
832
|
+
# Clean up temp directory
|
|
833
|
+
if temp_dir and temp_dir.exists():
|
|
834
|
+
shutil.rmtree(temp_dir)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def list_version_files(checks: List[VersionExtractor]) -> None:
|
|
838
|
+
"""List all files that are checked for versions."""
|
|
839
|
+
table = Table(title="Version Files", box=box.ROUNDED)
|
|
840
|
+
table.add_column("File", style="cyan")
|
|
841
|
+
table.add_column("Path", style="dim")
|
|
842
|
+
table.add_column("Exists", justify="center")
|
|
843
|
+
table.add_column("Type", style="magenta")
|
|
844
|
+
|
|
845
|
+
for check in checks:
|
|
846
|
+
exists = (
|
|
847
|
+
f"[{STYLE_SUCCESS}]✓[/{STYLE_SUCCESS}]"
|
|
848
|
+
if check.check_file_exists()
|
|
849
|
+
else f"[{STYLE_ERROR}]✗[/{STYLE_ERROR}]"
|
|
850
|
+
)
|
|
851
|
+
file_type = check.__class__.__name__.replace("VersionExtractor", "")
|
|
852
|
+
table.add_row(check.name, str(check.file_path), exists, file_type)
|
|
853
|
+
|
|
854
|
+
console.print(table)
|
|
855
|
+
sys.exit(0)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def handle_check_version(checks: List[VersionExtractor], args) -> int:
|
|
859
|
+
"""Handle the --check-version command.
|
|
860
|
+
|
|
861
|
+
Returns the exit code.
|
|
862
|
+
"""
|
|
863
|
+
results = []
|
|
864
|
+
versions_found = set()
|
|
865
|
+
errors = False
|
|
866
|
+
|
|
867
|
+
if not args.short:
|
|
868
|
+
console.print(
|
|
869
|
+
f"[{STYLE_INFO}]Checking versions in {args.root}...[/{STYLE_INFO}]"
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
for check in checks:
|
|
873
|
+
result = {
|
|
874
|
+
"file": check.name,
|
|
875
|
+
"version": None,
|
|
876
|
+
"status": "Unknown",
|
|
877
|
+
"message": "",
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if not check.check_file_exists():
|
|
881
|
+
result["status"] = "Missing"
|
|
882
|
+
result["message"] = "File not found"
|
|
883
|
+
else:
|
|
884
|
+
try:
|
|
885
|
+
version = check.get_version()
|
|
886
|
+
result["version"] = version
|
|
887
|
+
result["status"] = "Found"
|
|
888
|
+
versions_found.add(version)
|
|
889
|
+
except VersionNotPresent as e:
|
|
890
|
+
result["status"] = "Warning"
|
|
891
|
+
result["message"] = str(e)
|
|
892
|
+
except Exception as e:
|
|
893
|
+
result["status"] = "Error"
|
|
894
|
+
result["message"] = str(e)
|
|
895
|
+
errors = True
|
|
896
|
+
|
|
897
|
+
results.append(result)
|
|
898
|
+
|
|
899
|
+
consensus_version = None
|
|
900
|
+
if len(versions_found) == 1:
|
|
901
|
+
consensus_version = list(versions_found)[0]
|
|
902
|
+
elif len(versions_found) > 1:
|
|
903
|
+
errors = True
|
|
904
|
+
consensus_version = "MISMATCH"
|
|
905
|
+
|
|
906
|
+
if args.output_format == "json":
|
|
907
|
+
out_payload = {
|
|
908
|
+
"consensus_version": consensus_version,
|
|
909
|
+
"files": results,
|
|
910
|
+
"consistent": not errors and len(versions_found) == 1,
|
|
911
|
+
}
|
|
912
|
+
print(json.dumps(out_payload, indent=2))
|
|
913
|
+
return 1 if errors else 0
|
|
914
|
+
|
|
915
|
+
# Standard Rich table output
|
|
916
|
+
table = Table(title="Version Check Summary", box=box.ROUNDED)
|
|
917
|
+
table.add_column("File", style="cyan")
|
|
918
|
+
table.add_column("Version", style="magenta")
|
|
919
|
+
table.add_column("Status", justify="center")
|
|
920
|
+
table.add_column("Details")
|
|
921
|
+
|
|
922
|
+
for res in results:
|
|
923
|
+
status_style = res["status"]
|
|
924
|
+
if res["status"] == "Found":
|
|
925
|
+
status_style = f"[{STYLE_SUCCESS}]Found[/{STYLE_SUCCESS}]"
|
|
926
|
+
elif res["status"] == "Missing":
|
|
927
|
+
status_style = f"[{STYLE_WARNING}]Missing[/{STYLE_WARNING}]"
|
|
928
|
+
elif res["status"] == "Warning":
|
|
929
|
+
status_style = f"[{STYLE_WARNING}]Warning[/{STYLE_WARNING}]"
|
|
930
|
+
elif res["status"] == "Error":
|
|
931
|
+
status_style = f"[{STYLE_ERROR}]Error[/{STYLE_ERROR}]"
|
|
932
|
+
|
|
933
|
+
version_display = res["version"] if res["version"] else "-"
|
|
934
|
+
if res["version"]:
|
|
935
|
+
if (
|
|
936
|
+
consensus_version
|
|
937
|
+
and consensus_version != "MISMATCH"
|
|
938
|
+
and res["version"] == consensus_version
|
|
939
|
+
):
|
|
940
|
+
version_display = f"[{STYLE_SUCCESS}]{res['version']}[/{STYLE_SUCCESS}]"
|
|
941
|
+
elif consensus_version == "MISMATCH":
|
|
942
|
+
version_display = f"[{STYLE_WARNING}]{res['version']}[/{STYLE_WARNING}]"
|
|
943
|
+
|
|
944
|
+
table.add_row(res["file"], version_display, status_style, res["message"])
|
|
945
|
+
|
|
946
|
+
if not args.short:
|
|
947
|
+
console.print(table)
|
|
948
|
+
|
|
949
|
+
if args.short and consensus_version and consensus_version != "MISMATCH":
|
|
950
|
+
print(consensus_version)
|
|
951
|
+
|
|
952
|
+
if errors:
|
|
953
|
+
if len(versions_found) > 1:
|
|
954
|
+
console.print(
|
|
955
|
+
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Found conflicting versions: {', '.join(sorted(versions_found))}"
|
|
956
|
+
)
|
|
957
|
+
else:
|
|
958
|
+
console.print(
|
|
959
|
+
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Errors encountered (parsing errors)."
|
|
960
|
+
)
|
|
961
|
+
return 1
|
|
962
|
+
elif not versions_found:
|
|
963
|
+
console.print(
|
|
964
|
+
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] No version files found in {args.root}."
|
|
965
|
+
)
|
|
966
|
+
return 1
|
|
967
|
+
else:
|
|
968
|
+
if not args.short:
|
|
969
|
+
console.print(
|
|
970
|
+
f"\n[{STYLE_SUCCESS_STRONG}]SUCCESS:[/{STYLE_SUCCESS_STRONG}] All files match version [{STYLE_SUCCESS}]{consensus_version}[/{STYLE_SUCCESS}]."
|
|
971
|
+
)
|
|
972
|
+
return 0
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def perform_version_updates(
|
|
976
|
+
checks: List[VersionExtractor],
|
|
977
|
+
target_version: str,
|
|
978
|
+
dry_run: bool = False,
|
|
979
|
+
) -> Tuple[List[str], List[str], bool, List[Tuple[str, str, str]]]:
|
|
980
|
+
"""Apply version updates to all files.
|
|
981
|
+
|
|
982
|
+
Returns: (updated_files, updated_file_paths, failed, dry_run_rows)
|
|
983
|
+
dry_run_rows contains (name, old_version, new_version) tuples when dry_run=True.
|
|
984
|
+
"""
|
|
985
|
+
updated_files = []
|
|
986
|
+
updated_file_paths = []
|
|
987
|
+
failed = False
|
|
988
|
+
dry_run_rows: List[Tuple[str, str, str]] = []
|
|
989
|
+
|
|
990
|
+
for check in checks:
|
|
991
|
+
if check.check_file_exists():
|
|
992
|
+
try:
|
|
993
|
+
if dry_run:
|
|
994
|
+
curr = check.get_version()
|
|
995
|
+
dry_run_rows.append((check.name, curr, target_version))
|
|
996
|
+
else:
|
|
997
|
+
try:
|
|
998
|
+
old_version = check.get_version()
|
|
999
|
+
except VersionNotPresent:
|
|
1000
|
+
continue
|
|
1001
|
+
except Exception:
|
|
1002
|
+
old_version = "?"
|
|
1003
|
+
check.update_version(target_version)
|
|
1004
|
+
dry_run_rows.append((check.name, old_version, target_version))
|
|
1005
|
+
line = Text()
|
|
1006
|
+
line.append(f" {check.name:<22}", style="cyan")
|
|
1007
|
+
line.append(old_version, style=STYLE_OLD_VALUE)
|
|
1008
|
+
line.append(" → ", style="dim")
|
|
1009
|
+
line.append(target_version, style=STYLE_NEW_VALUE)
|
|
1010
|
+
console.print(line)
|
|
1011
|
+
updated_files.append(check.name)
|
|
1012
|
+
updated_file_paths.append(str(check.file_path))
|
|
1013
|
+
except VersionNotPresent:
|
|
1014
|
+
pass # file exists but has no version configured; skip
|
|
1015
|
+
except Exception as e:
|
|
1016
|
+
console.print(
|
|
1017
|
+
f"[{STYLE_ERROR}]Failed to update {check.name}: {e}[/{STYLE_ERROR}]"
|
|
1018
|
+
)
|
|
1019
|
+
if not dry_run:
|
|
1020
|
+
failed = True
|
|
1021
|
+
|
|
1022
|
+
return updated_files, updated_file_paths, failed, dry_run_rows
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def show_dry_run_panel(
|
|
1026
|
+
dry_run_rows: List[Tuple[str, str, str]],
|
|
1027
|
+
pixi_lock_would_update: bool,
|
|
1028
|
+
git_lines: List[str],
|
|
1029
|
+
) -> None:
|
|
1030
|
+
"""Display a unified dry-run preview."""
|
|
1031
|
+
console.print(
|
|
1032
|
+
f"\n[{STYLE_WARNING_STRONG}]Dry run — no files were modified[/{STYLE_WARNING_STRONG}]"
|
|
1033
|
+
)
|
|
1034
|
+
console.print()
|
|
1035
|
+
|
|
1036
|
+
console.print(" [bold cyan]Files[/bold cyan]")
|
|
1037
|
+
console.print(f" [dim]{'─' * 44}[/dim]")
|
|
1038
|
+
for name, old, new in dry_run_rows:
|
|
1039
|
+
line = Text()
|
|
1040
|
+
line.append(f" {name:<22}", style="cyan")
|
|
1041
|
+
line.append(old, style=STYLE_OLD_VALUE)
|
|
1042
|
+
line.append(" → ", style="dim")
|
|
1043
|
+
line.append(new, style=STYLE_NEW_VALUE)
|
|
1044
|
+
console.print(line)
|
|
1045
|
+
if pixi_lock_would_update:
|
|
1046
|
+
line = Text()
|
|
1047
|
+
line.append(f" {'pixi.lock':<22}", style="cyan")
|
|
1048
|
+
line.append("regenerated via pixi list", style="dim")
|
|
1049
|
+
console.print(line)
|
|
1050
|
+
|
|
1051
|
+
if git_lines:
|
|
1052
|
+
console.print()
|
|
1053
|
+
console.print(" [bold cyan]Git[/bold cyan]")
|
|
1054
|
+
console.print(f" [dim]{'─' * 44}[/dim]")
|
|
1055
|
+
for cmd in git_lines:
|
|
1056
|
+
console.print(f" [{STYLE_MUTED}]{cmd}[/{STYLE_MUTED}]", highlight=False)
|
|
1057
|
+
console.print()
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def show_result_panel(
|
|
1061
|
+
pixi_lock_updated: bool,
|
|
1062
|
+
) -> None:
|
|
1063
|
+
"""Display a polished summary of completed version updates."""
|
|
1064
|
+
if pixi_lock_updated:
|
|
1065
|
+
line = Text()
|
|
1066
|
+
line.append(f" {'pixi.lock':<22}", style="cyan")
|
|
1067
|
+
line.append("regenerated via pixi list", style="dim")
|
|
1068
|
+
console.print(line)
|
|
1069
|
+
console.print()
|
|
1070
|
+
console.print(
|
|
1071
|
+
f"[{STYLE_SUCCESS_STRONG}]✓ Version updated successfully[/{STYLE_SUCCESS_STRONG}]"
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
class RichHelpAction(argparse.Action):
|
|
1076
|
+
def __init__(
|
|
1077
|
+
self,
|
|
1078
|
+
option_strings,
|
|
1079
|
+
dest=argparse.SUPPRESS,
|
|
1080
|
+
default=argparse.SUPPRESS,
|
|
1081
|
+
help=None,
|
|
1082
|
+
):
|
|
1083
|
+
super().__init__(
|
|
1084
|
+
option_strings=option_strings,
|
|
1085
|
+
dest=dest,
|
|
1086
|
+
default=default,
|
|
1087
|
+
nargs=0,
|
|
1088
|
+
help=help,
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
1092
|
+
if parser.description:
|
|
1093
|
+
console.print(Markdown(parser.description))
|
|
1094
|
+
|
|
1095
|
+
# Print the standard argparse usage and options
|
|
1096
|
+
# We clear the description to avoid printing the markdown source again
|
|
1097
|
+
original_description = parser.description
|
|
1098
|
+
parser.description = None
|
|
1099
|
+
|
|
1100
|
+
console.print(Text("\nCommand Reference:\n", style="bold"))
|
|
1101
|
+
console.print(Text(parser.format_help()))
|
|
1102
|
+
|
|
1103
|
+
parser.description = original_description
|
|
1104
|
+
parser.exit()
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def main():
|
|
1108
|
+
parser = argparse.ArgumentParser(
|
|
1109
|
+
description=__doc__,
|
|
1110
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1111
|
+
add_help=False,
|
|
1112
|
+
)
|
|
1113
|
+
parser.add_argument(
|
|
1114
|
+
"-h",
|
|
1115
|
+
"--help",
|
|
1116
|
+
action=RichHelpAction,
|
|
1117
|
+
help="Show this help message and exit",
|
|
1118
|
+
)
|
|
1119
|
+
parser.add_argument(
|
|
1120
|
+
"--root", type=Path, default=Path.cwd(), help="Project root directory"
|
|
1121
|
+
)
|
|
1122
|
+
parser.add_argument(
|
|
1123
|
+
"--confirm",
|
|
1124
|
+
action="store_true",
|
|
1125
|
+
help="Auto-confirm all actions without prompting.",
|
|
1126
|
+
)
|
|
1127
|
+
parser.add_argument(
|
|
1128
|
+
"--dry-run",
|
|
1129
|
+
action="store_true",
|
|
1130
|
+
help="Show what would change without modifying files.",
|
|
1131
|
+
)
|
|
1132
|
+
parser.add_argument(
|
|
1133
|
+
"--git-commit",
|
|
1134
|
+
nargs="?",
|
|
1135
|
+
const=True,
|
|
1136
|
+
default=None,
|
|
1137
|
+
metavar="MESSAGE",
|
|
1138
|
+
help="Commit version changes to git. Optionally provide a custom commit message. Use {version} as placeholder. Default: 'chore: bump version to {version}'",
|
|
1139
|
+
)
|
|
1140
|
+
parser.add_argument(
|
|
1141
|
+
"--git-tag",
|
|
1142
|
+
nargs="?",
|
|
1143
|
+
const=True,
|
|
1144
|
+
default=None,
|
|
1145
|
+
metavar="NAME",
|
|
1146
|
+
help="Create a git tag for the new version. Optionally provide a custom tag name. Use {version} as placeholder. Default: 'v{version}'",
|
|
1147
|
+
)
|
|
1148
|
+
parser.add_argument(
|
|
1149
|
+
"--git-tag-message",
|
|
1150
|
+
type=str,
|
|
1151
|
+
default=None,
|
|
1152
|
+
metavar="MESSAGE",
|
|
1153
|
+
help="Custom git tag message. Use {version} as placeholder for version number. Default: 'Release version {version}'",
|
|
1154
|
+
)
|
|
1155
|
+
parser.add_argument(
|
|
1156
|
+
"--short",
|
|
1157
|
+
action="store_true",
|
|
1158
|
+
help="Output only the final version string.",
|
|
1159
|
+
)
|
|
1160
|
+
parser.add_argument(
|
|
1161
|
+
"--output-format",
|
|
1162
|
+
choices=["text", "json"],
|
|
1163
|
+
default="text",
|
|
1164
|
+
help="Output format (default: text).",
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
1168
|
+
group.add_argument(
|
|
1169
|
+
"--check-version", action="store_true", help="Check versions across files."
|
|
1170
|
+
)
|
|
1171
|
+
group.add_argument(
|
|
1172
|
+
"--list-files",
|
|
1173
|
+
action="store_true",
|
|
1174
|
+
help="List all files that are checked for versions.",
|
|
1175
|
+
)
|
|
1176
|
+
group.add_argument(
|
|
1177
|
+
"--update-version",
|
|
1178
|
+
type=str,
|
|
1179
|
+
help="Update version in all files (enforces semver).",
|
|
1180
|
+
)
|
|
1181
|
+
group.add_argument(
|
|
1182
|
+
"--bump",
|
|
1183
|
+
choices=["major", "minor", "patch"],
|
|
1184
|
+
help="Bump the project version.",
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
args = parser.parse_args()
|
|
1188
|
+
root_dir = args.root
|
|
1189
|
+
|
|
1190
|
+
# Redirect console output to stderr for clean stdout with json/short
|
|
1191
|
+
global console
|
|
1192
|
+
if args.short or args.output_format == "json":
|
|
1193
|
+
console = Console(file=sys.stderr)
|
|
1194
|
+
else:
|
|
1195
|
+
console = Console()
|
|
1196
|
+
|
|
1197
|
+
if args.update_version:
|
|
1198
|
+
try:
|
|
1199
|
+
if not re.match(r"^\d+\.\d+\.\d+$", args.update_version):
|
|
1200
|
+
console.print(
|
|
1201
|
+
f"[{STYLE_ERROR}]Invalid SemVer '{args.update_version}'. strict X.Y.Z required.[/{STYLE_ERROR}]"
|
|
1202
|
+
)
|
|
1203
|
+
sys.exit(1)
|
|
1204
|
+
except Exception as e:
|
|
1205
|
+
console.print(
|
|
1206
|
+
f"[{STYLE_ERROR}]Error validating version: {e}[/{STYLE_ERROR}]"
|
|
1207
|
+
)
|
|
1208
|
+
sys.exit(1)
|
|
1209
|
+
|
|
1210
|
+
checks: List[VersionExtractor] = [
|
|
1211
|
+
XmlVersionExtractor(root_dir / "package.xml"),
|
|
1212
|
+
TomlVersionExtractor(root_dir / "pyproject.toml", ["project", "version"]),
|
|
1213
|
+
ChangelogVersionExtractor(root_dir / "CHANGELOG.md", r""),
|
|
1214
|
+
TomlVersionExtractor(root_dir / "pixi.toml", ["workspace", "version"]),
|
|
1215
|
+
YamlVersionExtractor(root_dir / "CITATION.cff", ["version"]),
|
|
1216
|
+
CMakeListsVersionExtractor(root_dir / "CMakeLists.txt"),
|
|
1217
|
+
]
|
|
1218
|
+
|
|
1219
|
+
if args.list_files:
|
|
1220
|
+
if args.output_format == "json":
|
|
1221
|
+
files_list = []
|
|
1222
|
+
for check in checks:
|
|
1223
|
+
files_list.append(
|
|
1224
|
+
{
|
|
1225
|
+
"name": check.name,
|
|
1226
|
+
"path": str(check.file_path),
|
|
1227
|
+
"exists": check.check_file_exists(),
|
|
1228
|
+
"type": check.__class__.__name__.replace(
|
|
1229
|
+
"VersionExtractor", ""
|
|
1230
|
+
),
|
|
1231
|
+
}
|
|
1232
|
+
)
|
|
1233
|
+
print(json.dumps(files_list, indent=2))
|
|
1234
|
+
else:
|
|
1235
|
+
list_version_files(checks)
|
|
1236
|
+
sys.exit(0)
|
|
1237
|
+
|
|
1238
|
+
if args.check_version:
|
|
1239
|
+
sys.exit(handle_check_version(checks, args))
|
|
1240
|
+
|
|
1241
|
+
current_version = None
|
|
1242
|
+
new_version_str = None
|
|
1243
|
+
|
|
1244
|
+
if args.update_version:
|
|
1245
|
+
new_version_str = args.update_version
|
|
1246
|
+
current_version = get_current_version(checks)
|
|
1247
|
+
if not args.dry_run:
|
|
1248
|
+
console.print(
|
|
1249
|
+
f"[{STYLE_INFO}]Updating versions to {new_version_str} in {root_dir}...[/{STYLE_INFO}]"
|
|
1250
|
+
)
|
|
1251
|
+
elif args.bump:
|
|
1252
|
+
current_version = get_current_version(checks)
|
|
1253
|
+
if not current_version:
|
|
1254
|
+
sys.exit(1)
|
|
1255
|
+
|
|
1256
|
+
try:
|
|
1257
|
+
new_version_str = bump_version(current_version, args.bump)
|
|
1258
|
+
except ValueError as e:
|
|
1259
|
+
console.print(f"[{STYLE_ERROR}]Error: {e}[/{STYLE_ERROR}]")
|
|
1260
|
+
sys.exit(1)
|
|
1261
|
+
|
|
1262
|
+
show_version_diff(current_version, new_version_str, args.bump)
|
|
1263
|
+
validate_version_progression(current_version, new_version_str, args.bump)
|
|
1264
|
+
|
|
1265
|
+
if args.dry_run:
|
|
1266
|
+
confirmed = True
|
|
1267
|
+
elif args.confirm:
|
|
1268
|
+
confirmed = True
|
|
1269
|
+
else:
|
|
1270
|
+
confirmed = Confirm.ask(
|
|
1271
|
+
f"\n[bold]Do you want to upgrade from [{STYLE_INFO}]{current_version}[/{STYLE_INFO}] to [{STYLE_NEW_VALUE}]{new_version_str}[/{STYLE_NEW_VALUE}]?[/bold]",
|
|
1272
|
+
default=True,
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
if not confirmed:
|
|
1276
|
+
console.print(f"[{STYLE_WARNING}]Upgrade cancelled.[/{STYLE_WARNING}]")
|
|
1277
|
+
sys.exit(0)
|
|
1278
|
+
|
|
1279
|
+
if new_version_str is None:
|
|
1280
|
+
console.print(
|
|
1281
|
+
f"[{STYLE_ERROR}]Internal error: target version is undefined.[/{STYLE_ERROR}]"
|
|
1282
|
+
)
|
|
1283
|
+
sys.exit(1)
|
|
1284
|
+
target_version: str = new_version_str
|
|
1285
|
+
|
|
1286
|
+
backups = {}
|
|
1287
|
+
if not args.dry_run:
|
|
1288
|
+
file_paths_to_backup = [
|
|
1289
|
+
check.file_path for check in checks if check.check_file_exists()
|
|
1290
|
+
]
|
|
1291
|
+
backups = create_backups(file_paths_to_backup)
|
|
1292
|
+
console.print(
|
|
1293
|
+
f"[{STYLE_MUTED}]Created backups for {len(backups)} files[/{STYLE_MUTED}]"
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
try:
|
|
1297
|
+
if not args.dry_run and args.output_format == "text":
|
|
1298
|
+
console.print()
|
|
1299
|
+
console.print(" [bold cyan]Files[/bold cyan]")
|
|
1300
|
+
console.print(f" [dim]{'─' * 44}[/dim]")
|
|
1301
|
+
updated_files, updated_file_paths, failed, dry_run_rows = (
|
|
1302
|
+
perform_version_updates(checks, target_version, args.dry_run)
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
if failed:
|
|
1306
|
+
if backups:
|
|
1307
|
+
console.print(
|
|
1308
|
+
f"[{STYLE_WARNING}]Restoring files from backup due to failures...[/{STYLE_WARNING}]"
|
|
1309
|
+
)
|
|
1310
|
+
restore_backups(backups)
|
|
1311
|
+
console.print(
|
|
1312
|
+
f"[{STYLE_SUCCESS}]Files restored from backup[/{STYLE_SUCCESS}]"
|
|
1313
|
+
)
|
|
1314
|
+
sys.exit(1)
|
|
1315
|
+
|
|
1316
|
+
try:
|
|
1317
|
+
pixi_lock_path = update_pixi_lock(root_dir, args.dry_run)
|
|
1318
|
+
if pixi_lock_path:
|
|
1319
|
+
updated_files.append("pixi.lock")
|
|
1320
|
+
updated_file_paths.append(pixi_lock_path)
|
|
1321
|
+
except RuntimeError as e:
|
|
1322
|
+
console.print(
|
|
1323
|
+
f"[{STYLE_ERROR}]Pixi lock update failed: {e}[/{STYLE_ERROR}]"
|
|
1324
|
+
)
|
|
1325
|
+
if backups:
|
|
1326
|
+
console.print(
|
|
1327
|
+
f"[{STYLE_WARNING}]Restoring files from backup...[/{STYLE_WARNING}]"
|
|
1328
|
+
)
|
|
1329
|
+
restore_backups(backups)
|
|
1330
|
+
console.print(
|
|
1331
|
+
f"[{STYLE_SUCCESS}]Files restored from backup[/{STYLE_SUCCESS}]"
|
|
1332
|
+
)
|
|
1333
|
+
sys.exit(1)
|
|
1334
|
+
|
|
1335
|
+
if backups:
|
|
1336
|
+
cleanup_backups(backups)
|
|
1337
|
+
except Exception as e:
|
|
1338
|
+
console.print(f"[{STYLE_ERROR}]Unexpected error: {e}[/{STYLE_ERROR}]")
|
|
1339
|
+
if backups:
|
|
1340
|
+
console.print(
|
|
1341
|
+
f"[{STYLE_WARNING}]Restoring files from backup...[/{STYLE_WARNING}]"
|
|
1342
|
+
)
|
|
1343
|
+
restore_backups(backups)
|
|
1344
|
+
console.print(
|
|
1345
|
+
f"[{STYLE_SUCCESS}]Files restored from backup[/{STYLE_SUCCESS}]"
|
|
1346
|
+
)
|
|
1347
|
+
raise
|
|
1348
|
+
|
|
1349
|
+
if args.output_format == "json":
|
|
1350
|
+
res_json = {
|
|
1351
|
+
"previous_version": current_version,
|
|
1352
|
+
"new_version": target_version,
|
|
1353
|
+
"updated_files": updated_files,
|
|
1354
|
+
"dry_run": args.dry_run,
|
|
1355
|
+
}
|
|
1356
|
+
print(json.dumps(res_json, indent=2))
|
|
1357
|
+
|
|
1358
|
+
elif args.short:
|
|
1359
|
+
print(target_version)
|
|
1360
|
+
|
|
1361
|
+
if args.dry_run:
|
|
1362
|
+
pixi_lock_would_update = (root_dir / "pixi.lock").exists()
|
|
1363
|
+
|
|
1364
|
+
git_lines: List[str] = []
|
|
1365
|
+
if args.git_commit is not None:
|
|
1366
|
+
custom_message = None if args.git_commit is True else args.git_commit
|
|
1367
|
+
commit_message = (
|
|
1368
|
+
custom_message.format(version=target_version)
|
|
1369
|
+
if custom_message
|
|
1370
|
+
else f"chore: bump version to {target_version}"
|
|
1371
|
+
)
|
|
1372
|
+
rel_paths = (
|
|
1373
|
+
[str(Path(p).relative_to(root_dir)) for p in updated_file_paths]
|
|
1374
|
+
if updated_file_paths
|
|
1375
|
+
else None
|
|
1376
|
+
)
|
|
1377
|
+
git_lines.append(f"$ git add {' '.join(rel_paths) if rel_paths else '-u'}")
|
|
1378
|
+
git_lines.append(f"$ git commit -m '{commit_message}'")
|
|
1379
|
+
if args.git_tag is not None:
|
|
1380
|
+
custom_tag_name = None if args.git_tag is True else args.git_tag
|
|
1381
|
+
tag_name = (
|
|
1382
|
+
custom_tag_name.format(version=target_version)
|
|
1383
|
+
if custom_tag_name
|
|
1384
|
+
else f"v{target_version}"
|
|
1385
|
+
)
|
|
1386
|
+
tag_message = (
|
|
1387
|
+
args.git_tag_message.format(version=target_version)
|
|
1388
|
+
if args.git_tag_message
|
|
1389
|
+
else f"Release version {target_version}"
|
|
1390
|
+
)
|
|
1391
|
+
git_lines.append(f"$ git tag -a {tag_name} -m '{tag_message}'")
|
|
1392
|
+
|
|
1393
|
+
show_dry_run_panel(dry_run_rows, pixi_lock_would_update, git_lines)
|
|
1394
|
+
sys.exit(0)
|
|
1395
|
+
else:
|
|
1396
|
+
if not args.short and args.output_format == "text":
|
|
1397
|
+
show_result_panel(pixi_lock_path is not None)
|
|
1398
|
+
|
|
1399
|
+
# Git operations - only perform if explicitly requested
|
|
1400
|
+
if args.git_tag is not None and args.git_commit is None:
|
|
1401
|
+
console.print(
|
|
1402
|
+
f"[{STYLE_WARNING}]Warning: --git-tag used without --git-commit. The tag will point to the current HEAD, not the version bump commit.[/{STYLE_WARNING}]"
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
if args.git_commit is not None:
|
|
1406
|
+
custom_message = None if args.git_commit is True else args.git_commit
|
|
1407
|
+
git_commit_version(
|
|
1408
|
+
root_dir,
|
|
1409
|
+
target_version,
|
|
1410
|
+
args.confirm,
|
|
1411
|
+
custom_message,
|
|
1412
|
+
updated_file_paths,
|
|
1413
|
+
)
|
|
1414
|
+
|
|
1415
|
+
if args.git_tag is not None:
|
|
1416
|
+
custom_tag_name = None if args.git_tag is True else args.git_tag
|
|
1417
|
+
git_tag_version(
|
|
1418
|
+
root_dir,
|
|
1419
|
+
target_version,
|
|
1420
|
+
args.confirm,
|
|
1421
|
+
custom_tag_name,
|
|
1422
|
+
args.git_tag_message,
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
if __name__ == "__main__":
|
|
1427
|
+
try:
|
|
1428
|
+
main()
|
|
1429
|
+
except KeyboardInterrupt:
|
|
1430
|
+
console.print(
|
|
1431
|
+
f"\n[{STYLE_WARNING}]Operation cancelled by user (Ctrl+C).[/{STYLE_WARNING}]"
|
|
1432
|
+
)
|
|
1433
|
+
sys.exit(130)
|