gabriel / muse public
feat BREAKING shelf feat/shelf-path-scoped-save #1 / 1
AI Agent gabriel · 15 hours ago · Oct 22, 2026 · Diff

feat(shelf): --path scoped shelving, the native replacement for /tmp backups

Prompted by user feedback during this session: I had a recurring pattern of `cp file.py /tmp/file_fixed.py` before reverting a fix to verify its test genuinely fails without it (RED), then `cp /tmp/file_fixed.py file.py` to restore (GREEN) -- an anti-pattern since /tmp is cleared by the OS and the whole point of Muse is to never need an out-of-band backup mechanism. The user asked: can muse shelf just become the idiomatic drop-in replacement?

Investigated muse shelf save's actual semantics first (live-tested, not assumed): it shelves the ENTIRE uncommitted working tree -- tracked changes AND untracked files -- as one atomic unit, relative to HEAD. This made it unsuitable as-is for the RED/GREEN use case: shelving "the fix" would also sweep away a brand-new, not-yet-committed test file, since there was no way to scope the operation to specific paths. Also found two pre-existing documentation inaccuracies while investigating (docs/agent-guide.md's git stash -> muse shelf table claimed "shelves all TRACKED changes" -- wrong, verified live -- and referenced a nonexistent `muse shelf show` subcommand and a `--index` flag that `shelf pop` has never had).

Added `muse shelf save --path <p>` (repeatable, `-p` short form, same convention as `muse diff --path`), matching `git stash push -- <pathspec>` semantics: - Only changes under the given path(s) are shelved; everything else in the working tree -- including untracked files that don't match -- is left completely untouched. - Unlike the unscoped case (which stores the COMPLETE working-tree manifest, by design, for unambiguous restoration across branch switches), a scoped entry's snapshot/deleted fields store ONLY the matched subset. This is a deliberate, intentional divergence: it's what makes popping a scoped entry safe even if other files were edited after it was saved -- the entry simply never references them. - The on-disk revert for a scoped save deliberately does NOT reuse apply_manifest(): that function requires every object in its target manifest to already exist in the store and unconditionally calls restore_object() for every target entry. An out-of-scope dirty file's current content was never written to the store (untracked, or modified but out of scope) -- passing it through at all, even as a "no-op" value, either crashes on a never-committed object or risks clobbering an unrelated edit. Found this the hard way: my first implementation attempt built full-sized prev/target manifests and crashed immediately on a missing object for an out-of-scope untracked file. Fixed by writing a small dedicated revert routine that only ever touches the matched paths' objects (which are guaranteed to exist: either just-written, in scope, or already in HEAD). - New shared `muse/core/snapshot.py::path_matches_pathspec` / `filter_manifest_by_paths` -- the same exact-or-directory-prefix matching `muse diff --path` already used, now shared instead of duplicated a third time; `diff.py`'s own `_filter_manifest` delegates to it, zero behavior change there (regression-tested). - `ShelfEntry` gains a `paths` field (`[]` for unscoped, the pathspec for scoped) surfaced through `shelf save`/`list`/`read` --json, with graceful backward-compatible defaulting for shelf entries written before this field existed. `run_pop`/`run_apply`/`run_diff` needed ZERO changes -- they already operate purely off whatever `snapshot`/`deleted` an entry stores, so a correctly-scoped entry at save time is automatically correctly scoped at restore time.

12 new tests in tests/test_shelf_path_scoped.py: the exact RED/GREEN use case (modified tracked file + untracked test file), an out-of-scope dirty tracked file left untouched, newly-added-file scoping, deletion scoping, directory-prefix pathspec matching, the no-match case, and explicit unscoped-behavior-unaffected regression guards. Confirmed via genuine RED (removed the --path flag and forced paths=[] unconditionally) -> 9 of 12 failed as expected (argparse rejected --path; 3 pass trivially since they assert unscoped/empty-paths behavior) -> GREEN. 511-test regression sweep across every shelf/diff/snapshot-relevant suite passes with zero regressions to the unscoped default. Live-verified end-to-end via muse-dev: modified-file scoping, newly-added-file scoping, and deletion scoping all confirmed working exactly as designed, including the full RED (shelve) -> pop (GREEN) cycle.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

sha256:a8de2a41b39b56b68a4f5024e8475ff463681093380c43b72ed39e256cf6a46c sha
+37 ~21 symbols
5 changed · 1253 in snapshot files
sha256:ea7e385c1182304c0e483d2c37561497ec4c16378d97d3a1d3bc91f62592b8cb snapshot
+37
symbols added
~21
symbols modified
5
files changed
1253
files in snapshot
0
dead code introduced
Semantic Changes 58 symbols
~ tests/test_shelf_path_scoped.py .py 29 symbols added
+ TestListAndReadExposePaths class class TestListAndReadExposePaths L216–241
+ test_list_json_includes_paths_field method method test_list_json_includes_paths_field L217–224
+ test_read_json_includes_paths_field method method test_read_json_includes_paths_field L226–233
+ test_unscoped_entry_paths_field_is_empty_list method method test_unscoped_entry_paths_field_is_empty_list L235–241
+ TestScopedSaveDeletion class class TestScopedSaveDeletion L128–148
+ test_deleted_in_scope_file_restored_then_redeleted_on_pop method method test_deleted_in_scope_file_restored_then_redeleted_on_pop L129–148
+ TestScopedSaveDirectoryPrefix class class TestScopedSaveDirectoryPrefix L151–167
+ test_directory_pathspec_matches_nested_files method method test_directory_pathspec_matches_nested_files L152–167
+ TestScopedSaveLeavesOtherFilesUntouched class class TestScopedSaveLeavesOtherFilesUntouched L41–102
+ test_modified_tracked_file_reverted_untracked_file_untouched method method test_modified_tracked_file_reverted_untracked_file_untouched L42–66
+ test_pop_restores_only_the_scoped_fix method method test_pop_restores_only_the_scoped_fix L89–102
+ test_unmatched_dirty_tracked_file_untouched method method test_unmatched_dirty_tracked_file_untouched L68–87
+ TestScopedSaveNewlyAddedFile class class TestScopedSaveNewlyAddedFile L105–125
+ test_newly_added_in_scope_file_removed_then_restored_on_pop method method test_newly_added_in_scope_file_removed_then_restored_on_pop L106–125
+ TestScopedSaveNoMatch class class TestScopedSaveNoMatch L170–185
+ test_no_changes_under_path_reports_nothing_to_shelf method method test_no_changes_under_path_reports_nothing_to_shelf L171–185
+ TestUnscopedBehaviorUnaffected class class TestUnscopedBehaviorUnaffected L188–213
+ test_unscoped_pop_restores_everything method method test_unscoped_pop_restores_everything L205–213
+ test_unscoped_save_still_shelves_everything method method test_unscoped_save_still_shelves_everything L192–203
+ _env function function _env L24–25
+ cli variable variable cli L20–20
+ CliRunner import import CliRunner L18–18
+ Mapping import import Mapping L14–14
+ annotations import import annotations L10–10
+ json import import json L12–12
+ pathlib import import pathlib L13–13
+ pytest import import pytest L16–16
+ repo function function repo L29–38
+ runner variable variable runner L21–21
~ docs/agent-guide.md .md 1 symbol added, 3 symbols modified
+ muse shelf save --path — scoped shelving (the native alternative to a /tmp backup) section muse shelf save --path — scoped shelving (the native alternative to a /tmp backup) L1783–1810
~ muse/cli/commands/diff.py .py 1 symbol added, 2 symbols modified
+ filter_manifest_by_paths import import filter_manifest_by_paths L68–68
~ muse/cli/commands/shelf.py .py 4 symbols added, 16 symbols modified
+ _prune_empty_directories import import _prune_empty_directories L115–115
+ contain_path import import contain_path L114–114
+ path_matches_pathspec import import path_matches_pathspec L95–95
+ restore_object import import restore_object L91–91
~ muse/core/snapshot.py .py 2 symbols added
+ filter_manifest_by_paths function function filter_manifest_by_paths L194–202
+ path_matches_pathspec function function path_matches_pathspec L176–192
Files Changed
+1 ~4
1253 in snapshot
← Older Oldest on feat/shelf-path-scoped-save
All commits
Newer → Latest on feat/shelf-path-scoped-save

0 comments

No comments yet. Be the first to start the discussion.

To add a comment, use the Muse CLI: muse hub commit comment sha256:a8de2a41b39b56b68a4f5024e8475ff463681093380c43b72ed39e256cf6a46c --body "your comment"