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