gabriel / muse public
cherry_pick.py python
135 lines 4.7 KB
bda49bdb feat: redesign .museignore as TOML with domain-scoped sections (#100) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """muse cherry-pick — apply a specific commit's changes on top of HEAD."""
2
3 from __future__ import annotations
4
5 import datetime
6 import json
7 import logging
8 import pathlib
9 import shutil
10
11 import typer
12
13 from muse.core.errors import ExitCode
14 from muse.core.merge_engine import write_merge_state
15 from muse.core.object_store import restore_object
16 from muse.core.repo import require_repo
17 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
18 from muse.core.store import (
19 CommitRecord,
20 SnapshotRecord,
21 get_head_commit_id,
22 get_head_snapshot_manifest,
23 read_commit,
24 read_snapshot,
25 resolve_commit_ref,
26 write_commit,
27 write_snapshot,
28 )
29 from muse.domain import SnapshotManifest
30 from muse.plugins.registry import read_domain, resolve_plugin
31
32 logger = logging.getLogger(__name__)
33
34 app = typer.Typer()
35
36
37 def _read_branch(root: pathlib.Path) -> str:
38 head_ref = (root / ".muse" / "HEAD").read_text().strip()
39 return head_ref.removeprefix("refs/heads/").strip()
40
41
42 def _read_repo_id(root: pathlib.Path) -> str:
43 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
44
45
46 @app.callback(invoke_without_command=True)
47 def cherry_pick(
48 ctx: typer.Context,
49 ref: str = typer.Argument(..., help="Commit ID to apply."),
50 no_commit: bool = typer.Option(False, "-n", "--no-commit", help="Apply but do not commit."),
51 ) -> None:
52 """Apply a specific commit's changes on top of HEAD."""
53 root = require_repo()
54 repo_id = _read_repo_id(root)
55 branch = _read_branch(root)
56 domain = read_domain(root)
57 plugin = resolve_plugin(root)
58
59 target = resolve_commit_ref(root, repo_id, branch, ref)
60 if target is None:
61 typer.echo(f"❌ Commit '{ref}' not found.")
62 raise typer.Exit(code=ExitCode.USER_ERROR)
63
64 # The delta for this cherry-pick is: target vs its parent.
65 # Applying that delta on top of HEAD is a three-way merge where the
66 # base is the target's parent, left is HEAD, and right is the target.
67 base_manifest: dict[str, str] = {}
68 if target.parent_commit_id:
69 parent_commit = read_commit(root, target.parent_commit_id)
70 if parent_commit:
71 parent_snap = read_snapshot(root, parent_commit.snapshot_id)
72 if parent_snap:
73 base_manifest = parent_snap.manifest
74
75 target_snap_rec = read_snapshot(root, target.snapshot_id)
76 target_manifest = target_snap_rec.manifest if target_snap_rec else {}
77 ours_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
78
79 base_snap = SnapshotManifest(files=base_manifest, domain=domain)
80 ours_snap = SnapshotManifest(files=ours_manifest, domain=domain)
81 target_snap = SnapshotManifest(files=target_manifest, domain=domain)
82
83 result = plugin.merge(base_snap, ours_snap, target_snap)
84
85 if not result.is_clean:
86 write_merge_state(
87 root,
88 base_commit=target.parent_commit_id or "",
89 ours_commit=get_head_commit_id(root, branch) or "",
90 theirs_commit=target.commit_id,
91 conflict_paths=result.conflicts,
92 )
93 typer.echo(f"❌ Cherry-pick conflict in {len(result.conflicts)} file(s):")
94 for p in sorted(result.conflicts):
95 typer.echo(f" CONFLICT (both modified): {p}")
96 raise typer.Exit(code=ExitCode.USER_ERROR)
97
98 merged_manifest = result.merged["files"]
99 workdir = root / "muse-work"
100 if workdir.exists():
101 shutil.rmtree(workdir)
102 workdir.mkdir()
103 for rel_path, object_id in merged_manifest.items():
104 restore_object(root, object_id, workdir / rel_path)
105
106 if no_commit:
107 typer.echo(f"Applied {target.commit_id[:8]} to muse-work/. Run 'muse commit' to record.")
108 return
109
110 head_commit_id = get_head_commit_id(root, branch)
111 # merged_manifest contains only object IDs already in the store
112 # (sourced from base, ours, or theirs — all previously committed).
113 # No re-scan or object re-write is needed.
114 manifest = merged_manifest
115 snapshot_id = compute_snapshot_id(manifest)
116 committed_at = datetime.datetime.now(datetime.timezone.utc)
117 commit_id = compute_commit_id(
118 parent_ids=[head_commit_id] if head_commit_id else [],
119 snapshot_id=snapshot_id,
120 message=target.message,
121 committed_at_iso=committed_at.isoformat(),
122 )
123
124 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest))
125 write_commit(root, CommitRecord(
126 commit_id=commit_id,
127 repo_id=repo_id,
128 branch=branch,
129 snapshot_id=snapshot_id,
130 message=target.message,
131 committed_at=committed_at,
132 parent_commit_id=head_commit_id,
133 ))
134 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
135 typer.echo(f"[{branch} {commit_id[:8]}] {target.message}")