gabriel / muse public
cherry_pick.py python
131 lines 4.5 KB
7ba4aa0b Remove all Maestro legacy code; clean mypy across full muse/ package Gabriel Cardona <gabriel@tellurstori.com> 7d ago
1 """muse cherry-pick — apply a specific commit's changes on top of HEAD."""
2 from __future__ import annotations
3
4 import datetime
5 import json
6 import logging
7 import pathlib
8
9 import typer
10
11 from muse.core.errors import ExitCode
12 from muse.core.merge_engine import apply_merge, detect_conflicts, diff_snapshots, write_merge_state
13 from muse.core.object_store import restore_object, write_object_from_path
14 from muse.core.repo import require_repo
15 from muse.core.snapshot import build_snapshot_manifest, 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_snapshot,
23 write_commit,
24 write_snapshot,
25 )
26
27 logger = logging.getLogger(__name__)
28
29 app = typer.Typer()
30
31
32 def _read_branch(root: pathlib.Path) -> str:
33 head_ref = (root / ".muse" / "HEAD").read_text().strip()
34 return head_ref.removeprefix("refs/heads/").strip()
35
36
37 def _read_repo_id(root: pathlib.Path) -> str:
38 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
39
40
41 @app.callback(invoke_without_command=True)
42 def cherry_pick(
43 ctx: typer.Context,
44 ref: str = typer.Argument(..., help="Commit ID to apply."),
45 no_commit: bool = typer.Option(False, "-n", "--no-commit", help="Apply but do not commit."),
46 ) -> None:
47 """Apply a specific commit's changes on top of HEAD."""
48 root = require_repo()
49 repo_id = _read_repo_id(root)
50 branch = _read_branch(root)
51
52 # Find the commit to cherry-pick
53 from muse.core.store import resolve_commit_ref
54 target = resolve_commit_ref(root, repo_id, branch, ref)
55 if target is None:
56 typer.echo(f"❌ Commit '{ref}' not found.")
57 raise typer.Exit(code=ExitCode.USER_ERROR)
58
59 # Delta = target vs its parent
60 base_manifest: dict[str, str] = {}
61 if target.parent_commit_id:
62 parent_commit = read_commit(root, target.parent_commit_id)
63 if parent_commit:
64 parent_snap = read_snapshot(root, parent_commit.snapshot_id)
65 if parent_snap:
66 base_manifest = parent_snap.manifest
67
68 target_snap = read_snapshot(root, target.snapshot_id)
69 target_manifest = target_snap.manifest if target_snap else {}
70
71 # Apply that delta on top of current HEAD
72 ours_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
73 theirs_changed = diff_snapshots(base_manifest, target_manifest)
74 ours_changed = diff_snapshots(base_manifest, ours_manifest)
75 conflicts = detect_conflicts(ours_changed, theirs_changed)
76
77 if conflicts:
78 write_merge_state(
79 root,
80 base_commit=target.parent_commit_id or "",
81 ours_commit=get_head_commit_id(root, branch) or "",
82 theirs_commit=target.commit_id,
83 conflict_paths=list(conflicts),
84 )
85 typer.echo(f"❌ Cherry-pick conflict in {len(conflicts)} file(s):")
86 for p in sorted(conflicts):
87 typer.echo(f" CONFLICT: {p}")
88 raise typer.Exit(code=ExitCode.USER_ERROR)
89
90 merged_manifest = apply_merge(
91 base_manifest, ours_manifest, target_manifest,
92 ours_changed, theirs_changed, set(),
93 )
94
95 import shutil
96 workdir = root / "muse-work"
97 if workdir.exists():
98 shutil.rmtree(workdir)
99 workdir.mkdir()
100 for rel_path, object_id in merged_manifest.items():
101 restore_object(root, object_id, workdir / rel_path)
102
103 if no_commit:
104 typer.echo(f"Applied {target.commit_id[:8]} to muse-work/. Run 'muse commit' to record.")
105 return
106
107 head_commit_id = get_head_commit_id(root, branch)
108 manifest = build_snapshot_manifest(workdir)
109 snapshot_id = compute_snapshot_id(manifest)
110 committed_at = datetime.datetime.now(datetime.timezone.utc)
111 commit_id = compute_commit_id(
112 parent_ids=[head_commit_id] if head_commit_id else [],
113 snapshot_id=snapshot_id,
114 message=target.message,
115 committed_at_iso=committed_at.isoformat(),
116 )
117
118 for rel_path, object_id in manifest.items():
119 write_object_from_path(root, object_id, workdir / rel_path)
120 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest))
121 write_commit(root, CommitRecord(
122 commit_id=commit_id,
123 repo_id=repo_id,
124 branch=branch,
125 snapshot_id=snapshot_id,
126 message=target.message,
127 committed_at=committed_at,
128 parent_commit_id=head_commit_id,
129 ))
130 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id)
131 typer.echo(f"[{branch} {commit_id[:8]}] {target.message}")