gabriel / muse public
init.py python
188 lines 5.9 KB
9ee9c39c refactor: rename music→midi domain, strip all 5-dim backward compat Gabriel Cardona <gabriel@tellurstori.com> 5d 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 muse-work/ — working tree (absent for --bare repos)
17 """
18 from __future__ import annotations
19
20 import datetime
21 import json
22 import logging
23 import pathlib
24 import shutil
25 import uuid
26
27 import typer
28
29 from muse.core.errors import ExitCode
30 from muse.core.repo import find_repo_root
31
32 logger = logging.getLogger(__name__)
33
34 app = typer.Typer()
35
36 _SCHEMA_VERSION = "2"
37
38 _DEFAULT_CONFIG = """\
39 [user]
40 name = ""
41 email = ""
42
43 [auth]
44 token = ""
45
46 [remotes]
47
48 [domain]
49 # Domain-specific configuration. Keys depend on the active domain.
50 # Music examples:
51 # ticks_per_beat = 480
52 # Genomics examples:
53 # reference_assembly = "GRCh38"
54 """
55
56 _BARE_CONFIG = """\
57 [core]
58 bare = true
59
60 [user]
61 name = ""
62 email = ""
63
64 [auth]
65 token = ""
66
67 [remotes]
68
69 [domain]
70 # Domain-specific configuration. Keys depend on the active domain.
71 # Music examples:
72 # ticks_per_beat = 480
73 # Genomics examples:
74 # reference_assembly = "GRCh38"
75 """
76
77
78 def _museattributes_template(domain: str) -> str:
79 """Return a TOML `.museattributes` template pre-filled with *domain*."""
80 return f"""\
81 # .museattributes — merge strategy overrides for this repository.
82 # Documentation: docs/reference/muse-attributes.md
83 #
84 # Format: TOML. [[rules]] entries are matched top-to-bottom; first match wins.
85 # Strategies: ours | theirs | union | auto | manual
86
87 [meta]
88 domain = "{domain}" # must match .muse/repo.json "domain" field
89
90 # Add [[rules]] entries below. Examples:
91 #
92 # [[rules]]
93 # path = "tracks/*"
94 # dimension = "*"
95 # strategy = "auto"
96 #
97 # [[rules]]
98 # path = "*"
99 # dimension = "*"
100 # strategy = "auto"
101 """
102
103
104 @app.callback(invoke_without_command=True)
105 def init(
106 ctx: typer.Context,
107 bare: bool = typer.Option(False, "--bare", help="Initialise as a bare repository (no muse-work/)."),
108 template: str | None = typer.Option(None, "--template", metavar="PATH", help="Copy PATH contents into muse-work/."),
109 default_branch: str = typer.Option("main", "--default-branch", metavar="BRANCH", help="Name of the initial branch."),
110 force: bool = typer.Option(False, "--force", help="Re-initialise even if already a Muse repository."),
111 domain: str = typer.Option("midi", "--domain", help="Domain plugin to use (e.g. midi). Must be registered in the plugin registry."),
112 ) -> None:
113 """Initialise a new Muse repository in the current directory."""
114 cwd = pathlib.Path.cwd()
115 muse_dir = cwd / ".muse"
116
117 template_path: pathlib.Path | None = None
118 if template is not None:
119 template_path = pathlib.Path(template)
120 if not template_path.is_dir():
121 typer.echo(f"❌ Template path is not a directory: {template_path}")
122 raise typer.Exit(code=ExitCode.USER_ERROR)
123
124 already_exists = muse_dir.is_dir()
125 if already_exists and not force:
126 typer.echo(f"Already a Muse repository at {cwd}.\nUse --force to reinitialise.")
127 raise typer.Exit(code=ExitCode.USER_ERROR)
128
129 existing_repo_id: str | None = None
130 if force and already_exists:
131 repo_json = muse_dir / "repo.json"
132 if repo_json.exists():
133 try:
134 existing_repo_id = json.loads(repo_json.read_text()).get("repo_id")
135 except (json.JSONDecodeError, OSError):
136 pass
137
138 try:
139 (muse_dir / "refs" / "heads").mkdir(parents=True, exist_ok=True)
140 for subdir in ("objects", "commits", "snapshots"):
141 (muse_dir / subdir).mkdir(exist_ok=True)
142
143 repo_id = existing_repo_id or str(uuid.uuid4())
144 repo_meta: dict[str, str | bool] = {
145 "repo_id": repo_id,
146 "schema_version": _SCHEMA_VERSION,
147 "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
148 "domain": domain,
149 }
150 if bare:
151 repo_meta["bare"] = True
152 (muse_dir / "repo.json").write_text(json.dumps(repo_meta, indent=2) + "\n")
153
154 (muse_dir / "HEAD").write_text(f"refs/heads/{default_branch}\n")
155
156 ref_file = muse_dir / "refs" / "heads" / default_branch
157 if not ref_file.exists() or force:
158 ref_file.write_text("")
159
160 config_path = muse_dir / "config.toml"
161 if not config_path.exists():
162 config_path.write_text(_BARE_CONFIG if bare else _DEFAULT_CONFIG)
163
164 attrs_path = cwd / ".museattributes"
165 if not attrs_path.exists():
166 attrs_path.write_text(_museattributes_template(domain))
167
168 if not bare:
169 work_dir = cwd / "muse-work"
170 work_dir.mkdir(exist_ok=True)
171 if template_path is not None:
172 for item in template_path.iterdir():
173 dest = work_dir / item.name
174 if item.is_dir():
175 shutil.copytree(item, dest, dirs_exist_ok=True)
176 else:
177 shutil.copy2(item, dest)
178
179 except PermissionError:
180 typer.echo(f"❌ Permission denied: cannot write to {cwd}.")
181 raise typer.Exit(code=ExitCode.USER_ERROR)
182 except OSError as exc:
183 typer.echo(f"❌ Failed to initialise repository: {exc}")
184 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
185
186 action = "Reinitialised" if (force and already_exists) else "Initialised"
187 kind = "bare " if bare else ""
188 typer.echo(f"✅ {action} {kind}Muse repository in {muse_dir}")