cgcardona / muse public
tag.py python
106 lines 3.3 KB
d87ef453 Introduce Muse v2 architecture: domain-agnostic VCS with plugin interface Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 """muse tag — attach and query semantic tags on commits.
2
3 Usage::
4
5 muse tag add emotion:joyful <commit> — tag a commit
6 muse tag list — list all tags in the repo
7 muse tag list <commit> — list tags on a specific commit
8 muse tag remove <tag> <commit> — remove a tag
9
10 Tag conventions::
11
12 emotion:* — emotional character (emotion:melancholic, emotion:tense)
13 section:* — song section (section:verse, section:chorus)
14 stage:* — production stage (stage:rough-mix, stage:master)
15 key:* — musical key (key:Am, key:Eb)
16 tempo:* — tempo annotation (tempo:120bpm)
17 ref:* — reference track (ref:beatles)
18 """
19 from __future__ import annotations
20
21 import json
22 import logging
23 import pathlib
24 import uuid
25 from typing import Optional
26
27 import typer
28
29 from muse.core.errors import ExitCode
30 from muse.core.repo import require_repo
31 from muse.core.store import (
32 TagRecord,
33 get_all_tags,
34 get_tags_for_commit,
35 resolve_commit_ref,
36 write_tag,
37 )
38
39 logger = logging.getLogger(__name__)
40
41 app = typer.Typer()
42 add_app = typer.Typer()
43 list_app = typer.Typer()
44 remove_app = typer.Typer()
45
46 app.add_typer(add_app, name="add", help="Attach a tag to a commit.")
47 app.add_typer(list_app, name="list", help="List tags.")
48 app.add_typer(remove_app, name="remove", help="Remove a tag from a commit.")
49
50
51 def _read_branch(root: pathlib.Path) -> str:
52 head_ref = (root / ".muse" / "HEAD").read_text().strip()
53 return head_ref.removeprefix("refs/heads/").strip()
54
55
56 def _read_repo_id(root: pathlib.Path) -> str:
57 return json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
58
59
60 @add_app.callback(invoke_without_command=True)
61 def add(
62 ctx: typer.Context,
63 tag_name: str = typer.Argument(..., help="Tag string (e.g. emotion:joyful)."),
64 ref: Optional[str] = typer.Argument(None, help="Commit ID or branch (default: HEAD)."),
65 ) -> None:
66 """Attach a tag to a commit."""
67 root = require_repo()
68 repo_id = _read_repo_id(root)
69 branch = _read_branch(root)
70
71 commit = resolve_commit_ref(root, repo_id, branch, ref)
72 if commit is None:
73 typer.echo(f"❌ Commit '{ref}' not found.")
74 raise typer.Exit(code=ExitCode.USER_ERROR)
75
76 write_tag(root, TagRecord(
77 tag_id=str(uuid.uuid4()),
78 repo_id=repo_id,
79 commit_id=commit.commit_id,
80 tag=tag_name,
81 ))
82 typer.echo(f"Tagged {commit.commit_id[:8]} with '{tag_name}'")
83
84
85 @list_app.callback(invoke_without_command=True)
86 def list_tags(
87 ctx: typer.Context,
88 ref: Optional[str] = typer.Argument(None, help="Commit ID to list tags for (default: all)."),
89 ) -> None:
90 """List tags."""
91 root = require_repo()
92 repo_id = _read_repo_id(root)
93 branch = _read_branch(root)
94
95 if ref:
96 commit = resolve_commit_ref(root, repo_id, branch, ref)
97 if commit is None:
98 typer.echo(f"❌ Commit '{ref}' not found.")
99 raise typer.Exit(code=ExitCode.USER_ERROR)
100 tags = get_tags_for_commit(root, repo_id, commit.commit_id)
101 for t in sorted(tags, key=lambda x: x.tag):
102 typer.echo(f"{t.commit_id[:8]} {t.tag}")
103 else:
104 tags = get_all_tags(root, repo_id)
105 for t in sorted(tags, key=lambda x: (x.tag, x.commit_id)):
106 typer.echo(f"{t.commit_id[:8]} {t.tag}")