gabriel / muse public
init.py python
422 lines 13.8 KB
59a915a4 refactor: repo root is the working tree — remove state/ subdirectory Gabriel Cardona <gabriel@tellurstori.com> 4d ago
1 """muse init — initialise a new Muse repository.
2
3 Creates the ``.muse/`` directory tree in the current working directory.
4
5 Layout::
6
7 .muse/
8 repo.json — repo_id, schema_version, domain, created_at
9 HEAD — symbolic ref → refs/heads/main
10 refs/heads/main — empty (no commits yet)
11 config.toml — [user], [auth], [remotes], [domain] stubs
12 objects/ — content-addressed blobs (created on first commit)
13 commits/ — commit records (JSON, one file per commit)
14 snapshots/ — snapshot manifests (JSON, one file per snapshot)
15 .museattributes — TOML merge strategy overrides (created in repo root)
16 .museignore — TOML ignore rules (created in repo root)
17
18 The repository root IS the working tree. There is no ``state/`` subdirectory.
19 Bare repositories (``--bare``) have no working tree; they store only ``.muse/``.
20 """
21
22 from __future__ import annotations
23
24 import datetime
25 import json
26 import logging
27 import pathlib
28 import shutil
29 import uuid
30
31 import typer
32
33 from muse.core.errors import ExitCode
34 from muse.core.repo import find_repo_root
35 from muse.core.validation import validate_branch_name, validate_domain_name
36
37 logger = logging.getLogger(__name__)
38
39 app = typer.Typer()
40
41 _SCHEMA_VERSION = "2"
42
43 _DEFAULT_CONFIG = """\
44 [user]
45 name = ""
46 email = ""
47 type = "human" # "human" | "agent"
48
49 [hub]
50 # url = "https://musehub.ai"
51 # Run `muse hub connect <url>` to attach this repo to MuseHub.
52 # Run `muse auth login` to authenticate.
53 # Credentials are stored in ~/.muse/identity.toml — never here.
54
55 [remotes]
56
57 [domain]
58 # Domain-specific configuration. Keys depend on the active domain.
59 # Music examples:
60 # ticks_per_beat = "480"
61 # Genomics examples:
62 # reference_assembly = "GRCh38"
63 """
64
65 _BARE_CONFIG = """\
66 [core]
67 bare = true
68
69 [user]
70 name = ""
71 email = ""
72 type = "human" # "human" | "agent"
73
74 [hub]
75 # url = "https://musehub.ai"
76 # Run `muse hub connect <url>` to attach this repo to MuseHub.
77 # Run `muse auth login` to authenticate.
78 # Credentials are stored in ~/.muse/identity.toml — never here.
79
80 [remotes]
81
82 [domain]
83 # Domain-specific configuration. Keys depend on the active domain.
84 # Music examples:
85 # ticks_per_beat = "480"
86 # Genomics examples:
87 # reference_assembly = "GRCh38"
88 """
89
90
91 def _museignore_template(domain: str) -> str:
92 """Return a TOML ``.museignore`` template pre-filled for *domain*.
93
94 The ``[global]`` section covers cross-domain OS artifacts. The
95 ``[domain.<name>]`` section lists patterns specific to the chosen domain.
96 Patterns from other domains are never loaded at snapshot time.
97 """
98 global_section = """\
99 [global]
100 # Patterns applied to every domain. Last match wins; prefix with ! to un-ignore.
101 patterns = [
102 ".DS_Store",
103 "Thumbs.db",
104 "*.tmp",
105 "*.swp",
106 "*.swo",
107 ]
108 """
109 midi_section = """\
110 [domain.midi]
111 # Patterns applied only when the active domain plugin is "midi".
112 patterns = [
113 "*.bak",
114 "*.autosave",
115 "/renders/",
116 "/exports/",
117 "/previews/",
118 ]
119 """
120 code_section = """\
121 [domain.code]
122 # Patterns applied only when the active domain plugin is "code".
123 patterns = [
124 "__pycache__/",
125 "*.pyc",
126 "*.pyo",
127 "node_modules/",
128 "dist/",
129 "build/",
130 ".venv/",
131 "venv/",
132 ".tox/",
133 "*.egg-info/",
134 ]
135 """
136 genomics_section = """\
137 [domain.genomics]
138 # Patterns applied only when the active domain plugin is "genomics".
139 patterns = [
140 "*.sam",
141 "*.bam.bai",
142 "pipeline-cache/",
143 "*.log",
144 ]
145 """
146 simulation_section = """\
147 [domain.simulation]
148 # Patterns applied only when the active domain plugin is "simulation".
149 patterns = [
150 "frames/raw/",
151 "*.frame.bin",
152 "checkpoint-tmp/",
153 ]
154 """
155 spatial_section = """\
156 [domain.spatial]
157 # Patterns applied only when the active domain plugin is "spatial".
158 patterns = [
159 "previews/",
160 "*.preview.vdb",
161 "**/.shadercache/",
162 ]
163 """
164
165 domain_blocks: dict[str, str] = {
166 "midi": midi_section,
167 "code": code_section,
168 "genomics": genomics_section,
169 "simulation": simulation_section,
170 "spatial": spatial_section,
171 }
172 domain_block = domain_blocks.get(domain, f"""\
173 [domain.{domain}]
174 # Patterns applied only when the active domain plugin is "{domain}".
175 # patterns = [
176 # "*.generated",
177 # "/cache/",
178 # ]
179 """)
180
181 header = f"""\
182 # .museignore — snapshot exclusion rules for this repository.
183 # Documentation: docs/reference/museignore.md
184 #
185 # Format: TOML with [global] and [domain.<name>] sections.
186 # [global] — patterns applied to every domain
187 # [domain.<name>] — patterns applied only when the active domain is <name>
188 #
189 # Pattern syntax (gitignore-compatible):
190 # *.ext ignore files with this extension at any depth
191 # /path anchor to the root of state/
192 # dir/ directory pattern (silently skipped — Muse tracks files)
193 # !pattern un-ignore a previously matched path
194 #
195 # Last matching rule wins.
196
197 """
198 return header + global_section + "\n" + domain_block
199
200
201 def _museattributes_template(domain: str) -> str:
202 """Return a TOML `.museattributes` template pre-filled with *domain*."""
203 return f"""\
204 # .museattributes — merge strategy overrides for this repository.
205 # Documentation: docs/reference/muse-attributes.md
206 #
207 # Format: TOML with an optional [meta] header and an ordered [[rules]] array.
208 # Rules are evaluated top-to-bottom after sorting by priority (descending).
209 # The first matching rule wins. Unmatched paths fall back to "auto".
210 #
211 # ─── Strategies ───────────────────────────────────────────────────────────────
212 #
213 # ours Take the current-branch (left) version; remove from conflicts.
214 # theirs Take the incoming-branch (right) version; remove from conflicts.
215 # union Include all additions from both sides. Deletions are honoured
216 # only when both sides agree. Best for independent element sets
217 # (MIDI notes, symbol additions, import sets, genomic mutations).
218 # Falls back to "ours" for binary blobs.
219 # base Revert to the common ancestor; discard changes from both branches.
220 # Use this for generated files, lock files, or pinned assets.
221 # auto Default — let the three-way merge engine decide.
222 # manual Force the path into the conflict list for human review, even when
223 # the engine would auto-resolve it.
224 #
225 # ─── Rule fields ──────────────────────────────────────────────────────────────
226 #
227 # path (required) fnmatch glob against workspace-relative POSIX paths.
228 # dimension (required) Domain axis name (e.g. "notes", "symbols") or "*".
229 # strategy (required) One of the six strategies above.
230 # comment (optional) Free-form note explaining the rule — ignored at runtime.
231 # priority (optional) Integer; higher-priority rules are tried first.
232 # Default 0; ties preserve declaration order.
233
234 [meta]
235 domain = "{domain}" # must match the "domain" field in .muse/repo.json
236
237 # ─── MIDI domain examples ─────────────────────────────────────────────────────
238 # [[rules]]
239 # path = "drums/*"
240 # dimension = "*"
241 # strategy = "ours"
242 # comment = "Drum tracks are always authored on this branch."
243 # priority = 20
244 #
245 # [[rules]]
246 # path = "keys/*.mid"
247 # dimension = "pitch_bend"
248 # strategy = "theirs"
249 # comment = "Remote always has the better pitch-bend automation."
250 # priority = 15
251 #
252 # [[rules]]
253 # path = "stems/*"
254 # dimension = "notes"
255 # strategy = "union"
256 # comment = "Unify note additions from both arrangers; let the engine merge."
257 #
258 # [[rules]]
259 # path = "mixdown.mid"
260 # dimension = "*"
261 # strategy = "base"
262 # comment = "Mixdown is generated — always revert to ancestor during merge."
263 #
264 # [[rules]]
265 # path = "master.mid"
266 # dimension = "*"
267 # strategy = "manual"
268 # comment = "Master track must always be reviewed by a human before merge."
269
270 # ─── Code domain examples ─────────────────────────────────────────────────────
271 # [[rules]]
272 # path = "src/generated/**"
273 # dimension = "*"
274 # strategy = "base"
275 # comment = "Generated code — revert to base; re-run codegen after merge."
276 # priority = 30
277 #
278 # [[rules]]
279 # path = "src/**/*.py"
280 # dimension = "imports"
281 # strategy = "union"
282 # comment = "Import sets are independent; accumulate additions from both sides."
283 #
284 # [[rules]]
285 # path = "tests/**"
286 # dimension = "symbols"
287 # strategy = "union"
288 # comment = "Test additions from both branches are always safe to combine."
289 #
290 # [[rules]]
291 # path = "src/core/**"
292 # dimension = "*"
293 # strategy = "manual"
294 # comment = "Core module changes need human review on every merge."
295 # priority = 25
296 #
297 # [[rules]]
298 # path = "package-lock.json"
299 # dimension = "*"
300 # strategy = "ours"
301 # comment = "Lock file is managed by this branch's CI; ignore incoming."
302
303 # ─── Generic / domain-agnostic examples ───────────────────────────────────────
304 # [[rules]]
305 # path = "docs/**"
306 # dimension = "*"
307 # strategy = "union"
308 # comment = "Documentation additions from both branches are always welcome."
309 #
310 # [[rules]]
311 # path = "config/secrets.*"
312 # dimension = "*"
313 # strategy = "manual"
314 # comment = "Secrets files require manual review — never auto-merge."
315 # priority = 100
316 #
317 # [[rules]]
318 # path = "*"
319 # dimension = "*"
320 # strategy = "auto"
321 # comment = "Fallback: let the engine decide for everything else."
322 """
323
324
325 @app.callback(invoke_without_command=True)
326 def init(
327 ctx: typer.Context,
328 bare: bool = typer.Option(False, "--bare", help="Initialise as a bare repository (no working tree)."),
329 template: str | None = typer.Option(None, "--template", metavar="PATH", help="Copy PATH contents into the working tree."),
330 default_branch: str = typer.Option("main", "--default-branch", metavar="BRANCH", help="Name of the initial branch."),
331 force: bool = typer.Option(False, "--force", help="Re-initialise even if already a Muse repository."),
332 domain: str = typer.Option("midi", "--domain", help="Domain plugin to use (e.g. midi). Must be registered in the plugin registry."),
333 ) -> None:
334 """Initialise a new Muse repository in the current directory."""
335 try:
336 validate_branch_name(default_branch)
337 except ValueError as exc:
338 typer.echo(f"❌ Invalid --default-branch: {exc}")
339 raise typer.Exit(code=ExitCode.USER_ERROR)
340
341 try:
342 validate_domain_name(domain)
343 except ValueError as exc:
344 typer.echo(f"❌ Invalid --domain: {exc}")
345 raise typer.Exit(code=ExitCode.USER_ERROR)
346
347 cwd = pathlib.Path.cwd()
348 muse_dir = cwd / ".muse"
349
350 template_path: pathlib.Path | None = None
351 if template is not None:
352 template_path = pathlib.Path(template).resolve()
353 if not template_path.is_dir():
354 typer.echo(f"❌ Template path is not a directory: {template_path}")
355 raise typer.Exit(code=ExitCode.USER_ERROR)
356
357 already_exists = muse_dir.is_dir()
358 if already_exists and not force:
359 typer.echo(f"Already a Muse repository at {cwd}.\nUse --force to reinitialise.")
360 raise typer.Exit(code=ExitCode.USER_ERROR)
361
362 existing_repo_id: str | None = None
363 if force and already_exists:
364 repo_json = muse_dir / "repo.json"
365 if repo_json.exists():
366 try:
367 existing_repo_id = json.loads(repo_json.read_text()).get("repo_id")
368 except (json.JSONDecodeError, OSError):
369 pass
370
371 try:
372 (muse_dir / "refs" / "heads").mkdir(parents=True, exist_ok=True)
373 for subdir in ("objects", "commits", "snapshots"):
374 (muse_dir / subdir).mkdir(exist_ok=True)
375
376 repo_id = existing_repo_id or str(uuid.uuid4())
377 repo_meta: dict[str, str | bool] = {
378 "repo_id": repo_id,
379 "schema_version": _SCHEMA_VERSION,
380 "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
381 "domain": domain,
382 }
383 if bare:
384 repo_meta["bare"] = True
385 (muse_dir / "repo.json").write_text(json.dumps(repo_meta, indent=2) + "\n")
386
387 (muse_dir / "HEAD").write_text(f"refs/heads/{default_branch}\n")
388
389 ref_file = muse_dir / "refs" / "heads" / default_branch
390 if not ref_file.exists() or force:
391 ref_file.write_text("")
392
393 config_path = muse_dir / "config.toml"
394 if not config_path.exists():
395 config_path.write_text(_BARE_CONFIG if bare else _DEFAULT_CONFIG)
396
397 attrs_path = cwd / ".museattributes"
398 if not attrs_path.exists():
399 attrs_path.write_text(_museattributes_template(domain))
400
401 ignore_path = cwd / ".museignore"
402 if not ignore_path.exists():
403 ignore_path.write_text(_museignore_template(domain))
404
405 if not bare and template_path is not None:
406 for item in template_path.iterdir():
407 dest = cwd / item.name
408 if item.is_dir():
409 shutil.copytree(item, dest, dirs_exist_ok=True)
410 else:
411 shutil.copy2(item, dest)
412
413 except PermissionError:
414 typer.echo(f"❌ Permission denied: cannot write to {cwd}.")
415 raise typer.Exit(code=ExitCode.USER_ERROR)
416 except OSError as exc:
417 typer.echo(f"❌ Failed to initialise repository: {exc}")
418 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
419
420 action = "Reinitialised" if (force and already_exists) else "Initialised"
421 kind = "bare " if bare else ""
422 typer.echo(f"✅ {action} {kind}Muse repository in {muse_dir}")