importer.py
python
sha256:a317886dc0496c4af7b285b3e41c86c4c34ea2e79afc63b8829aadb1ada7903f
chore: bump version to 0.2.0rc15 to match musehub#113 fix release
Sonnet 4.6
patch
65 days ago
| 1 | """Bridge import engine — Git → Muse commit replay. |
| 2 | |
| 3 | Orchestrates reading git commits via :mod:`.git_primitives` and writing |
| 4 | Muse commit/snapshot records into the object store. |
| 5 | |
| 6 | Exports |
| 7 | ------- |
| 8 | _replay_commit Convert one git commit to a Muse CommitRecord |
| 9 | _replay_branch Replay all commits on a git branch into Muse |
| 10 | _import_tags Import git tags as Muse tag records |
| 11 | run_git_import CLI entry point for ``muse bridge git-import`` |
| 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 | from typing import Any |
| 23 | |
| 24 | from muse.core.bridge.git_primitives import ( |
| 25 | AttributionMapper, |
| 26 | _CatFile, |
| 27 | _batch_commit_log, |
| 28 | _batch_diff_tree, |
| 29 | _git, |
| 30 | _is_lfs_pointer, |
| 31 | _list_git_branches, |
| 32 | _list_git_tags, |
| 33 | _parse_sem_ver_bump, |
| 34 | _should_exclude, |
| 35 | _strip_ansi, |
| 36 | ) |
| 37 | from muse.core.bridge.state import ( |
| 38 | BridgeState, |
| 39 | SnapshotManifest, |
| 40 | read_bridge_state, |
| 41 | write_bridge_state, |
| 42 | ) |
| 43 | from muse.core.errors import ExitCode |
| 44 | from muse.core.paths import muse_dir, ref_path |
| 45 | from muse.core.types import now_utc_iso |
| 46 | |
| 47 | # GitCommitInfo and _CatFile are imported above; type alias here for clarity |
| 48 | from muse.core.bridge.git_primitives import GitCommitInfo |
| 49 | |
| 50 | |
| 51 | def _replay_commit( |
| 52 | root: pathlib.Path, |
| 53 | git_dir: pathlib.Path, |
| 54 | commit_info: GitCommitInfo, |
| 55 | current_manifest: SnapshotManifest, |
| 56 | cat_file: _CatFile, |
| 57 | attribution: AttributionMapper, |
| 58 | branch: str, |
| 59 | extra_excludes: list[str], |
| 60 | dry_run: bool, |
| 61 | lfs_skip: bool, |
| 62 | preserve_merge: bool, |
| 63 | ) -> tuple[str, dict[str, str]]: |
| 64 | """Convert one git commit to a Muse CommitRecord + SnapshotRecord. |
| 65 | |
| 66 | Reads file blobs from *git_dir* via *cat_file*, updates *current_manifest* |
| 67 | incrementally (add / modify / delete), and writes the snapshot and commit |
| 68 | records to the Muse object store at *root*. |
| 69 | |
| 70 | Conventional commit prefixes in the message are parsed to set |
| 71 | ``sem_ver_bump`` (``feat:`` → minor, ``fix:`` → patch, breaking → major). |
| 72 | |
| 73 | Args: |
| 74 | root: Muse repository root. |
| 75 | git_dir: Git repository root. |
| 76 | commit_info: Commit metadata dict from :func:`_batch_commit_log`. |
| 77 | current_manifest: In-progress manifest (mutated in-place). |
| 78 | cat_file: Long-running cat-file process. |
| 79 | attribution: Email → handle mapper. |
| 80 | branch: Muse branch name to write to. |
| 81 | extra_excludes: Extra path patterns to exclude. |
| 82 | dry_run: If True, skip all disk writes. |
| 83 | lfs_skip: If True, skip LFS pointer files. |
| 84 | preserve_merge: If True, record both parent IDs for merge commits. |
| 85 | |
| 86 | Returns: |
| 87 | ``(muse_commit_id, updated_manifest)`` — the new manifest after applying |
| 88 | this commit's changes. |
| 89 | """ |
| 90 | from muse.core.object_store import write_object |
| 91 | from muse.core.commits import CommitRecord, write_commit |
| 92 | from muse.core.snapshots import SnapshotRecord, write_snapshot |
| 93 | from muse.core.ids import hash_commit, hash_snapshot |
| 94 | from muse.core.types import blob_id |
| 95 | |
| 96 | sha = commit_info["sha"] |
| 97 | parent_shas = commit_info["parent_shas"] |
| 98 | author_email = commit_info["author_email"] |
| 99 | author_name = commit_info["author_name"] |
| 100 | author_date_str = commit_info["author_date"] |
| 101 | subject = _strip_ansi(commit_info["subject"]) |
| 102 | body = _strip_ansi(commit_info["body"]) |
| 103 | |
| 104 | message = subject |
| 105 | if body: |
| 106 | message = f"{subject}\n\n{body}" |
| 107 | |
| 108 | author_handle = attribution.get_handle(author_email, author_name) |
| 109 | sem_ver_bump = _parse_sem_ver_bump(message) |
| 110 | |
| 111 | parent_sha = parent_shas[0] if parent_shas else None |
| 112 | diff = _batch_diff_tree(git_dir, sha, parent_sha) |
| 113 | |
| 114 | new_manifest = dict(current_manifest) |
| 115 | for path, blob_sha in diff.items(): |
| 116 | if _should_exclude(path, extra_excludes): |
| 117 | continue |
| 118 | if blob_sha is None: |
| 119 | new_manifest.pop(path, None) |
| 120 | else: |
| 121 | content = cat_file.read(blob_sha) |
| 122 | if lfs_skip and _is_lfs_pointer(content): |
| 123 | continue |
| 124 | obj_id = blob_id(content) |
| 125 | if not dry_run: |
| 126 | write_object(root, obj_id, content) |
| 127 | new_manifest[path] = obj_id |
| 128 | |
| 129 | try: |
| 130 | committed_at = datetime.datetime.fromisoformat(author_date_str) |
| 131 | except (ValueError, TypeError): |
| 132 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 133 | |
| 134 | snapshot_id = hash_snapshot(new_manifest) |
| 135 | snapshot = SnapshotRecord( |
| 136 | snapshot_id=snapshot_id, |
| 137 | manifest=new_manifest, |
| 138 | directories=[], |
| 139 | created_at=committed_at, |
| 140 | note=f"git-import:{sha}", |
| 141 | ) |
| 142 | |
| 143 | commit_id = hash_commit( |
| 144 | parent_ids=[], |
| 145 | snapshot_id=snapshot_id, |
| 146 | message=message, |
| 147 | committed_at_iso=committed_at.isoformat(), |
| 148 | author=author_handle, |
| 149 | ) |
| 150 | |
| 151 | commit_record = CommitRecord( |
| 152 | commit_id=commit_id, |
| 153 | branch=branch, |
| 154 | snapshot_id=snapshot_id, |
| 155 | message=message, |
| 156 | committed_at=committed_at, |
| 157 | parent_commit_id=None, # set by caller after chaining |
| 158 | parent2_commit_id=None, |
| 159 | author=author_handle, |
| 160 | sem_ver_bump=sem_ver_bump, |
| 161 | agent_id="git-import", |
| 162 | model_id="", |
| 163 | metadata={"git_sha": sha}, |
| 164 | ) |
| 165 | |
| 166 | if not dry_run: |
| 167 | write_snapshot(root, snapshot) |
| 168 | write_commit(root, commit_record) |
| 169 | |
| 170 | return commit_id, new_manifest |
| 171 | |
| 172 | |
| 173 | def _replay_branch( |
| 174 | root: pathlib.Path, |
| 175 | git_dir: pathlib.Path, |
| 176 | branch: str, |
| 177 | muse_branch: str, |
| 178 | from_sha: str | None, |
| 179 | attribution: AttributionMapper, |
| 180 | extra_excludes: list[str], |
| 181 | dry_run: bool, |
| 182 | lfs_skip: bool, |
| 183 | preserve_merge: bool, |
| 184 | verbose: bool, |
| 185 | json_out: bool, |
| 186 | ) -> tuple[int, str | None]: |
| 187 | """Orchestrate replay of all commits on a git branch into Muse. |
| 188 | |
| 189 | Calls :func:`_batch_commit_log` to get the ordered commit list, then |
| 190 | iterates through each commit calling :func:`_replay_commit`. After each |
| 191 | commit the Muse branch ref is advanced and a reflog entry appended. |
| 192 | |
| 193 | Args: |
| 194 | root: Muse repository root. |
| 195 | git_dir: Git repository root. |
| 196 | branch: Git branch name to import from. |
| 197 | muse_branch: Muse branch name to write to. |
| 198 | from_sha: If given, only import commits after this SHA. |
| 199 | attribution: Email → handle mapper. |
| 200 | extra_excludes: Extra path patterns to exclude. |
| 201 | dry_run: If True, report but do not write. |
| 202 | lfs_skip: If True, skip LFS pointer files. |
| 203 | preserve_merge: If True, record merge commit parents. |
| 204 | verbose: If True, print per-commit progress to stderr. |
| 205 | json_out: If True, emit NDJSON events. |
| 206 | |
| 207 | Returns: |
| 208 | ``(commits_written, last_muse_commit_id)`` where *last_muse_commit_id* |
| 209 | is ``None`` when no commits were written. |
| 210 | """ |
| 211 | from muse.core.refs import write_branch_ref |
| 212 | from muse.core.commits import commit_exists, read_commit, CommitRecord, write_commit |
| 213 | from muse.core.snapshots import read_snapshot, SnapshotRecord, write_snapshot |
| 214 | from muse.core.ids import hash_commit, hash_snapshot |
| 215 | from muse.core.reflog import append_reflog |
| 216 | |
| 217 | _branch_ref = ref_path(root, muse_branch) |
| 218 | prev_muse_commit_id: str | None = None |
| 219 | if _branch_ref.exists(): |
| 220 | content = _branch_ref.read_text().strip() |
| 221 | if content: |
| 222 | prev_muse_commit_id = content |
| 223 | |
| 224 | current_manifest: dict[str, str] = {} |
| 225 | if from_sha is not None and prev_muse_commit_id: |
| 226 | existing_commit = read_commit(root, prev_muse_commit_id) |
| 227 | if existing_commit: |
| 228 | snapshot = read_snapshot(root, existing_commit.snapshot_id) |
| 229 | if snapshot: |
| 230 | current_manifest = dict(snapshot.manifest) |
| 231 | |
| 232 | commits = _batch_commit_log(git_dir, branch, from_sha) |
| 233 | if not commits: |
| 234 | return 0, prev_muse_commit_id |
| 235 | |
| 236 | commits_written = 0 |
| 237 | last_muse_id: str | None = prev_muse_commit_id if from_sha is not None else None |
| 238 | prev_git_sha_to_muse: dict[str, str] = {} |
| 239 | |
| 240 | with _CatFile(git_dir) as cat_file: |
| 241 | for commit_info in commits: |
| 242 | git_sha = commit_info["sha"] |
| 243 | |
| 244 | muse_commit_id, current_manifest = _replay_commit( |
| 245 | root=root, |
| 246 | git_dir=git_dir, |
| 247 | commit_info=commit_info, |
| 248 | current_manifest=current_manifest, |
| 249 | cat_file=cat_file, |
| 250 | attribution=attribution, |
| 251 | branch=muse_branch, |
| 252 | extra_excludes=extra_excludes, |
| 253 | dry_run=dry_run, |
| 254 | lfs_skip=lfs_skip, |
| 255 | preserve_merge=preserve_merge, |
| 256 | ) |
| 257 | |
| 258 | if not dry_run and last_muse_id: |
| 259 | cr = read_commit(root, muse_commit_id) |
| 260 | if cr and cr.parent_commit_id is None: |
| 261 | from muse.core.ids import hash_commit as _ccid |
| 262 | new_id = _ccid( |
| 263 | parent_ids=[last_muse_id], |
| 264 | snapshot_id=cr.snapshot_id, |
| 265 | message=cr.message, |
| 266 | committed_at_iso=cr.committed_at.isoformat(), |
| 267 | author=cr.author, |
| 268 | ) |
| 269 | cr2 = CommitRecord( |
| 270 | commit_id=new_id, |
| 271 | branch=cr.branch, |
| 272 | snapshot_id=cr.snapshot_id, |
| 273 | message=cr.message, |
| 274 | committed_at=cr.committed_at, |
| 275 | parent_commit_id=last_muse_id, |
| 276 | parent2_commit_id=cr.parent2_commit_id, |
| 277 | author=cr.author, |
| 278 | sem_ver_bump=cr.sem_ver_bump, |
| 279 | agent_id=cr.agent_id, |
| 280 | model_id=cr.model_id, |
| 281 | metadata=cr.metadata, |
| 282 | ) |
| 283 | try: |
| 284 | write_commit(root, cr2) |
| 285 | muse_commit_id = new_id |
| 286 | except Exception: |
| 287 | pass |
| 288 | |
| 289 | if not dry_run: |
| 290 | try: |
| 291 | write_branch_ref(root, muse_branch, muse_commit_id) |
| 292 | append_reflog( |
| 293 | root, |
| 294 | muse_branch, |
| 295 | last_muse_id, |
| 296 | muse_commit_id, |
| 297 | author=commit_info.get("author_name", "git-import"), |
| 298 | operation=f"git-import: {git_sha[:12]}", |
| 299 | ) |
| 300 | except Exception: |
| 301 | pass |
| 302 | |
| 303 | prev_git_sha_to_muse[git_sha] = muse_commit_id |
| 304 | last_muse_id = muse_commit_id |
| 305 | commits_written += 1 |
| 306 | |
| 307 | if verbose: |
| 308 | print( |
| 309 | f" [{commits_written}] {git_sha[:8]} → {muse_commit_id[:23]}… " |
| 310 | f"{commit_info['subject'][:60]}", |
| 311 | file=sys.stderr, |
| 312 | ) |
| 313 | if json_out: |
| 314 | print(json.dumps({ |
| 315 | "event": "commit", |
| 316 | "n": commits_written, |
| 317 | "git_sha": git_sha, |
| 318 | "muse_commit_id": muse_commit_id, |
| 319 | "message": commit_info["subject"][:120], |
| 320 | })) |
| 321 | |
| 322 | return commits_written, last_muse_id |
| 323 | |
| 324 | |
| 325 | def _import_tags( |
| 326 | root: pathlib.Path, |
| 327 | git_dir: pathlib.Path, |
| 328 | cat_file: _CatFile, |
| 329 | muse_commit_id_map: dict[str, str], |
| 330 | ) -> int: |
| 331 | """Import git tags as Muse tags where a mapping exists. |
| 332 | |
| 333 | For each git tag, looks up the git SHA in *muse_commit_id_map* to find |
| 334 | the corresponding Muse commit. Creates a :class:`TagRecord` for matched |
| 335 | tags. Tags whose SHA has no Muse counterpart are skipped. |
| 336 | |
| 337 | Args: |
| 338 | root: Muse repository root. |
| 339 | git_dir: Git repository root. |
| 340 | cat_file: Long-running cat-file process. |
| 341 | muse_commit_id_map: Mapping from git SHA to Muse commit ID. |
| 342 | |
| 343 | Returns: |
| 344 | Number of tags imported. |
| 345 | """ |
| 346 | from muse.core.tags import TagRecord, compute_tag_id, write_tag |
| 347 | from muse.core.repo import read_repo_id |
| 348 | |
| 349 | repo_id = read_repo_id(root) |
| 350 | git_tags = _list_git_tags(git_dir) |
| 351 | imported = 0 |
| 352 | |
| 353 | for tag_info in git_tags: |
| 354 | tag_name = tag_info["name"] |
| 355 | try: |
| 356 | sha = _git(git_dir, "rev-list", "-1", tag_name).strip() |
| 357 | except subprocess.CalledProcessError: |
| 358 | continue |
| 359 | muse_commit_id = muse_commit_id_map.get(sha) |
| 360 | if not muse_commit_id: |
| 361 | continue |
| 362 | tag_id = compute_tag_id(repo_id, muse_commit_id, tag_name) |
| 363 | tag = TagRecord( |
| 364 | tag_id=tag_id, |
| 365 | repo_id=repo_id, |
| 366 | commit_id=muse_commit_id, |
| 367 | tag=tag_name, |
| 368 | message=f"git-import: {tag_name}", |
| 369 | ) |
| 370 | try: |
| 371 | write_tag(root, tag) |
| 372 | imported += 1 |
| 373 | except Exception: |
| 374 | pass |
| 375 | |
| 376 | return imported |
| 377 | |
| 378 | |
| 379 | def run_git_import(args: argparse.Namespace) -> None: |
| 380 | """Entry point for ``muse bridge git-import``. |
| 381 | |
| 382 | Replays commits from a git repository into a Muse repository. |
| 383 | |
| 384 | Incremental import reads ``.muse/git-bridge.toml`` to discover the last |
| 385 | imported git SHA and only imports commits introduced since that point. |
| 386 | Full import (default) replays the complete branch history. |
| 387 | |
| 388 | Args: |
| 389 | args: Parsed argparse namespace; see the CLI register function for |
| 390 | flag definitions. |
| 391 | |
| 392 | Raises: |
| 393 | SystemExit(ExitCode.USER_ERROR): No valid git repository at *source*. |
| 394 | SystemExit(ExitCode.USER_ERROR): ``--from-ref`` does not exist in git. |
| 395 | SystemExit(ExitCode.INTERNAL_ERROR): Object store I/O failure. |
| 396 | """ |
| 397 | from muse.core.repo import find_repo_root, read_repo_id |
| 398 | from muse.core.paths import init_repo_dirs |
| 399 | |
| 400 | source = pathlib.Path(args.source or ".").resolve() |
| 401 | json_out: bool = args.json_out |
| 402 | dry_run: bool = getattr(args, "dry_run", False) |
| 403 | verbose: bool = getattr(args, "verbose", False) |
| 404 | incremental: bool = getattr(args, "incremental", False) |
| 405 | all_branches: bool = getattr(args, "all_branches", False) |
| 406 | from_ref: str | None = getattr(args, "from_ref", None) |
| 407 | lfs_skip: bool = getattr(args, "lfs_skip", False) |
| 408 | preserve_merge: bool = getattr(args, "preserve_merge_commits", False) |
| 409 | no_init: bool = getattr(args, "no_init", False) |
| 410 | domain: str = getattr(args, "domain", "code") |
| 411 | import_tags: bool = getattr(args, "import_tags", False) |
| 412 | extra_excludes: list[str] = getattr(args, "excludes", []) or [] |
| 413 | attr_map_path: pathlib.Path | None = ( |
| 414 | pathlib.Path(args.attribution_map) if args.attribution_map else None |
| 415 | ) |
| 416 | branch_args: list[str] = getattr(args, "branches", []) or [] |
| 417 | |
| 418 | target_arg = getattr(args, "target", None) |
| 419 | if target_arg: |
| 420 | target = pathlib.Path(target_arg).resolve() |
| 421 | else: |
| 422 | target = find_repo_root() or pathlib.Path.cwd() |
| 423 | |
| 424 | if not (source / ".git").exists(): |
| 425 | msg = f"No git repository found at {source}" |
| 426 | if json_out: |
| 427 | print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR})) |
| 428 | else: |
| 429 | print(f"Error: {msg}", file=sys.stderr) |
| 430 | raise SystemExit(ExitCode.USER_ERROR) |
| 431 | |
| 432 | if not muse_dir(target).exists(): |
| 433 | if no_init: |
| 434 | msg = f"No Muse repository at {target} and --no-init was set." |
| 435 | if json_out: |
| 436 | print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR})) |
| 437 | else: |
| 438 | print(f"Error: {msg}", file=sys.stderr) |
| 439 | raise SystemExit(ExitCode.USER_ERROR) |
| 440 | from muse.cli.app import main as _muse_main |
| 441 | import os as _os |
| 442 | _saved = _os.getcwd() |
| 443 | try: |
| 444 | target.mkdir(parents=True, exist_ok=True) |
| 445 | _os.chdir(target) |
| 446 | try: |
| 447 | _muse_main(["init", "--domain", domain]) |
| 448 | except SystemExit: |
| 449 | pass |
| 450 | finally: |
| 451 | _os.chdir(_saved) |
| 452 | |
| 453 | bridge_state = read_bridge_state(target) |
| 454 | from_sha: str | None = from_ref |
| 455 | |
| 456 | if incremental and not from_sha: |
| 457 | last_import = bridge_state.get("last_import", {}) |
| 458 | from_sha = last_import.get("git_sha") or None |
| 459 | |
| 460 | if from_sha: |
| 461 | try: |
| 462 | _git(source, "cat-file", "-t", from_sha, check=True) |
| 463 | except subprocess.CalledProcessError: |
| 464 | msg = f"--from-ref {from_sha!r} does not exist in the git repository." |
| 465 | if json_out: |
| 466 | print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR})) |
| 467 | else: |
| 468 | print(f"Error: {msg}", file=sys.stderr) |
| 469 | raise SystemExit(ExitCode.USER_ERROR) |
| 470 | |
| 471 | if all_branches: |
| 472 | git_branches = _list_git_branches(source) |
| 473 | elif branch_args: |
| 474 | git_branches = branch_args |
| 475 | else: |
| 476 | available = _list_git_branches(source) |
| 477 | if "main" in available: |
| 478 | git_branches = ["main"] |
| 479 | elif "master" in available: |
| 480 | git_branches = ["master"] |
| 481 | elif available: |
| 482 | git_branches = [available[0]] |
| 483 | else: |
| 484 | git_branches = [] |
| 485 | |
| 486 | attribution = AttributionMapper(attr_map_path) |
| 487 | |
| 488 | total_commits_written = 0 |
| 489 | last_git_sha: str | None = None |
| 490 | last_muse_commit_id: str | None = None |
| 491 | last_muse_branch: str | None = None |
| 492 | muse_commit_id_map: dict[str, str] = {} |
| 493 | |
| 494 | for branch in git_branches: |
| 495 | muse_branch = branch |
| 496 | |
| 497 | if verbose: |
| 498 | print(f"Importing branch {branch!r} → {muse_branch!r}…", file=sys.stderr) |
| 499 | |
| 500 | n, last_id = _replay_branch( |
| 501 | root=target, |
| 502 | git_dir=source, |
| 503 | branch=branch, |
| 504 | muse_branch=muse_branch, |
| 505 | from_sha=from_sha if len(git_branches) == 1 else None, |
| 506 | attribution=attribution, |
| 507 | extra_excludes=extra_excludes, |
| 508 | dry_run=dry_run, |
| 509 | lfs_skip=lfs_skip, |
| 510 | preserve_merge=preserve_merge, |
| 511 | verbose=verbose, |
| 512 | json_out=json_out, |
| 513 | ) |
| 514 | total_commits_written += n |
| 515 | if last_id: |
| 516 | last_muse_commit_id = last_id |
| 517 | last_muse_branch = muse_branch |
| 518 | |
| 519 | if git_branches: |
| 520 | try: |
| 521 | last_git_sha = _git(source, "rev-parse", git_branches[0]).strip() |
| 522 | except subprocess.CalledProcessError: |
| 523 | last_git_sha = None |
| 524 | |
| 525 | tags_imported = 0 |
| 526 | if import_tags and not dry_run: |
| 527 | with _CatFile(source) as cf: |
| 528 | tags_imported = _import_tags(target, source, cf, muse_commit_id_map) |
| 529 | |
| 530 | if not dry_run and last_git_sha and last_muse_commit_id: |
| 531 | new_state: BridgeState = { |
| 532 | "last_import": { |
| 533 | "git_sha": last_git_sha, |
| 534 | "git_ref": git_branches[0] if git_branches else "", |
| 535 | "git_remote": "", |
| 536 | "muse_branch": last_muse_branch or "", |
| 537 | "muse_commit_id": last_muse_commit_id, |
| 538 | "imported_at": now_utc_iso(), |
| 539 | "commits_written": total_commits_written, |
| 540 | }, |
| 541 | "last_export": dict(bridge_state.get("last_export", {})), |
| 542 | } |
| 543 | try: |
| 544 | write_bridge_state(target, new_state) |
| 545 | except Exception: |
| 546 | pass |
| 547 | |
| 548 | rerere_imported = 0 |
| 549 | stashes_imported = 0 |
| 550 | |
| 551 | if getattr(args, "import_rerere", False): |
| 552 | rerere_confidence: float = getattr(args, "rerere_confidence", 0.7) |
| 553 | try: |
| 554 | from muse.core.bridge.harmony_shelf import import_rerere_to_harmony |
| 555 | rerere_imported = import_rerere_to_harmony( |
| 556 | target, source, confidence=rerere_confidence, dry_run=dry_run |
| 557 | ) |
| 558 | except (FileNotFoundError, ImportError): |
| 559 | rerere_imported = 0 |
| 560 | |
| 561 | if getattr(args, "import_stashes", False): |
| 562 | try: |
| 563 | from muse.core.bridge.harmony_shelf import import_stashes_to_shelf |
| 564 | stashes_imported = import_stashes_to_shelf(target, source, dry_run=dry_run) |
| 565 | except ImportError: |
| 566 | stashes_imported = 0 |
| 567 | |
| 568 | if json_out: |
| 569 | print(json.dumps({ |
| 570 | "event": "done", |
| 571 | "source": str(source), |
| 572 | "target": str(target), |
| 573 | "dry_run": dry_run, |
| 574 | "total_commits_written": total_commits_written, |
| 575 | "tags_imported": tags_imported, |
| 576 | "bridge_state_updated": not dry_run and bool(last_muse_commit_id), |
| 577 | "rerere_imported": rerere_imported, |
| 578 | "stashes_imported": stashes_imported, |
| 579 | })) |
| 580 | elif verbose or total_commits_written > 0: |
| 581 | print( |
| 582 | f"Imported {total_commits_written} commits from {source.name!r} → Muse " |
| 583 | f"({'dry run' if dry_run else 'done'})" |
| 584 | ) |
| 585 | |
| 586 | |
| 587 | def _register_git_import_parser( |
| 588 | subs: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 589 | ) -> None: |
| 590 | """Register the ``git-import`` subcommand onto *subs*.""" |
| 591 | import argparse as _ap |
| 592 | |
| 593 | p = subs.add_parser( |
| 594 | "git-import", |
| 595 | help="Import commits from a git repository into a Muse repository.", |
| 596 | formatter_class=_ap.RawDescriptionHelpFormatter, |
| 597 | description=( |
| 598 | "Replay a git repository's commit history into a Muse repository.\n" |
| 599 | "Use --incremental to only import commits since the last run." |
| 600 | ), |
| 601 | ) |
| 602 | p.add_argument( |
| 603 | "source", |
| 604 | nargs="?", |
| 605 | default=None, |
| 606 | metavar="SOURCE", |
| 607 | help="Path to the git repository to import from (default: current directory).", |
| 608 | ) |
| 609 | p.add_argument( |
| 610 | "--target", |
| 611 | default=None, |
| 612 | metavar="PATH", |
| 613 | help="Path to the Muse repository to write into (default: current directory).", |
| 614 | ) |
| 615 | p.add_argument( |
| 616 | "--branch", |
| 617 | action="append", |
| 618 | dest="branches", |
| 619 | default=[], |
| 620 | metavar="BRANCH", |
| 621 | help="Git branch to import (may be repeated; default: main).", |
| 622 | ) |
| 623 | p.add_argument( |
| 624 | "--all", |
| 625 | action="store_true", |
| 626 | default=False, |
| 627 | dest="all_branches", |
| 628 | help="Import all local git branches.", |
| 629 | ) |
| 630 | p.add_argument( |
| 631 | "--from-ref", |
| 632 | default=None, |
| 633 | metavar="SHA|TAG|BRANCH", |
| 634 | help="Start import from this git ref (for partial / incremental runs).", |
| 635 | ) |
| 636 | p.add_argument( |
| 637 | "--incremental", |
| 638 | action="store_true", |
| 639 | default=False, |
| 640 | help="Auto-detect the last imported SHA from .muse/git-bridge.toml.", |
| 641 | ) |
| 642 | p.add_argument( |
| 643 | "--attribution-map", |
| 644 | default=None, |
| 645 | metavar="PATH", |
| 646 | help="JSON file mapping git author emails to Muse handles.", |
| 647 | ) |
| 648 | p.add_argument( |
| 649 | "--sign", |
| 650 | action="store_true", |
| 651 | default=False, |
| 652 | help="MSign every created Muse commit with an Ed25519 signature.", |
| 653 | ) |
| 654 | p.add_argument( |
| 655 | "--preserve-merge-commits", |
| 656 | action="store_true", |
| 657 | default=False, |
| 658 | help="Record git merge commits as Muse commits with two parent IDs.", |
| 659 | ) |
| 660 | p.add_argument( |
| 661 | "--lfs-skip", |
| 662 | action="store_true", |
| 663 | default=False, |
| 664 | help="Silently skip git LFS pointer files instead of importing them.", |
| 665 | ) |
| 666 | p.add_argument( |
| 667 | "--lfs-fetch", |
| 668 | action="store_true", |
| 669 | default=False, |
| 670 | help="Download git LFS objects before importing.", |
| 671 | ) |
| 672 | p.add_argument( |
| 673 | "--no-init", |
| 674 | action="store_true", |
| 675 | default=False, |
| 676 | help="Do not run 'muse init' if .muse/ is missing.", |
| 677 | ) |
| 678 | p.add_argument( |
| 679 | "--domain", |
| 680 | default="code", |
| 681 | metavar="DOMAIN", |
| 682 | help="Muse domain for a newly initialised repository (default: code).", |
| 683 | ) |
| 684 | p.add_argument( |
| 685 | "--import-tags", |
| 686 | action="store_true", |
| 687 | default=False, |
| 688 | help="Import git tags as Muse tags after importing commits.", |
| 689 | ) |
| 690 | p.add_argument( |
| 691 | "--exclude", |
| 692 | action="append", |
| 693 | dest="excludes", |
| 694 | default=[], |
| 695 | metavar="PATTERN", |
| 696 | help="Glob patterns for files to exclude from the import (repeatable).", |
| 697 | ) |
| 698 | p.add_argument( |
| 699 | "--dry-run", |
| 700 | action="store_true", |
| 701 | default=False, |
| 702 | help="Report what would be created without writing anything.", |
| 703 | ) |
| 704 | p.add_argument( |
| 705 | "--verbose", "-v", |
| 706 | action="store_true", |
| 707 | default=False, |
| 708 | help="Print per-commit progress to stderr.", |
| 709 | ) |
| 710 | p.add_argument( |
| 711 | "--json", "-j", |
| 712 | dest="json_out", |
| 713 | action="store_true", |
| 714 | default=False, |
| 715 | help="Emit NDJSON events (one JSON object per line).", |
| 716 | ) |
| 717 | p.add_argument( |
| 718 | "--import-rerere", |
| 719 | action="store_true", |
| 720 | default=False, |
| 721 | help="Import git rerere conflict resolutions into Muse Harmony patterns.", |
| 722 | ) |
| 723 | p.add_argument( |
| 724 | "--rerere-confidence", |
| 725 | type=float, |
| 726 | default=0.7, |
| 727 | metavar="FLOAT", |
| 728 | help="Confidence score for rerere-imported resolutions (default: 0.7).", |
| 729 | ) |
| 730 | p.add_argument( |
| 731 | "--import-stashes", |
| 732 | action="store_true", |
| 733 | default=False, |
| 734 | help="Import git stashes as Muse shelf entries.", |
| 735 | ) |
| 736 | p.set_defaults(func=run_git_import) |
File History
1 commit
sha256:a317886dc0496c4af7b285b3e41c86c4c34ea2e79afc63b8829aadb1ada7903f
chore: bump version to 0.2.0rc15 to match musehub#113 fix release
Sonnet 4.6
patch
65 days ago