exporter.py
python
sha256:b7be56ec091919a612cffe7f3c8b600209d5155517443fdac0e16954c8c7d81f
Merge 'task/git-export-ignored-file-deletion' into 'dev' — …
Human
11 days ago
| 1 | """Bridge export engine — Muse → Git snapshot sync. |
| 2 | |
| 3 | Exports a Muse snapshot into a git working tree by reading objects from |
| 4 | the Muse object store and writing them into a git repository. |
| 5 | |
| 6 | Exports |
| 7 | ------- |
| 8 | GitExporter Orchestrates the full export pipeline |
| 9 | _ensure_git_branch Create or checkout a git branch |
| 10 | _watch_loop Polling loop for watch mode |
| 11 | run_git_export CLI entry point for ``muse bridge git-export`` |
| 12 | """ |
| 13 | |
| 14 | from __future__ import annotations |
| 15 | |
| 16 | import argparse |
| 17 | import datetime |
| 18 | import json |
| 19 | import pathlib |
| 20 | import subprocess |
| 21 | import sys |
| 22 | |
| 23 | from muse.core.bridge.git_primitives import ( |
| 24 | _BRANCH_SAFE_RE, |
| 25 | _git, |
| 26 | _should_exclude, |
| 27 | ) |
| 28 | from muse.core.bridge.hooks import load_bridge_hooks, run_hook |
| 29 | from muse.core.bridge.state import ( |
| 30 | BridgeState, |
| 31 | SnapshotManifest, |
| 32 | _SidecarData, |
| 33 | read_bridge_state, |
| 34 | write_bridge_state, |
| 35 | ) |
| 36 | from muse.core.errors import ExitCode |
| 37 | from muse.core.ignore import is_ignored, load_ignore_config, resolve_patterns |
| 38 | from muse.core.paths import git_bridge_sidecar_path, head_path, ref_path |
| 39 | from muse.core.repo import find_repo_root |
| 40 | from muse.core.types import load_json_file, now_utc_iso |
| 41 | |
| 42 | |
| 43 | class GitExporter: |
| 44 | """Synchronise a Muse snapshot into a git working tree. |
| 45 | |
| 46 | Export algorithm (delete+replace): |
| 47 | |
| 48 | 1. Resolve ``--muse-ref`` to a full commit_id via ``.muse/refs/heads/<branch>`` |
| 49 | or HEAD. |
| 50 | 2. Read the :class:`SnapshotRecord` from the Muse object store. |
| 51 | 3. Verify ``--git-dir`` is a real git repository. |
| 52 | 4. If ``--git-branch`` does not exist in git, create it. |
| 53 | 5. Delete all tracked files in ``--git-dir`` (except ``.git/`` and excluded |
| 54 | patterns). |
| 55 | 6. Write every file from the Muse snapshot manifest into ``--git-dir``. |
| 56 | 7. Run ``git add -A``. |
| 57 | 8. Check for staged changes (``git diff --cached --quiet``). |
| 58 | 9. If changes exist (or ``--allow-empty``): ``git commit -m <message>``. |
| 59 | 10. If not ``--no-push``: ``git push <remote> <branch>``. |
| 60 | 11. Return the new git SHA (or ``None`` if nothing was committed). |
| 61 | |
| 62 | Args: |
| 63 | root: Muse repository root directory. |
| 64 | git_dir: Target git repository directory. |
| 65 | git_branch: Git branch to write to. |
| 66 | git_remote: Git remote name for push. |
| 67 | muse_ref: Muse branch name or full ``sha256:`` commit ID to export. |
| 68 | """ |
| 69 | |
| 70 | def __init__( |
| 71 | self, |
| 72 | root: pathlib.Path, |
| 73 | git_dir: pathlib.Path, |
| 74 | git_branch: str, |
| 75 | git_remote: str, |
| 76 | muse_ref: str | None = None, |
| 77 | ) -> None: |
| 78 | self.root = root |
| 79 | self.git_dir = git_dir |
| 80 | self.git_branch = git_branch |
| 81 | self.git_remote = git_remote |
| 82 | self.muse_ref = muse_ref |
| 83 | self.muse_commit_id: str = "" |
| 84 | self.muse_branch: str = "" |
| 85 | |
| 86 | def _user_error(self, msg: str) -> None: |
| 87 | print(f"Error: {msg}", file=sys.stderr) |
| 88 | raise SystemExit(ExitCode.USER_ERROR) |
| 89 | |
| 90 | def resolve_muse_ref(self, muse_ref: str | None) -> tuple[str, str]: |
| 91 | """Resolve *muse_ref* to ``(commit_id, snapshot_id)``.""" |
| 92 | from muse.core.commits import read_commit |
| 93 | |
| 94 | root = self.root |
| 95 | |
| 96 | if muse_ref is None or muse_ref == "HEAD": |
| 97 | _head_file = head_path(root) |
| 98 | if not _head_file.exists(): |
| 99 | self._user_error(f"No .muse/HEAD found in {root}") |
| 100 | head_content = _head_file.read_text().strip() |
| 101 | if head_content.startswith("ref: refs/heads/"): |
| 102 | branch = head_content[len("ref: refs/heads/"):] |
| 103 | else: |
| 104 | branch = head_content |
| 105 | muse_ref = branch |
| 106 | |
| 107 | if muse_ref.startswith("sha256:"): |
| 108 | commit_id = muse_ref |
| 109 | self.muse_branch = "HEAD" |
| 110 | else: |
| 111 | _branch_ref = ref_path(root, muse_ref) |
| 112 | if not _branch_ref.exists(): |
| 113 | self._user_error( |
| 114 | f"Muse ref {muse_ref!r} not found. " |
| 115 | f"Run 'muse branch --json' to list available branches." |
| 116 | ) |
| 117 | commit_id = _branch_ref.read_text().strip() |
| 118 | self.muse_branch = muse_ref |
| 119 | |
| 120 | self.muse_commit_id = commit_id |
| 121 | |
| 122 | cr = read_commit(root, commit_id) |
| 123 | if cr is None: |
| 124 | self._user_error( |
| 125 | f"Muse commit {commit_id[:23]}… not found in object store." |
| 126 | ) |
| 127 | assert cr is not None |
| 128 | return commit_id, cr.snapshot_id |
| 129 | |
| 130 | def read_snapshot(self, snapshot_id: str) -> SnapshotManifest: |
| 131 | """Return the snapshot manifest ``{relative_path: object_id}`` for *snapshot_id*.""" |
| 132 | from muse.core.snapshots import read_snapshot as _read_snapshot |
| 133 | |
| 134 | snap = _read_snapshot(self.root, snapshot_id) |
| 135 | if snap is None: |
| 136 | self._user_error( |
| 137 | f"Snapshot {snapshot_id[:23]}… not found in object store." |
| 138 | ) |
| 139 | assert snap is not None |
| 140 | return dict(snap.manifest) |
| 141 | |
| 142 | def _read_owned_paths(self) -> set[str]: |
| 143 | """Return the manifest paths written by the previous successful export. |
| 144 | |
| 145 | Empty on the very first export for this repo (no prior sidecar |
| 146 | entry) — meaning nothing is a delete candidate yet, by design. See |
| 147 | issue #65. |
| 148 | """ |
| 149 | sidecar_path = git_bridge_sidecar_path(self.root) |
| 150 | sidecar: _SidecarData = load_json_file(sidecar_path) or _SidecarData() |
| 151 | return set(sidecar.get("exported_manifest_paths", [])) |
| 152 | |
| 153 | def _write_owned_paths(self, paths: set[str]) -> None: |
| 154 | """Persist *paths* as the ownership record for the next export run.""" |
| 155 | sidecar_path = git_bridge_sidecar_path(self.root) |
| 156 | sidecar: _SidecarData = load_json_file(sidecar_path) or _SidecarData() |
| 157 | sidecar["exported_manifest_paths"] = sorted(paths) |
| 158 | sidecar_path.parent.mkdir(parents=True, exist_ok=True) |
| 159 | sidecar_path.write_text(json.dumps(sidecar), encoding="utf-8") |
| 160 | |
| 161 | def _ignored_paths(self, git_dir: pathlib.Path, candidates: set[str]) -> set[str]: |
| 162 | """Return the subset of *candidates* that are gitignored or museignored. |
| 163 | |
| 164 | Consults ``git check-ignore --stdin`` (a single batched call, not one |
| 165 | subprocess per path) and ``.museignore``'s global patterns. Never |
| 166 | raises — a git-dir that isn't a real git repo, or has no |
| 167 | ``.gitignore``, simply contributes an empty set from that source. |
| 168 | |
| 169 | Known limitation of the ``git check-ignore`` half: git never reports |
| 170 | a path as ignored while it is still tracked in the git index — a |
| 171 | tracked file overrides gitignore rules everywhere in git, not just |
| 172 | here. If a delete candidate was tracked by a prior export commit and |
| 173 | the caller freshly gitignores that exact path before the next |
| 174 | export, ``check-ignore`` will not protect it until something |
| 175 | actually untracks it first (e.g. ``git rm --cached``). ``.museignore`` |
| 176 | has no such limitation — it is Muse's own policy layer, independent |
| 177 | of git's tracking state — so a caller relying on ignore-based |
| 178 | protection for a *reused, previously-tracked* path should use |
| 179 | ``.museignore``, not ``.gitignore``, for that specific case. |
| 180 | """ |
| 181 | if not candidates: |
| 182 | return set() |
| 183 | |
| 184 | ignored: set[str] = set() |
| 185 | |
| 186 | try: |
| 187 | result = subprocess.run( |
| 188 | ["git", "-C", str(git_dir), "check-ignore", "--stdin"], |
| 189 | input="\n".join(sorted(candidates)), |
| 190 | capture_output=True, |
| 191 | text=True, |
| 192 | ) |
| 193 | # check-ignore prints only the paths that matched an ignore rule; |
| 194 | # exit code 1 ("no paths matched") is not an error to us. |
| 195 | ignored.update(line for line in result.stdout.splitlines() if line) |
| 196 | except (OSError, subprocess.SubprocessError): |
| 197 | pass |
| 198 | |
| 199 | try: |
| 200 | config = load_ignore_config(self.root) |
| 201 | patterns = resolve_patterns(config, domain="") |
| 202 | if patterns: |
| 203 | ignored.update(c for c in candidates if is_ignored(c, patterns)) |
| 204 | except Exception: # noqa: BLE001 — never let ignore-file parsing block export |
| 205 | pass |
| 206 | |
| 207 | return ignored |
| 208 | |
| 209 | def sync_to_git( |
| 210 | self, |
| 211 | manifest: dict[str, str], |
| 212 | excludes: list[str], |
| 213 | strip_muse: bool, |
| 214 | fix_modes: bool, |
| 215 | allow_delete_ignored: bool = False, |
| 216 | ) -> int: |
| 217 | """Write every file from *manifest* into ``git_dir``, deleting only |
| 218 | paths this exporter itself wrote in a prior run and no longer wants. |
| 219 | |
| 220 | Delete candidates are computed as ``owned_before - owned_now`` — |
| 221 | the set of paths recorded in the previous successful export's |
| 222 | manifest that are absent from *manifest* this run. A path Muse |
| 223 | never wrote (an ignored secret, an untracked scratch file, anything |
| 224 | that was never part of any Muse snapshot) can never appear in |
| 225 | ``owned_before`` and is therefore never a delete candidate, on the |
| 226 | very first export or any subsequent one. See issue #65. |
| 227 | |
| 228 | Within the delete-candidate set, a path currently matched by |
| 229 | ``.gitignore`` or ``.museignore`` is additionally skipped unless |
| 230 | *allow_delete_ignored* is passed — defense in depth for the case |
| 231 | where a path was legitimately Muse-owned before but has since been |
| 232 | reused by a gitignored local file. |
| 233 | |
| 234 | Returns: |
| 235 | Number of files written. |
| 236 | """ |
| 237 | import fnmatch |
| 238 | from muse.core.object_store import read_object |
| 239 | |
| 240 | git_dir = self.git_dir |
| 241 | |
| 242 | def _should_skip(rel: str) -> bool: |
| 243 | if ".git" in rel.split("/") or rel.startswith(".git/") or rel == ".git": |
| 244 | return True |
| 245 | if strip_muse and (rel.startswith(".muse/") or rel == ".muse"): |
| 246 | return True |
| 247 | for pat in (excludes or []): |
| 248 | basename = rel.split("/")[-1] |
| 249 | if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(basename, pat) or pat in rel: |
| 250 | return True |
| 251 | return _should_exclude(rel, None) |
| 252 | |
| 253 | owned_now = {rel for rel in manifest if not _should_skip(rel)} |
| 254 | owned_before = self._read_owned_paths() |
| 255 | delete_candidates = owned_before - owned_now |
| 256 | |
| 257 | if delete_candidates and not allow_delete_ignored: |
| 258 | ignored = self._ignored_paths(git_dir, delete_candidates) |
| 259 | delete_candidates -= ignored |
| 260 | |
| 261 | removed_dirs: set[pathlib.Path] = set() |
| 262 | for rel in delete_candidates: |
| 263 | p = git_dir / rel |
| 264 | if p.is_file(): |
| 265 | p.unlink() |
| 266 | removed_dirs.add(p.parent) |
| 267 | |
| 268 | for d in sorted(removed_dirs, key=lambda p: len(p.parts), reverse=True): |
| 269 | if d == git_dir: |
| 270 | continue |
| 271 | if ".git" in d.relative_to(git_dir).parts: |
| 272 | continue |
| 273 | try: |
| 274 | d.rmdir() |
| 275 | except OSError: |
| 276 | pass |
| 277 | |
| 278 | files_written = 0 |
| 279 | for rel_path, object_id in manifest.items(): |
| 280 | if _should_skip(rel_path): |
| 281 | continue |
| 282 | content = read_object(self.root, object_id) |
| 283 | if content is None: |
| 284 | continue |
| 285 | dest = git_dir / rel_path |
| 286 | dest.parent.mkdir(parents=True, exist_ok=True) |
| 287 | dest.write_bytes(content) |
| 288 | files_written += 1 |
| 289 | |
| 290 | if fix_modes: |
| 291 | self.fix_file_modes(manifest) |
| 292 | |
| 293 | self._write_owned_paths(owned_now) |
| 294 | |
| 295 | return files_written |
| 296 | |
| 297 | def fix_file_modes(self, manifest: SnapshotManifest) -> None: |
| 298 | """Set file permissions based on shebang detection. |
| 299 | |
| 300 | Files whose first two bytes are ``#!`` get ``0o755``; all others get ``0o644``. |
| 301 | """ |
| 302 | import stat |
| 303 | |
| 304 | MODE_REGULAR = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH |
| 305 | MODE_EXECUTABLE = MODE_REGULAR | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH |
| 306 | |
| 307 | for rel_path in manifest: |
| 308 | dest = self.git_dir / rel_path |
| 309 | if dest.exists() and dest.is_file(): |
| 310 | try: |
| 311 | dest.resolve().relative_to(self.git_dir.resolve()) |
| 312 | except ValueError: |
| 313 | continue |
| 314 | if ".git" in dest.relative_to(self.git_dir).parts: |
| 315 | continue |
| 316 | dest.chmod( |
| 317 | MODE_EXECUTABLE if GitExporter._has_shebang(dest) else MODE_REGULAR |
| 318 | ) |
| 319 | |
| 320 | @staticmethod |
| 321 | def _has_shebang(dest: "pathlib.Path") -> bool: |
| 322 | """Return True iff *dest*'s first two bytes are ``#!``.""" |
| 323 | try: |
| 324 | with dest.open("rb") as f: |
| 325 | return f.read(2) == b"#!" |
| 326 | except OSError: |
| 327 | return False |
| 328 | |
| 329 | def git_commit( |
| 330 | self, |
| 331 | commit_id: str, |
| 332 | message_template: str, |
| 333 | allow_empty: bool, |
| 334 | author: str = "muse-bridge", |
| 335 | ) -> str | None: |
| 336 | """Stage all changes and create a git commit. |
| 337 | |
| 338 | Returns: |
| 339 | The new git SHA-1 (40 chars) or ``None`` when nothing was committed. |
| 340 | """ |
| 341 | subprocess.run( |
| 342 | ["git", "-C", str(self.git_dir), "add", "-A"], |
| 343 | check=True, |
| 344 | capture_output=True, |
| 345 | ) |
| 346 | |
| 347 | diff_result = subprocess.run( |
| 348 | ["git", "-C", str(self.git_dir), "diff", "--cached", "--quiet"], |
| 349 | capture_output=True, |
| 350 | ) |
| 351 | has_changes = diff_result.returncode != 0 |
| 352 | |
| 353 | if not has_changes and not allow_empty: |
| 354 | return None |
| 355 | |
| 356 | now = datetime.datetime.now(datetime.timezone.utc) |
| 357 | msg = message_template.format( |
| 358 | commit_id=commit_id, |
| 359 | commit_id_short=commit_id[7:23] if commit_id.startswith("sha256:") else commit_id[:16], |
| 360 | branch=self.muse_branch, |
| 361 | timestamp=now.isoformat(), |
| 362 | author=author, |
| 363 | ) |
| 364 | |
| 365 | cmd = ["git", "-C", str(self.git_dir), "commit", "-m", msg] |
| 366 | if allow_empty and not has_changes: |
| 367 | cmd.append("--allow-empty") |
| 368 | subprocess.run(cmd, check=True, capture_output=True) |
| 369 | |
| 370 | sha = _git(self.git_dir, "rev-parse", "HEAD").strip() |
| 371 | return sha |
| 372 | |
| 373 | def git_push(self, remote: str, branch: str, force: bool = False) -> None: |
| 374 | """Push *branch* to *remote*.""" |
| 375 | cmd = ["git", "-C", str(self.git_dir), "push", remote, branch] |
| 376 | if force: |
| 377 | cmd.append("--force") |
| 378 | subprocess.run(cmd, check=True, capture_output=True) |
| 379 | |
| 380 | |
| 381 | def _ensure_git_branch(git_dir: pathlib.Path, branch: str) -> None: |
| 382 | """Ensure *branch* exists in the git repo at *git_dir*, creating it if needed.""" |
| 383 | result = subprocess.run( |
| 384 | ["git", "-C", str(git_dir), "rev-parse", "--verify", branch], |
| 385 | capture_output=True, |
| 386 | ) |
| 387 | branch_exists = result.returncode == 0 |
| 388 | if not branch_exists: |
| 389 | subprocess.run( |
| 390 | ["git", "-C", str(git_dir), "checkout", "-b", branch], |
| 391 | check=True, |
| 392 | capture_output=True, |
| 393 | ) |
| 394 | else: |
| 395 | subprocess.run( |
| 396 | ["git", "-C", str(git_dir), "checkout", branch], |
| 397 | check=True, |
| 398 | capture_output=True, |
| 399 | ) |
| 400 | |
| 401 | |
| 402 | def _watch_loop( |
| 403 | args: argparse.Namespace, |
| 404 | root: pathlib.Path, |
| 405 | git_dir: pathlib.Path, |
| 406 | interval: int, |
| 407 | ) -> None: |
| 408 | """Poll Muse HEAD every *interval* seconds and auto-export on new commits.""" |
| 409 | import time |
| 410 | |
| 411 | def _head_commit_id() -> str | None: |
| 412 | _head_file = head_path(root) |
| 413 | if not _head_file.exists(): |
| 414 | return None |
| 415 | content = _head_file.read_text().strip() |
| 416 | if content.startswith("ref: refs/heads/"): |
| 417 | branch = content[len("ref: refs/heads/"):] |
| 418 | _branch_ref = ref_path(root, branch) |
| 419 | return _branch_ref.read_text().strip() if _branch_ref.exists() else None |
| 420 | return content |
| 421 | |
| 422 | last_commit_id = _head_commit_id() |
| 423 | |
| 424 | while True: |
| 425 | time.sleep(interval) |
| 426 | current_commit_id = _head_commit_id() |
| 427 | changed = current_commit_id != last_commit_id and current_commit_id is not None |
| 428 | |
| 429 | if args.json_out: |
| 430 | print(json.dumps({ |
| 431 | "event": "poll", |
| 432 | "muse_commit_id": current_commit_id or "", |
| 433 | "changed": changed, |
| 434 | }), flush=True) |
| 435 | |
| 436 | if changed: |
| 437 | last_commit_id = current_commit_id |
| 438 | exporter = GitExporter( |
| 439 | root=root, |
| 440 | git_dir=git_dir, |
| 441 | git_branch=args.git_branch, |
| 442 | git_remote=args.git_remote, |
| 443 | muse_ref=args.muse_ref, |
| 444 | ) |
| 445 | try: |
| 446 | commit_id, snapshot_id = exporter.resolve_muse_ref(args.muse_ref) |
| 447 | manifest = exporter.read_snapshot(snapshot_id) |
| 448 | _ensure_git_branch(git_dir, args.git_branch) |
| 449 | exporter.sync_to_git( |
| 450 | manifest, |
| 451 | excludes=getattr(args, "excludes", []) or [], |
| 452 | strip_muse=getattr(args, "strip_muse_metadata", True), |
| 453 | fix_modes=getattr(args, "fix_modes", False), |
| 454 | ) |
| 455 | git_sha = exporter.git_commit( |
| 456 | commit_id, |
| 457 | args.commit_message, |
| 458 | allow_empty=getattr(args, "allow_empty", False), |
| 459 | ) |
| 460 | if git_sha and not getattr(args, "no_push", True): |
| 461 | exporter.git_push( |
| 462 | args.git_remote, |
| 463 | args.git_branch, |
| 464 | force=getattr(args, "force_push", False), |
| 465 | ) |
| 466 | if args.json_out: |
| 467 | print(json.dumps({ |
| 468 | "event": "exported", |
| 469 | "muse_commit_id": commit_id, |
| 470 | "git_sha": git_sha or "", |
| 471 | }), flush=True) |
| 472 | except SystemExit: |
| 473 | pass |
| 474 | |
| 475 | |
| 476 | def run_git_export(args: argparse.Namespace) -> None: |
| 477 | """Entry point for ``muse bridge git-export``. |
| 478 | |
| 479 | Syncs a Muse snapshot into a git working tree. |
| 480 | |
| 481 | CI env vars: |
| 482 | |
| 483 | * ``MUSE_AGENT_KEY`` — PEM-encoded Ed25519 private key. |
| 484 | * ``MUSE_AGENT_HANDLE`` — registered agent handle. |
| 485 | """ |
| 486 | git_dir_arg = getattr(args, "git_dir", None) |
| 487 | json_out: bool = args.json_out |
| 488 | dry_run: bool = getattr(args, "dry_run", False) |
| 489 | no_push: bool = getattr(args, "no_push", True) |
| 490 | force_push: bool = getattr(args, "force_push", False) |
| 491 | git_branch: str = getattr(args, "git_branch", "muse-mirror") |
| 492 | git_remote: str = getattr(args, "git_remote", "origin") |
| 493 | muse_ref: str | None = getattr(args, "muse_ref", None) |
| 494 | excludes: list[str] = getattr(args, "excludes", []) or [] |
| 495 | strip_muse: bool = getattr(args, "strip_muse_metadata", True) |
| 496 | fix_modes: bool = getattr(args, "fix_modes", False) |
| 497 | allow_delete_ignored: bool = getattr(args, "allow_delete_ignored", False) |
| 498 | allow_empty: bool = getattr(args, "allow_empty", False) |
| 499 | commit_message: str = getattr(args, "commit_message", "mirror: muse {commit_id}") |
| 500 | watch_interval: int | None = getattr(args, "watch", None) |
| 501 | |
| 502 | def _emit_error(msg: str) -> None: |
| 503 | if json_out: |
| 504 | print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR})) |
| 505 | else: |
| 506 | print(f"Error: {msg}", file=sys.stderr) |
| 507 | |
| 508 | if not git_dir_arg: |
| 509 | _emit_error("--git-dir is required for muse bridge git-export") |
| 510 | raise SystemExit(ExitCode.USER_ERROR) |
| 511 | |
| 512 | git_dir = pathlib.Path(git_dir_arg).resolve() |
| 513 | |
| 514 | if not (git_dir / ".git").exists(): |
| 515 | _emit_error(f"No git repository found at {git_dir}") |
| 516 | raise SystemExit(ExitCode.USER_ERROR) |
| 517 | |
| 518 | if not _BRANCH_SAFE_RE.match(git_branch): |
| 519 | _emit_error( |
| 520 | f"--git-branch {git_branch!r} contains invalid characters. " |
| 521 | "Only alphanumerics, dots, hyphens, underscores, and slashes are allowed." |
| 522 | ) |
| 523 | raise SystemExit(ExitCode.USER_ERROR) |
| 524 | |
| 525 | root = find_repo_root() |
| 526 | if root is None: |
| 527 | _emit_error("No Muse repository found. Run 'muse init' first.") |
| 528 | raise SystemExit(ExitCode.USER_ERROR) |
| 529 | |
| 530 | if dry_run: |
| 531 | if json_out: |
| 532 | print(json.dumps({ |
| 533 | "event": "done", |
| 534 | "dry_run": True, |
| 535 | "git_dir": str(git_dir), |
| 536 | "git_branch": git_branch, |
| 537 | "muse_ref": muse_ref, |
| 538 | })) |
| 539 | else: |
| 540 | print( |
| 541 | f"dry-run: would export muse ref {muse_ref or 'HEAD'!r} " |
| 542 | f"to {git_dir} branch {git_branch!r}" |
| 543 | ) |
| 544 | return |
| 545 | |
| 546 | exporter = GitExporter( |
| 547 | root=root, |
| 548 | git_dir=git_dir, |
| 549 | git_branch=git_branch, |
| 550 | git_remote=git_remote, |
| 551 | muse_ref=muse_ref, |
| 552 | ) |
| 553 | |
| 554 | commit_id, snapshot_id = exporter.resolve_muse_ref(muse_ref) |
| 555 | manifest = exporter.read_snapshot(snapshot_id) |
| 556 | _ensure_git_branch(git_dir, git_branch) |
| 557 | |
| 558 | _hook_env: dict[str, str] = { |
| 559 | "MUSE_BRIDGE_GIT_DIR": str(git_dir), |
| 560 | "MUSE_BRIDGE_GIT_BRANCH": git_branch, |
| 561 | "MUSE_BRIDGE_COMMIT_ID": commit_id, |
| 562 | } |
| 563 | |
| 564 | _bridge_hooks = load_bridge_hooks(root) |
| 565 | for _hook in _bridge_hooks.pre_bridge: |
| 566 | run_hook(_hook, cwd=root, env=_hook_env) |
| 567 | |
| 568 | files_written = exporter.sync_to_git( |
| 569 | manifest, |
| 570 | excludes=excludes, |
| 571 | strip_muse=strip_muse, |
| 572 | fix_modes=fix_modes, |
| 573 | allow_delete_ignored=allow_delete_ignored, |
| 574 | ) |
| 575 | |
| 576 | git_sha = exporter.git_commit(commit_id, commit_message, allow_empty=allow_empty) |
| 577 | |
| 578 | for _hook in _bridge_hooks.post_bridge: |
| 579 | run_hook(_hook, cwd=git_dir, env=_hook_env) |
| 580 | |
| 581 | if git_sha and not no_push: |
| 582 | exporter.git_push(git_remote, git_branch, force=force_push) |
| 583 | |
| 584 | now_iso = now_utc_iso() |
| 585 | bridge_state = read_bridge_state(root) |
| 586 | new_state: BridgeState = { |
| 587 | "last_import": dict(bridge_state.get("last_import", {})), |
| 588 | "last_export": { |
| 589 | "muse_branch": exporter.muse_branch, |
| 590 | "muse_commit_id": commit_id, |
| 591 | "git_remote": git_remote, |
| 592 | "git_ref": git_branch, |
| 593 | "git_sha": git_sha or "", |
| 594 | "exported_at": now_iso, |
| 595 | }, |
| 596 | } |
| 597 | try: |
| 598 | write_bridge_state(root, new_state) |
| 599 | except Exception: |
| 600 | pass |
| 601 | |
| 602 | rerere_exported = 0 |
| 603 | shelves_exported = 0 |
| 604 | |
| 605 | if getattr(args, "export_rerere", False): |
| 606 | from muse.core.bridge.harmony_shelf import export_harmony_to_rerere |
| 607 | rerere_exported = export_harmony_to_rerere(root, git_dir, dry_run=dry_run) |
| 608 | |
| 609 | if getattr(args, "export_shelves", False): |
| 610 | from muse.core.bridge.harmony_shelf import export_shelves_to_stash |
| 611 | shelves_exported = export_shelves_to_stash(root, git_dir, dry_run=dry_run) |
| 612 | |
| 613 | if json_out: |
| 614 | print(json.dumps({ |
| 615 | "event": "done", |
| 616 | "muse_commit_id": commit_id, |
| 617 | "snapshot_id": snapshot_id, |
| 618 | "files_written": files_written, |
| 619 | "git_sha": git_sha or "", |
| 620 | "git_branch": git_branch, |
| 621 | "pushed": bool(git_sha and not no_push), |
| 622 | "dry_run": False, |
| 623 | "rerere_exported": rerere_exported, |
| 624 | "shelves_exported": shelves_exported, |
| 625 | })) |
| 626 | else: |
| 627 | status = "committed" if git_sha else "no changes" |
| 628 | print( |
| 629 | f"Exported {files_written} files → git {git_branch!r} " |
| 630 | f"({status}{', pushed' if git_sha and not no_push else ''})" |
| 631 | ) |
| 632 | |
| 633 | if watch_interval: |
| 634 | _watch_loop(args, root, git_dir, watch_interval) |
| 635 | |
| 636 | |
| 637 | def _register_git_export_parser( |
| 638 | subs: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 639 | ) -> None: |
| 640 | """Register the ``git-export`` subcommand onto *subs*.""" |
| 641 | import argparse as _ap |
| 642 | |
| 643 | p = subs.add_parser( |
| 644 | "git-export", |
| 645 | help="Sync a Muse snapshot into a git working tree.", |
| 646 | formatter_class=_ap.RawDescriptionHelpFormatter, |
| 647 | description=( |
| 648 | "Export a Muse snapshot to a git repository.\n" |
| 649 | "Creates a git commit with message 'mirror: muse <commit_id>'." |
| 650 | ), |
| 651 | ) |
| 652 | p.add_argument( |
| 653 | "--muse-ref", |
| 654 | default=None, |
| 655 | metavar="BRANCH|COMMIT_ID", |
| 656 | help="Muse ref to export (default: HEAD).", |
| 657 | ) |
| 658 | p.add_argument( |
| 659 | "--git-dir", |
| 660 | default=None, |
| 661 | metavar="PATH", |
| 662 | help="Git repository to sync into (required).", |
| 663 | ) |
| 664 | p.add_argument( |
| 665 | "--git-branch", |
| 666 | default="muse-mirror", |
| 667 | metavar="BRANCH", |
| 668 | help="Git branch to commit to (default: muse-mirror).", |
| 669 | ) |
| 670 | p.add_argument( |
| 671 | "--git-remote", |
| 672 | default="origin", |
| 673 | metavar="REMOTE", |
| 674 | help="Git remote for push (default: origin).", |
| 675 | ) |
| 676 | p.add_argument( |
| 677 | "--no-push", |
| 678 | action="store_true", |
| 679 | default=False, |
| 680 | help="Create the git commit but skip git push.", |
| 681 | ) |
| 682 | p.add_argument( |
| 683 | "--force-push", |
| 684 | action="store_true", |
| 685 | default=False, |
| 686 | help="Use git push --force (for mirror branches).", |
| 687 | ) |
| 688 | p.add_argument( |
| 689 | "--exclude", |
| 690 | action="append", |
| 691 | dest="excludes", |
| 692 | default=[], |
| 693 | metavar="PATTERN", |
| 694 | help="Glob patterns for files to omit from the git export (repeatable).", |
| 695 | ) |
| 696 | p.add_argument( |
| 697 | "--strip-muse-metadata", |
| 698 | action="store_true", |
| 699 | default=True, |
| 700 | help="Exclude .muse/ from the git export (default: true).", |
| 701 | ) |
| 702 | p.add_argument( |
| 703 | "--allow-delete-ignored", |
| 704 | action="store_true", |
| 705 | default=False, |
| 706 | help=( |
| 707 | "Allow deleting a previously Muse-owned path even if it is " |
| 708 | "currently matched by .gitignore or .museignore (default: " |
| 709 | "protected). Never affects paths Muse never wrote — those are " |
| 710 | "always protected regardless of this flag." |
| 711 | ), |
| 712 | ) |
| 713 | p.add_argument( |
| 714 | "--commit-message", |
| 715 | default="mirror: muse {commit_id}", |
| 716 | metavar="TEMPLATE", |
| 717 | help="Git commit message template (default: 'mirror: muse {commit_id}').", |
| 718 | ) |
| 719 | p.add_argument( |
| 720 | "--allow-empty", |
| 721 | action="store_true", |
| 722 | default=False, |
| 723 | help="Create a git commit even if nothing changed.", |
| 724 | ) |
| 725 | p.add_argument( |
| 726 | "--fix-modes", |
| 727 | action=_ap.BooleanOptionalAction, |
| 728 | default=True, |
| 729 | help="chmod files after writing — 0o644 for regular files, 0o755 for files with a POSIX shebang. Disable with --no-fix-modes.", |
| 730 | ) |
| 731 | p.add_argument( |
| 732 | "--dry-run", |
| 733 | action="store_true", |
| 734 | default=False, |
| 735 | help="Print what would happen without writing anything.", |
| 736 | ) |
| 737 | p.add_argument( |
| 738 | "--json", "-j", |
| 739 | dest="json_out", |
| 740 | action="store_true", |
| 741 | default=False, |
| 742 | help="Emit NDJSON events (one JSON object per line).", |
| 743 | ) |
| 744 | p.add_argument( |
| 745 | "--watch", |
| 746 | default=None, |
| 747 | type=int, |
| 748 | metavar="SECONDS", |
| 749 | help="Poll Muse HEAD every N seconds and auto-export on new commits.", |
| 750 | ) |
| 751 | p.add_argument( |
| 752 | "--export-rerere", |
| 753 | action="store_true", |
| 754 | default=False, |
| 755 | help="Export Muse Harmony resolutions to git rerere format (.git/rr-cache/).", |
| 756 | ) |
| 757 | p.add_argument( |
| 758 | "--export-shelves", |
| 759 | action="store_true", |
| 760 | default=False, |
| 761 | help="Export Muse shelf entries as git stashes.", |
| 762 | ) |
| 763 | p.set_defaults(func=run_git_export) |
File History
9 commits
sha256:b7be56ec091919a612cffe7f3c8b600209d5155517443fdac0e16954c8c7d81f
Merge 'task/git-export-ignored-file-deletion' into 'dev' — …
Human
11 days ago
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6
chore: bump version to 0.2.0.dev2 — nightly.2
Sonnet 4.6
patch
11 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b
revert: keep pyproject.toml in canonical PEP 440 form
Sonnet 4.6
patch
14 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9
docs: add issue docs for push have-negotiation bug (#55) an…
Sonnet 4.6
18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74
chore: trigger push to surface null-OID paths
Sonnet 4.6
23 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8
chore: prebuild timing test
Sonnet 4.6
33 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1
merge: pull staging/dev — advance to 0.2.0rc12
Sonnet 4.6
patch
35 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea
fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub …
Sonnet 4.6
47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e
fix: rename objects→blobs in push client and all stale test…
Sonnet 4.6
patch
49 days ago