cgcardona / muse public
init.py python
230 lines 7.5 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse init — initialise a new Muse repository.
2
3 Creates the ``.muse/`` directory tree in the current working directory and
4 writes all identity/configuration files that subsequent commands depend on.
5
6 Normal (non-bare) layout::
7
8 .muse/
9 repo.json repo_id (UUID), schema_version, created_at, bare flag
10 HEAD text pointer → refs/heads/<branch>
11 refs/heads/<branch> empty (no commits yet)
12 config.toml [user] [auth] [remotes] stubs
13 muse-work/ working-tree root (absent for --bare repos)
14
15 Bare layout (``--bare``)::
16
17 .muse/
18 repo.json … bare = true …
19 HEAD refs/heads/<branch>
20 refs/heads/<branch>
21
22 Bare repositories have no ``muse-work/`` directory. They are used as
23 Muse Hub remotes — objects and refs only, no live working copy.
24
25 Flags
26 -----
27 ``--bare``
28 Initialise as a bare repository (no ``muse-work/`` checkout).
29 Writes ``bare = true`` into ``.muse/config.toml``.
30 ``--template <path>``
31 Copy the contents of *path* into ``muse-work/`` after creating the
32 directory structure. Useful for studio project templates.
33 ``--default-branch TEXT``
34 Name of the initial branch (default: ``main``).
35 ``--force``
36 Re-initialise even if a ``.muse/`` directory already exists.
37 Preserves the existing ``repo_id`` so remote-tracking metadata stays
38 coherent.
39 """
40 from __future__ import annotations
41
42 import datetime
43 import json
44 import logging
45 import pathlib
46 import shutil
47 import uuid
48
49 import typer
50
51 from maestro.muse_cli._repo import find_repo_root
52 from maestro.muse_cli.errors import ExitCode
53
54 logger = logging.getLogger(__name__)
55
56 app = typer.Typer()
57
58 _SCHEMA_VERSION = "1"
59
60 # Default config.toml written on first init; intentionally minimal.
61 _DEFAULT_CONFIG_TOML = """\
62 [user]
63 name = ""
64 email = ""
65
66 [auth]
67 token = ""
68
69 [remotes]
70 """
71
72 _BARE_CONFIG_TOML = """\
73 [core]
74 bare = true
75
76 [user]
77 name = ""
78 email = ""
79
80 [auth]
81 token = ""
82
83 [remotes]
84 """
85
86
87 @app.callback(invoke_without_command=True)
88 def init(
89 ctx: typer.Context,
90 force: bool = typer.Option(
91 False,
92 "--force",
93 help="Re-initialise even if this is already a Muse repository.",
94 ),
95 bare: bool = typer.Option(
96 False,
97 "--bare",
98 help=(
99 "Initialise as a bare repository (no muse-work/ checkout). "
100 "Used for remote/server-side repos that store objects and refs "
101 "but no working copy."
102 ),
103 ),
104 template: str | None = typer.Option(
105 None,
106 "--template",
107 metavar="PATH",
108 help=(
109 "Copy the contents of PATH into muse-work/ after initialisation. "
110 "Lets studios pre-populate a standard folder structure "
111 "(e.g. drums/, bass/, keys/, vocals/) for every new project."
112 ),
113 ),
114 default_branch: str = typer.Option(
115 "main",
116 "--default-branch",
117 metavar="BRANCH",
118 help="Name of the initial branch (default: main).",
119 ),
120 ) -> None:
121 """Initialise a new Muse repository in the current directory."""
122 cwd = pathlib.Path.cwd()
123 muse_dir = cwd / ".muse"
124
125 # Validate template path early before doing any filesystem work.
126 template_path: pathlib.Path | None = None
127 if template is not None:
128 template_path = pathlib.Path(template)
129 if not template_path.is_dir():
130 typer.echo(
131 f"❌ Template path does not exist or is not a directory: {template_path}"
132 )
133 raise typer.Exit(code=ExitCode.USER_ERROR)
134
135 # Check if a .muse/ already exists anywhere in cwd (not parents).
136 # We deliberately only check the *immediate* cwd, not parents, so that
137 # `muse init` inside a nested sub-directory works as expected.
138 already_exists = muse_dir.is_dir()
139
140 if already_exists and not force:
141 typer.echo(
142 f"Already a Muse repository at {cwd}.\n"
143 "Use --force to reinitialise."
144 )
145 raise typer.Exit(code=ExitCode.USER_ERROR)
146
147 # On reinitialise: preserve the existing repo_id for remote-tracking
148 # coherence — a force-init must not break an existing push target.
149 existing_repo_id: str | None = None
150 if force and already_exists:
151 repo_json_path = muse_dir / "repo.json"
152 if repo_json_path.exists():
153 try:
154 existing_repo_id = json.loads(repo_json_path.read_text()).get("repo_id")
155 except (json.JSONDecodeError, OSError):
156 pass # Corrupt file — generate a fresh ID.
157
158 # --- Create directory structure ---
159 # Wrap all filesystem writes in a single OSError handler so that
160 # PermissionError (e.g. CWD is not writable, common when running
161 # `docker compose exec maestro muse init` from /app/) produces a clean
162 # user-facing message instead of a raw Python traceback.
163 try:
164 (muse_dir / "refs" / "heads").mkdir(parents=True, exist_ok=True)
165
166 # repo.json — identity file
167 repo_id = existing_repo_id or str(uuid.uuid4())
168 repo_json: dict[str, str | bool] = {
169 "repo_id": repo_id,
170 "schema_version": _SCHEMA_VERSION,
171 "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
172 }
173 if bare:
174 repo_json["bare"] = True
175 (muse_dir / "repo.json").write_text(json.dumps(repo_json, indent=2) + "\n")
176
177 # HEAD — current branch pointer (uses --default-branch name)
178 (muse_dir / "HEAD").write_text(f"refs/heads/{default_branch}\n")
179
180 # refs/heads/<branch> — empty = no commits on this branch yet
181 ref_file = muse_dir / "refs" / "heads" / default_branch
182 if not ref_file.exists() or force:
183 ref_file.write_text("")
184
185 # config.toml — only written on fresh init (not overwritten on --force)
186 # so existing remote/user config is preserved.
187 config_path = muse_dir / "config.toml"
188 if not config_path.exists():
189 config_path.write_text(_BARE_CONFIG_TOML if bare else _DEFAULT_CONFIG_TOML)
190
191 # muse-work/ — working-tree root (skipped for bare repos)
192 if not bare:
193 work_dir = cwd / "muse-work"
194 work_dir.mkdir(exist_ok=True)
195
196 # --template: copy template contents into muse-work/
197 if template_path is not None:
198 for item in template_path.iterdir():
199 dest = work_dir / item.name
200 if item.is_dir():
201 shutil.copytree(item, dest, dirs_exist_ok=True)
202 else:
203 shutil.copy2(item, dest)
204
205 except PermissionError:
206 typer.echo(
207 f"❌ Permission denied: cannot write to {cwd}.\n"
208 "Run `muse init` from a directory you have write access to.\n"
209 "Tip: if running inside Docker, create a writable directory first:\n"
210 " docker compose exec maestro sh -c "
211 '"mkdir -p /tmp/my-project && cd /tmp/my-project && python -m maestro.muse_cli.app init"'
212 )
213 logger.error("❌ Permission denied creating .muse/ in %s", cwd)
214 raise typer.Exit(code=ExitCode.USER_ERROR)
215 except OSError as exc:
216 typer.echo(f"❌ Failed to initialise repository: {exc}")
217 logger.error("❌ OSError creating .muse/ in %s: %s", cwd, exc)
218 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
219
220 action = "Reinitialised" if (force and already_exists) else "Initialised"
221 kind = "bare " if bare else ""
222 typer.echo(f"✅ {action} {kind}Muse repository in {muse_dir}")
223 logger.info(
224 "✅ %s %sMuse repository in %s (repo_id=%s, branch=%s)",
225 action,
226 kind,
227 muse_dir,
228 repo_id,
229 default_branch,
230 )