gabriel / muse public
exporter.py python
763 lines 26.6 KB
Raw
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