migrate.py python
921 lines 30.1 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
1 """muse code migrate — Replay a Git commit graph into a Muse repository.
2
3 Converts an existing Git repository into a Muse repository by replaying
4 every requested branch oldest-first, preserving original author names,
5 e-mail addresses, and committer timestamps verbatim in every CommitRecord.
6
7 Examples
8 --------
9 ::
10
11 # Migrate the git repo in the current directory (main branch only)
12 muse code migrate
13
14 # Migrate into a separate target directory
15 muse code migrate /path/to/git-repo --target /path/to/muse-repo
16
17 # Replay all local branches
18 muse code migrate --all
19
20 # Replay specific branches in order
21 muse code migrate --branch main dev feat/x
22
23 # Preview without writing anything
24 muse code migrate --all --dry-run
25
26 # Incremental: only replay commits after a known git SHA
27 muse code migrate --from-ref abc123
28
29 # Add custom exclusion patterns on top of the defaults
30 muse code migrate --exclude "dist/" "*.lock"
31
32 # Emit structured NDJSON (useful in agent pipelines — streams progress events)
33 muse code migrate --json
34
35 Strategy
36 --------
37 1. Walk requested branches oldest-first. ``main`` is always replayed
38 first so other branches can branch from the correct Muse ancestor.
39 2. Merge commits (more than one parent) are skipped — they carry no
40 unique file-state delta; the Muse DAG is reconstructed faithfully
41 through the parent chain on each branch.
42 3. For each commit, ``git diff-tree --root -r --raw`` fetches only the
43 *delta* (added / modified / deleted / renamed files) rather than the
44 full tree. A running manifest is maintained in memory and updated
45 incrementally — unchanged files are never re-read or re-hashed.
46 4. All blob content is streamed through a single long-running
47 ``git cat-file --batch`` process (one per branch) so there is no
48 per-file subprocess overhead.
49 5. If ``.muse/repo.json`` does not exist, ``muse init --domain code``
50 is run automatically before migration begins.
51 """
52
53 from __future__ import annotations
54
55 import argparse
56 import dataclasses
57 import datetime
58 import hashlib
59 import json
60 import logging
61 import os
62 import pathlib
63 import subprocess
64 import sys
65 import time
66
67 from muse.core.object_store import write_object
68 from muse.core.reflog import append_reflog
69 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
70 from muse.core.store import (
71 CommitRecord,
72 SnapshotRecord,
73 write_commit,
74 write_head_branch,
75 write_snapshot,
76 )
77
78 logger = logging.getLogger(__name__)
79
80 # Type alias — maps relative POSIX path → muse object id (sha256 hex)
81 Manifest = dict[str, str]
82
83 # ---------------------------------------------------------------------------
84 # Default exclusion rules (mirror .museignore + hidden-dir walk logic)
85 # ---------------------------------------------------------------------------
86
87 _DEFAULT_EXCLUDE_PREFIXES: tuple[str, ...] = (
88 ".git/",
89 ".muse/",
90 ".venv/",
91 ".tox/",
92 ".mypy_cache/",
93 ".pytest_cache/",
94 ".hypothesis/",
95 "artifacts/",
96 "__pycache__/",
97 "node_modules/",
98 ".cache/",
99 )
100
101 _DEFAULT_EXCLUDE_SUFFIXES: tuple[str, ...] = (
102 ".pyc",
103 ".pyo",
104 ".egg-info",
105 ".swp",
106 ".swo",
107 ".tmp",
108 "Thumbs.db",
109 ".DS_Store",
110 )
111
112
113 def _should_exclude(
114 rel_path: str,
115 extra_prefixes: tuple[str, ...],
116 extra_suffixes: tuple[str, ...],
117 ) -> bool:
118 all_prefixes = _DEFAULT_EXCLUDE_PREFIXES + extra_prefixes
119 all_suffixes = _DEFAULT_EXCLUDE_SUFFIXES + extra_suffixes
120 for prefix in all_prefixes:
121 if rel_path.startswith(prefix) or rel_path == prefix.rstrip("/"):
122 return True
123 for suffix in all_suffixes:
124 if rel_path.endswith(suffix):
125 return True
126 return False
127
128
129 # ---------------------------------------------------------------------------
130 # Delta entry — one changed file from git diff-tree
131 # ---------------------------------------------------------------------------
132
133
134 @dataclasses.dataclass(slots=True)
135 class _Delta:
136 status: str # A add, M modify, D delete, R rename, C copy, T type-change
137 blob_sha: str # git blob SHA of the new content (empty string for deletes)
138 path: str # destination path
139 old_path: str # source path for renames/copies, else same as path
140
141
142 # ---------------------------------------------------------------------------
143 # git cat-file --batch — long-running process for streaming blob reads
144 # ---------------------------------------------------------------------------
145
146
147 class _CatFile:
148 """Wraps a ``git cat-file --batch`` subprocess for efficient blob reads.
149
150 Maintains one long-running process for the duration of a branch replay,
151 eliminating the per-file subprocess spawn overhead that dominates small
152 repos and becomes catastrophic for large ones.
153 """
154
155 def __init__(self, git_root: pathlib.Path) -> None:
156 proc = subprocess.Popen(
157 ["git", "cat-file", "--batch"],
158 cwd=git_root,
159 stdin=subprocess.PIPE,
160 stdout=subprocess.PIPE,
161 )
162 if proc.stdin is None or proc.stdout is None:
163 proc.kill()
164 raise RuntimeError("git cat-file --batch failed to open stdin/stdout pipes")
165 self._proc = proc
166 self._stdin = proc.stdin
167 self._stdout = proc.stdout
168
169 def read(self, sha: str) -> bytes:
170 """Return the raw blob content for *sha*; empty bytes if missing or deleted."""
171 self._stdin.write(f"{sha}\n".encode())
172 self._stdin.flush()
173 header = self._stdout.readline().decode(errors="replace")
174 parts = header.split()
175 if len(parts) < 3 or parts[1] == "missing":
176 return b""
177 size = int(parts[2])
178 content = self._stdout.read(size)
179 self._stdout.read(1) # consume trailing newline
180 return content
181
182 def close(self) -> None:
183 """Close the subprocess stdin and wait for it to exit cleanly."""
184 self._stdin.close()
185 self._proc.wait()
186
187 def __enter__(self) -> "_CatFile":
188 return self
189
190 def __exit__(self, *_: object) -> None:
191 self.close()
192
193
194 # ---------------------------------------------------------------------------
195 # git helpers
196 # ---------------------------------------------------------------------------
197
198 def _git(repo_root: pathlib.Path, *args: str) -> str:
199 result = subprocess.run(
200 ["git", *args],
201 cwd=repo_root,
202 capture_output=True,
203 text=True,
204 check=True,
205 )
206 return result.stdout.strip()
207
208
209 def _git_local_branches(repo_root: pathlib.Path) -> list[str]:
210 raw = _git(repo_root, "branch", "--format=%(refname:short)")
211 return [b.strip() for b in raw.splitlines() if b.strip()]
212
213
214 def _git_is_repo(path: pathlib.Path) -> bool:
215 try:
216 _git(path, "rev-parse", "--git-dir")
217 return True
218 except (subprocess.CalledProcessError, FileNotFoundError):
219 return False
220
221
222 # ASCII control characters as field/record separators — safe in commit messages.
223 _FIELD_SEP = "\x1f" # Unit Separator
224 _RECORD_SEP = "\x1e" # Record Separator
225
226
227 def _batch_commit_log(
228 repo_root: pathlib.Path,
229 branch: str,
230 exclude_branches: list[str] | None = None,
231 from_ref: str | None = None,
232 ) -> list[tuple[str, dict[str, str]]]:
233 """Return ``[(sha, meta_dict), ...]`` oldest-first in a single git log call.
234
235 Streams git log output in 64 KiB chunks and parses records on the fly —
236 the raw output is never fully buffered in memory. Critical for repos with
237 hundreds of thousands of commits where a single ``git log`` call can produce
238 gigabytes of output.
239 """
240 fmt = (
241 f"%H{_FIELD_SEP}%an{_FIELD_SEP}%ae{_FIELD_SEP}"
242 f"%at{_FIELD_SEP}%P{_FIELD_SEP}%B{_RECORD_SEP}"
243 )
244 cmd = ["log", "--topo-order", "--reverse", f"--format={fmt}"]
245 if exclude_branches:
246 cmd.append(branch)
247 for excl in exclude_branches:
248 cmd.append(f"^{excl}")
249 elif from_ref:
250 cmd.append(f"{from_ref}..{branch}")
251 else:
252 cmd.append(branch)
253
254 proc = subprocess.Popen(
255 ["git", *cmd],
256 cwd=repo_root,
257 stdout=subprocess.PIPE,
258 text=True,
259 )
260 if proc.stdout is None:
261 proc.kill()
262 raise RuntimeError("git log failed to open stdout pipe")
263
264 results: list[tuple[str, dict[str, str]]] = []
265 buf = ""
266 _chunk = 65536
267 try:
268 while True:
269 chunk = proc.stdout.read(_chunk)
270 if not chunk:
271 break
272 buf += chunk
273 while _RECORD_SEP in buf:
274 record, buf = buf.split(_RECORD_SEP, 1)
275 record = record.strip()
276 if not record:
277 continue
278 parts = record.split(_FIELD_SEP, 5)
279 if len(parts) < 6:
280 continue
281 sha, name, email, ts, parents, message = parts
282 results.append((sha.strip(), {
283 "name": name.strip(),
284 "email": email.strip(),
285 "ts": ts.strip(),
286 "parents": parents.strip(),
287 "message": message.strip(),
288 }))
289 # Flush any trailing record not followed by a separator.
290 record = buf.strip()
291 if record:
292 parts = record.split(_FIELD_SEP, 5)
293 if len(parts) >= 6:
294 sha, name, email, ts, parents, message = parts
295 results.append((sha.strip(), {
296 "name": name.strip(),
297 "email": email.strip(),
298 "ts": ts.strip(),
299 "parents": parents.strip(),
300 "message": message.strip(),
301 }))
302 finally:
303 proc.stdout.close()
304 proc.wait()
305
306 return results
307
308
309 def _batch_diff_tree(
310 repo_root: pathlib.Path,
311 shas: list[str],
312 ) -> dict[str, list[_Delta]]:
313 """Return ``{sha: [delta, ...]}`` for all *shas* in one ``git diff-tree --stdin`` call.
314
315 Streams stdout line by line — the entire diff-tree output is never buffered
316 in memory at once. For a repository with 1 M commits and ~10 changed files
317 per commit, ``communicate()`` would buffer ~2 GB; streaming keeps memory
318 proportional to the largest single-commit diff, not the entire history.
319
320 ``--root`` ensures the first (root) commit is compared against the empty
321 tree so all its files appear as additions.
322 """
323 if not shas:
324 return {}
325
326 proc = subprocess.Popen(
327 ["git", "diff-tree", "--stdin", "--root", "-r", "--raw", "--no-abbrev"],
328 cwd=repo_root,
329 stdin=subprocess.PIPE,
330 stdout=subprocess.PIPE,
331 text=True,
332 )
333 if proc.stdin is None or proc.stdout is None:
334 proc.kill()
335 raise RuntimeError("git diff-tree failed to open stdin/stdout pipes")
336
337 # Write all SHAs to stdin then close it so git starts processing.
338 proc.stdin.write("\n".join(shas) + "\n")
339 proc.stdin.close()
340
341 result: dict[str, list[_Delta]] = {sha: [] for sha in shas}
342 current_sha: str | None = None
343 sha_set = set(shas)
344
345 try:
346 for line in proc.stdout:
347 line = line.rstrip("\n")
348 if not line:
349 continue
350 if not line.startswith(":"):
351 # Commit SHA header line — first token is the SHA.
352 candidate = line.split()[0] if line.split() else ""
353 if candidate in sha_set:
354 current_sha = candidate
355 continue
356 if current_sha is None:
357 continue
358
359 meta_part, _tab, path_part = line.partition("\t")
360 fields = meta_part.split()
361 if len(fields) < 5:
362 continue
363 new_sha = fields[3]
364 status = fields[4][0]
365
366 if status in ("R", "C"):
367 old_path, _, new_path = path_part.partition("\t")
368 result[current_sha].append(
369 _Delta(status=status, blob_sha=new_sha, path=new_path, old_path=old_path)
370 )
371 else:
372 result[current_sha].append(
373 _Delta(status=status, blob_sha=new_sha, path=path_part, old_path=path_part)
374 )
375 finally:
376 proc.stdout.close()
377 proc.wait()
378
379 return result
380
381
382 # ---------------------------------------------------------------------------
383 # Manifest helpers
384 # ---------------------------------------------------------------------------
385
386
387 def _manifest_directories(manifest: Manifest) -> list[str]:
388 """Derive the sorted set of directory paths from the manifest keys.
389
390 Git does not track empty directories — every directory in a migrated
391 snapshot is implied by its file entries. This produces a consistent
392 set that matches what ``walk_workdir_with_dirs`` would return on a
393 filesystem that mirrors the manifest.
394 """
395 dirs: set[str] = set()
396 for path in manifest:
397 p = pathlib.PurePosixPath(path)
398 for parent in p.parents:
399 s = str(parent)
400 if s and s != ".":
401 dirs.add(s)
402 return sorted(dirs)
403
404
405 # ---------------------------------------------------------------------------
406 # Muse ref helpers
407 # ---------------------------------------------------------------------------
408
409
410 def _refs_dir(muse_root: pathlib.Path) -> pathlib.Path:
411 return muse_root / ".muse" / "refs" / "heads"
412
413
414 def _set_branch_head(muse_root: pathlib.Path, branch: str, commit_id: str) -> None:
415 ref_path = _refs_dir(muse_root) / branch
416 ref_path.parent.mkdir(parents=True, exist_ok=True)
417 ref_path.write_text(commit_id + "\n")
418
419
420 def _get_branch_head(muse_root: pathlib.Path, branch: str) -> str | None:
421 ref_path = _refs_dir(muse_root) / branch
422 if not ref_path.exists():
423 return None
424 return ref_path.read_text().strip() or None
425
426
427 def _ensure_branch_exists(muse_root: pathlib.Path, branch: str) -> None:
428 _refs_dir(muse_root).mkdir(parents=True, exist_ok=True)
429 ref_path = _refs_dir(muse_root) / branch
430 if not ref_path.exists():
431 ref_path.write_text("")
432
433
434 def _load_repo_id(muse_root: pathlib.Path) -> str:
435 repo_json = muse_root / ".muse" / "repo.json"
436 data: dict[str, str] = json.loads(repo_json.read_text())
437 return data["repo_id"]
438
439
440 # ---------------------------------------------------------------------------
441 # Core replay logic — incremental manifest, no temp workdir
442 # ---------------------------------------------------------------------------
443
444
445 def _replay_commit(
446 muse_root: pathlib.Path,
447 manifest: Manifest,
448 muse_branch: str,
449 parent_muse_id: str | None,
450 meta: dict[str, str],
451 repo_id: str,
452 dry_run: bool,
453 ) -> str:
454 """Write one Muse commit from the current *manifest* state.
455
456 The manifest has already been updated by the caller (added/deleted files
457 applied). This function computes the snapshot ID, writes the snapshot
458 and commit records, and returns the new Muse commit ID.
459 """
460 directories = _manifest_directories(manifest)
461 snapshot_id = compute_snapshot_id(manifest, directories=directories)
462
463 committed_at = datetime.datetime.fromtimestamp(
464 int(meta["ts"]), tz=datetime.timezone.utc
465 )
466 author = f"{meta['name']} <{meta['email']}>"
467 message = meta["message"] or "no message"
468 committed_at_iso = committed_at.isoformat()
469 parent_ids = [parent_muse_id] if parent_muse_id else []
470 commit_id = compute_commit_id(
471 parent_ids=parent_ids,
472 snapshot_id=snapshot_id,
473 message=message,
474 committed_at_iso=committed_at_iso,
475 )
476
477 if dry_run:
478 return commit_id
479
480 write_snapshot(
481 muse_root,
482 SnapshotRecord(
483 snapshot_id=snapshot_id,
484 manifest=dict(manifest),
485 directories=directories,
486 ),
487 )
488 write_commit(
489 muse_root,
490 CommitRecord(
491 commit_id=commit_id,
492 repo_id=repo_id,
493 branch=muse_branch,
494 snapshot_id=snapshot_id,
495 message=message,
496 committed_at=committed_at,
497 parent_commit_id=parent_muse_id,
498 author=author,
499 ),
500 )
501 _set_branch_head(muse_root, muse_branch, commit_id)
502 append_reflog(
503 muse_root,
504 muse_branch,
505 old_id=parent_muse_id,
506 new_id=commit_id,
507 author=author,
508 operation=f"migrate: {message[:60]}",
509 )
510 return commit_id
511
512
513 def _replay_branch(
514 git_root: pathlib.Path,
515 muse_root: pathlib.Path,
516 git_shas: list[str],
517 muse_branch: str,
518 start_parent_muse_id: str | None,
519 repo_id: str,
520 dry_run: bool,
521 verbose: bool,
522 extra_prefixes: tuple[str, ...],
523 extra_suffixes: tuple[str, ...],
524 as_json: bool,
525 initial_manifest: Manifest,
526 all_meta: dict[str, dict[str, str]],
527 ) -> tuple[dict[str, str], Manifest]:
528 """Replay *git_shas* (oldest-first) onto *muse_branch*.
529
530 Uses three batched git processes for the entire branch (not one per commit):
531
532 1. ``git diff-tree --stdin`` — all per-file deltas in one call.
533 2. ``git cat-file --batch`` — all blob content streamed through one process.
534
535 Commit metadata is pre-loaded in *all_meta* (from a single ``git log``
536 call in the caller).
537
538 The manifest is maintained incrementally: only added/modified/deleted files
539 are touched per commit, so unchanged files are never re-read or re-hashed.
540
541 Returns ``(git_sha → muse_commit_id mapping, final manifest state)``.
542 """
543 _ensure_branch_exists(muse_root, muse_branch)
544
545 git_to_muse: dict[str, str] = {}
546 parent_muse_id = start_parent_muse_id
547 manifest: Manifest = dict(initial_manifest)
548 total = len(git_shas)
549
550 # Batch all delta reads in a single git diff-tree --stdin call.
551 all_deltas = _batch_diff_tree(git_root, git_shas)
552
553 _PROGRESS_EVERY = 100 # emit a JSON progress event every N commits
554
555 with _CatFile(git_root) as cat:
556 for i, sha in enumerate(git_shas, 1):
557 meta = all_meta[sha]
558
559 if as_json:
560 if i == 1 or i == total or i % _PROGRESS_EVERY == 0:
561 print(json.dumps({
562 "event": "progress",
563 "branch": muse_branch,
564 "committed": i,
565 "total": total,
566 }), flush=True)
567 elif verbose or i % 50 == 0 or i == 1 or i == total:
568 logger.info(
569 "[%s] %d/%d git:%s %s",
570 muse_branch, i, total, sha[:12],
571 repr(meta["message"][:60]),
572 )
573
574 if not dry_run:
575 for delta in all_deltas.get(sha, []):
576 if _should_exclude(delta.path, extra_prefixes, extra_suffixes):
577 manifest.pop(delta.path, None)
578 continue
579
580 if delta.status == "D":
581 manifest.pop(delta.path, None)
582 continue
583
584 if delta.status in ("R", "C"):
585 manifest.pop(delta.old_path, None)
586
587 null40 = "0" * 40
588 if delta.blob_sha and delta.blob_sha != null40:
589 content = cat.read(delta.blob_sha)
590 if content:
591 oid = hashlib.sha256(content).hexdigest()
592 write_object(muse_root, oid, content)
593 manifest[delta.path] = oid
594
595 muse_id = _replay_commit(
596 muse_root=muse_root,
597 manifest=manifest,
598 muse_branch=muse_branch,
599 parent_muse_id=parent_muse_id,
600 meta=meta,
601 repo_id=repo_id,
602 dry_run=dry_run,
603 )
604
605 git_to_muse[sha] = muse_id
606 parent_muse_id = muse_id
607
608 return git_to_muse, manifest
609
610
611 # ---------------------------------------------------------------------------
612 # CLI wiring
613 # ---------------------------------------------------------------------------
614
615
616 def register(
617 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
618 ) -> None:
619 """Register the ``migrate`` subcommand under ``muse code``."""
620 parser = subparsers.add_parser(
621 "migrate",
622 help="Replay a Git repository's commit history into a Muse repository.",
623 description=__doc__,
624 formatter_class=argparse.RawDescriptionHelpFormatter,
625 )
626
627 parser.add_argument(
628 "source",
629 nargs="?",
630 type=pathlib.Path,
631 default=None,
632 metavar="SOURCE",
633 help=(
634 "Path to the Git repository to migrate from. "
635 "Defaults to the current working directory."
636 ),
637 )
638 parser.add_argument(
639 "--target",
640 type=pathlib.Path,
641 default=None,
642 metavar="PATH",
643 dest="target",
644 help=(
645 "Path to write the Muse repository into. "
646 "Defaults to SOURCE (migrate in-place)."
647 ),
648 )
649
650 branch_group = parser.add_mutually_exclusive_group()
651 branch_group.add_argument(
652 "--branch",
653 nargs="+",
654 default=None,
655 metavar="BRANCH",
656 dest="branches",
657 help=(
658 "Git branch(es) to replay in the given order (default: main). "
659 "The first branch is treated as primary and replayed in full; "
660 "subsequent branches replay only commits not reachable from any "
661 "previously replayed branch."
662 ),
663 )
664 branch_group.add_argument(
665 "--all",
666 action="store_true",
667 dest="all_branches",
668 help="Replay every local branch (main first, then the rest alphabetically).",
669 )
670
671 parser.add_argument(
672 "--from-ref",
673 default=None,
674 metavar="SHA",
675 dest="from_ref",
676 help=(
677 "Only replay commits newer than this Git SHA. "
678 "Useful for incremental re-runs after initial migration."
679 ),
680 )
681 parser.add_argument(
682 "--exclude",
683 nargs="+",
684 default=[],
685 metavar="PATTERN",
686 dest="excludes",
687 help=(
688 "Additional path prefixes (ending with '/') or suffixes (starting "
689 "with '.') to exclude, on top of the built-in defaults."
690 ),
691 )
692 parser.add_argument(
693 "--no-init",
694 action="store_true",
695 dest="no_init",
696 help="Skip auto-initialisation when the target already has a Muse repo.",
697 )
698 parser.add_argument(
699 "--dry-run",
700 action="store_true",
701 dest="dry_run",
702 help="Log what would happen without writing anything to disk.",
703 )
704 parser.add_argument(
705 "--verbose", "-v",
706 action="store_true",
707 dest="verbose",
708 help="Log every commit (default: log first, last, and every 50th).",
709 )
710 parser.add_argument(
711 "--json",
712 action="store_true",
713 dest="as_json",
714 help=(
715 "Emit NDJSON progress events during migration and a final 'done' "
716 "summary. Each line is a JSON object with an 'event' field: "
717 "'branch_start', 'progress', 'branch_done', or 'done'."
718 ),
719 )
720
721 parser.set_defaults(func=run)
722
723
724 def run(args: argparse.Namespace) -> None:
725 """Entry point for ``muse code migrate``."""
726 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
727
728 source: pathlib.Path = (args.source or pathlib.Path.cwd()).resolve()
729 target: pathlib.Path = (args.target or source).resolve()
730 dry_run: bool = args.dry_run
731 verbose: bool = args.verbose
732 as_json: bool = args.as_json
733 no_init: bool = args.no_init
734 from_ref: str | None = args.from_ref
735 excludes: list[str] = args.excludes
736
737 extra_prefixes: tuple[str, ...] = tuple(e for e in excludes if e.endswith("/"))
738 extra_suffixes: tuple[str, ...] = tuple(e for e in excludes if not e.endswith("/"))
739
740 if not _git_is_repo(source):
741 logger.error("❌ %s is not a Git repository.", source)
742 sys.exit(1)
743
744 if args.all_branches:
745 all_branches = _git_local_branches(source)
746 primaries = [b for b in all_branches if b == "main"]
747 others = sorted(b for b in all_branches if b != "main")
748 requested: list[str] = primaries + others
749 else:
750 requested = args.branches or ["main"]
751
752 if not requested:
753 logger.error("❌ No branches to replay.")
754 sys.exit(1)
755
756 if not as_json:
757 logger.info("━━━ muse code migrate ━━━")
758 logger.info(" source : %s", source)
759 logger.info(" target : %s", target)
760 logger.info(" branches: %s", requested)
761 if dry_run:
762 logger.info(" mode : DRY RUN — nothing will be written")
763
764 muse_json = target / ".muse" / "repo.json"
765 if not muse_json.exists() and not no_init:
766 if not as_json:
767 logger.info("No .muse/repo.json — running 'muse init --domain code' …")
768 if not dry_run:
769 target.mkdir(parents=True, exist_ok=True)
770 result = subprocess.run(
771 ["muse", "init", "--domain", "code"],
772 cwd=target,
773 capture_output=True,
774 text=True,
775 )
776 if result.returncode != 0:
777 logger.error("❌ muse init failed:\n%s", result.stderr)
778 sys.exit(1)
779 if not as_json:
780 logger.info("✅ muse init succeeded")
781
782 repo_id = _load_repo_id(target) if muse_json.exists() else "dry-run-placeholder"
783
784 started_at = time.monotonic()
785 all_git_to_muse: dict[str, str] = {}
786 replayed_branches: list[str] = []
787 branch_stats: list[dict[str, object]] = []
788 # Carry the final manifest of the primary branch so feature branches
789 # start from the right tree state instead of from empty.
790 primary_final_manifest: Manifest = {}
791
792 for branch in requested:
793 if not as_json:
794 logger.info("━━━ Replaying branch: %s ━━━", branch)
795
796 exclude_refs = replayed_branches if replayed_branches else None
797
798 # One git log call: all SHA + metadata for the branch, oldest first.
799 commit_log = _batch_commit_log(
800 source, branch,
801 exclude_branches=exclude_refs,
802 from_ref=from_ref if not exclude_refs else None,
803 )
804
805 # Split non-merge from merge commits using the pre-loaded parent field.
806 non_merge: list[tuple[str, dict[str, str]]] = []
807 merge_count = 0
808 for sha, meta in commit_log:
809 parents = meta.get("parents", "").split()
810 if len(parents) > 1:
811 merge_count += 1
812 else:
813 non_merge.append((sha, meta))
814
815 git_shas = [sha for sha, _ in non_merge]
816 all_meta: dict[str, dict[str, str]] = {sha: meta for sha, meta in non_merge}
817
818 if as_json:
819 print(json.dumps({
820 "event": "branch_start",
821 "branch": branch,
822 "total_commits": len(git_shas),
823 "merge_commits_skipped": merge_count,
824 }), flush=True)
825 else:
826 logger.info(
827 " %d commits unique to %s (%d merge commits skipped)",
828 len(git_shas), branch, merge_count,
829 )
830
831 if not git_shas:
832 if not as_json:
833 logger.info(" %s has no unique commits — skipping", branch)
834 replayed_branches.append(branch)
835 stat: dict[str, object] = {
836 "branch": branch,
837 "commits_written": 0,
838 "merge_commits_skipped": merge_count,
839 "skipped": True,
840 }
841 branch_stats.append(stat)
842 if as_json:
843 print(json.dumps({"event": "branch_done", **stat}), flush=True)
844 continue
845
846 # Find the Muse parent commit to branch from.
847 start_parent_muse_id: str | None = None
848 oldest_sha = git_shas[0]
849 oldest_parents = all_meta[oldest_sha].get("parents", "").split()
850 for gp in oldest_parents:
851 if gp in all_git_to_muse:
852 start_parent_muse_id = all_git_to_muse[gp]
853 break
854 if start_parent_muse_id is None and replayed_branches:
855 start_parent_muse_id = _get_branch_head(target, replayed_branches[0])
856
857 # Secondary branches start from the primary's final tree state so the
858 # incremental diff-tree approach produces correct manifests.
859 initial_manifest: Manifest = (
860 {} if not replayed_branches else dict(primary_final_manifest)
861 )
862
863 write_head_branch(target, branch)
864 mapping, final_manifest = _replay_branch(
865 git_root=source,
866 muse_root=target,
867 git_shas=git_shas,
868 muse_branch=branch,
869 start_parent_muse_id=start_parent_muse_id,
870 repo_id=repo_id,
871 dry_run=dry_run,
872 verbose=verbose,
873 extra_prefixes=extra_prefixes,
874 extra_suffixes=extra_suffixes,
875 as_json=as_json,
876 initial_manifest=initial_manifest,
877 all_meta=all_meta,
878 )
879
880 all_git_to_muse.update(mapping)
881 replayed_branches.append(branch)
882 if not replayed_branches[1:]: # first branch = primary
883 primary_final_manifest = final_manifest
884
885 branch_stat: dict[str, object] = {
886 "branch": branch,
887 "commits_written": len(mapping),
888 "merge_commits_skipped": merge_count,
889 "skipped": False,
890 }
891 branch_stats.append(branch_stat)
892 if as_json:
893 print(json.dumps({"event": "branch_done", **branch_stat}), flush=True)
894 else:
895 logger.info("✅ %s: %d commits written", branch, len(mapping))
896
897 if not dry_run and replayed_branches:
898 write_head_branch(target, replayed_branches[0])
899
900 elapsed = time.monotonic() - started_at
901 total_written = len(all_git_to_muse)
902
903 if as_json:
904 print(json.dumps({
905 "event": "done",
906 "source": str(source),
907 "target": str(target),
908 "dry_run": dry_run,
909 "branches": branch_stats,
910 "total_commits_written": total_written,
911 "elapsed_seconds": round(elapsed, 2),
912 }), flush=True)
913 else:
914 logger.info(
915 "━━━ Done ━━━ %d commits written across %d branch(es) in %.1fs",
916 total_written,
917 len(replayed_branches),
918 elapsed,
919 )
920 if dry_run:
921 logger.info("(dry-run — nothing was written)")
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago