tag.py python
596 lines 21.5 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 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": "<uuid> | null",
31 "commit_id": "<sha256>",
32 "tag": "<tag_string>",
33 "namespace": "<prefix before ':' | null>",
34 "created_at": "<iso8601> | null",
35 "dry_run": false
36 }
37
38 Exit codes::
39
40 0 — success
41 1 — commit not found, tag not found, invalid tag name, invalid format
42 2 — not inside a Muse repository
43 """
44
45 from __future__ import annotations
46
47 import argparse
48 import datetime
49 import fnmatch
50 import json
51 import logging
52 import re
53 import sys
54 import uuid
55 from typing import TypedDict
56
57 from muse.core.errors import ExitCode
58 from muse.core.repo import read_repo_id, require_repo
59 from muse.core.store import (
60 TagRecord,
61 delete_tag,
62 get_all_tags,
63 get_tags_for_commit,
64 read_current_branch,
65 resolve_commit_ref,
66 write_tag,
67 )
68 from muse.core.validation import sanitize_display
69
70 logger = logging.getLogger(__name__)
71
72 # Maximum length for a tag string (prevents runaway storage).
73 _MAX_TAG_LEN: int = 256
74
75 # Reject control characters (C0 + DEL) to prevent ANSI injection when tags
76 # are embedded in terminal output or stored on disk.
77 _TAG_FORBIDDEN_RE: re.Pattern[str] = re.compile(r"[\x00-\x1f\x7f]")
78
79
80 class _TagJson(TypedDict):
81 """Stable JSON schema for a single tag entry (list output)."""
82
83 tag_id: str
84 commit_id: str
85 tag: str
86 namespace: str | None
87 created_at: str
88
89
90 def _tag_namespace(tag: str) -> str | None:
91 """Return the namespace prefix (part before ':'), or None if absent."""
92 return tag.split(":", 1)[0] if ":" in tag else None
93
94
95 def _validate_tag_name(name: str) -> str:
96 """Return *name* unchanged if safe; raise ``ValueError`` otherwise.
97
98 Guards:
99 - Not empty.
100 - Max length ``_MAX_TAG_LEN`` (256 chars).
101 - No C0 control characters or DEL (prevents ANSI injection in terminal
102 output and on-disk storage).
103 """
104 if not name:
105 raise ValueError("Tag name must not be empty.")
106 if len(name) > _MAX_TAG_LEN:
107 raise ValueError(
108 f"Tag name too long ({len(name)} chars); maximum is {_MAX_TAG_LEN}."
109 )
110 if _TAG_FORBIDDEN_RE.search(name):
111 raise ValueError(
112 "Tag name contains forbidden control characters "
113 "(use printable ASCII/Unicode only)."
114 )
115 return name
116
117
118 def _tag_to_json(t: TagRecord) -> _TagJson:
119 """Convert a TagRecord to the stable JSON wire format."""
120 return _TagJson(
121 tag_id=t.tag_id,
122 commit_id=t.commit_id,
123 tag=t.tag,
124 namespace=_tag_namespace(t.tag),
125 created_at=t.created_at.isoformat(),
126 )
127
128
129 def _sort_tags(tags: list[TagRecord], sort_key: str) -> list[TagRecord]:
130 """Sort *tags* by *sort_key*: ``tag`` (default), ``created``, ``commit``."""
131 if sort_key == "created":
132 return sorted(tags, key=lambda t: t.created_at)
133 if sort_key == "commit":
134 return sorted(tags, key=lambda t: t.commit_id)
135 return sorted(tags, key=lambda t: (t.tag, t.commit_id))
136
137
138 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
139 """Register the ``muse tag`` subcommand tree."""
140 parser = subparsers.add_parser(
141 "tag",
142 help="Attach and query semantic tags on commits.",
143 description=__doc__,
144 formatter_class=argparse.RawDescriptionHelpFormatter,
145 )
146 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
147 subs.required = True
148
149 # -- add ------------------------------------------------------------------
150 add_p = subs.add_parser(
151 "add",
152 help="Attach a tag to a commit.",
153 description=(
154 "Attach a tag string to a commit (HEAD by default).\n\n"
155 "Tags follow a 'namespace:value' convention (not enforced):\n"
156 " emotion:joyful section:chorus stage:master\n"
157 " key:Am tempo:120bpm ref:beatles\n\n"
158 "Tag name rules:\n"
159 f" - Maximum {_MAX_TAG_LEN} characters\n"
160 " - No control characters (C0/DEL) — printable ASCII/Unicode only\n\n"
161 "Duplicate guard (default): adding the same tag twice to the same\n"
162 "commit is a no-op — the existing tag_id is returned in JSON output\n"
163 "so agent workflows remain idempotent. Use --allow-duplicate to bypass.\n\n"
164 "Agent quickstart:\n"
165 " muse tag add emotion:joyful\n"
166 " muse tag add emotion:joyful --json\n"
167 " muse tag add emotion:joyful --json -j # same, short flag\n"
168 " muse tag add emotion:joyful <commit_id_or_branch>\n"
169 " muse tag add emotion:joyful --dry-run --json\n\n"
170 "JSON schema:\n"
171 " {\"status\": \"tagged|already_tagged|dry_run\",\n"
172 " \"tag_id\": \"<uuid>|null\", \"commit_id\": \"<sha256>\",\n"
173 " \"tag\": \"<tag>\", \"namespace\": \"<prefix>|null\",\n"
174 " \"created_at\": \"<iso8601>|null\", \"dry_run\": false}\n\n"
175 "Exit codes:\n"
176 " 0 Tagged successfully (or already_tagged, or dry_run)\n"
177 " 1 Invalid tag name, commit not found, or invalid --format\n"
178 " 2 Not inside a Muse repository"
179 ),
180 formatter_class=argparse.RawDescriptionHelpFormatter,
181 )
182 add_p.add_argument("tag_name", help="Tag string (e.g. emotion:joyful).")
183 add_p.add_argument(
184 "ref", nargs="?", default=None,
185 help="Commit ID or branch name (default: HEAD).",
186 )
187 add_p.add_argument(
188 "--allow-duplicate", action="store_true",
189 help="Allow adding the same tag twice to the same commit.",
190 )
191 add_p.add_argument(
192 "-n", "--dry-run", action="store_true",
193 help="Validate and preview without writing any data.",
194 )
195 add_p.add_argument("--format", "-f", default="text", dest="fmt",
196 help="Output format: text or json.")
197 add_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
198 help="Shorthand for --format json.")
199 add_p.set_defaults(func=run_add)
200
201 # -- list -----------------------------------------------------------------
202 list_p = subs.add_parser(
203 "list",
204 help="List tags.",
205 description=(
206 "List tags in the repository, optionally filtered by commit or glob pattern.\n\n"
207 "With no arguments, lists all tags across all commits.\n"
208 "Pass a commit ID or branch name to list only tags on that commit.\n\n"
209 "Glob pattern examples (--match / -m):\n"
210 " emotion:* — all emotion-namespace tags\n"
211 " *:joyful — any namespace, value 'joyful'\n"
212 " section:* — all section tags\n"
213 " * — all tags (same as no --match)\n\n"
214 "Sort options (--sort):\n"
215 " tag (default) — alphabetical by tag string\n"
216 " created — chronological by creation time\n"
217 " commit — lexicographic by commit SHA\n\n"
218 "Agent quickstart:\n"
219 " muse tag list\n"
220 " muse tag list --json\n"
221 " muse tag list -j # same, short flag\n"
222 " muse tag list --match 'emotion:*' --json\n"
223 " muse tag list --sort created --json\n"
224 " muse tag list HEAD --json\n"
225 " muse tag list --json | jq '.tags[] | .tag'\n\n"
226 "JSON schema:\n"
227 " {\"total\": <N>, \"tags\": [\n"
228 " {\"tag_id\": \"<uuid>\", \"commit_id\": \"<sha256>\",\n"
229 " \"tag\": \"<str>\", \"namespace\": \"<str>|null\",\n"
230 " \"created_at\": \"<iso8601>\"}, ...]}\n\n"
231 "Exit codes:\n"
232 " 0 Always (empty list is a valid result)\n"
233 " 1 Invalid --format, or commit ref not found\n"
234 " 2 Not inside a Muse repository"
235 ),
236 formatter_class=argparse.RawDescriptionHelpFormatter,
237 )
238 list_p.add_argument(
239 "ref", nargs="?", default=None,
240 help="Commit ID or branch name to list tags for (default: all commits).",
241 )
242 list_p.add_argument(
243 "--match", "-m", default=None, dest="match",
244 help="Filter by glob pattern (e.g. 'emotion:*', '*:joyful').",
245 )
246 list_p.add_argument(
247 "--sort", choices=["tag", "created", "commit"], default="tag",
248 help="Sort order: tag (default), created, commit.",
249 )
250 list_p.add_argument("--format", "-f", default="text", dest="fmt",
251 help="Output format: text or json.")
252 list_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
253 help="Shorthand for --format json.")
254 list_p.set_defaults(func=run_list)
255
256 # -- remove ---------------------------------------------------------------
257 remove_p = subs.add_parser(
258 "remove",
259 help="Remove a tag from a commit.",
260 description=(
261 "Remove all matching tags with the given name from a commit.\n\n"
262 "Removal is idempotent — if the tag does not exist on the commit,\n"
263 "exit code is still 0. Check 'removed_count' in JSON output to\n"
264 "determine whether anything actually changed.\n\n"
265 "When --allow-duplicate was used to add the same tag multiple times,\n"
266 "this command removes ALL copies in a single call.\n\n"
267 "Tag name rules (same as tag add):\n"
268 f" - Maximum {_MAX_TAG_LEN} characters\n"
269 " - No control characters (C0/DEL) — printable ASCII/Unicode only\n\n"
270 "Agent quickstart:\n"
271 " muse tag remove emotion:joyful\n"
272 " muse tag remove emotion:joyful --json\n"
273 " muse tag remove emotion:joyful -j # same, short flag\n"
274 " muse tag remove emotion:joyful <commit_id_or_branch>\n"
275 " muse tag remove emotion:joyful --json | jq '.removed_count'\n\n"
276 "JSON schema:\n"
277 " {\"status\": \"removed|not_found\", \"removed_count\": <N>,\n"
278 " \"tag_ids\": [\"<uuid>\", ...], \"commit_id\": \"<sha256>\",\n"
279 " \"tag\": \"<tag_string>\"}\n\n"
280 "Exit codes:\n"
281 " 0 Removed (or not found — idempotent)\n"
282 " 1 Invalid tag name, commit not found, or invalid --format\n"
283 " 2 Not inside a Muse repository"
284 ),
285 formatter_class=argparse.RawDescriptionHelpFormatter,
286 )
287 remove_p.add_argument("tag_name", help="Tag string to remove (e.g. emotion:joyful).")
288 remove_p.add_argument(
289 "ref", nargs="?", default=None,
290 help="Commit ID or branch name (default: HEAD).",
291 )
292 remove_p.add_argument("--format", "-f", default="text", dest="fmt",
293 help="Output format: text or json.")
294 remove_p.add_argument("--json", "-j", action="store_const", const="json", dest="fmt",
295 help="Shorthand for --format json.")
296 remove_p.set_defaults(func=run_remove)
297
298
299 def run_add(args: argparse.Namespace) -> None:
300 """Attach a tag to a commit.
301
302 By default, adding the same tag to the same commit twice is a no-op
303 (idempotent). Use ``--allow-duplicate`` to bypass the deduplication guard.
304
305 Use ``--dry-run`` to validate the commit reference and preview the result
306 without writing any tag to disk. Exits 0 on success, 1 on validation error.
307
308 All error messages go to **stderr**; stdout is reserved for structured output.
309
310 JSON schema::
311
312 {
313 "status": "tagged | already_tagged | dry_run",
314 "tag_id": "<uuid> | null",
315 "commit_id": "<sha256>",
316 "tag": "<tag_string>",
317 "namespace": "<prefix before ':'> | null",
318 "created_at": "<iso8601> | null",
319 "dry_run": false
320 }
321
322 Exit codes::
323
324 0 — success (tagged, already_tagged, or dry_run)
325 1 — invalid tag name, commit not found, invalid format
326 2 — not inside a Muse repository
327 """
328 tag_name: str = args.tag_name
329 ref: str | None = args.ref
330 fmt: str = args.fmt
331 dry_run: bool = args.dry_run
332 allow_duplicate: bool = args.allow_duplicate
333
334 if fmt not in ("text", "json"):
335 print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
336 file=sys.stderr)
337 raise SystemExit(ExitCode.USER_ERROR)
338
339 try:
340 _validate_tag_name(tag_name)
341 except ValueError as exc:
342 print(f"❌ Invalid tag name: {exc}", file=sys.stderr)
343 raise SystemExit(ExitCode.USER_ERROR)
344
345 root = require_repo()
346 repo_id = read_repo_id(root)
347 branch = read_current_branch(root)
348
349 commit = resolve_commit_ref(root, repo_id, branch, ref)
350 if commit is None:
351 print(
352 f"❌ Commit '{sanitize_display(str(ref))}' not found.",
353 file=sys.stderr,
354 )
355 raise SystemExit(ExitCode.USER_ERROR)
356
357 namespace = _tag_namespace(tag_name)
358
359 # Dry-run: validate, then exit without writing.
360 if dry_run:
361 if fmt == "json":
362 print(json.dumps({
363 "status": "dry_run",
364 "tag_id": None,
365 "commit_id": commit.commit_id,
366 "tag": tag_name,
367 "namespace": namespace,
368 "created_at": None,
369 "dry_run": True,
370 }))
371 else:
372 print(
373 f"Would tag {commit.commit_id[:8]} with '{sanitize_display(tag_name)}'"
374 " (dry run — nothing written)"
375 )
376 return
377
378 # Duplicate guard: check if this exact tag already exists on the commit.
379 if not allow_duplicate:
380 existing = get_tags_for_commit(root, repo_id, commit.commit_id)
381 if any(t.tag == tag_name for t in existing):
382 if fmt == "json":
383 # Surface the existing tag_id for idempotent agent workflows.
384 existing_tag = next(t for t in existing if t.tag == tag_name)
385 print(json.dumps({
386 "status": "already_tagged",
387 "tag_id": existing_tag.tag_id,
388 "commit_id": commit.commit_id,
389 "tag": tag_name,
390 "namespace": namespace,
391 "created_at": existing_tag.created_at.isoformat(),
392 "dry_run": False,
393 }))
394 else:
395 print(
396 f"Tag '{sanitize_display(tag_name)}' already on "
397 f"{commit.commit_id[:8]} — skipped."
398 )
399 return
400
401 tag_id = str(uuid.uuid4())
402 created_at = datetime.datetime.now(datetime.timezone.utc)
403 write_tag(root, TagRecord(
404 tag_id=tag_id,
405 repo_id=repo_id,
406 commit_id=commit.commit_id,
407 tag=tag_name,
408 created_at=created_at,
409 ))
410
411 if fmt == "json":
412 print(json.dumps({
413 "status": "tagged",
414 "tag_id": tag_id,
415 "commit_id": commit.commit_id,
416 "tag": tag_name,
417 "namespace": namespace,
418 "created_at": created_at.isoformat(),
419 "dry_run": False,
420 }))
421 else:
422 print(f"Tagged {commit.commit_id[:8]} with '{sanitize_display(tag_name)}'")
423
424
425 def run_list(args: argparse.Namespace) -> None:
426 """List tags, optionally filtered by commit or glob pattern.
427
428 When ``ref`` is omitted, lists all tags in the repository.
429 Use ``--match`` to filter by glob pattern (e.g. ``'emotion:*'``).
430 Use ``--sort`` to control ordering (tag, created, commit).
431
432 All error messages go to **stderr**; stdout is reserved for structured output.
433
434 JSON schema::
435
436 {
437 "total": <N>,
438 "tags": [
439 {
440 "tag_id": "<uuid>",
441 "commit_id": "<sha256>",
442 "tag": "<tag_string>",
443 "namespace": "<prefix before ':'> | null",
444 "created_at": "<iso8601>"
445 },
446 ...
447 ]
448 }
449
450 Exit codes::
451
452 0 — always (empty list is a valid result)
453 1 — invalid format, commit not found
454 2 — not inside a Muse repository
455 """
456 ref: str | None = args.ref
457 fmt: str = args.fmt
458 match_pattern: str | None = args.match
459 sort_key: str = args.sort
460
461 if fmt not in ("text", "json"):
462 print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
463 file=sys.stderr)
464 raise SystemExit(ExitCode.USER_ERROR)
465
466 root = require_repo()
467 repo_id = read_repo_id(root)
468 branch = read_current_branch(root)
469
470 if ref:
471 commit = resolve_commit_ref(root, repo_id, branch, ref)
472 if commit is None:
473 print(
474 f"❌ Commit '{sanitize_display(str(ref))}' not found.",
475 file=sys.stderr,
476 )
477 raise SystemExit(ExitCode.USER_ERROR)
478 tags = get_tags_for_commit(root, repo_id, commit.commit_id)
479 else:
480 tags = get_all_tags(root, repo_id)
481
482 if match_pattern:
483 tags = [t for t in tags if fnmatch.fnmatch(t.tag, match_pattern)]
484
485 tags = _sort_tags(tags, sort_key)
486
487 if fmt == "json":
488 print(json.dumps({
489 "total": len(tags),
490 "tags": [_tag_to_json(t) for t in tags],
491 }))
492 return
493
494 if not tags:
495 print("No tags.")
496 return
497 for t in tags:
498 print(f"{t.commit_id[:8]} {sanitize_display(t.tag)}")
499
500
501 def run_remove(args: argparse.Namespace) -> None:
502 """Remove all matching tags from a commit.
503
504 Validates the tag name format before any I/O — same rules as
505 :func:`run_add` — so invalid names produce a clear error rather than
506 silently returning ``not_found``.
507
508 Finds all tags with the exact name on the given commit and deletes them.
509 Exits 0 even if no tags were found (idempotent removal). Use the
510 ``removed_count`` field in JSON output to detect whether anything changed.
511
512 When ``--allow-duplicate`` was used during add, all copies of the tag on
513 that commit are removed in a single call.
514
515 All error messages go to **stderr**; stdout is reserved for structured output.
516
517 JSON schema::
518
519 {
520 "status": "removed | not_found",
521 "removed_count": <N>,
522 "tag_ids": ["<uuid>", ...],
523 "commit_id": "<sha256>",
524 "tag": "<tag_string>"
525 }
526
527 Exit codes::
528
529 0 — success (removed or not_found — removal is idempotent)
530 1 — invalid tag name, commit not found, invalid format
531 2 — not inside a Muse repository
532 """
533 tag_name: str = args.tag_name
534 ref: str | None = args.ref
535 fmt: str = args.fmt
536
537 if fmt not in ("text", "json"):
538 print(f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
539 file=sys.stderr)
540 raise SystemExit(ExitCode.USER_ERROR)
541
542 try:
543 _validate_tag_name(tag_name)
544 except ValueError as exc:
545 print(f"❌ Invalid tag name: {exc}", file=sys.stderr)
546 raise SystemExit(ExitCode.USER_ERROR)
547
548 root = require_repo()
549 repo_id = read_repo_id(root)
550 branch = read_current_branch(root)
551
552 commit = resolve_commit_ref(root, repo_id, branch, ref)
553 if commit is None:
554 print(
555 f"❌ Commit '{sanitize_display(str(ref))}' not found.",
556 file=sys.stderr,
557 )
558 raise SystemExit(ExitCode.USER_ERROR)
559
560 tags = get_tags_for_commit(root, repo_id, commit.commit_id)
561 matching = [t for t in tags if t.tag == tag_name]
562
563 if not matching:
564 if fmt == "json":
565 print(json.dumps({
566 "status": "not_found",
567 "removed_count": 0,
568 "tag_ids": [],
569 "commit_id": commit.commit_id,
570 "tag": tag_name,
571 }))
572 else:
573 print(
574 f"Tag '{sanitize_display(tag_name)}' not found on "
575 f"{commit.commit_id[:8]} — nothing removed."
576 )
577 return
578
579 removed_ids: list[str] = []
580 for t in matching:
581 delete_tag(root, repo_id, t.tag_id)
582 removed_ids.append(t.tag_id)
583
584 if fmt == "json":
585 print(json.dumps({
586 "status": "removed",
587 "removed_count": len(matching),
588 "tag_ids": removed_ids,
589 "commit_id": commit.commit_id,
590 "tag": tag_name,
591 }))
592 else:
593 print(
594 f"Removed {len(matching)} tag(s) '{sanitize_display(tag_name)}' "
595 f"from {commit.commit_id[:8]}."
596 )
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago