guard.py
python
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