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