init.py python
510 lines 16.3 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 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)::
30
31 {
32 "repo_id": "...", // UUID — stable across reinit with --force
33 "branch": "main", // initial branch name
34 "domain": "code", // active domain plugin
35 "path": "/abs", // absolute path to the .muse directory
36 "reinitialised": false, // true when --force was used on an existing repo
37 "bare": false,
38 "schema_version": 1 // integer; bumps only on breaking layout changes
39 }
40 """
41
42 from __future__ import annotations
43
44 import argparse
45 import datetime
46 import json
47 import logging
48 import os
49 import pathlib
50 import shutil
51 import sys
52 import uuid
53
54 from muse.core.errors import ExitCode
55 from muse.core.store import write_head_branch, write_text_atomic
56 from muse.core.validation import (
57 assert_not_symlink,
58 sanitize_display,
59 validate_branch_name,
60 validate_domain_name,
61 )
62
63 type _RepoMeta = dict[str, str | int | bool]
64 type _StrMap = dict[str, str]
65
66 logger = logging.getLogger(__name__)
67
68 # Bumped only when the on-disk .muse/ layout changes in a breaking way.
69 # Intentionally separate from the package version (pyproject.toml) so that
70 # patch releases do not falsely signal a schema migration.
71 _REPO_SCHEMA_VERSION: int = 1
72
73 # Subdirectories created unconditionally at init time. Must be a superset of
74 # muse.core.repo._CRITICAL_MUSE_DIRS so that _verify_muse_dir_integrity() is
75 # satisfied on the very first require_repo() call after init.
76 # NOTE: "refs" is listed explicitly even though "refs/heads" (with parents=True)
77 # would create it implicitly — explicit listing ensures it is covered by the
78 # post-init integrity check loop.
79 _INIT_SUBDIRS: tuple[str, ...] = (
80 "refs",
81 "refs/heads",
82 "objects",
83 "commits",
84 "snapshots",
85 "tags",
86 )
87
88 _DEFAULT_CONFIG = """\
89 [user]
90 name = ""
91 email = ""
92 type = "human" # "human" | "agent"
93
94 [hub]
95 # url = "https://musehub.ai"
96 # Run `muse hub connect <url>` to attach this repo to MuseHub.
97 # Run `muse auth register` to authenticate.
98 # Credentials are stored in ~/.muse/identity.toml — never here.
99
100 [remotes]
101
102 [domain]
103 # Domain-specific configuration. Keys depend on the active domain plugin.
104 """
105
106 _BARE_CONFIG = """\
107 [core]
108 bare = true
109
110 [user]
111 name = ""
112 email = ""
113 type = "human" # "human" | "agent"
114
115 [hub]
116 # url = "https://musehub.ai"
117
118 [remotes]
119
120 [domain]
121 """
122
123 _MUSEIGNORE_HEADER = """\
124 # .museignore — snapshot exclusion rules for this repository.
125
126 """
127
128 _MUSEIGNORE_GLOBAL = """\
129 [global]
130 patterns = [
131 ".DS_Store",
132 "Thumbs.db",
133 "*.tmp",
134 "*.swp",
135 "*.swo",
136 ]
137 """
138
139 _MUSEIGNORE_DOMAIN_BLOCKS: _StrMap = {
140 "midi": """\
141 [domain.midi]
142 patterns = [
143 "*.bak",
144 "*.autosave",
145 "/renders/",
146 "/exports/",
147 "/previews/",
148 ]
149 """,
150 "code": """\
151 [domain.code]
152 patterns = [
153 "__pycache__/",
154 "*.pyc",
155 "*.pyo",
156 "node_modules/",
157 "dist/",
158 "build/",
159 ".venv/",
160 "venv/",
161 ".tox/",
162 "*.egg-info/",
163 ]
164 """,
165 "knowtation": """\
166 [domain.knowtation]
167 patterns = [
168 # Knowtation index and vector-store data — never snapshot the index.
169 "data/",
170 # Local config (contains secrets and machine-specific paths).
171 "config/local.yaml",
172 # OS and editor artefacts inside the vault.
173 ".obsidian/",
174 ".logseq/",
175 # Node.js tooling (CLI / indexer).
176 "node_modules/",
177 "*.log",
178 ]
179 """,
180 }
181
182 # Domain-specific `.museattributes` rule blocks, appended by
183 # _museattributes_template() when a domain has opinionated defaults.
184 _MUSEATTRIBUTES_DOMAIN_RULES: _StrMap = {
185 "knowtation": """\
186 # ─── Knowtation defaults ──────────────────────────────────────────────────────
187 # Rule 1 — Frontmatter sets (tags, entities, attachments) are independent.
188 [[rules]]
189 path = "**/*.md"
190 dimension = "frontmatter"
191 strategy = "union"
192 comment = "Tag/entity/attachment sets are independent — accumulate additions from both sides."
193 priority = 20
194
195 # Rule 2 — Inbox notes are locally-authoritative.
196 [[rules]]
197 path = "inbox/**"
198 dimension = "*"
199 strategy = "ours"
200 comment = "Inbox captures are local-authoritative; remote edits are discarded."
201 priority = 10
202
203 # Rule 3 — Archived notes are immutable.
204 [[rules]]
205 path = "archive/**"
206 dimension = "*"
207 strategy = "ours"
208 comment = "Archived notes are immutable; local version is always preferred."
209 priority = 10
210 """,
211 }
212
213
214 def _museignore_template(domain: str) -> str:
215 """Return a TOML ``.museignore`` template pre-filled for *domain*.
216
217 The ``[global]`` section covers cross-domain OS artifacts. The
218 ``[domain.<name>]`` section lists patterns specific to the chosen domain.
219 Patterns from other domains are never loaded at snapshot time.
220 """
221 domain_block = _MUSEIGNORE_DOMAIN_BLOCKS.get(domain, f"""\
222 [domain.{domain}]
223 # patterns = []
224 """)
225 return _MUSEIGNORE_HEADER + _MUSEIGNORE_GLOBAL + "\n" + domain_block
226
227
228 def _museattributes_template(domain: str) -> str:
229 """Return a TOML `.museattributes` template pre-filled with *domain*.
230
231 When *domain* has an entry in :data:`_MUSEATTRIBUTES_DOMAIN_RULES`, the
232 pre-populated rules are written as active (uncommented) blocks so the user
233 gets working defaults immediately. Unknown domains fall back to a minimal
234 commented-out example.
235 """
236 domain_rules = _MUSEATTRIBUTES_DOMAIN_RULES.get(domain)
237 if domain_rules:
238 rules_section = "\n" + domain_rules
239 else:
240 rules_section = """\
241
242 # [[rules]]
243 # path = "*"
244 # dimension = "*"
245 # strategy = "auto"
246 """
247 return f"""\
248 # .museattributes — merge strategy overrides for this repository.
249 # Documentation: docs/reference/muse-attributes.md
250
251 [meta]
252 domain = "{domain}"
253 {rules_section}"""
254
255
256 def _copy_template(template_path: pathlib.Path, dest_root: pathlib.Path) -> None:
257 """Copy *template_path* contents into *dest_root*, with two safety guards.
258
259 Guards:
260 1. Any item whose name is ``.muse`` is skipped — prevents a malicious
261 template from overwriting the freshly created VCS state directory.
262 2. Any item that is a symlink is skipped — prevents a template with
263 symlinks pointing outside the tree from reading or overwriting
264 sensitive files (e.g. ``/etc/passwd``).
265
266 Args:
267 template_path: Verified-existing directory to copy from.
268 dest_root: Repository working-tree root (``cwd``).
269 """
270 for item in template_path.iterdir():
271 if item.name == ".muse":
272 logger.warning(
273 "⚠️ init: skipping .muse/ in template — "
274 "templates must not contain VCS state directories."
275 )
276 continue
277 if item.is_symlink():
278 logger.warning(
279 "⚠️ init: skipping symlink %s in template — "
280 "symlinks are not copied to prevent path-traversal.",
281 item.name,
282 )
283 continue
284 dest = dest_root / item.name
285 if item.is_dir():
286 shutil.copytree(item, dest, dirs_exist_ok=True)
287 else:
288 shutil.copy2(item, dest)
289
290
291 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
292 """Register the init subcommand."""
293 parser = subparsers.add_parser(
294 "init",
295 help="Initialise a new Muse repository.",
296 description=__doc__,
297 formatter_class=argparse.RawDescriptionHelpFormatter,
298 )
299 parser.add_argument(
300 "--bare",
301 action="store_true",
302 help="Initialise as a bare repository (no working tree).",
303 )
304 parser.add_argument(
305 "--template",
306 default=None,
307 metavar="PATH",
308 help="Copy PATH contents into the working tree after initialising.",
309 )
310 parser.add_argument(
311 "--default-branch",
312 default="main",
313 metavar="BRANCH",
314 dest="default_branch",
315 help="Name of the initial branch (default: main).",
316 )
317 parser.add_argument(
318 "--force", "-f",
319 action="store_true",
320 help="Re-initialise even if already a Muse repository. Preserves repo_id.",
321 )
322 parser.add_argument(
323 "--domain", "-d",
324 default="code",
325 help="Domain plugin to activate (e.g. code, midi). Default: code.",
326 )
327 parser.add_argument(
328 "--json",
329 action="store_true",
330 dest="as_json",
331 help="Emit a machine-readable JSON result and exit.",
332 )
333 parser.set_defaults(func=run)
334
335
336 def run(args: argparse.Namespace) -> None:
337 """Initialise a new Muse repository in the current directory."""
338 bare: bool = args.bare
339 template: str | None = args.template
340 default_branch: str = args.default_branch
341 force: bool = args.force
342 domain: str = args.domain
343 as_json: bool = args.as_json
344
345 try:
346 validate_branch_name(default_branch)
347 except ValueError as exc:
348 if as_json:
349 print(json.dumps({"error": f"Invalid --default-branch: {exc}"}))
350 else:
351 print(f"❌ Invalid --default-branch: {sanitize_display(str(exc))}", file=sys.stderr)
352 raise SystemExit(ExitCode.USER_ERROR)
353
354 try:
355 validate_domain_name(domain)
356 except ValueError as exc:
357 if as_json:
358 print(json.dumps({"error": f"Invalid --domain: {exc}"}))
359 else:
360 print(f"❌ Invalid --domain: {sanitize_display(str(exc))}", file=sys.stderr)
361 raise SystemExit(ExitCode.USER_ERROR)
362
363 cwd = pathlib.Path.cwd()
364 muse_dir = cwd / ".muse"
365
366 template_path: pathlib.Path | None = None
367 if template is not None:
368 raw_template = pathlib.Path(template)
369 # Check for symlink BEFORE resolving so a symlinked path is caught
370 # before .resolve() follows it to the real target. A symlinked
371 # template directory could be swapped between validation and use.
372 if raw_template.is_symlink():
373 msg = "Template path must not be a symbolic link."
374 if as_json:
375 print(json.dumps({"error": msg}))
376 else:
377 print(f"❌ {msg}", file=sys.stderr)
378 raise SystemExit(ExitCode.USER_ERROR)
379 template_path = raw_template.resolve()
380 if not template_path.is_dir():
381 msg = f"Template path is not a directory: {template_path}"
382 if as_json:
383 print(json.dumps({"error": msg}))
384 else:
385 print(f"❌ {sanitize_display(msg)}", file=sys.stderr)
386 raise SystemExit(ExitCode.USER_ERROR)
387
388 already_exists = muse_dir.is_dir()
389 if already_exists and not force:
390 if as_json:
391 print(json.dumps({"error": "Already a Muse repository. Use --force to reinitialise."}))
392 else:
393 print(
394 f"Already a Muse repository at {sanitize_display(str(cwd))}.\n"
395 "Use --force to reinitialise.",
396 file=sys.stderr,
397 )
398 raise SystemExit(ExitCode.USER_ERROR)
399
400 existing_repo_id: str | None = None
401 if force and already_exists:
402 repo_json = muse_dir / "repo.json"
403 if repo_json.exists():
404 try:
405 raw_id = json.loads(repo_json.read_text(encoding="utf-8")).get("repo_id")
406 if isinstance(raw_id, str):
407 existing_repo_id = raw_id
408 except (json.JSONDecodeError, OSError):
409 pass
410
411 try:
412 # Create all required subdirectories up front. _INIT_SUBDIRS must be
413 # a superset of _CRITICAL_MUSE_DIRS so _verify_muse_dir_integrity()
414 # never sees an expected directory that is missing.
415 for subdir in _INIT_SUBDIRS:
416 (muse_dir / subdir).mkdir(parents=True, exist_ok=True)
417
418 repo_id = existing_repo_id or str(uuid.uuid4())
419 created_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
420 repo_meta: _RepoMeta = {
421 "repo_id": repo_id,
422 "schema_version": _REPO_SCHEMA_VERSION,
423 "created_at": created_at,
424 "domain": domain,
425 }
426 if bare:
427 repo_meta["bare"] = True
428
429 # Use write_text_atomic for repo.json: a SIGKILL between write and
430 # rename would otherwise leave a zero-byte file, breaking every
431 # subsequent command that reads repo_id.
432 write_text_atomic(
433 muse_dir / "repo.json",
434 json.dumps(repo_meta, indent=2) + "\n",
435 )
436
437 write_head_branch(muse_dir.parent, default_branch)
438
439 # Write an empty branch ref only if it does not yet exist (fresh) or
440 # --force was given. write_text_atomic protects against torn writes
441 # on the ref itself (also used by write_head_branch).
442 ref_file = muse_dir / "refs" / "heads" / default_branch
443 if not ref_file.exists() or force:
444 write_text_atomic(ref_file, "")
445
446 config_path = muse_dir / "config.toml"
447 if not config_path.exists():
448 write_text_atomic(
449 config_path,
450 _BARE_CONFIG if bare else _DEFAULT_CONFIG,
451 )
452
453 if not bare:
454 attrs_path = cwd / ".museattributes"
455 if not attrs_path.exists():
456 write_text_atomic(attrs_path, _museattributes_template(domain))
457
458 ignore_path = cwd / ".museignore"
459 if not ignore_path.exists():
460 write_text_atomic(ignore_path, _museignore_template(domain))
461
462 if not bare and template_path is not None:
463 _copy_template(template_path, cwd)
464
465 # Post-init integrity check: verify every critical directory is a real
466 # directory (not a symlink) before returning success. This catches
467 # environmental races and confirms the layout is self-consistent.
468 for subdir in _INIT_SUBDIRS:
469 candidate = muse_dir / subdir
470 try:
471 assert_not_symlink(candidate, label=f".muse/{subdir}")
472 except ValueError as exc:
473 msg = f"Repository structure compromised during init: {exc}"
474 if as_json:
475 print(json.dumps({"error": msg}))
476 else:
477 print(f"❌ {msg}", file=sys.stderr)
478 raise SystemExit(ExitCode.INTERNAL_ERROR)
479
480 except PermissionError:
481 msg = f"Permission denied: cannot write to {cwd}."
482 if as_json:
483 print(json.dumps({"error": msg}))
484 else:
485 print(f"❌ {sanitize_display(msg)}", file=sys.stderr)
486 raise SystemExit(ExitCode.USER_ERROR)
487 except OSError as exc:
488 msg = f"Failed to initialise repository: {exc}"
489 if as_json:
490 print(json.dumps({"error": msg}))
491 else:
492 print(f"❌ {sanitize_display(msg)}", file=sys.stderr)
493 raise SystemExit(ExitCode.INTERNAL_ERROR)
494
495 reinitialised = bool(force and already_exists)
496
497 if as_json:
498 print(json.dumps({
499 "repo_id": repo_id,
500 "branch": default_branch,
501 "domain": domain,
502 "path": str(muse_dir),
503 "reinitialised": reinitialised,
504 "bare": bare,
505 "schema_version": _REPO_SCHEMA_VERSION,
506 }))
507 else:
508 action = "Reinitialised" if reinitialised else "Initialised"
509 kind = "bare " if bare else ""
510 print(f"✅ {action} {kind}Muse repository in {sanitize_display(str(muse_dir))}")
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago