gabriel / muse public
tag.py python
710 lines 22.2 KB
Raw
sha256:3a1795edd9596c7ff33dcae13cc364248d54cb359aeda9a5bd886e15a37eb037 Merge branch 'dev' into main Human 22 days ago
1 """``muse tag`` — protocol-native semantic version tags.
2
3 Version tags are lightweight, local-first named pointers from a semantic
4 version string (e.g. ``v0.2.0-rc14``) to a commit. They live under
5 ``.muse/version-tags/`` and are independent of the label-annotation system
6 (``muse label``) and the MuseHub-aware release system (``muse release``).
7
8 Usage::
9
10 muse tag add <tag> [<ref>] [--message <msg>] [--author <name>]
11 — create or overwrite a version tag
12 muse tag list [--pre] — list version tags (latest first)
13 muse tag read <tag> — inspect a single version tag
14 muse tag delete <tag> — delete a version tag
15 muse tag latest [--pre] — print the latest version tag
16
17 Version tag format::
18
19 v<major>.<minor>.<patch>[-<pre>][+<build>]
20
21 Examples:
22 v0.2.0-rc14 release candidate
23 v1.0.0 stable release
24 v1.1.0-beta.3 beta with a minor-version bump
25 v2.0.0-alpha first alpha of a major
26
27 Pre-release precedence (descending):
28 stable > rc<N> > beta[.<N>] > alpha[.<N>] > generic
29
30 JSON output (``--json``) schema for ``tag add``::
31
32 {
33 "status": "tagged | already_tagged",
34 "tag_id": "<sha256:...>",
35 "tag": "v0.2.0-rc14",
36 "semver": {"major": 0, "minor": 2, "patch": 0, "pre": "rc14", "build": ""},
37 "commit_id": "<sha256:...>",
38 "created_at": "<iso8601>",
39 "author": "gabriel",
40 "message": "Release candidate 14",
41 "duration_ms": 1.234,
42 "exit_code": 0
43 }
44
45 JSON output schema for ``tag list``::
46
47 {
48 "total": <N>,
49 "tags": [
50 {
51 "tag_id": "<sha256:...>",
52 "tag": "v0.2.0-rc14",
53 "semver": { ... },
54 "commit_id": "<sha256:...>",
55 "created_at": "<iso8601>",
56 "author": "gabriel",
57 "message": ""
58 }, ...
59 ],
60 "duration_ms": 1.234,
61 "exit_code": 0
62 }
63
64 Exit codes::
65
66 0 — success
67 1 — invalid tag name, commit not found, tag not found
68 2 — not inside a Muse repository
69 """
70
71 import argparse
72 import datetime
73 import json
74 import logging
75 import sys
76 from typing import TypedDict
77
78 from muse.core.errors import ExitCode
79 from muse.core.repo import read_repo_id, require_repo
80 from muse.core.refs import read_current_branch
81 from muse.core.commits import resolve_commit_ref
82 from muse.core.version_tags import (
83 VersionTagRecord,
84 _validate_version_tag_name,
85 compute_version_tag_id,
86 delete_version_tag,
87 list_version_tags,
88 read_version_tag,
89 semver_key,
90 write_version_tag,
91 )
92 from muse.core.semver import SemVerTag, parse_semver
93 from muse.cli.config import get_config_value, get_remote, get_signing_identity
94 from muse.core.envelope import EnvelopeJson, make_envelope
95 from muse.core.transport import make_transport, TransportError
96 from muse.core.validation import sanitize_display
97 from muse.core.timing import start_timer
98 from muse.core.types import JsonValue
99
100 logger = logging.getLogger(__name__)
101
102
103 # ---------------------------------------------------------------------------
104 # Wire-format TypedDicts for CLI output
105 # ---------------------------------------------------------------------------
106
107 class _SemVerJson(TypedDict):
108 major: int
109 minor: int
110 patch: int
111 pre: str
112 build: str
113
114
115 class _TagEntryJson(TypedDict):
116 tag_id: str
117 tag: str
118 semver: _SemVerJson
119 commit_id: str
120 created_at: str
121 author: str
122 message: str
123
124
125 class _TagAddJson(EnvelopeJson):
126 status: str
127 tag_id: str
128 tag: str
129 semver: _SemVerJson
130 commit_id: str
131 created_at: str
132 author: str
133 message: str
134
135
136 class _TagListJson(EnvelopeJson):
137 total: int
138 tags: list[_TagEntryJson]
139
140
141 class _TagReadJson(EnvelopeJson):
142 tag_id: str
143 tag: str
144 semver: _SemVerJson
145 commit_id: str
146 created_at: str
147 author: str
148 message: str
149
150
151 class _TagDeleteJson(EnvelopeJson):
152 status: str
153 tag: str
154 deleted: bool
155
156
157 class _TagLatestJson(EnvelopeJson):
158 latest: str | None
159 tag_id: str | None
160 semver: _SemVerJson | None
161 commit_id: str | None
162 created_at: str | None
163 author: str | None
164 message: str | None
165
166
167 class _TagPushJson(EnvelopeJson):
168 status: str # "pushed" | "already_current" | "conflict"
169 tag: str
170 remote: str
171 commit_id: str
172
173
174 # ---------------------------------------------------------------------------
175 # Helpers
176 # ---------------------------------------------------------------------------
177
178 def _semver_to_json(sv: SemVerTag) -> _SemVerJson:
179 return _SemVerJson(
180 major=sv["major"],
181 minor=sv["minor"],
182 patch=sv["patch"],
183 pre=sv["pre"],
184 build=sv["build"],
185 )
186
187
188 def _record_to_entry(r: VersionTagRecord) -> _TagEntryJson:
189 return _TagEntryJson(
190 tag_id=r.tag_id,
191 tag=r.tag,
192 semver=_semver_to_json(r.semver),
193 commit_id=r.commit_id,
194 created_at=r.created_at.isoformat(),
195 author=r.author,
196 message=r.message,
197 )
198
199
200 def _default_author(root) -> str:
201 """Read user.handle from repo config, fall back to 'unknown'."""
202 try:
203 val = get_config_value("user.handle", root)
204 return val if val else "unknown"
205 except Exception:
206 return "unknown"
207
208
209 def _is_pre_release(r: VersionTagRecord) -> bool:
210 return bool(r.semver["pre"])
211
212
213 # ---------------------------------------------------------------------------
214 # register
215 # ---------------------------------------------------------------------------
216
217 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
218 """Register the ``muse tag`` subcommand tree."""
219 parser = subparsers.add_parser(
220 "tag",
221 help="Create and query semantic version tags.",
222 description=__doc__,
223 formatter_class=argparse.RawDescriptionHelpFormatter,
224 )
225 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
226 subs.required = True
227
228 # -- add ------------------------------------------------------------------
229 add_p = subs.add_parser(
230 "add",
231 help="Create (or overwrite) a version tag pointing to a commit.",
232 formatter_class=argparse.RawDescriptionHelpFormatter,
233 )
234 add_p.add_argument("tag_name", help="Version tag (e.g. v0.2.0-rc14).")
235 add_p.add_argument(
236 "ref", nargs="?", default=None,
237 help="Commit ID or branch name (default: HEAD).",
238 )
239 add_p.add_argument(
240 "--message", "-m", default="",
241 help="Optional annotation message for this tag.",
242 )
243 add_p.add_argument(
244 "--author", default=None,
245 help="Author name (default: user.handle from repo config).",
246 )
247 add_p.add_argument("--json", "-j", action="store_true", dest="json_out",
248 help="Emit machine-readable JSON on stdout.")
249 add_p.set_defaults(func=run_add, json_out=False)
250
251 # -- list -----------------------------------------------------------------
252 list_p = subs.add_parser(
253 "list",
254 help="List all version tags sorted by SemVer precedence (latest first).",
255 formatter_class=argparse.RawDescriptionHelpFormatter,
256 )
257 list_p.add_argument(
258 "--pre", action="store_true",
259 help="Include pre-release tags (rc, beta, alpha). By default only stable tags are shown.",
260 )
261 list_p.add_argument("--json", "-j", action="store_true", dest="json_out",
262 help="Emit machine-readable JSON on stdout.")
263 list_p.set_defaults(func=run_list, json_out=False, pre=False)
264
265 # -- read -----------------------------------------------------------------
266 read_p = subs.add_parser(
267 "read",
268 help="Inspect a single version tag.",
269 formatter_class=argparse.RawDescriptionHelpFormatter,
270 )
271 read_p.add_argument("tag_name", help="Version tag to inspect (e.g. v0.2.0-rc14).")
272 read_p.add_argument("--json", "-j", action="store_true", dest="json_out",
273 help="Emit machine-readable JSON on stdout.")
274 read_p.set_defaults(func=run_read, json_out=False)
275
276 # -- delete ---------------------------------------------------------------
277 delete_p = subs.add_parser(
278 "delete",
279 help="Delete a version tag.",
280 formatter_class=argparse.RawDescriptionHelpFormatter,
281 )
282 delete_p.add_argument("tag_name", help="Version tag to delete (e.g. v0.2.0-rc14).")
283 delete_p.add_argument("--json", "-j", action="store_true", dest="json_out",
284 help="Emit machine-readable JSON on stdout.")
285 delete_p.set_defaults(func=run_delete, json_out=False)
286
287 # -- latest ---------------------------------------------------------------
288 latest_p = subs.add_parser(
289 "latest",
290 help="Print the latest version tag (highest SemVer precedence).",
291 formatter_class=argparse.RawDescriptionHelpFormatter,
292 )
293 latest_p.add_argument(
294 "--pre", action="store_true",
295 help="Consider pre-release tags too. By default only stable tags are considered.",
296 )
297 latest_p.add_argument("--json", "-j", action="store_true", dest="json_out",
298 help="Emit machine-readable JSON on stdout.")
299 latest_p.set_defaults(func=run_latest, json_out=False, pre=False)
300
301 # -- push -----------------------------------------------------------------
302 push_p = subs.add_parser(
303 "push",
304 help="Push a version tag to a remote.",
305 formatter_class=argparse.RawDescriptionHelpFormatter,
306 )
307 push_p.add_argument("remote", help="Remote name (e.g. local, origin).")
308 push_p.add_argument("tag_name", help="Version tag to push (e.g. v1.0.0).")
309 push_p.add_argument(
310 "--force", "-f", action="store_true",
311 help="Overwrite the tag on the remote if it already exists with a different commit.",
312 )
313 push_p.add_argument("--json", "-j", action="store_true", dest="json_out",
314 help="Emit machine-readable JSON on stdout.")
315 push_p.set_defaults(func=run_push, json_out=False, force=False)
316
317
318 # ---------------------------------------------------------------------------
319 # run_add
320 # ---------------------------------------------------------------------------
321
322 def run_add(args: argparse.Namespace) -> None:
323 """Create (or overwrite) a version tag pointing to a commit."""
324 elapsed = start_timer()
325
326 tag_name: str = args.tag_name
327 ref: str | None = args.ref
328 message: str = args.message
329 author_arg: str | None = args.author
330 json_out: bool = args.json_out
331
332 def _emit_error(msg: str, code: int, error_key: str = "error", **extra: JsonValue) -> None:
333 if json_out:
334 payload = {
335 **make_envelope(elapsed, exit_code=code),
336 "error": error_key,
337 "message": msg,
338 }
339 payload.update(extra)
340 print(json.dumps(payload))
341 else:
342 print(f"❌ {msg}", file=sys.stderr)
343 raise SystemExit(code)
344
345 try:
346 _validate_version_tag_name(tag_name)
347 except ValueError as exc:
348 _emit_error(
349 f"Invalid version tag: {sanitize_display(str(exc))}",
350 ExitCode.USER_ERROR,
351 "invalid_tag_name",
352 )
353
354 root = require_repo()
355 repo_id = read_repo_id(root)
356 branch = read_current_branch(root)
357 author = author_arg if author_arg else _default_author(root)
358
359 commit = resolve_commit_ref(root, branch, ref)
360 if commit is None:
361 _emit_error(
362 f"Commit '{sanitize_display(str(ref))}' not found.",
363 ExitCode.USER_ERROR,
364 "commit_not_found",
365 ref=str(ref),
366 )
367
368 semver = parse_semver(tag_name)
369 tag_id = compute_version_tag_id(repo_id=repo_id, tag=tag_name)
370 created_at = datetime.datetime.now(datetime.timezone.utc)
371
372 existing = read_version_tag(root, tag_name)
373 status = "already_tagged" if existing else "tagged"
374
375 record = VersionTagRecord(
376 tag_id=tag_id,
377 repo_id=repo_id,
378 tag=tag_name,
379 semver=semver,
380 commit_id=commit.commit_id,
381 created_at=created_at if not existing else existing.created_at,
382 author=author if not existing else existing.author,
383 message=message if message else (existing.message if existing else ""),
384 )
385 write_version_tag(root, record)
386
387 if json_out:
388 print(json.dumps(_TagAddJson(
389 **make_envelope(elapsed),
390 status=status,
391 tag_id=record.tag_id,
392 tag=record.tag,
393 semver=_semver_to_json(record.semver),
394 commit_id=record.commit_id,
395 created_at=record.created_at.isoformat(),
396 author=record.author,
397 message=record.message,
398 )))
399 else:
400 action = "Tagged" if status == "tagged" else "Tag already exists —"
401 print(f"{action} {record.commit_id} as {sanitize_display(record.tag)}")
402
403
404 # ---------------------------------------------------------------------------
405 # run_list
406 # ---------------------------------------------------------------------------
407
408 def run_list(args: argparse.Namespace) -> None:
409 """List all version tags sorted by SemVer precedence (latest first)."""
410 elapsed = start_timer()
411
412 include_pre: bool = args.pre
413 json_out: bool = args.json_out
414
415 root = require_repo()
416 all_tags = list_version_tags(root)
417
418 if not include_pre:
419 all_tags = [r for r in all_tags if not _is_pre_release(r)]
420
421 if json_out:
422 print(json.dumps(_TagListJson(
423 **make_envelope(elapsed),
424 total=len(all_tags),
425 tags=[_record_to_entry(r) for r in all_tags],
426 )))
427 return
428
429 if not all_tags:
430 print("No version tags.")
431 return
432 for r in all_tags:
433 pre_suffix = f"-{r.semver['pre']}" if r.semver["pre"] else ""
434 print(f"{r.tag:<24} {r.commit_id} {r.created_at.isoformat()}")
435
436
437 # ---------------------------------------------------------------------------
438 # run_read
439 # ---------------------------------------------------------------------------
440
441 def run_read(args: argparse.Namespace) -> None:
442 """Inspect a single version tag."""
443 elapsed = start_timer()
444
445 tag_name: str = args.tag_name
446 json_out: bool = args.json_out
447
448 def _emit_error(msg: str, code: int, error_key: str = "error", **extra: JsonValue) -> None:
449 if json_out:
450 payload = {
451 **make_envelope(elapsed, exit_code=code),
452 "error": error_key,
453 "message": msg,
454 }
455 payload.update(extra)
456 print(json.dumps(payload))
457 else:
458 print(f"❌ {msg}", file=sys.stderr)
459 raise SystemExit(code)
460
461 try:
462 _validate_version_tag_name(tag_name)
463 except ValueError as exc:
464 _emit_error(
465 f"Invalid version tag: {sanitize_display(str(exc))}",
466 ExitCode.USER_ERROR,
467 "invalid_tag_name",
468 )
469
470 root = require_repo()
471 record = read_version_tag(root, tag_name)
472 if record is None:
473 _emit_error(
474 f"Version tag '{sanitize_display(tag_name)}' not found.",
475 ExitCode.USER_ERROR,
476 "tag_not_found",
477 tag=tag_name,
478 )
479
480 if json_out:
481 print(json.dumps(_TagReadJson(
482 **make_envelope(elapsed),
483 tag_id=record.tag_id,
484 tag=record.tag,
485 semver=_semver_to_json(record.semver),
486 commit_id=record.commit_id,
487 created_at=record.created_at.isoformat(),
488 author=record.author,
489 message=record.message,
490 )))
491 else:
492 print(f"tag: {sanitize_display(record.tag)}")
493 print(f"commit_id: {record.commit_id}")
494 print(f"author: {sanitize_display(record.author)}")
495 print(f"created: {record.created_at.isoformat()}")
496 if record.message:
497 print(f"message: {sanitize_display(record.message)}")
498
499
500 # ---------------------------------------------------------------------------
501 # run_delete
502 # ---------------------------------------------------------------------------
503
504 def run_delete(args: argparse.Namespace) -> None:
505 """Delete a version tag."""
506 elapsed = start_timer()
507
508 tag_name: str = args.tag_name
509 json_out: bool = args.json_out
510
511 def _emit_error(msg: str, code: int, error_key: str = "error", **extra: JsonValue) -> None:
512 if json_out:
513 payload = {
514 **make_envelope(elapsed, exit_code=code),
515 "error": error_key,
516 "message": msg,
517 }
518 payload.update(extra)
519 print(json.dumps(payload))
520 else:
521 print(f"❌ {msg}", file=sys.stderr)
522 raise SystemExit(code)
523
524 try:
525 _validate_version_tag_name(tag_name)
526 except ValueError as exc:
527 _emit_error(
528 f"Invalid version tag: {sanitize_display(str(exc))}",
529 ExitCode.USER_ERROR,
530 "invalid_tag_name",
531 )
532
533 root = require_repo()
534 deleted = delete_version_tag(root, tag_name)
535
536 if json_out:
537 print(json.dumps(_TagDeleteJson(
538 **make_envelope(elapsed),
539 status="deleted" if deleted else "not_found",
540 tag=tag_name,
541 deleted=deleted,
542 )))
543 else:
544 if deleted:
545 print(f"Deleted version tag '{sanitize_display(tag_name)}'.")
546 else:
547 print(f"Version tag '{sanitize_display(tag_name)}' not found — nothing deleted.")
548
549
550 # ---------------------------------------------------------------------------
551 # run_latest
552 # ---------------------------------------------------------------------------
553
554 def run_latest(args: argparse.Namespace) -> None:
555 """Print the latest version tag by SemVer precedence."""
556 elapsed = start_timer()
557
558 include_pre: bool = args.pre
559 json_out: bool = args.json_out
560
561 root = require_repo()
562 all_tags = list_version_tags(root)
563
564 if not include_pre:
565 all_tags = [r for r in all_tags if not _is_pre_release(r)]
566
567 if not all_tags:
568 if json_out:
569 print(json.dumps(_TagLatestJson(
570 **make_envelope(elapsed),
571 latest=None,
572 tag_id=None,
573 semver=None,
574 commit_id=None,
575 created_at=None,
576 author=None,
577 message=None,
578 )))
579 else:
580 print("No version tags.")
581 return
582
583 r = all_tags[0] # list_version_tags already sorts latest-first
584 if json_out:
585 print(json.dumps(_TagLatestJson(
586 **make_envelope(elapsed),
587 latest=r.tag,
588 tag_id=r.tag_id,
589 semver=_semver_to_json(r.semver),
590 commit_id=r.commit_id,
591 created_at=r.created_at.isoformat(),
592 author=r.author,
593 message=r.message,
594 )))
595 else:
596 print(sanitize_display(r.tag))
597
598
599 # ---------------------------------------------------------------------------
600 # run_push
601 # ---------------------------------------------------------------------------
602
603 def run_push(args: argparse.Namespace) -> None:
604 """Push a single version tag to a remote."""
605 elapsed = start_timer()
606
607 remote: str = args.remote
608 tag_name: str = args.tag_name
609 force: bool = args.force
610 json_out: bool = args.json_out
611
612 def _emit_error(msg: str, code: int, error_key: str = "error", **extra: JsonValue) -> None:
613 if json_out:
614 payload = {
615 **make_envelope(elapsed, exit_code=code),
616 "error": error_key,
617 "message": msg,
618 }
619 payload.update(extra)
620 print(json.dumps(payload))
621 else:
622 print(f"❌ {msg}", file=sys.stderr)
623 raise SystemExit(code)
624
625 try:
626 _validate_version_tag_name(tag_name)
627 except ValueError as exc:
628 _emit_error(
629 f"Invalid version tag: {sanitize_display(str(exc))}",
630 ExitCode.USER_ERROR,
631 "invalid_tag_name",
632 )
633
634 root = require_repo()
635
636 url = get_remote(remote, root)
637 if url is None:
638 _emit_error(
639 f"Remote '{sanitize_display(remote)}' is not configured. "
640 "Add one with: muse remote add <name> <url>",
641 ExitCode.USER_ERROR,
642 "remote_not_configured",
643 remote=remote,
644 )
645
646 record = read_version_tag(root, tag_name)
647 if record is None:
648 _emit_error(
649 f"Version tag '{sanitize_display(tag_name)}' not found locally. "
650 "Create it first with: muse tag add <tag>",
651 ExitCode.USER_ERROR,
652 "tag_not_found",
653 tag=tag_name,
654 )
655
656 signing = get_signing_identity(root, remote_url=url)
657 transport = make_transport(url)
658
659 tag_dict = {
660 "tag_id": record.tag_id,
661 "repo_id": record.repo_id,
662 "tag": record.tag,
663 "semver": {
664 "major": record.semver["major"],
665 "minor": record.semver["minor"],
666 "patch": record.semver["patch"],
667 "pre": record.semver["pre"],
668 "build": record.semver["build"],
669 },
670 "commit_id": record.commit_id,
671 "created_at": record.created_at.isoformat(),
672 "author": record.author,
673 "message": record.message,
674 }
675
676 try:
677 result = transport.push_version_tags(url, signing, [tag_dict], force=force)
678 except TransportError as exc:
679 if exc.status_code == 409:
680 _emit_error(
681 f"Tag '{sanitize_display(tag_name)}' conflicts with the remote — "
682 "use --force to overwrite.",
683 ExitCode.USER_ERROR,
684 "conflict",
685 tag=tag_name,
686 status="conflict",
687 )
688 else:
689 _emit_error(
690 f"Push failed: {sanitize_display(str(exc))}",
691 ExitCode.USER_ERROR,
692 "push_failed",
693 )
694
695 stored = result.get("stored", 0)
696 status = "pushed" if stored > 0 else "already_current"
697
698 if json_out:
699 print(json.dumps(_TagPushJson(
700 **make_envelope(elapsed),
701 status=status,
702 tag=tag_name,
703 remote=remote,
704 commit_id=record.commit_id,
705 )))
706 else:
707 if status == "pushed":
708 print(f"✅ Pushed {sanitize_display(tag_name)} → {sanitize_display(remote)}")
709 else:
710 print(f"Tag {sanitize_display(tag_name)} already up-to-date on {sanitize_display(remote)}")
File History 7 commits
sha256:3a1795edd9596c7ff33dcae13cc364248d54cb359aeda9a5bd886e15a37eb037 Merge branch 'dev' into main Human 22 days ago
sha256:ae1eca169c5efe00a6415971ed0e7259f68df3e3ce23935c4b1b714c2df6a240 Merge branch 'dev' into main Human 22 days ago
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 30 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 30 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 33 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 52 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 105 days ago