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