gabriel / muse public
guard.py python
129 lines 5.3 KB
Raw
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4 fix: carry dev changes harmony dropped in merge — detached … Sonnet 4.6 minor ⚠ breaking 45 days ago
1 """Shared CLI pre-flight guards for operations that modify the working tree.
2
3 Any command that calls :func:`muse.core.workdir.apply_manifest` — merge,
4 checkout, reset, revert, cherry-pick, pull — should call
5 :func:`require_clean_workdir` first so users never lose uncommitted work
6 silently. The check mirrors Git's behaviour: modified/deleted tracked files
7 block the operation; newly untracked files are left alone.
8 """
9
10 import json
11 import pathlib
12 import sys
13
14 from muse.core.types import load_json_file
15 from muse.core.paths import repo_json_path as _repo_json_path
16 from muse.core.errors import ExitCode
17 from muse.core.snapshot import diff_workdir_vs_snapshot
18 from muse.core.refs import read_head
19 from muse.core.snapshots import get_head_snapshot_manifest
20 from muse.core.validation import sanitize_display
21
22 def require_clean_workdir(
23 root: pathlib.Path,
24 operation: str,
25 *,
26 force: bool = False,
27 target_manifest: dict[str, str] | None = None,
28 json_out: bool = False,
29 ) -> None:
30 """Abort with a friendly error if the working tree has uncommitted changes.
31
32 Protects against silent data loss when *operation* (merge, checkout,
33 reset, …) is about to overwrite files via ``apply_manifest``. Pass
34 ``force=True`` to bypass the check (e.g. for ``--force`` flags).
35
36 Only modified and deleted tracked files are considered dangerous —
37 brand-new files that have never been committed are left untouched by any
38 manifest-apply, so they are not flagged.
39
40 ``target_manifest`` is accepted for API compatibility but is no longer
41 used to narrow the dirty-file set. Previously, only files that differed
42 between the current HEAD and the target manifest were blocked; this
43 "carry through" behaviour caused dirty working-tree state to silently
44 bleed onto branches the user never touched (see TestDirtyWorkdirBleedThrough).
45 The guard now refuses on *any* dirty tracked file regardless of whether
46 the target branch shares the same committed version.
47
48 When *json_out* is ``True``, a machine-readable error object is emitted to
49 stdout before exiting so that agents using ``--json`` still receive an
50 actionable response.
51
52 Args:
53 root: Repository root (directory that contains ``.muse/``).
54 operation: Human-readable name of the operation (used in the message).
55 force: When ``True`` the guard is a no-op.
56 target_manifest: Accepted for API compatibility; no longer used.
57 json_out: Emit JSON error to stdout instead of human text to stderr.
58
59 Raises:
60 SystemExit: With :attr:`ExitCode.USER_ERROR` when dirty files exist.
61 """
62 if force:
63 return
64
65 _head_state = read_head(root)
66 _repo_meta = load_json_file(_repo_json_path(root)) or {}
67 repo_id = str(_repo_meta.get("repo_id", ""))
68 if _head_state["kind"] == "branch":
69 head_manifest = get_head_snapshot_manifest(root, _head_state["branch"]) or {}
70 else:
71 # Detached HEAD: read manifest from the HEAD commit directly.
72 from muse.core.commits import read_commit
73 from muse.core.snapshots import read_snapshot
74 _det_commit = read_commit(root, _head_state["commit_id"])
75 if _det_commit is None:
76 head_manifest = {}
77 else:
78 _snap = read_snapshot(root, _det_commit.snapshot_id)
79 head_manifest = dict(_snap.manifest) if _snap else {}
80
81 # If the branch has no commits yet there is nothing to protect.
82 if not head_manifest:
83 return
84
85 added, modified, deleted, _, _added_dirs, _deleted_dirs = diff_workdir_vs_snapshot(root, head_manifest)
86
87 # Added paths are brand-new files never seen in a snapshot; apply_manifest
88 # won't touch them, so they are safe to ignore. Modified and deleted
89 # paths ARE in the snapshot and would be overwritten or removed.
90 dirty = modified | deleted
91 if not dirty:
92 return
93
94 dirty_sorted = sorted(dirty)
95 if json_out:
96 print(json.dumps({
97 "error": "dirty_workdir",
98 "operation": operation,
99 "message": (
100 f"uncommitted changes would be overwritten by {operation}"
101 ),
102 "files": dirty_sorted,
103 "hint": (
104 f"use 'muse shelf save' then retry, "
105 f"'muse {operation} --autoshelf', or "
106 f"'muse {operation} --force' to discard changes"
107 ),
108 }))
109 raise SystemExit(ExitCode.USER_ERROR)
110
111 print(
112 f"❌ error: Your local changes to the following files would be "
113 f"overwritten by {sanitize_display(operation)}:",
114 file=sys.stderr,
115 )
116 shown = dirty_sorted[:10]
117 for path in shown:
118 print(f" {sanitize_display(path)}", file=sys.stderr)
119 if len(dirty) > 10:
120 print(f" … and {len(dirty) - 10} more", file=sys.stderr)
121 print(
122 f"\nRun one of:\n"
123 f" muse shelf save — shelve changes and retry\n"
124 f" muse {sanitize_display(operation)} --merge — carry changes forward (three-way merge)\n"
125 f" muse {sanitize_display(operation)} --force — discard changes\n"
126 f" muse {sanitize_display(operation)} --autoshelf — shelf automatically, switch, pop on arrival",
127 file=sys.stderr,
128 )
129 raise SystemExit(ExitCode.USER_ERROR)
File History 2 commits
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4 fix: carry dev changes harmony dropped in merge — detached … Sonnet 4.6 minor 45 days ago
sha256:45e1291ec44e0e86fe353b0b55306b2689a7f6ffa39bafb4bd2782b5be1c9cb8 fix: checkout from detached HEAD state raises ValueError Sonnet 4.6 minor 47 days ago