gabriel / muse public
test_apply_manifest_dir_pruning.py python
231 lines 9.4 KB
Raw
sha256:a5f8a020b53ddab68f231cf27489fe865525f85ee924133034262d39b332076e fix(clone,pull): never claim success when a snapshot hash m… Sonnet 5 patch 23 minutes ago
1 """muse#95 — apply_manifest leaves empty directories behind.
2
3 `apply_manifest` removes files present in `prev_manifest` but absent from
4 `target_manifest`, but never pruned the directories those files lived in --
5 so a directory whose only files were removed persists as an empty,
6 untracked leftover. Confirmed to affect every caller of apply_manifest
7 (switch, checkout, merge, pull, fast-forward, rebase, cherry-pick, clone,
8 reset, revert, shelf) since the bug lives in the one shared function all of
9 them delegate to.
10
11 Git has no analogous state-inconsistency here (it never tracked the
12 directory as an entity), but muse's content-addressed directories make a
13 leftover empty directory indistinguishable from one a human just created
14 and hasn't `code add`ed yet.
15
16 Coverage:
17 - unit: apply_manifest prunes a directory whose last file was removed,
18 recursively up the tree when that empties a grandparent too.
19 - unit: a directory still listed in target_directories (even with zero
20 files on the target -- an intentionally-tracked empty directory) is NOT
21 pruned.
22 - unit: a directory that still has other files on the target is untouched
23 (the common, non-buggy case).
24 - integration: the exact CLI repro from the ticket (muse switch between
25 branches with different directory structures) via muse-dev's real CLI
26 behavior proxied through direct apply_manifest + commit plumbing.
27 """
28 from __future__ import annotations
29
30 import os
31 import pathlib
32
33 import pytest
34
35 from tests.cli_test_helper import CliRunner, InvokeResult
36 from muse.core.object_store import write_object
37 from muse.core.workdir import apply_manifest
38 from muse.core.types import Manifest, blob_id
39
40 runner = CliRunner()
41
42
43 def _run(repo: pathlib.Path, *args: str) -> InvokeResult:
44 saved = os.getcwd()
45 try:
46 os.chdir(repo)
47 return runner.invoke(None, list(args))
48 finally:
49 os.chdir(saved)
50
51
52 def _write_blob(root: pathlib.Path, content: bytes) -> str:
53 oid = blob_id(content)
54 write_object(root, oid, content)
55 return oid
56
57
58 @pytest.fixture()
59 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
60 _run(tmp_path, "init")
61 return tmp_path
62
63
64 # ---------------------------------------------------------------------------
65 # Unit: apply_manifest prunes newly-empty directories
66 # ---------------------------------------------------------------------------
67
68 def test_apply_manifest_prunes_directory_whose_last_file_was_removed(repo: pathlib.Path) -> None:
69 oid = _write_blob(repo, b"content")
70 keep_oid = _write_blob(repo, b"keep")
71 prev: Manifest = {"scaffold/sub/file.md": oid, "keep.md": keep_oid}
72 target: Manifest = {"keep.md": keep_oid}
73
74 (repo / "scaffold" / "sub").mkdir(parents=True)
75 (repo / "scaffold" / "sub" / "file.md").write_text("content")
76 (repo / "keep.md").write_text("keep")
77
78 apply_manifest(repo, prev, target)
79
80 assert not (repo / "scaffold" / "sub" / "file.md").exists()
81 assert not (repo / "scaffold" / "sub").exists(), "empty dir left behind"
82 assert not (repo / "scaffold").exists(), "empty grandparent dir left behind"
83
84
85 def test_apply_manifest_prunes_recursively_up_to_but_not_past_root(repo: pathlib.Path) -> None:
86 oid = _write_blob(repo, b"deep")
87 keep_oid = _write_blob(repo, b"keep")
88 prev: Manifest = {"a/b/c/d/file.md": oid, "keep.md": keep_oid}
89 target: Manifest = {"keep.md": keep_oid}
90
91 (repo / "a" / "b" / "c" / "d").mkdir(parents=True)
92 (repo / "a" / "b" / "c" / "d" / "file.md").write_text("deep")
93 (repo / "keep.md").write_text("keep")
94
95 apply_manifest(repo, prev, target)
96
97 assert not (repo / "a").exists()
98 assert repo.exists()
99
100
101 def test_apply_manifest_does_not_prune_directory_still_in_target_directories(repo: pathlib.Path) -> None:
102 """An intentionally-tracked empty directory on the target branch (listed
103 in target_directories even though it has zero files) must survive."""
104 oid = _write_blob(repo, b"content")
105 keep_oid = _write_blob(repo, b"keep")
106 prev: Manifest = {"scaffold/file.md": oid, "keep.md": keep_oid}
107 target: Manifest = {"keep.md": keep_oid}
108
109 (repo / "scaffold").mkdir(parents=True)
110 (repo / "scaffold" / "file.md").write_text("content")
111 (repo / "keep.md").write_text("keep")
112
113 apply_manifest(repo, prev, target, target_directories=["scaffold"])
114
115 assert not (repo / "scaffold" / "file.md").exists()
116 assert (repo / "scaffold").exists(), "intentionally-tracked empty dir must survive"
117
118
119 def test_apply_manifest_leaves_directory_with_other_surviving_files_alone(repo: pathlib.Path) -> None:
120 """The common, non-buggy case: a directory with other files in the
121 target manifest is untouched (it was never actually empty)."""
122 oid_removed = _write_blob(repo, b"removed")
123 oid_kept = _write_blob(repo, b"kept")
124 prev: Manifest = {"scaffold/removed.md": oid_removed, "scaffold/kept.md": oid_kept}
125 target: Manifest = {"scaffold/kept.md": oid_kept}
126
127 (repo / "scaffold").mkdir(parents=True)
128 (repo / "scaffold" / "removed.md").write_text("removed")
129 (repo / "scaffold" / "kept.md").write_text("kept")
130
131 apply_manifest(repo, prev, target)
132
133 assert not (repo / "scaffold" / "removed.md").exists()
134 assert (repo / "scaffold" / "kept.md").exists()
135 assert (repo / "scaffold").exists()
136
137
138 def test_apply_manifest_does_not_prune_directory_containing_untracked_file(repo: pathlib.Path) -> None:
139 """A directory that LOOKS empty from the manifest's perspective but
140 actually still has a human-created untracked file on disk must not be
141 deleted -- only genuinely empty directories are pruned."""
142 oid = _write_blob(repo, b"content")
143 keep_oid = _write_blob(repo, b"keep")
144 prev: Manifest = {"scaffold/file.md": oid, "keep.md": keep_oid}
145 target: Manifest = {"keep.md": keep_oid}
146
147 (repo / "scaffold").mkdir(parents=True)
148 (repo / "scaffold" / "file.md").write_text("content")
149 (repo / "scaffold" / "notes.txt").write_text("untracked human notes")
150 (repo / "keep.md").write_text("keep")
151
152 apply_manifest(repo, prev, target)
153
154 assert not (repo / "scaffold" / "file.md").exists()
155 assert (repo / "scaffold" / "notes.txt").exists()
156 assert (repo / "scaffold").exists(), "directory with untracked content must survive"
157
158
159 # ---------------------------------------------------------------------------
160 # Integration: the exact ticket repro via the real CLI
161 # ---------------------------------------------------------------------------
162
163 def test_switch_between_branches_leaves_no_empty_directories(tmp_path: pathlib.Path) -> None:
164 _run(tmp_path, "init")
165 (tmp_path / "README.md").write_text("hello\n")
166 _run(tmp_path, "code", "add", "README.md")
167 _run(tmp_path, "commit", "-m", "init")
168
169 r = _run(tmp_path, "branch", "feat/scaffold")
170 assert r.exit_code == 0, r.stderr
171 r = _run(tmp_path, "checkout", "feat/scaffold")
172 assert r.exit_code == 0, r.stderr
173
174 (tmp_path / "scaffold" / "sub").mkdir(parents=True)
175 (tmp_path / "scaffold" / "sub" / "file.md").write_text("content\n")
176 r = _run(tmp_path, "code", "add", "scaffold/")
177 assert r.exit_code == 0, r.stderr
178 r = _run(tmp_path, "commit", "-m", "add scaffold files")
179 assert r.exit_code == 0, r.stderr
180
181 r = _run(tmp_path, "checkout", "main")
182 assert r.exit_code == 0, r.stderr
183
184 assert not (tmp_path / "scaffold" / "sub").exists(), "scaffold/sub left behind"
185 assert not (tmp_path / "scaffold").exists(), "scaffold left behind"
186
187 status = _run(tmp_path, "status", "--json")
188 assert status.exit_code == 0, status.stderr
189 import json as _json
190 payload = _json.loads(status.output)
191 scaffold_leftovers = [p for p in payload["untracked"] if p.startswith("scaffold")]
192 assert scaffold_leftovers == [], f"leftover untracked entries: {scaffold_leftovers}"
193
194
195 def test_fast_forward_merge_leaves_no_empty_directories(tmp_path: pathlib.Path) -> None:
196 """Covers the broader blast radius from the ticket's own follow-up
197 comment: the same leftover-directory bug from a plain fast-forward
198 along a single branch's own history, not just a cross-branch switch.
199 Uses a same-repo fast-forward (checkout behind, then merge forward)
200 rather than a networked clone/pull round trip -- exercises the exact
201 fast-forward apply_manifest call site noted in the comment without
202 needing a live remote."""
203 _run(tmp_path, "init")
204 (tmp_path / "keep.md").write_text("keep\n")
205 (tmp_path / "tools" / "plugin").mkdir(parents=True)
206 (tmp_path / "tools" / "plugin" / "README.md").write_text("plugin docs\n")
207 _run(tmp_path, "code", "add", ".")
208 _run(tmp_path, "commit", "-m", "add tools/plugin")
209
210 r = _run(tmp_path, "branch", "behind")
211 assert r.exit_code == 0, r.stderr
212
213 # main moves ahead: removes the plugin dir's only file.
214 (tmp_path / "tools" / "plugin" / "README.md").unlink()
215 _run(tmp_path, "code", "add", "tools/")
216 _run(tmp_path, "commit", "-m", "chore: remove stale tools/plugin")
217
218 # "behind" fast-forward-merges main's new tip -- same apply_manifest
219 # fast-forward path a `pull` would exercise.
220 r = _run(tmp_path, "checkout", "behind")
221 assert r.exit_code == 0, r.stderr
222 r = _run(tmp_path, "merge", "main")
223 assert r.exit_code == 0, r.stderr
224
225 assert not (tmp_path / "tools" / "plugin").exists(), "tools/plugin left behind after fast-forward"
226
227 status = _run(tmp_path, "status", "--json")
228 import json as _json
229 payload = _json.loads(status.output)
230 tools_leftovers = [p for p in payload["untracked"] if p.startswith("tools")]
231 assert tools_leftovers == [], f"leftover untracked entries: {tools_leftovers}"
File History 1 commit
sha256:a5f8a020b53ddab68f231cf27489fe865525f85ee924133034262d39b332076e fix(clone,pull): never claim success when a snapshot hash m… Sonnet 5 patch 23 minutes ago