gabriel / muse public
init.py python
584 lines 18.3 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days 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], [hub], [remotes], [domain] stubs
12 objects/ — content-addressed blobs (SHA-256 sharded)
13 commits/ — commit records (msgpack, one file per commit)
14 snapshots/ — snapshot manifests (msgpack, one file per snapshot)
15 tags/ — semantic tags (msgpack, one file per tag)
16 .museattributes — TOML merge strategy overrides (working-tree only)
17 .museignore — TOML ignore rules (working-tree only)
18
19 The repository root IS the working tree. There is no ``state/`` subdirectory.
20 Bare repositories (``--bare``) have no working tree; they store only ``.muse/``
21 and do not receive ``.museattributes`` or ``.museignore``.
22
23 Agent use
24 ---------
25 Pass ``--json`` to receive a machine-readable result instead of prose::
26
27 muse init --json | jq .repo_id
28
29 JSON schema (exit 0) — all keys always present::
30
31 {
32 "status": "ok", // always "ok" on success
33 "error": "", // always empty string on success
34 "warnings": [], // notices (e.g. template symlinks skipped)
35 "repo_id": "...", // sha256:<hex> genesis fingerprint — stable across reinit with --force
36 "branch": "main", // initial branch name
37 "domain": "code", // active domain plugin
38 "path": "/abs", // absolute path to the .muse directory
39 "reinitialised": false, // true when --force was used on an existing repo
40 "bare": false,
41 "schema_version": 1, // integer; bumps only on breaking layout changes
42 "created_at": "...", // ISO 8601 UTC timestamp written to repo.json
43 "duration_ms": 0.0, // wall-clock time for the init operation
44 "exit_code": 0
45 }
46
47 JSON schema (exit non-zero) — error payload::
48
49 {
50 "status": "error",
51 "error": "<human-readable message>",
52 "warnings": [],
53 "exit_code": 1
54 }
55 """
56
57 from __future__ import annotations
58
59 import argparse
60 import json
61 import logging
62 import os
63 import pathlib
64 import shutil
65 import sys
66 from muse.core._types import content_hash, load_json_file, now_utc_iso
67 from muse.core.paths import muse_dir as _muse_dir, ref_path as _ref_path
68 from muse.core.envelope import EnvelopeJson, make_envelope
69 from muse.core.errors import ExitCode
70 from muse.core.store import write_head_branch, write_text_atomic
71 from muse.core.timing import start_timer
72 from muse.core.validation import (
73 assert_not_symlink,
74 sanitize_display,
75 validate_branch_name,
76 validate_domain_name,
77 )
78
79 type _RepoMeta = dict[str, str | int | bool]
80 type _StrMap = dict[str, str]
81
82
83 class _InitJson(EnvelopeJson):
84 status: str # "ok"
85 error: str # "" on success
86 repo_id: str
87 branch: str
88 domain: str
89 path: str
90 reinitialised: bool
91 bare: bool
92 schema_version: int
93 created_at: str # ISO 8601 UTC
94
95
96 class _InitErrorJson(EnvelopeJson):
97 status: str # "error"
98 error: str
99
100 logger = logging.getLogger(__name__)
101
102 # Bumped only when the on-disk .muse/ layout changes in a breaking way.
103 # Intentionally separate from the package version (pyproject.toml) so that
104 # patch releases do not falsely signal a schema migration.
105 _REPO_SCHEMA_VERSION: int = 1
106
107 # Subdirectories created unconditionally at init time. Must be a superset of
108 # muse.core.repo._CRITICAL_MUSE_DIRS so that _verify_muse_dir_integrity() is
109 # satisfied on the very first require_repo() call after init.
110 # NOTE: "refs" is listed explicitly even though "refs/heads" (with parents=True)
111 # would create it implicitly — explicit listing ensures it is covered by the
112 # post-init integrity check loop.
113 _INIT_SUBDIRS: tuple[str, ...] = (
114 "refs",
115 "refs/heads",
116 "objects",
117 "commits",
118 "snapshots",
119 "tags",
120 "cache",
121 )
122
123 _DEFAULT_CONFIG = """\
124 [user]
125 name = ""
126 email = ""
127 type = "human" # "human" | "agent"
128
129 [hub]
130 # url = "https://musehub.ai"
131 # Run `muse hub connect <url>` to attach this repo to MuseHub.
132 # Run `muse auth register` to authenticate.
133 # Credentials are stored in ~/.muse/identity.toml — never here.
134
135 [remotes]
136
137 [domain]
138 # Domain-specific configuration. Keys depend on the active domain plugin.
139 """
140
141 _BARE_CONFIG = """\
142 [core]
143 bare = true
144
145 [user]
146 name = ""
147 email = ""
148 type = "human" # "human" | "agent"
149
150 [hub]
151 # url = "https://musehub.ai"
152
153 [remotes]
154
155 [domain]
156 """
157
158 _MUSEIGNORE_HEADER = """\
159 # .museignore — snapshot exclusion rules for this repository.
160
161 """
162
163 _MUSEIGNORE_GLOBAL = """\
164 [global]
165 patterns = [
166 ".DS_Store",
167 "Thumbs.db",
168 "*.tmp",
169 "*.swp",
170 "*.swo",
171 ]
172 """
173
174 _MUSEIGNORE_DOMAIN_BLOCKS: _StrMap = {
175 "midi": """\
176 [domain.midi]
177 patterns = [
178 "*.bak",
179 "*.autosave",
180 "/renders/",
181 "/exports/",
182 "/previews/",
183 ]
184 """,
185 "code": """\
186 [domain.code]
187 patterns = [
188 "__pycache__/",
189 "*.pyc",
190 "*.pyo",
191 "node_modules/",
192 "dist/",
193 "build/",
194 ".venv/",
195 "venv/",
196 ".tox/",
197 "*.egg-info/",
198 "*.key",
199 "*.crt",
200 "*.pem",
201 ]
202 """,
203 }
204
205
206 def _museignore_template(domain: str) -> str:
207 """Return a TOML ``.museignore`` template pre-filled for *domain*.
208
209 The ``[global]`` section covers cross-domain OS artifacts. The
210 ``[domain.<name>]`` section lists patterns specific to the chosen domain.
211 Patterns from other domains are never loaded at snapshot time.
212 """
213 domain_block = _MUSEIGNORE_DOMAIN_BLOCKS.get(domain, f"""\
214 [domain.{domain}]
215 # patterns = []
216 """)
217 return f"{_MUSEIGNORE_HEADER}{_MUSEIGNORE_GLOBAL}\n{domain_block}"
218
219
220 def _museattributes_template(domain: str) -> str:
221 """Return a TOML `.museattributes` template pre-filled with *domain*."""
222 return f"""\
223 # .museattributes — merge strategy overrides for this repository.
224
225 [meta]
226 domain = "{domain}"
227
228 # [[rules]]
229 # path = "*"
230 # dimension = "*"
231 # strategy = "auto"
232 """
233
234
235 def _copy_template(
236 template_path: pathlib.Path,
237 dest_root: pathlib.Path,
238 warnings: list[str],
239 *,
240 json_out: bool = False,
241 ) -> None:
242 """Copy *template_path* contents into *dest_root*, with two safety guards.
243
244 Guards:
245 1. Any item whose name is ``.muse`` is skipped — prevents a malicious
246 template from overwriting the freshly created VCS state directory.
247 2. Any item that is a symlink is skipped — prevents a template with
248 symlinks pointing outside the tree from reading or overwriting
249 sensitive files (e.g. ``/etc/passwd``).
250
251 Skipped items are appended to *warnings* so agents can audit what was
252 omitted without parsing log output. When *json_out* is True the human-
253 readable ``logger.warning`` is suppressed — the warnings list is the
254 sole channel, keeping stderr clean for machine consumers.
255
256 Args:
257 template_path: Verified-existing directory to copy from.
258 dest_root: Repository working-tree root (``cwd``).
259 warnings: Mutable list; skipped-item notices are appended here.
260 json_out: When True, suppress human-readable stderr log lines.
261 """
262 for item in template_path.iterdir():
263 if item.name == ".muse":
264 msg = (
265 "init: skipping .muse/ in template — "
266 "templates must not contain VCS state directories."
267 )
268 if not json_out:
269 logger.warning("⚠️ %s", msg)
270 warnings.append(msg)
271 continue
272 if item.is_symlink():
273 msg = (
274 f"init: skipping symlink {item.name!r} in template — "
275 "symlinks are not copied to prevent path-traversal."
276 )
277 if not json_out:
278 logger.warning("⚠️ %s", msg)
279 warnings.append(msg)
280 continue
281 dest = dest_root / item.name
282 if item.is_dir():
283 shutil.copytree(item, dest, dirs_exist_ok=True)
284 else:
285 shutil.copy2(item, dest)
286
287
288 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
289 """Register the init subcommand."""
290 parser = subparsers.add_parser(
291 "init",
292 help="Initialise a new Muse repository.",
293 description=__doc__,
294 formatter_class=argparse.RawDescriptionHelpFormatter,
295 )
296 parser.add_argument(
297 "--bare",
298 action="store_true",
299 help="Initialise as a bare repository (no working tree).",
300 )
301 parser.add_argument(
302 "--template",
303 default=None,
304 metavar="PATH",
305 help="Copy PATH contents into the working tree after initialising.",
306 )
307 parser.add_argument(
308 "--default-branch",
309 default="main",
310 metavar="BRANCH",
311 dest="default_branch",
312 help="Name of the initial branch (default: main).",
313 )
314 parser.add_argument(
315 "--force", "-f",
316 action="store_true",
317 help="Re-initialise even if already a Muse repository. Preserves repo_id.",
318 )
319 parser.add_argument(
320 "--domain", "-d",
321 default="code",
322 help="Domain plugin to activate (e.g. code, midi). Default: code.",
323 )
324 parser.add_argument(
325 "directory",
326 nargs="?",
327 default=None,
328 metavar="DIRECTORY",
329 help=(
330 "Directory to initialise as a Muse repository. "
331 "Created (including any missing parents) if it does not yet exist. "
332 "Defaults to the current working directory."
333 ),
334 )
335 parser.add_argument(
336 "--json", "-j",
337 action="store_true",
338 dest="json_out",
339 help="Emit a machine-readable JSON result and exit.",
340 )
341 parser.set_defaults(func=run)
342
343
344 def _emit_error(json_out: bool, msg: str, code: ExitCode, warnings: list[str], elapsed) -> None:
345 """Print an error and raise SystemExit. Never returns."""
346 if json_out:
347 print(json.dumps(_InitErrorJson(
348 **make_envelope(elapsed, exit_code=int(code), warnings=list(warnings)),
349 status="error",
350 error=msg,
351 )))
352 else:
353 print(f"❌ {sanitize_display(msg)}", file=sys.stderr)
354 raise SystemExit(code)
355
356
357 def run(args: argparse.Namespace) -> None:
358 """Initialise a new Muse repository in the current directory (or DIRECTORY).
359
360 Creates the ``.muse/`` directory structure and writes an initial
361 ``HEAD``, ``config.toml``, and empty ``objects/`` tree. Safe to re-run
362 with ``--force`` on an existing repo (reinitialises without data loss).
363
364 Agent quickstart
365 ----------------
366 ::
367
368 muse init --json
369 muse init --domain code --json
370 muse init /tmp/new-repo --json
371 muse init --bare --json
372
373 JSON fields
374 -----------
375 status ``"ok"`` on success.
376 repo_id Content-addressed ID of the new repository.
377 branch Default branch name created.
378 domain Domain plugin name configured.
379 path Absolute path to the ``.muse/`` directory.
380 reinitialised ``true`` if an existing repo was reinitialised.
381 bare ``true`` if initialised as a bare repository.
382 schema_version On-disk layout version.
383 created_at ISO 8601 UTC timestamp of creation.
384
385 Exit codes
386 ----------
387 0 Repository initialised (or reinitialised).
388 1 Invalid arguments or domain name.
389 3 I/O error creating the repository.
390 """
391 elapsed = start_timer()
392 bare: bool = args.bare
393 template: str | None = args.template
394 default_branch: str = args.default_branch
395 force: bool = args.force
396 domain: str = args.domain
397 json_out: bool = args.json_out
398 directory: str | None = args.directory
399
400 warnings: list[str] = []
401
402 try:
403 validate_branch_name(default_branch)
404 except ValueError as exc:
405 _emit_error(json_out, f"Invalid --default-branch: {exc}", ExitCode.USER_ERROR, warnings, elapsed)
406
407 try:
408 validate_domain_name(domain)
409 except ValueError as exc:
410 _emit_error(json_out, f"Invalid --domain: {exc}", ExitCode.USER_ERROR, warnings, elapsed)
411
412 # Resolve the target directory (defaults to CWD when no argument is given).
413 if directory is not None:
414 raw_dir = pathlib.Path(directory)
415 target = (pathlib.Path.cwd() / raw_dir).resolve() if not raw_dir.is_absolute() else raw_dir.resolve()
416 try:
417 target.mkdir(parents=True, exist_ok=True)
418 except OSError as exc:
419 _emit_error(
420 json_out,
421 f"Cannot create directory '{sanitize_display(str(target))}': {exc}",
422 ExitCode.INTERNAL_ERROR,
423 warnings,
424 elapsed,
425 )
426 os.chdir(target)
427
428 cwd = pathlib.Path.cwd()
429 muse_dir = _muse_dir(cwd)
430
431 template_path: pathlib.Path | None = None
432 if template is not None:
433 raw_template = pathlib.Path(template)
434 # Check for symlink BEFORE resolving so a symlinked path is caught
435 # before .resolve() follows it to the real target. A symlinked
436 # template directory could be swapped between validation and use.
437 if raw_template.is_symlink():
438 _emit_error(
439 json_out,
440 "Template path must not be a symbolic link.",
441 ExitCode.USER_ERROR,
442 warnings,
443 elapsed,
444 )
445 template_path = raw_template.resolve()
446 if not template_path.is_dir():
447 _emit_error(
448 json_out,
449 f"Template path is not a directory: {template_path}",
450 ExitCode.USER_ERROR,
451 warnings,
452 elapsed,
453 )
454
455 already_exists = muse_dir.is_dir()
456 if already_exists and not force:
457 _emit_error(
458 json_out,
459 "Already a Muse repository. Use --force to reinitialise.",
460 ExitCode.USER_ERROR,
461 warnings,
462 elapsed,
463 )
464
465 existing_repo_id: str | None = None
466 if force and already_exists:
467 repo_json = muse_dir / "repo.json"
468 if repo_json.exists():
469 _repo_data = load_json_file(repo_json)
470 if _repo_data is not None:
471 raw_id = _repo_data.get("repo_id")
472 if isinstance(raw_id, str):
473 existing_repo_id = raw_id
474
475 try:
476 # Create all required subdirectories up front. _INIT_SUBDIRS must be
477 # a superset of _CRITICAL_MUSE_DIRS so _verify_muse_dir_integrity()
478 # never sees an expected directory that is missing.
479 for subdir in _INIT_SUBDIRS:
480 (muse_dir / subdir).mkdir(parents=True, exist_ok=True)
481
482 created_at = now_utc_iso()
483 if existing_repo_id:
484 repo_id = existing_repo_id
485 else:
486 repo_id = content_hash({"created_at": created_at, "domain": domain, "path": str(cwd)})
487 repo_meta: _RepoMeta = {
488 "repo_id": repo_id,
489 "schema_version": _REPO_SCHEMA_VERSION,
490 "created_at": created_at,
491 "domain": domain,
492 }
493 if bare:
494 repo_meta["bare"] = True
495
496 # Use write_text_atomic for repo.json: a SIGKILL between write and
497 # rename would otherwise leave a zero-byte file, breaking every
498 # subsequent command that reads repo_id.
499 write_text_atomic(
500 muse_dir / "repo.json",
501 f"{json.dumps(repo_meta)}\n",
502 )
503
504 write_head_branch(muse_dir.parent, default_branch)
505
506 # Write an empty branch ref only if it does not yet exist (fresh) or
507 # --force was given. write_text_atomic protects against torn writes
508 # on the ref itself (also used by write_head_branch).
509 ref_file = _ref_path(cwd, default_branch)
510 if not ref_file.exists() or force:
511 write_text_atomic(ref_file, "")
512
513 config_path = muse_dir / "config.toml"
514 if not config_path.exists():
515 write_text_atomic(
516 config_path,
517 _BARE_CONFIG if bare else _DEFAULT_CONFIG,
518 )
519
520 if not bare:
521 attrs_path = cwd / ".museattributes"
522 if not attrs_path.exists():
523 write_text_atomic(attrs_path, _museattributes_template(domain))
524
525 ignore_path = cwd / ".museignore"
526 if not ignore_path.exists():
527 write_text_atomic(ignore_path, _museignore_template(domain))
528
529 if not bare and template_path is not None:
530 _copy_template(template_path, cwd, warnings, json_out=json_out)
531
532 # Post-init integrity check: verify every critical directory is a real
533 # directory (not a symlink) before returning success. This catches
534 # environmental races and confirms the layout is self-consistent.
535 for subdir in _INIT_SUBDIRS:
536 candidate = muse_dir / subdir
537 try:
538 assert_not_symlink(candidate, label=f".muse/{subdir}")
539 except ValueError as exc:
540 _emit_error(
541 json_out,
542 f"Repository structure compromised during init: {exc}",
543 ExitCode.INTERNAL_ERROR,
544 warnings,
545 elapsed,
546 )
547
548 except PermissionError:
549 _emit_error(
550 json_out,
551 f"Permission denied: cannot write to {cwd}.",
552 ExitCode.USER_ERROR,
553 warnings,
554 elapsed,
555 )
556 except OSError as exc:
557 _emit_error(
558 json_out,
559 f"Failed to initialise repository: {exc}",
560 ExitCode.INTERNAL_ERROR,
561 warnings,
562 elapsed,
563 )
564
565 reinitialised = bool(force and already_exists)
566
567 if json_out:
568 print(json.dumps(_InitJson(
569 **make_envelope(elapsed, warnings=warnings),
570 status="ok",
571 error="",
572 repo_id=repo_id,
573 branch=default_branch,
574 domain=domain,
575 path=str(muse_dir),
576 reinitialised=reinitialised,
577 bare=bare,
578 schema_version=_REPO_SCHEMA_VERSION,
579 created_at=created_at,
580 )))
581 else:
582 action = "Reinitialised" if reinitialised else "Initialised"
583 kind = "bare " if bare else ""
584 print(f"✅ {action} {kind}Muse repository in {sanitize_display(str(muse_dir))}")
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago