gabriel / muse public
cherry_pick.py python
130 lines 4.6 KB
f7645c07 feat(store): self-describing HEAD format with typed read/write API (#163) Gabriel Cardona <cgcardona@gmail.com> 3d 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
10 import typer
11
12 from muse.core.errors import ExitCode
13 from muse.core.merge_engine import write_merge_state
14 from muse.core.repo import require_repo
15 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
16 from muse.core.store import (
17 CommitRecord,
18 SnapshotRecord,
19 get_head_commit_id,
20 get_head_snapshot_manifest,
21 read_commit,
22 read_current_branch,
23 read_snapshot,
24 resolve_commit_ref,
25 write_commit,
26 write_snapshot,
27 )
28 from muse.core.validation import sanitize_display
29 from muse.core.workdir import apply_manifest
30 from muse.domain import SnapshotManifest
31 from muse.plugins.registry import read_domain, resolve_plugin
32
33 logger = logging.getLogger(__name__)
34
35 app = typer.Typer()
36
37
38 def _read_branch(root: pathlib.Path) -> str:
39 return read_current_branch(root)
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 apply_manifest(root, merged_manifest)
100
101 if no_commit:
102 typer.echo(f"Applied {target.commit_id[:8]} to working tree. Run 'muse commit' to record.")
103 return
104
105 head_commit_id = get_head_commit_id(root, branch)
106 # merged_manifest contains only object IDs already in the store
107 # (sourced from base, ours, or theirs — all previously committed).
108 # No re-scan or object re-write is needed.
109 manifest = merged_manifest
110 snapshot_id = compute_snapshot_id(manifest)
111 committed_at = datetime.datetime.now(datetime.timezone.utc)
112 commit_id = compute_commit_id(
113 parent_ids=[head_commit_id] if head_commit_id else [],
114 snapshot_id=snapshot_id,
115 message=target.message,
116 committed_at_iso=committed_at.isoformat(),
117 )
118
119 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest))
120 write_commit(root, CommitRecord(
121 commit_id=commit_id,
122 repo_id=repo_id,
123 branch=branch,
124 snapshot_id=snapshot_id,
125 message=target.message,
126 committed_at=committed_at,
127 parent_commit_id=head_commit_id,
128 ))
129 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
130 typer.echo(f"[{sanitize_display(branch)} {commit_id[:8]}] {sanitize_display(target.message)}")