cgcardona / muse public
symbolic_ref.py python
248 lines 7.9 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse symbolic-ref — read or write a symbolic ref in the Muse repository.
2
3 A symbolic ref is a file whose contents point to another ref. The canonical
4 example is ``.muse/HEAD``, which contains ``refs/heads/main`` when on the main
5 branch and a bare 40-char SHA when in detached HEAD state.
6
7 Usage
8 -----
9 ::
10
11 muse symbolic-ref HEAD # read: prints refs/heads/main
12 muse symbolic-ref --short HEAD # read short: prints main
13 muse symbolic-ref HEAD refs/heads/feature/x # write new target
14 muse symbolic-ref --delete HEAD # delete (detached HEAD scenarios)
15 -q / --quiet suppresses error output when the ref is not symbolic.
16
17 Design notes
18 ------------
19 - Pure filesystem operations — no DB session needed.
20 - The ``name`` argument is resolved relative to ``.muse/``, so callers pass
21 bare names like ``HEAD`` or ``refs/heads/main``.
22 - ``--delete`` removes the file entirely. Callers that rely on HEAD being
23 absent after deletion must handle ``FileNotFoundError`` themselves.
24 - Mirrors the contract of ``git symbolic-ref``.
25 """
26 from __future__ import annotations
27
28 import logging
29 import pathlib
30
31 import typer
32
33 from maestro.muse_cli._repo import require_repo
34 from maestro.muse_cli.errors import ExitCode
35
36 logger = logging.getLogger(__name__)
37
38 app = typer.Typer()
39
40 _SYMBOLIC_REF_PREFIX = "refs/"
41
42
43 # ---------------------------------------------------------------------------
44 # Pure logic — testable without Typer
45 # ---------------------------------------------------------------------------
46
47
48 class SymbolicRefResult:
49 """Structured result of a symbolic-ref read operation.
50
51 Attributes
52 ----------
53 ref:
54 The full target ref string stored in the file, e.g. ``refs/heads/main``.
55 short:
56 The short form — the last component of *ref* after the final ``/``.
57 For ``refs/heads/main`` this is ``main``.
58 name:
59 The symbolic ref name that was queried, e.g. ``HEAD``.
60 """
61
62 __slots__ = ("ref", "short", "name")
63
64 def __init__(self, *, name: str, ref: str) -> None:
65 self.name = name
66 self.ref = ref
67 self.short = ref.rsplit("/", 1)[-1] if "/" in ref else ref
68
69
70 def read_symbolic_ref(
71 muse_dir: pathlib.Path,
72 name: str,
73 *,
74 quiet: bool = False,
75 ) -> SymbolicRefResult | None:
76 """Read a symbolic ref from ``muse_dir``.
77
78 Returns a :class:`SymbolicRefResult` when the file exists and its content
79 starts with ``refs/``. Returns ``None`` when:
80 - The file does not exist.
81 - The content does not start with ``refs/`` (detached HEAD / bare SHA).
82
83 When *quiet* is ``False`` and the ref is not symbolic, a warning is written
84 via the module logger. Callers may also echo to the user themselves.
85
86 Args:
87 muse_dir: Path to the ``.muse/`` directory.
88 name: Ref name, e.g. ``HEAD`` or ``refs/heads/main``.
89 quiet: Suppress warning log when the ref is not symbolic.
90
91 Returns:
92 :class:`SymbolicRefResult` or ``None``.
93 """
94 ref_path = muse_dir / name
95 if not ref_path.exists():
96 if not quiet:
97 logger.warning("⚠️ symbolic-ref: %s not found in %s", name, muse_dir)
98 return None
99
100 content = ref_path.read_text().strip()
101 if not content.startswith(_SYMBOLIC_REF_PREFIX):
102 if not quiet:
103 logger.warning(
104 "⚠️ symbolic-ref: %s is not a symbolic ref (content: %r)", name, content
105 )
106 return None
107
108 return SymbolicRefResult(name=name, ref=content)
109
110
111 def write_symbolic_ref(
112 muse_dir: pathlib.Path,
113 name: str,
114 target: str,
115 ) -> None:
116 """Write *target* into the symbolic ref *name* inside *muse_dir*.
117
118 Creates any intermediate directories needed so that writing
119 ``refs/heads/feature/guitar`` works without a prior ``mkdir``.
120
121 Args:
122 muse_dir: Path to the ``.muse/`` directory.
123 name: Ref name, e.g. ``HEAD`` or ``refs/heads/new-branch``.
124 target: Full ref target, e.g. ``refs/heads/feature/guitar``.
125
126 Raises:
127 ValueError: If *target* does not start with ``refs/``.
128 """
129 if not target.startswith(_SYMBOLIC_REF_PREFIX):
130 raise ValueError(
131 f"Invalid symbolic-ref target {target!r}: must start with 'refs/'"
132 )
133 ref_path = muse_dir / name
134 ref_path.parent.mkdir(parents=True, exist_ok=True)
135 ref_path.write_text(target + "\n")
136 logger.info("✅ symbolic-ref: wrote %r → %r", name, target)
137
138
139 def delete_symbolic_ref(
140 muse_dir: pathlib.Path,
141 name: str,
142 *,
143 quiet: bool = False,
144 ) -> bool:
145 """Delete the symbolic ref file *name* from *muse_dir*.
146
147 Args:
148 muse_dir: Path to the ``.muse/`` directory.
149 name: Ref name to delete, e.g. ``HEAD``.
150 quiet: When ``True``, suppress the warning log if the file is absent.
151
152 Returns:
153 ``True`` if the file was deleted, ``False`` if it was already absent.
154 """
155 ref_path = muse_dir / name
156 if not ref_path.exists():
157 if not quiet:
158 logger.warning("⚠️ symbolic-ref: cannot delete %s — file not found", name)
159 return False
160 ref_path.unlink()
161 logger.info("✅ symbolic-ref: deleted %r", name)
162 return True
163
164
165 # ---------------------------------------------------------------------------
166 # Typer command
167 # ---------------------------------------------------------------------------
168
169
170 @app.callback(invoke_without_command=True)
171 def symbolic_ref(
172 ctx: typer.Context,
173 name: str = typer.Argument(
174 ...,
175 metavar="<name>",
176 help="Symbolic ref name, e.g. HEAD or refs/heads/main.",
177 ),
178 new_target: str | None = typer.Argument(
179 None,
180 metavar="<ref>",
181 help=(
182 "When supplied, write this target into the symbolic ref. "
183 "Must start with 'refs/'."
184 ),
185 ),
186 short: bool = typer.Option(
187 False,
188 "--short",
189 help="Print just the branch name instead of the full ref path.",
190 ),
191 delete: bool = typer.Option(
192 False,
193 "--delete",
194 "-d",
195 help="Delete the symbolic ref file entirely.",
196 ),
197 quiet: bool = typer.Option(
198 False,
199 "--quiet",
200 "-q",
201 help="Suppress error output when the ref is not symbolic.",
202 ),
203 ) -> None:
204 """Read or write a symbolic ref in the Muse repository.
205
206 Read: ``muse symbolic-ref HEAD`` prints ``refs/heads/main``.
207 Write: ``muse symbolic-ref HEAD refs/heads/feature/x`` updates .muse/HEAD.
208 Short: ``muse symbolic-ref --short HEAD`` prints ``main``.
209 Delete: ``muse symbolic-ref --delete HEAD`` removes the file.
210 """
211 root = require_repo()
212 muse_dir = root / ".muse"
213
214 if delete:
215 deleted = delete_symbolic_ref(muse_dir, name, quiet=quiet)
216 if not deleted:
217 if not quiet:
218 typer.echo(f"❌ {name}: not found — nothing to delete")
219 raise typer.Exit(code=ExitCode.USER_ERROR)
220 typer.echo(f"✅ Deleted symbolic ref {name!r}")
221 return
222
223 if new_target is not None:
224 if not new_target.startswith(_SYMBOLIC_REF_PREFIX):
225 typer.echo(
226 f"❌ Invalid symbolic-ref target {new_target!r}: must start with 'refs/'"
227 )
228 raise typer.Exit(code=ExitCode.USER_ERROR)
229 try:
230 write_symbolic_ref(muse_dir, name, new_target)
231 except ValueError as exc:
232 typer.echo(f"❌ {exc}")
233 raise typer.Exit(code=ExitCode.USER_ERROR)
234 except Exception as exc:
235 typer.echo(f"❌ muse symbolic-ref write failed: {exc}")
236 logger.error("❌ muse symbolic-ref write error: %s", exc, exc_info=True)
237 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
238 typer.echo(f"✅ {name} → {new_target}")
239 return
240
241 # Read path
242 result = read_symbolic_ref(muse_dir, name, quiet=quiet)
243 if result is None:
244 if not quiet:
245 typer.echo(f"❌ {name} is not a symbolic ref or does not exist")
246 raise typer.Exit(code=ExitCode.USER_ERROR)
247
248 typer.echo(result.short if short else result.ref)