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