gabriel / muse public
workspace.py python
284 lines 8.4 KB
e0353dfe feat: muse reflog, gc, archive, bisect, blame, worktree, workspace Gabriel Cardona <cgcardona@gmail.com> 4d ago
1 """Workspace management — compose multiple Muse repositories.
2
3 A *workspace* is a collection of related Muse repositories that are developed
4 together. Think of a film score that references a sound library, a machine
5 learning pipeline that includes a dataset repo, or a multi-service codebase
6 where each service lives in its own Muse repo.
7
8 Design
9 ------
10 Workspaces are distinct from worktrees:
11
12 - A **worktree** is one checkout of *one* repo with *one* ``.muse/`` store.
13 - A **workspace** is an envelope that *links* multiple separate repos together.
14
15 The workspace manifest lives at ``.muse/workspace.toml``::
16
17 [[members]]
18 name = "core"
19 url = "https://musehub.ai/acme/core"
20 path = "repos/core" # relative to workspace root
21 branch = "main" # pinned branch
22
23 [[members]]
24 name = "dataset"
25 url = "https://musehub.ai/acme/dataset"
26 path = "repos/dataset"
27 branch = "v2"
28
29 Agent workflow
30 --------------
31 Each member repo is a fully independent Muse repository. Agents can commit
32 to member repos independently and the workspace provides a unified status view
33 and one-shot sync.
34
35 ``muse workspace sync`` walks all members and runs ``muse fetch`` + ``muse pull``
36 so the workspace root always has the latest HEAD for every pinned branch.
37 """
38
39 from __future__ import annotations
40
41 import json
42 import logging
43 import pathlib
44 import subprocess
45 from dataclasses import dataclass
46 from typing import TypedDict
47
48 logger = logging.getLogger(__name__)
49
50 _WORKSPACE_FILE = ".muse/workspace.toml"
51
52
53 # ---------------------------------------------------------------------------
54 # Types
55 # ---------------------------------------------------------------------------
56
57
58 class WorkspaceMemberDict(TypedDict):
59 """One entry in the workspace manifest."""
60
61 name: str
62 url: str
63 path: str
64 branch: str
65
66
67 class WorkspaceManifestDict(TypedDict):
68 """Top-level workspace manifest."""
69
70 members: list[WorkspaceMemberDict]
71
72
73 @dataclass
74 class WorkspaceMemberStatus:
75 """Runtime status of one workspace member."""
76
77 name: str
78 path: pathlib.Path
79 branch: str
80 url: str
81 present: bool
82 head_commit: str | None
83
84
85 # ---------------------------------------------------------------------------
86 # Paths
87 # ---------------------------------------------------------------------------
88
89
90 def _workspace_path(repo_root: pathlib.Path) -> pathlib.Path:
91 return repo_root / ".muse" / "workspace.toml"
92
93
94 # ---------------------------------------------------------------------------
95 # Persistence
96 # ---------------------------------------------------------------------------
97
98
99 def _load_manifest(repo_root: pathlib.Path) -> WorkspaceManifestDict | None:
100 import tomllib
101
102 path = _workspace_path(repo_root)
103 if not path.exists():
104 return None
105 try:
106 raw = tomllib.loads(path.read_text(encoding="utf-8"))
107 except Exception as exc:
108 logger.warning("⚠️ Could not read workspace manifest: %s", exc)
109 return None
110 members: list[WorkspaceMemberDict] = []
111 for m in raw.get("members", []):
112 if not isinstance(m, dict):
113 continue
114 members.append(WorkspaceMemberDict(
115 name=str(m.get("name", "")),
116 url=str(m.get("url", "")),
117 path=str(m.get("path", "")),
118 branch=str(m.get("branch", "main")),
119 ))
120 return WorkspaceManifestDict(members=members)
121
122
123 def _save_manifest(repo_root: pathlib.Path, manifest: WorkspaceManifestDict) -> None:
124 path = _workspace_path(repo_root)
125 path.parent.mkdir(parents=True, exist_ok=True)
126 lines: list[str] = []
127 for m in manifest["members"]:
128 lines.append("[[members]]")
129 lines.append(f'name = "{m["name"]}"')
130 lines.append(f'url = "{m["url"]}"')
131 lines.append(f'path = "{m["path"]}"')
132 lines.append(f'branch = "{m["branch"]}"')
133 lines.append("")
134 tmp = path.with_suffix(".tmp")
135 tmp.write_text("\n".join(lines), encoding="utf-8")
136 tmp.replace(path)
137
138
139 # ---------------------------------------------------------------------------
140 # Public API
141 # ---------------------------------------------------------------------------
142
143
144 def add_workspace_member(
145 repo_root: pathlib.Path,
146 name: str,
147 url: str,
148 path: str = "",
149 branch: str = "main",
150 ) -> None:
151 """Register a new member repository in the workspace manifest.
152
153 Args:
154 repo_root: The workspace root (where ``.muse/`` lives).
155 name: Short identifier for this member.
156 url: URL or local path to the member Muse repository.
157 path: Relative checkout path inside the workspace (default: ``repos/<name>``).
158 branch: Branch to track (default: ``main``).
159
160 Raises:
161 ValueError: If a member with the same name already exists.
162 """
163 from muse.core.validation import validate_branch_name
164
165 validate_branch_name(name)
166 effective_path = path or f"repos/{name}"
167
168 manifest = _load_manifest(repo_root) or WorkspaceManifestDict(members=[])
169 for m in manifest["members"]:
170 if m["name"] == name:
171 raise ValueError(f"Workspace member '{name}' already exists.")
172
173 manifest["members"].append(WorkspaceMemberDict(
174 name=name,
175 url=url,
176 path=effective_path,
177 branch=branch,
178 ))
179 _save_manifest(repo_root, manifest)
180
181
182 def remove_workspace_member(repo_root: pathlib.Path, name: str) -> None:
183 """Remove a member from the workspace manifest (does not delete the directory).
184
185 Raises:
186 ValueError: If no member with that name exists.
187 """
188 manifest = _load_manifest(repo_root)
189 if manifest is None:
190 raise ValueError("No workspace manifest found.")
191 before = len(manifest["members"])
192 manifest["members"] = [m for m in manifest["members"] if m["name"] != name]
193 if len(manifest["members"]) == before:
194 raise ValueError(f"Workspace member '{name}' not found.")
195 _save_manifest(repo_root, manifest)
196
197
198 def list_workspace_members(repo_root: pathlib.Path) -> list[WorkspaceMemberStatus]:
199 """Return status for every workspace member."""
200 manifest = _load_manifest(repo_root)
201 if manifest is None:
202 return []
203
204 results: list[WorkspaceMemberStatus] = []
205 for m in manifest["members"]:
206 member_path = repo_root / m["path"]
207 present = member_path.exists() and (member_path / ".muse").exists()
208 head_commit: str | None = None
209 if present:
210 try:
211 from muse.core.store import get_head_commit_id
212 head_commit = get_head_commit_id(member_path, m["branch"])
213 except Exception:
214 pass
215 results.append(WorkspaceMemberStatus(
216 name=m["name"],
217 path=member_path,
218 branch=m["branch"],
219 url=m["url"],
220 present=present,
221 head_commit=head_commit,
222 ))
223 return results
224
225
226 def sync_workspace_member(
227 repo_root: pathlib.Path,
228 member: WorkspaceMemberDict,
229 ) -> str:
230 """Clone or pull the latest state for one workspace member.
231
232 Returns:
233 A status string: 'cloned', 'pulled', or 'error: <msg>'.
234 """
235 member_path = repo_root / member["path"]
236
237 if not member_path.exists() or not (member_path / ".muse").exists():
238 # Clone from URL.
239 member_path.parent.mkdir(parents=True, exist_ok=True)
240 result = subprocess.run(
241 ["muse", "clone", member["url"], str(member_path)],
242 capture_output=True,
243 text=True,
244 )
245 if result.returncode != 0:
246 return f"error: {result.stderr.strip()[:200]}"
247 return "cloned"
248
249 # Pull to get latest.
250 result = subprocess.run(
251 ["muse", "pull", "--branch", member["branch"]],
252 capture_output=True,
253 text=True,
254 cwd=str(member_path),
255 )
256 if result.returncode != 0:
257 return f"error: {result.stderr.strip()[:200]}"
258 return "pulled"
259
260
261 def sync_workspace(
262 repo_root: pathlib.Path,
263 member_name: str | None = None,
264 ) -> list[tuple[str, str]]:
265 """Sync all (or one named) workspace members.
266
267 Returns:
268 List of (member_name, status_str) pairs.
269 """
270 manifest = _load_manifest(repo_root)
271 if manifest is None:
272 return []
273
274 targets = (
275 [m for m in manifest["members"] if m["name"] == member_name]
276 if member_name is not None
277 else manifest["members"]
278 )
279
280 results: list[tuple[str, str]] = []
281 for m in targets:
282 status = sync_workspace_member(repo_root, m)
283 results.append((m["name"], status))
284 return results