gabriel / muse public
tag.py python
694 lines 24.8 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """``muse tag`` — attach and query semantic tags on commits.
2
3 Tags are arbitrary ``namespace:value`` strings (convention, not enforced) that
4 annotate commits with machine-readable metadata. All tag operations are
5 idempotent by default: adding the same tag twice to the same commit is a
6 no-op (use ``--allow-duplicate`` to bypass).
7
8 Usage::
9
10 muse tag add <tag> [<ref>] — tag a commit (HEAD if omitted)
11 muse tag add <tag> [<ref>] --dry-run — preview without writing
12 muse tag list [<ref>] — list tags (all or per-commit)
13 muse tag list --match "emotion:*" — filter by glob pattern
14 muse tag list --sort created — sort by creation time
15 muse tag remove <tag> [<ref>] — remove matching tags from a commit
16
17 Tag conventions (not enforced)::
18
19 emotion:* — emotional character (emotion:melancholic, emotion:tense)
20 section:* — song section (section:verse, section:chorus)
21 stage:* — production stage (stage:rough-mix, stage:master)
22 key:* — musical key (key:Am, key:Eb)
23 tempo:* — tempo annotation (tempo:120bpm)
24 ref:* — reference track (ref:beatles)
25
26 JSON output (``--format json`` / ``--json``) schema for ``tag add``::
27
28 {
29 "status": "tagged | already_tagged | dry_run",
30 "tag_id": "<sha256:...> | null",
31 "commit_id": "<sha256:...>",
32 "tag": "<tag_string>",
33 "namespace": "<prefix before ':' | null>",
34 "created_at": "<iso8601> | null",
35 "dry_run": false,
36 "duration_ms": 1.234,
37 "exit_code": 0
38 }
39
40 JSON error output (always to stdout when ``--json`` so agents can parse failures)::
41
42 {
43 "error": "<error_key>",
44 "message": "<human-readable description>",
45 "duration_ms": 0.3,
46 "exit_code": 1
47 }
48
49 Exit codes::
50
51 0 — success
52 1 — commit not found, tag not found, invalid tag name, invalid format
53 2 — not inside a Muse repository
54 """
55
56 from __future__ import annotations
57
58 import argparse
59 import datetime
60 import fnmatch
61 import json
62 import logging
63 import os
64 import re
65 import sys
66 from typing import TypedDict
67
68 from muse.core._types import JsonValue, blob_id, short_id
69 from muse.core.errors import ExitCode
70 from muse.core.repo import read_repo_id, require_repo
71 from muse.core.store import (
72 TagRecord,
73 compute_tag_id,
74 delete_tag,
75 get_all_tags,
76 get_tags_for_commit,
77 read_current_branch,
78 resolve_commit_ref,
79 write_tag,
80 )
81 from muse.core.envelope import EnvelopeJson, make_envelope
82 from muse.core.validation import sanitize_display
83 from muse.core.timing import start_timer
84
85 logger = logging.getLogger(__name__)
86
87 # Maximum length for a tag string (prevents runaway storage).
88 _MAX_TAG_LEN: int = 256
89
90 # Reject control characters (C0 + DEL) to prevent ANSI injection when tags
91 # are embedded in terminal output or stored on disk.
92 _TAG_FORBIDDEN_RE: re.Pattern[str] = re.compile(r"[\x00-\x1f\x7f]")
93
94
95 class _TagEntryJson(TypedDict):
96 """Per-tag entry in list / add / remove output."""
97
98 tag_id: str
99 commit_id: str
100 tag: str
101 namespace: str | None
102 created_at: str
103
104
105 class _TagAddJson(EnvelopeJson):
106 status: str
107 tag_id: str | None
108 commit_id: str
109 tag: str
110 namespace: str | None
111 created_at: str | None
112 dry_run: bool
113
114
115 class _TagListJson(EnvelopeJson):
116 total: int
117 tags: list[_TagEntryJson]
118
119
120 class _TagRemoveJson(EnvelopeJson):
121 status: str
122 removed_count: int
123 tag_ids: list[str]
124 commit_id: str
125 tag: str
126
127
128 def _tag_namespace(tag: str) -> str | None:
129 """Return the namespace prefix (part before ':'), or None if absent."""
130 return tag.split(":", 1)[0] if ":" in tag else None
131
132
133 def _validate_tag_name(name: str) -> str:
134 """Return *name* unchanged if safe; raise ``ValueError`` otherwise.
135
136 Guards:
137 - Not empty.
138 - Max length ``_MAX_TAG_LEN`` (256 chars).
139 - No C0 control characters or DEL (prevents ANSI injection in terminal
140 output and on-disk storage).
141 """
142 if not name:
143 raise ValueError("Tag name must not be empty.")
144 if len(name) > _MAX_TAG_LEN:
145 raise ValueError(
146 f"Tag name too long ({len(name)} chars); maximum is {_MAX_TAG_LEN}."
147 )
148 if _TAG_FORBIDDEN_RE.search(name):
149 raise ValueError(
150 "Tag name contains forbidden control characters "
151 "(use printable ASCII/Unicode only)."
152 )
153 return name
154
155
156 def _tag_to_json(t: TagRecord) -> _TagEntryJson:
157 """Convert a TagRecord to the stable JSON wire format."""
158 return _TagEntryJson(
159 tag_id=t.tag_id,
160 commit_id=t.commit_id,
161 tag=t.tag,
162 namespace=_tag_namespace(t.tag),
163 created_at=t.created_at.isoformat(),
164 )
165
166
167 def _sort_tags(tags: list[TagRecord], sort_key: str) -> list[TagRecord]:
168 """Sort *tags* by *sort_key*: ``tag`` (default), ``created``, ``commit``."""
169 if sort_key == "created":
170 return sorted(tags, key=lambda t: t.created_at)
171 if sort_key == "commit":
172 return sorted(tags, key=lambda t: t.commit_id)
173 return sorted(tags, key=lambda t: (t.tag, t.commit_id))
174
175
176 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
177 """Register the ``muse tag`` subcommand tree."""
178 parser = subparsers.add_parser(
179 "tag",
180 help="Attach and query semantic tags on commits.",
181 description=__doc__,
182 formatter_class=argparse.RawDescriptionHelpFormatter,
183 )
184 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
185 subs.required = True
186
187 # -- add ------------------------------------------------------------------
188 add_p = subs.add_parser(
189 "add",
190 help="Attach a tag to a commit.",
191 description=(
192 "Attach a tag string to a commit (HEAD by default).\n\n"
193 "Tags follow a 'namespace:value' convention (not enforced):\n"
194 " emotion:joyful section:chorus stage:master\n"
195 " key:Am tempo:120bpm ref:beatles\n\n"
196 "Tag name rules:\n"
197 f" - Maximum {_MAX_TAG_LEN} characters\n"
198 " - No control characters (C0/DEL) — printable ASCII/Unicode only\n\n"
199 "Duplicate guard (default): adding the same tag twice to the same\n"
200 "commit is a no-op — the existing tag_id is returned in JSON output\n"
201 "so agent workflows remain idempotent. Use --allow-duplicate to bypass.\n\n"
202 "Agent quickstart:\n"
203 " muse tag add emotion:joyful\n"
204 " muse tag add emotion:joyful --json\n"
205 " muse tag add emotion:joyful --json -j # same, short flag\n"
206 " muse tag add emotion:joyful <commit_id_or_branch>\n"
207 " muse tag add emotion:joyful --dry-run --json\n\n"
208 "JSON schema:\n"
209 " {\"status\": \"tagged|already_tagged|dry_run\",\n"
210 " \"tag_id\": \"<sha256:...>|null\", \"commit_id\": \"<sha256:...>\",\n"
211 " \"tag\": \"<tag>\", \"namespace\": \"<prefix>|null\",\n"
212 " \"created_at\": \"<iso8601>|null\", \"dry_run\": false,\n"
213 " \"duration_ms\": 1.234, \"exit_code\": 0}\n\n"
214 "Exit codes:\n"
215 " 0 Tagged successfully (or already_tagged, or dry_run)\n"
216 " 1 Invalid tag name, commit not found, or invalid --format\n"
217 " 2 Not inside a Muse repository"
218 ),
219 formatter_class=argparse.RawDescriptionHelpFormatter,
220 )
221 add_p.add_argument("tag_name", help="Tag string (e.g. emotion:joyful).")
222 add_p.add_argument(
223 "ref", nargs="?", default=None,
224 help="Commit ID or branch name (default: HEAD).",
225 )
226 add_p.add_argument(
227 "--allow-duplicate", action="store_true",
228 help="Allow adding the same tag twice to the same commit.",
229 )
230 add_p.add_argument(
231 "-n", "--dry-run", action="store_true",
232 help="Validate and preview without writing any data.",
233 )
234 add_p.add_argument("--json", "-j", action="store_true", dest="json_out",
235 help="Emit machine-readable JSON on stdout.")
236 add_p.set_defaults(func=run_add, json_out=False)
237
238 # -- list -----------------------------------------------------------------
239 list_p = subs.add_parser(
240 "list",
241 help="List tags.",
242 description=(
243 "List tags in the repository, optionally filtered by commit or glob pattern.\n\n"
244 "With no arguments, lists all tags across all commits.\n"
245 "Pass a commit ID or branch name to list only tags on that commit.\n\n"
246 "Glob pattern examples (--match / -m):\n"
247 " emotion:* — all emotion-namespace tags\n"
248 " *:joyful — any namespace, value 'joyful'\n"
249 " section:* — all section tags\n"
250 " * — all tags (same as no --match)\n\n"
251 "Sort options (--sort):\n"
252 " tag (default) — alphabetical by tag string\n"
253 " created — chronological by creation time\n"
254 " commit — lexicographic by commit SHA\n\n"
255 "Agent quickstart:\n"
256 " muse tag list\n"
257 " muse tag list --json\n"
258 " muse tag list -j # same, short flag\n"
259 " muse tag list --match 'emotion:*' --json\n"
260 " muse tag list --sort created --json\n"
261 " muse tag list HEAD --json\n"
262 " muse tag list --json | jq '.tags[] | .tag'\n\n"
263 "JSON schema:\n"
264 " {\"total\": <N>, \"tags\": [\n"
265 " {\"tag_id\": \"<sha256:...>\", \"commit_id\": \"<sha256:...>\",\n"
266 " \"tag\": \"<str>\", \"namespace\": \"<str>|null\",\n"
267 " \"created_at\": \"<iso8601>\"}, ...],\n"
268 " \"duration_ms\": 1.234, \"exit_code\": 0}\n\n"
269 "Exit codes:\n"
270 " 0 Always (empty list is a valid result)\n"
271 " 1 Invalid --format, or commit ref not found\n"
272 " 2 Not inside a Muse repository"
273 ),
274 formatter_class=argparse.RawDescriptionHelpFormatter,
275 )
276 list_p.add_argument(
277 "ref", nargs="?", default=None,
278 help="Commit ID or branch name to list tags for (default: all commits).",
279 )
280 list_p.add_argument(
281 "--match", "-m", default=None, dest="match",
282 help="Filter by glob pattern (e.g. 'emotion:*', '*:joyful').",
283 )
284 list_p.add_argument(
285 "--sort", choices=["tag", "created", "commit"], default="tag",
286 help="Sort order: tag (default), created, commit.",
287 )
288 list_p.add_argument("--json", "-j", action="store_true", dest="json_out",
289 help="Emit machine-readable JSON on stdout.")
290 list_p.set_defaults(func=run_list, json_out=False)
291
292 # -- remove ---------------------------------------------------------------
293 remove_p = subs.add_parser(
294 "remove",
295 help="Remove a tag from a commit.",
296 description=(
297 "Remove all matching tags with the given name from a commit.\n\n"
298 "Removal is idempotent — if the tag does not exist on the commit,\n"
299 "exit code is still 0. Check 'removed_count' in JSON output to\n"
300 "determine whether anything actually changed.\n\n"
301 "When --allow-duplicate was used to add the same tag multiple times,\n"
302 "this command removes ALL copies in a single call.\n\n"
303 "Tag name rules (same as tag add):\n"
304 f" - Maximum {_MAX_TAG_LEN} characters\n"
305 " - No control characters (C0/DEL) — printable ASCII/Unicode only\n\n"
306 "Agent quickstart:\n"
307 " muse tag remove emotion:joyful\n"
308 " muse tag remove emotion:joyful --json\n"
309 " muse tag remove emotion:joyful -j # same, short flag\n"
310 " muse tag remove emotion:joyful <commit_id_or_branch>\n"
311 " muse tag remove emotion:joyful --json | jq '.removed_count'\n\n"
312 "JSON schema:\n"
313 " {\"status\": \"removed|not_found\", \"removed_count\": <N>,\n"
314 " \"tag_ids\": [\"<sha256:...>\", ...], \"commit_id\": \"<sha256:...>\",\n"
315 " \"tag\": \"<tag_string>\", \"duration_ms\": 1.234, \"exit_code\": 0}\n\n"
316 "Exit codes:\n"
317 " 0 Removed (or not found — idempotent)\n"
318 " 1 Invalid tag name, commit not found, or invalid --format\n"
319 " 2 Not inside a Muse repository"
320 ),
321 formatter_class=argparse.RawDescriptionHelpFormatter,
322 )
323 remove_p.add_argument("tag_name", help="Tag string to remove (e.g. emotion:joyful).")
324 remove_p.add_argument(
325 "ref", nargs="?", default=None,
326 help="Commit ID or branch name (default: HEAD).",
327 )
328 remove_p.add_argument("--json", "-j", action="store_true", dest="json_out",
329 help="Emit machine-readable JSON on stdout.")
330 remove_p.set_defaults(func=run_remove, json_out=False)
331
332
333 def run_add(args: argparse.Namespace) -> None:
334 """Attach a tag to a commit.
335
336 By default, adding the same tag to the same commit twice is a no-op
337 (idempotent) — the existing ``tag_id`` is returned so agent workflows
338 stay idempotent. Use ``--allow-duplicate`` to bypass the deduplication
339 guard. Use ``--dry-run`` to preview without writing.
340
341 Agent quickstart::
342
343 muse tag add emotion:joyful --json
344 muse tag add section:chorus HEAD --json
345 muse tag add emotion:joyful --dry-run --json
346 muse tag add emotion:joyful <commit_id> --json
347
348 JSON fields::
349
350 status ``"tagged"``, ``"already_tagged"``, or ``"dry_run"``.
351 tag_id SHA-256 tag ID; ``null`` on dry-run.
352 commit_id Target commit ID.
353 tag Full tag string.
354 namespace Prefix before ``:``; ``null`` when absent.
355 created_at ISO-8601 creation timestamp; ``null`` on dry-run.
356 dry_run ``true`` when ``--dry-run`` was passed.
357 muse_version Muse release that produced this output.
358 schema Envelope schema version (int).
359 exit_code ``0`` on success, ``1`` on user error.
360 duration_ms Wall-clock milliseconds for the command.
361 timestamp ISO-8601 UTC timestamp of command completion.
362 warnings List of non-fatal advisory messages.
363
364 Exit codes::
365
366 0 Success (tagged, already_tagged, or dry_run).
367 1 Invalid tag name, commit not found, invalid format.
368 2 Not inside a Muse repository.
369 """
370 elapsed = start_timer()
371
372
373 tag_name: str = args.tag_name
374 ref: str | None = args.ref
375 json_out: bool = args.json_out
376 dry_run: bool = args.dry_run
377 allow_duplicate: bool = args.allow_duplicate
378
379 def _emit_error(msg: str, code: int, error_key: str = "error", **extra: JsonValue) -> None:
380 if json_out:
381 payload = {
382 **make_envelope(elapsed, exit_code=code),
383 "error": error_key,
384 "message": msg,
385 }
386 payload.update(extra)
387 print(json.dumps(payload))
388 else:
389 print(f"❌ {msg}", file=sys.stderr)
390 raise SystemExit(code)
391
392 try:
393 _validate_tag_name(tag_name)
394 except ValueError as exc:
395 _emit_error(
396 f"Invalid tag name: {sanitize_display(str(exc))}",
397 ExitCode.USER_ERROR,
398 "invalid_tag_name",
399 )
400
401 root = require_repo()
402 repo_id = read_repo_id(root)
403 branch = read_current_branch(root)
404
405 commit = resolve_commit_ref(root, repo_id, branch, ref)
406 if commit is None:
407 _emit_error(
408 f"Commit '{sanitize_display(str(ref))}' not found.",
409 ExitCode.USER_ERROR,
410 "commit_not_found",
411 ref=str(ref),
412 )
413
414 namespace = _tag_namespace(tag_name)
415
416 # Dry-run: validate, then exit without writing.
417 if dry_run:
418 if json_out:
419 print(json.dumps(_TagAddJson(
420 **make_envelope(elapsed),
421 status="dry_run",
422 tag_id=None,
423 commit_id=commit.commit_id,
424 tag=tag_name,
425 namespace=namespace,
426 created_at=None,
427 dry_run=True,
428 )))
429 else:
430 print(
431 f"Would tag {short_id(commit.commit_id)} with '{sanitize_display(tag_name)}'"
432 " (dry run — nothing written)"
433 )
434 return
435
436 # Duplicate guard: check if this exact tag already exists on the commit.
437 if not allow_duplicate:
438 existing = get_tags_for_commit(root, repo_id, commit.commit_id)
439 if any(t.tag == tag_name for t in existing):
440 if json_out:
441 # Surface the existing tag_id for idempotent agent workflows.
442 existing_tag = next(t for t in existing if t.tag == tag_name)
443 print(json.dumps(_TagAddJson(
444 **make_envelope(elapsed),
445 status="already_tagged",
446 tag_id=existing_tag.tag_id,
447 commit_id=commit.commit_id,
448 tag=tag_name,
449 namespace=namespace,
450 created_at=existing_tag.created_at.isoformat(),
451 dry_run=False,
452 )))
453 else:
454 print(
455 f"Tag '{sanitize_display(tag_name)}' already on "
456 f"{short_id(commit.commit_id)} — skipped."
457 )
458 return
459
460 # When --allow-duplicate is set, generate a unique ID per write so each
461 # copy is stored as a separate record and can be enumerated / removed.
462 if allow_duplicate:
463 tag_id = blob_id(os.urandom(32))
464 else:
465 tag_id = compute_tag_id(repo_id=repo_id, commit_id=commit.commit_id, tag=tag_name)
466 created_at = datetime.datetime.now(datetime.timezone.utc)
467 write_tag(root, TagRecord(
468 tag_id=tag_id,
469 repo_id=repo_id,
470 commit_id=commit.commit_id,
471 tag=tag_name,
472 created_at=created_at,
473 ))
474
475 if json_out:
476 print(json.dumps(_TagAddJson(
477 **make_envelope(elapsed),
478 status="tagged",
479 tag_id=tag_id,
480 commit_id=commit.commit_id,
481 tag=tag_name,
482 namespace=namespace,
483 created_at=created_at.isoformat(),
484 dry_run=False,
485 )))
486 else:
487 print(f"Tagged {short_id(commit.commit_id)} with '{sanitize_display(tag_name)}'")
488
489
490 def run_list(args: argparse.Namespace) -> None:
491 """List tags, optionally filtered by commit or glob pattern.
492
493 When ``ref`` is omitted, lists all tags in the repository across all
494 commits. Use ``--match`` for glob filtering (e.g. ``'emotion:*'``) and
495 ``--sort`` for ordering (tag, created, commit).
496
497 Agent quickstart::
498
499 muse tag list --json
500 muse tag list HEAD --json
501 muse tag list --match 'emotion:*' --json
502 muse tag list --sort created --json
503
504 JSON fields::
505
506 total Total number of tags returned.
507 tags List of tag entries: ``tag_id``, ``commit_id``, ``tag``,
508 ``namespace``, ``created_at``.
509 muse_version Muse release that produced this output.
510 schema Envelope schema version (int).
511 exit_code ``0`` always (empty list is a valid result).
512 duration_ms Wall-clock milliseconds for the command.
513 timestamp ISO-8601 UTC timestamp of command completion.
514 warnings List of non-fatal advisory messages.
515
516 Exit codes::
517
518 0 Always (empty list is a valid result).
519 1 Invalid format, commit not found.
520 2 Not inside a Muse repository.
521 """
522 elapsed = start_timer()
523
524
525 ref: str | None = args.ref
526 json_out: bool = args.json_out
527 match_pattern: str | None = args.match
528 sort_key: str = args.sort
529
530 def _emit_error(msg: str, code: int, error_key: str = "error", **extra: JsonValue) -> None:
531 if json_out:
532 payload = {
533 **make_envelope(elapsed, exit_code=code),
534 "error": error_key,
535 "message": msg,
536 }
537 payload.update(extra)
538 print(json.dumps(payload))
539 else:
540 print(f"❌ {msg}", file=sys.stderr)
541 raise SystemExit(code)
542
543 root = require_repo()
544 repo_id = read_repo_id(root)
545 branch = read_current_branch(root)
546
547 if ref:
548 commit = resolve_commit_ref(root, repo_id, branch, ref)
549 if commit is None:
550 _emit_error(
551 f"Commit '{sanitize_display(str(ref))}' not found.",
552 ExitCode.USER_ERROR,
553 "commit_not_found",
554 ref=str(ref),
555 )
556 tags = get_tags_for_commit(root, repo_id, commit.commit_id)
557 else:
558 tags = get_all_tags(root, repo_id)
559
560 if match_pattern:
561 tags = [t for t in tags if fnmatch.fnmatch(t.tag, match_pattern)]
562
563 tags = _sort_tags(tags, sort_key)
564
565 if json_out:
566 print(json.dumps(_TagListJson(
567 **make_envelope(elapsed),
568 total=len(tags),
569 tags=[_tag_to_json(t) for t in tags],
570 )))
571 return
572
573 if not tags:
574 print("No tags.")
575 return
576 for t in tags:
577 print(f"{short_id(t.commit_id)} {sanitize_display(t.tag)}")
578
579
580 def run_remove(args: argparse.Namespace) -> None:
581 """Remove all matching tags from a commit.
582
583 Validates the tag name format first (same rules as ``tag add``), then
584 finds all tags with that exact name on the commit and deletes them.
585 Exits 0 even if no tags were found — removal is idempotent. Use
586 ``removed_count`` in JSON output to detect whether anything changed.
587
588 Agent quickstart::
589
590 muse tag remove emotion:joyful --json
591 muse tag remove emotion:joyful HEAD --json
592 muse tag remove emotion:joyful <commit_id> --json
593
594 JSON fields::
595
596 status ``"removed"`` or ``"not_found"``.
597 removed_count Number of tag copies deleted (0 when not_found).
598 tag_ids List of deleted tag IDs.
599 commit_id Target commit ID.
600 tag Tag string that was targeted.
601 muse_version Muse release that produced this output.
602 schema Envelope schema version (int).
603 exit_code ``0`` always (removal is idempotent).
604 duration_ms Wall-clock milliseconds for the command.
605 timestamp ISO-8601 UTC timestamp of command completion.
606 warnings List of non-fatal advisory messages.
607
608 Exit codes::
609
610 0 Success (removed or not_found — removal is idempotent).
611 1 Invalid tag name, commit not found, invalid format.
612 2 Not inside a Muse repository.
613 """
614 elapsed = start_timer()
615
616
617 tag_name: str = args.tag_name
618 ref: str | None = args.ref
619 json_out: bool = args.json_out
620
621 def _emit_error(msg: str, code: int, error_key: str = "error", **extra: JsonValue) -> None:
622 if json_out:
623 payload = {
624 **make_envelope(elapsed, exit_code=code),
625 "error": error_key,
626 "message": msg,
627 }
628 payload.update(extra)
629 print(json.dumps(payload))
630 else:
631 print(f"❌ {msg}", file=sys.stderr)
632 raise SystemExit(code)
633
634 try:
635 _validate_tag_name(tag_name)
636 except ValueError as exc:
637 _emit_error(
638 f"Invalid tag name: {sanitize_display(str(exc))}",
639 ExitCode.USER_ERROR,
640 "invalid_tag_name",
641 )
642
643 root = require_repo()
644 repo_id = read_repo_id(root)
645 branch = read_current_branch(root)
646
647 commit = resolve_commit_ref(root, repo_id, branch, ref)
648 if commit is None:
649 _emit_error(
650 f"Commit '{sanitize_display(str(ref))}' not found.",
651 ExitCode.USER_ERROR,
652 "commit_not_found",
653 ref=str(ref),
654 )
655
656 tags = get_tags_for_commit(root, repo_id, commit.commit_id)
657 matching = [t for t in tags if t.tag == tag_name]
658
659 if not matching:
660 if json_out:
661 print(json.dumps(_TagRemoveJson(
662 **make_envelope(elapsed),
663 status="not_found",
664 removed_count=0,
665 tag_ids=[],
666 commit_id=commit.commit_id,
667 tag=tag_name,
668 )))
669 else:
670 print(
671 f"Tag '{sanitize_display(tag_name)}' not found on "
672 f"{short_id(commit.commit_id)} — nothing removed."
673 )
674 return
675
676 removed_ids: list[str] = []
677 for t in matching:
678 delete_tag(root, repo_id, t.tag_id)
679 removed_ids.append(t.tag_id)
680
681 if json_out:
682 print(json.dumps(_TagRemoveJson(
683 **make_envelope(elapsed),
684 status="removed",
685 removed_count=len(matching),
686 tag_ids=removed_ids,
687 commit_id=commit.commit_id,
688 tag=tag_name,
689 )))
690 else:
691 print(
692 f"Removed {len(matching)} tag(s) '{sanitize_display(tag_name)}' "
693 f"from {short_id(commit.commit_id)}."
694 )
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago