cgcardona / muse public
resolve.py python
228 lines 8.4 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse resolve — mark a conflicted file as resolved.
2
3 Workflow
4 --------
5 When ``muse merge`` encounters conflicts it writes ``.muse/MERGE_STATE.json``
6 and exits. The user then inspects the listed conflict paths and resolves each
7 one:
8
9 - ``--ours``: Keep the current branch's version already in ``muse-work/``.
10 The file is left untouched; the path is removed from the conflict
11 list in ``MERGE_STATE.json``.
12
13 - ``--theirs``: Accept the incoming branch's version. This command fetches
14 the object from the local store (written when the other branch's
15 commits were made) and writes it to ``muse-work/<path>`` before
16 removing the path from the conflict list.
17
18 After resolving each conflict, run ``muse merge --continue`` to create the
19 merge commit.
20
21 Resolution strategies
22 ---------------------
23 Both strategies ultimately remove the path from ``conflict_paths`` in
24 ``MERGE_STATE.json``. When the list reaches zero, ``muse merge --continue``
25 can proceed.
26
27 The ``--theirs`` strategy requires the theirs commit's objects to be present
28 in the local ``.muse/objects/`` store. Objects are written there when commits
29 are made locally; ``muse pull`` fetches them from the remote.
30 """
31 from __future__ import annotations
32
33 import asyncio
34 import logging
35 import pathlib
36 from typing import TYPE_CHECKING
37
38 import typer
39
40 from maestro.muse_cli._repo import require_repo
41 from maestro.muse_cli.errors import ExitCode
42 from maestro.muse_cli.merge_engine import (
43 apply_resolution,
44 read_merge_state,
45 write_merge_state,
46 )
47
48 if TYPE_CHECKING:
49 from sqlalchemy.ext.asyncio import AsyncSession
50
51 logger = logging.getLogger(__name__)
52
53 app = typer.Typer()
54
55
56 # ---------------------------------------------------------------------------
57 # Testable async core — no Typer coupling
58 # ---------------------------------------------------------------------------
59
60
61 async def resolve_conflict_async(
62 *,
63 file_path: str,
64 ours: bool,
65 root: pathlib.Path,
66 session: AsyncSession,
67 ) -> None:
68 """Mark *file_path* as resolved in ``.muse/MERGE_STATE.json``.
69
70 For ``--ours`` no file change is made — the current ``muse-work/`` content
71 is accepted as-is. For ``--theirs`` this function fetches the theirs
72 branch's object from the local store and writes it to
73 ``muse-work/<file_path>``.
74
75 Args:
76 file_path: Path of the conflicted file. Accepted as:
77 - absolute path (converted to relative to ``muse-work/``)
78 - path relative to ``muse-work/`` (e.g. ``meta/foo.json``)
79 - path relative to repo root (e.g. ``muse-work/meta/foo.json``)
80 ours: ``True`` to accept ours (no file change); ``False`` to
81 accept theirs (object is fetched from local store and written
82 to ``muse-work/<file_path>``).
83 root: Repository root containing ``.muse/``.
84 session: Open async DB session (used for ``--theirs`` to look up the
85 theirs commit's snapshot manifest).
86
87 Raises:
88 :class:`typer.Exit`: On user errors (no merge in progress, path not
89 in conflict list, object missing from local store).
90 """
91 merge_state = read_merge_state(root)
92 if merge_state is None:
93 typer.echo("❌ No merge in progress. Nothing to resolve.")
94 raise typer.Exit(code=ExitCode.USER_ERROR)
95
96 # Normalise path to be relative to muse-work/.
97 workdir = root / "muse-work"
98 abs_target = pathlib.Path(file_path)
99 if not abs_target.is_absolute():
100 # Try treating as relative to repo root first, then fall back to muse-work.
101 candidate = root / file_path
102 if candidate.exists() or str(file_path).startswith("muse-work/"):
103 abs_target = candidate
104 else:
105 abs_target = workdir / file_path
106
107 try:
108 rel_path = abs_target.relative_to(workdir).as_posix()
109 except ValueError:
110 # File may be given as a bare relative path already relative to muse-work/
111 rel_path = file_path.lstrip("/")
112
113 if rel_path not in merge_state.conflict_paths:
114 typer.echo(
115 f"❌ '{rel_path}' is not listed as a conflict.\n"
116 f" Current conflicts: {merge_state.conflict_paths}"
117 )
118 raise typer.Exit(code=ExitCode.USER_ERROR)
119
120 # For --theirs, fetch the object from the local store and write to workdir.
121 if not ours:
122 theirs_commit_id = merge_state.theirs_commit
123 if not theirs_commit_id:
124 typer.echo("❌ MERGE_STATE.json is missing theirs_commit. Cannot resolve --theirs.")
125 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
126
127 from maestro.muse_cli.db import get_commit_snapshot_manifest
128
129 theirs_manifest = (
130 await get_commit_snapshot_manifest(session, theirs_commit_id) or {}
131 )
132 object_id = theirs_manifest.get(rel_path)
133
134 if object_id is None:
135 # Path was deleted on the theirs branch — remove from workdir.
136 dest = workdir / rel_path
137 if dest.exists():
138 dest.unlink()
139 typer.echo(f"✅ Resolved '{rel_path}' — file deleted on theirs branch")
140 logger.info("✅ muse resolve %r --theirs (deleted on theirs)", rel_path)
141 else:
142 try:
143 apply_resolution(root, rel_path, object_id)
144 except FileNotFoundError:
145 typer.echo(
146 f"❌ Object for '{rel_path}' is not in the local store.\n"
147 " Run 'muse pull' to fetch the remote objects, then retry."
148 )
149 raise typer.Exit(code=ExitCode.USER_ERROR)
150 typer.echo(f"✅ Resolved '{rel_path}' — keeping theirs")
151 logger.info("✅ muse resolve %r --theirs", rel_path)
152 else:
153 typer.echo(f"✅ Resolved '{rel_path}' — keeping ours")
154 logger.info("✅ muse resolve %r --ours", rel_path)
155
156 remaining = [p for p in merge_state.conflict_paths if p != rel_path]
157
158 # Always rewrite MERGE_STATE with the updated (possibly empty) conflict list.
159 # Keeping the file even when conflict_paths=[] lets `muse merge --continue`
160 # read the stored commit IDs (ours_commit, theirs_commit) to build the merge
161 # commit. `muse merge --continue` is responsible for clearing this file.
162 write_merge_state(
163 root,
164 base_commit=merge_state.base_commit or "",
165 ours_commit=merge_state.ours_commit or "",
166 theirs_commit=merge_state.theirs_commit or "",
167 conflict_paths=remaining,
168 other_branch=merge_state.other_branch,
169 )
170
171 if remaining:
172 typer.echo(
173 f" {len(remaining)} conflict(s) remaining. "
174 "Resolve all, then run 'muse merge --continue'."
175 )
176 else:
177 typer.echo(
178 "✅ All conflicts resolved. Run 'muse merge --continue' to create the merge commit."
179 )
180 logger.info("✅ muse resolve: all conflicts cleared, ready for --continue")
181
182
183 # ---------------------------------------------------------------------------
184 # Typer command
185 # ---------------------------------------------------------------------------
186
187
188 @app.callback(invoke_without_command=True)
189 def resolve(
190 ctx: typer.Context,
191 file_path: str = typer.Argument(
192 ...,
193 help="Conflicted file path (relative to muse-work/ or repo root).",
194 ),
195 ours: bool = typer.Option(
196 False,
197 "--ours/--no-ours",
198 help="Keep the current branch's version (no file change required).",
199 ),
200 theirs: bool = typer.Option(
201 False,
202 "--theirs/--no-theirs",
203 help="Accept the incoming branch's version (fetched from local object store).",
204 ),
205 ) -> None:
206 """Mark a conflicted file as resolved using --ours or --theirs."""
207 if ours == theirs:
208 typer.echo("❌ Specify exactly one of --ours or --theirs.")
209 raise typer.Exit(code=ExitCode.USER_ERROR)
210
211 root = require_repo()
212
213 async def _run() -> None:
214 from maestro.muse_cli.db import open_session
215
216 async with open_session() as session:
217 await resolve_conflict_async(
218 file_path=file_path, ours=ours, root=root, session=session
219 )
220
221 try:
222 asyncio.run(_run())
223 except typer.Exit:
224 raise
225 except Exception as exc:
226 typer.echo(f"❌ muse resolve failed: {exc}")
227 logger.error("❌ muse resolve error: %s", exc, exc_info=True)
228 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)