gabriel / muse public
test_security_symlink.py python
614 lines 23.8 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Phase 2.2 — Symlink attack tests.
2
3 Covers every identified attack vector:
4
5 1. ``.muse/`` itself replaced by a symlink → ``find_repo_root`` rejects it.
6 2. Critical subdirectories (``.muse/objects/``, ``.muse/commits/``, etc.)
7 replaced by symlinks → ``require_repo`` detects and exits.
8 3. ``write_object`` / ``write_object_from_path`` detect a symlinked shard dir
9 or objects directory and raise before writing.
10 4. ``write_text_atomic`` / ``_write_msgpack_atomic`` detect a symlinked parent
11 directory and raise before writing.
12 5. ``cleanup_stale_object_temps`` skips symlinked shard directories safely.
13 6. ``_cleanup_muse_dir_temps`` skips symlinked subdirectories safely.
14 7. Tracked-file symlinks are silently skipped by the workdir walker
15 (``os.lstat`` + ``S_ISREG`` filter).
16 8. Stress: 50 concurrent symlink-swap attempts during an object write do not
17 corrupt or redirect any data.
18
19 Each test creates its own isolated temporary directory — no shared state.
20 """
21
22 from __future__ import annotations
23
24 import os
25 import pathlib
26 import tempfile
27 import threading
28 import time
29 import uuid
30
31 import pytest
32
33 from muse.core._types import DEFAULT_HASH_ALGO, blob_id, fake_id, split_id
34 from muse.core.object_store import (
35 cleanup_stale_object_temps,
36 objects_algo_dir,
37 write_object,
38 write_object_from_path,
39 )
40 from muse.core.repo import _cleanup_muse_dir_temps, _verify_muse_dir_integrity, find_repo_root
41 from muse.core.store import CommitDict, _write_msgpack_atomic, write_text_atomic
42 from muse.core.validation import assert_not_symlink, assert_write_inside_repo
43 from tests.cli_test_helper import CliRunner
44
45
46 # ---------------------------------------------------------------------------
47 # Helpers
48 # ---------------------------------------------------------------------------
49
50
51 def _make_real_repo(tmp_path: pathlib.Path) -> pathlib.Path:
52 """Initialise a minimal real (non-symlinked) ``.muse/`` repo layout."""
53 repo = tmp_path / "repo"
54 repo.mkdir()
55 muse = repo / ".muse"
56 for sub in ("objects", "commits", "snapshots", "refs", "refs/heads", "tags"):
57 (muse / sub).mkdir(parents=True)
58 (muse / "HEAD").write_text("ref: refs/heads/main\n")
59 (muse / "repo.json").write_text('{"repo_id": "test-repo"}')
60 return repo
61
62
63 def _sha256_content(data: bytes) -> str:
64 return blob_id(data)
65
66
67 # ---------------------------------------------------------------------------
68 # Unit tests — assert_not_symlink / assert_write_inside_repo
69 # ---------------------------------------------------------------------------
70
71
72 class TestAssertNotSymlink:
73 def test_real_dir_passes(self, tmp_path: pathlib.Path) -> None:
74 real = tmp_path / "real"
75 real.mkdir()
76 assert_not_symlink(real, "real dir") # should not raise
77
78 def test_real_file_passes(self, tmp_path: pathlib.Path) -> None:
79 f = tmp_path / "file.txt"
80 f.write_text("hello")
81 assert_not_symlink(f, "file") # should not raise
82
83 def test_nonexistent_passes(self, tmp_path: pathlib.Path) -> None:
84 # A path that does not yet exist is not a symlink.
85 assert_not_symlink(tmp_path / "no-such-path", "ghost")
86
87 def test_symlink_to_dir_raises(self, tmp_path: pathlib.Path) -> None:
88 target = tmp_path / "target"
89 target.mkdir()
90 link = tmp_path / "link"
91 link.symlink_to(target)
92 with pytest.raises(ValueError, match="symbolic link"):
93 assert_not_symlink(link, "test link")
94
95 def test_symlink_to_file_raises(self, tmp_path: pathlib.Path) -> None:
96 target = tmp_path / "target.txt"
97 target.write_text("data")
98 link = tmp_path / "link.txt"
99 link.symlink_to(target)
100 with pytest.raises(ValueError, match="symbolic link"):
101 assert_not_symlink(link)
102
103 def test_dangling_symlink_raises(self, tmp_path: pathlib.Path) -> None:
104 link = tmp_path / "dangling"
105 link.symlink_to(tmp_path / "nonexistent")
106 with pytest.raises(ValueError, match="symbolic link"):
107 assert_not_symlink(link, "dangling link")
108
109 def test_error_message_contains_label(self, tmp_path: pathlib.Path) -> None:
110 link = tmp_path / "evil"
111 link.symlink_to(tmp_path)
112 with pytest.raises(ValueError, match="evil-label"):
113 assert_not_symlink(link, "evil-label")
114
115
116 class TestAssertWriteInsideRepo:
117 def test_path_inside_passes(self, tmp_path: pathlib.Path) -> None:
118 repo = tmp_path / "repo"
119 repo.mkdir()
120 target = repo / ".muse" / "commits" / "abc.msgpack"
121 assert_write_inside_repo(repo, target) # should not raise
122
123 def test_path_outside_raises(self, tmp_path: pathlib.Path) -> None:
124 repo = tmp_path / "repo"
125 repo.mkdir()
126 outside = tmp_path / "other" / "evil.txt"
127 with pytest.raises(ValueError, match="outside the repository root"):
128 assert_write_inside_repo(repo, outside)
129
130 def test_symlink_escaping_raises(self, tmp_path: pathlib.Path) -> None:
131 """If dest resolves outside repo via symlink, the check catches it."""
132 repo = tmp_path / "repo"
133 repo.mkdir()
134 muse = repo / ".muse"
135 muse.mkdir()
136 attacker = tmp_path / "attacker"
137 attacker.mkdir()
138 # Symlink .muse/objects → /tmp/attacker
139 evil_link = muse / "objects"
140 evil_link.symlink_to(attacker)
141 # The destination inside the objects dir resolves to attacker/...
142 # Parenthesise to avoid PosixPath * int precedence error.
143 dest = evil_link / "ab" / ("cd" * 31)
144 with pytest.raises(ValueError, match="outside the repository root"):
145 assert_write_inside_repo(repo, dest)
146
147
148 # ---------------------------------------------------------------------------
149 # find_repo_root — symlinked .muse/ is rejected
150 # ---------------------------------------------------------------------------
151
152
153 class TestFindRepoRootSymlink:
154 def test_real_muse_dir_found(self, tmp_path: pathlib.Path) -> None:
155 repo = _make_real_repo(tmp_path)
156 found = find_repo_root(start=repo)
157 assert found == repo
158
159 def test_symlinked_muse_dir_not_found(self, tmp_path: pathlib.Path) -> None:
160 """If .muse/ is a symlink, find_repo_root must not return that directory."""
161 real_muse = tmp_path / "real_muse_dir"
162 real_muse.mkdir()
163 repo = tmp_path / "repo"
164 repo.mkdir()
165 (repo / ".muse").symlink_to(real_muse)
166 result = find_repo_root(start=repo)
167 assert result is None, (
168 f"find_repo_root should return None for symlinked .muse/, got {result}"
169 )
170
171 def test_dangling_symlink_muse_dir_not_found(self, tmp_path: pathlib.Path) -> None:
172 repo = tmp_path / "repo"
173 repo.mkdir()
174 (repo / ".muse").symlink_to(tmp_path / "nonexistent")
175 assert find_repo_root(start=repo) is None
176
177 def test_symlink_to_symlink_muse_dir_rejected(self, tmp_path: pathlib.Path) -> None:
178 real_muse = tmp_path / "real_muse"
179 real_muse.mkdir()
180 intermediate = tmp_path / "intermediate"
181 intermediate.symlink_to(real_muse)
182 repo = tmp_path / "repo"
183 repo.mkdir()
184 (repo / ".muse").symlink_to(intermediate)
185 assert find_repo_root(start=repo) is None
186
187 def test_env_override_still_requires_real_muse(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
188 """MUSE_REPO_ROOT override: returns None if .muse/ is a symlink."""
189 real_muse = tmp_path / "real_muse"
190 real_muse.mkdir()
191 repo = tmp_path / "repo"
192 repo.mkdir()
193 (repo / ".muse").symlink_to(real_muse)
194 monkeypatch.setenv("MUSE_REPO_ROOT", str(repo))
195 result = find_repo_root()
196 assert result is None
197
198
199 # ---------------------------------------------------------------------------
200 # _verify_muse_dir_integrity — critical subdirs must not be symlinks
201 # ---------------------------------------------------------------------------
202
203
204 class TestVerifyMuseDirIntegrity:
205 def test_clean_repo_passes(self, tmp_path: pathlib.Path) -> None:
206 repo = _make_real_repo(tmp_path)
207 _verify_muse_dir_integrity(repo / ".muse") # must not raise
208
209 @pytest.mark.parametrize("subdir", [
210 "objects",
211 "commits",
212 "snapshots",
213 "refs",
214 "refs/heads",
215 "tags",
216 ])
217 def test_symlinked_subdir_causes_exit(
218 self, tmp_path: pathlib.Path, subdir: str
219 ) -> None:
220 import shutil
221 repo = _make_real_repo(tmp_path)
222 muse = repo / ".muse"
223 attacker = tmp_path / "attacker"
224 attacker.mkdir(parents=True, exist_ok=True)
225 target = muse / subdir
226 # Remove the real directory tree (may be non-empty, e.g. refs/).
227 if target.exists() and not target.is_symlink():
228 shutil.rmtree(target)
229 target.symlink_to(attacker)
230 with pytest.raises(SystemExit):
231 _verify_muse_dir_integrity(muse)
232
233 def test_missing_subdirs_pass(self, tmp_path: pathlib.Path) -> None:
234 """Newly-initialised repos may not have all dirs yet — that's fine."""
235 repo = tmp_path / "fresh"
236 repo.mkdir()
237 muse = repo / ".muse"
238 muse.mkdir()
239 _verify_muse_dir_integrity(muse) # no dirs present yet — must not raise
240
241
242 # ---------------------------------------------------------------------------
243 # write_object — symlinked shard directory is rejected
244 # ---------------------------------------------------------------------------
245
246
247 class TestWriteObjectSymlink:
248 def test_normal_write_succeeds(self, tmp_path: pathlib.Path) -> None:
249 repo = _make_real_repo(tmp_path)
250 content = b"hello world"
251 oid = _sha256_content(content)
252 result = write_object(repo, oid, content)
253 assert result is True
254
255 def test_symlinked_objects_dir_raises(self, tmp_path: pathlib.Path) -> None:
256 """If .muse/objects/ is a symlink, write_object must raise ValueError."""
257 repo = _make_real_repo(tmp_path)
258 attacker = tmp_path / "attacker"
259 attacker.mkdir()
260 import shutil
261 shutil.rmtree(repo / ".muse" / "objects")
262 (repo / ".muse" / "objects").symlink_to(attacker)
263
264 content = b"evil payload"
265 oid = _sha256_content(content)
266 # write_object creates the shard dir, then checks it
267 with pytest.raises((ValueError, SystemExit)):
268 write_object(repo, oid, content)
269 # Verify nothing was written to the attacker dir
270 assert not any(attacker.rglob("*")), "Data must not be written to symlink target"
271
272 def test_symlinked_shard_dir_raises(self, tmp_path: pathlib.Path) -> None:
273 """A symlinked shard dir (e.g. objects/ab/ → /tmp/evil/) is rejected."""
274 repo = _make_real_repo(tmp_path)
275 content = b"shard attack"
276 oid = _sha256_content(content)
277 prefix = split_id(oid)[1][:2]
278 attacker = tmp_path / "attacker_shard"
279 attacker.mkdir()
280 shard = objects_algo_dir(repo) / prefix
281 shard.mkdir(parents=True, exist_ok=True)
282 # Replace real shard dir with symlink
283 import shutil
284 shutil.rmtree(shard)
285 shard.symlink_to(attacker)
286
287 with pytest.raises((ValueError, SystemExit)):
288 write_object(repo, oid, content)
289 assert not any(attacker.rglob("*")), "No data must reach symlink target"
290
291 def test_write_object_from_path_symlinked_objects_dir_raises(
292 self, tmp_path: pathlib.Path
293 ) -> None:
294 repo = _make_real_repo(tmp_path)
295 attacker = tmp_path / "attacker"
296 attacker.mkdir()
297 import shutil
298 shutil.rmtree(repo / ".muse" / "objects")
299 (repo / ".muse" / "objects").symlink_to(attacker)
300
301 src = tmp_path / "source.bin"
302 src.write_bytes(b"from path content")
303 oid = _sha256_content(src.read_bytes())
304 with pytest.raises((ValueError, SystemExit)):
305 write_object_from_path(repo, oid, src)
306 assert not any(attacker.rglob("*")), "Data must not be written to symlink target"
307
308
309 # ---------------------------------------------------------------------------
310 # write_text_atomic — symlinked parent directory is rejected
311 # ---------------------------------------------------------------------------
312
313
314 class TestWriteTextAtomicSymlink:
315 def test_normal_write_succeeds(self, tmp_path: pathlib.Path) -> None:
316 target = tmp_path / "HEAD"
317 write_text_atomic(target, "ref: refs/heads/main\n")
318 assert target.read_text() == "ref: refs/heads/main\n"
319
320 def test_symlinked_parent_raises(self, tmp_path: pathlib.Path) -> None:
321 """If the parent directory is a symlink, write_text_atomic must raise."""
322 real_dir = tmp_path / "real"
323 real_dir.mkdir()
324 attacker = tmp_path / "attacker"
325 attacker.mkdir()
326 link_dir = tmp_path / "link_dir"
327 link_dir.symlink_to(attacker)
328 target = link_dir / "HEAD"
329
330 with pytest.raises(ValueError, match="symbolic link"):
331 write_text_atomic(target, "ref: refs/heads/main\n")
332 # Verify attacker dir untouched
333 assert not any(attacker.iterdir()), "No data must reach symlink target"
334
335 def test_symlink_at_destination_is_replaced(self, tmp_path: pathlib.Path) -> None:
336 """POSIX os.replace on a symlink replaces the symlink entry itself.
337
338 This is the SAFE case: writing HEAD when HEAD is a symlink replaces
339 the symlink with a real file — data goes to .muse/HEAD, not to the
340 symlink target. This test documents that behaviour is preserved.
341 """
342 real_parent = tmp_path / "muse_dir"
343 real_parent.mkdir()
344 elsewhere = tmp_path / "elsewhere.txt"
345 elsewhere.write_text("original")
346
347 head = real_parent / "HEAD"
348 head.symlink_to(elsewhere)
349 assert head.is_symlink()
350
351 write_text_atomic(head, "new content\n")
352
353 # The symlink should be gone — HEAD is now a real file
354 assert not head.is_symlink(), "symlink at destination must be replaced by real file"
355 assert head.read_text() == "new content\n"
356 # The symlink target is untouched
357 assert elsewhere.read_text() == "original"
358
359
360 # ---------------------------------------------------------------------------
361 # _write_msgpack_atomic — symlinked parent directory is rejected
362 # ---------------------------------------------------------------------------
363
364
365 class TestWriteMsgpackAtomicSymlink:
366 def _minimal_commit_dict(self) -> CommitDict:
367 return CommitDict(
368 commit_id="a" * 64,
369 repo_id=fake_id("repo"),
370 branch="main",
371 parent_commit_id=None,
372 parent2_commit_id=None,
373 snapshot_id="b" * 64,
374 message="test commit",
375 author="test",
376 committed_at="2026-01-01T00:00:00+00:00",
377 metadata={},
378 )
379
380 def test_normal_write_succeeds(self, tmp_path: pathlib.Path) -> None:
381 real_dir = tmp_path / "commits"
382 real_dir.mkdir()
383 target = real_dir / "abc.msgpack"
384 _write_msgpack_atomic(target, self._minimal_commit_dict())
385 assert target.exists()
386
387 def test_symlinked_parent_raises(self, tmp_path: pathlib.Path) -> None:
388 attacker = tmp_path / "attacker_commits"
389 attacker.mkdir()
390 link_dir = tmp_path / "commits_link"
391 link_dir.symlink_to(attacker)
392 target = link_dir / "abc.msgpack"
393
394 with pytest.raises(ValueError, match="symbolic link"):
395 _write_msgpack_atomic(target, self._minimal_commit_dict())
396 assert not any(attacker.iterdir()), "No data must reach symlink target"
397
398
399 # ---------------------------------------------------------------------------
400 # cleanup_stale_object_temps — symlinked shards are skipped
401 # ---------------------------------------------------------------------------
402
403
404 class TestCleanupSkipsSymlinks:
405 def test_symlinked_shard_not_entered(self, tmp_path: pathlib.Path) -> None:
406 """cleanup_stale_object_temps must skip symlinked shard directories."""
407 repo = _make_real_repo(tmp_path)
408 attacker = tmp_path / "attacker"
409 attacker.mkdir()
410 # Place a "stale temp" file inside the attacker directory
411 victim = attacker / ".obj-tmp-should-not-be-deleted"
412 victim.write_bytes(b"important attacker data")
413
414 # Replace a shard with a symlink → attacker
415 shard = objects_algo_dir(repo) / "ab"
416 shard.mkdir(parents=True, exist_ok=True)
417 import shutil
418 shutil.rmtree(shard)
419 shard.symlink_to(attacker)
420
421 removed = cleanup_stale_object_temps(repo)
422 assert removed == 0, "Symlinked shard must not be entered"
423 assert victim.exists(), "File in symlink target must not be deleted"
424
425 def test_real_shards_are_cleaned(self, tmp_path: pathlib.Path) -> None:
426 repo = _make_real_repo(tmp_path)
427 shard = objects_algo_dir(repo) / "cd"
428 shard.mkdir(parents=True)
429 stale = shard / ".obj-tmp-stale123"
430 stale.write_bytes(b"stale data")
431 # Backdate mtime so the 60-second age gate treats this file as stale.
432 os.utime(stale, (0, 0))
433 removed = cleanup_stale_object_temps(repo)
434 assert removed == 1
435 assert not stale.exists()
436
437
438 class TestCleanupMuseDirSkipsSymlinks:
439 def test_symlinked_subdir_not_entered(self, tmp_path: pathlib.Path) -> None:
440 """_cleanup_muse_dir_temps must skip symlinked subdirectories."""
441 repo = _make_real_repo(tmp_path)
442 attacker = tmp_path / "attacker_commits"
443 attacker.mkdir()
444 victim = attacker / ".muse-tmp-should-not-be-deleted"
445 victim.write_bytes(b"important data")
446
447 muse = repo / ".muse"
448 import shutil
449 shutil.rmtree(muse / "commits")
450 (muse / "commits").symlink_to(attacker)
451
452 removed = _cleanup_muse_dir_temps(muse)
453 assert removed == 0, "Symlinked subdir must not be entered"
454 assert victim.exists(), "File in symlink target must not be deleted"
455
456
457 # ---------------------------------------------------------------------------
458 # Tracked-file symlinks — workdir walker skips them
459 # ---------------------------------------------------------------------------
460
461
462 class TestTrackedFileSymlinks:
463 def test_symlink_to_sensitive_file_not_staged(self, tmp_path: pathlib.Path) -> None:
464 """A tracked file that is a symlink is silently excluded from the manifest.
465
466 The workdir walker uses os.lstat + S_ISREG, so symlinks are never
467 hashed or stored — even if they point to /etc/passwd.
468 """
469 from muse.core.snapshot import build_snapshot_manifest
470
471 repo = _make_real_repo(tmp_path)
472 workdir = repo
473
474 # Create a real file (should be tracked)
475 real_file = workdir / "song.mid"
476 real_file.write_bytes(b"\x4d\x54\x68\x64" + b"\x00" * 10)
477
478 # Create a symlink to a sensitive target
479 sensitive = tmp_path / "sensitive.txt"
480 sensitive.write_text("secret data")
481 evil_link = workdir / "evil.txt"
482 evil_link.symlink_to(sensitive)
483
484 manifest = build_snapshot_manifest(workdir)
485 assert "song.mid" in manifest, "real file must be tracked"
486 assert "evil.txt" not in manifest, "symlink must NOT be in manifest"
487
488 def test_symlink_to_nonexistent_target_not_staged(self, tmp_path: pathlib.Path) -> None:
489 from muse.core.snapshot import build_snapshot_manifest
490
491 repo = _make_real_repo(tmp_path)
492 workdir = repo
493 dangling = workdir / "dangling.txt"
494 dangling.symlink_to(tmp_path / "nonexistent")
495
496 manifest = build_snapshot_manifest(workdir)
497 assert "dangling.txt" not in manifest
498
499
500 # ---------------------------------------------------------------------------
501 # Stress: concurrent symlink-swap during write_object
502 # ---------------------------------------------------------------------------
503
504
505 class TestConcurrentSymlinkSwapStress:
506 def test_concurrent_symlink_swap_does_not_corrupt(
507 self, tmp_path: pathlib.Path
508 ) -> None:
509 """50 concurrent symlink-swap threads racing against write_object.
510
511 write_object either succeeds (writes to the real location) or raises
512 ValueError (detects the symlink). It must never silently write to
513 the attacker-controlled location.
514 """
515 repo = _make_real_repo(tmp_path)
516 attacker = tmp_path / "attacker_stress"
517 attacker.mkdir()
518 objects_dir = repo / ".muse" / "objects"
519
520 content = b"stress test object " + uuid.uuid4().bytes
521 oid = _sha256_content(content)
522 shard_prefix = oid[:2]
523 shard_dir = objects_dir / shard_prefix
524
525 errors: list[str] = []
526 swap_active = threading.Event()
527 stop_swapping = threading.Event()
528
529 def swap_shard() -> None:
530 """Repeatedly swap shard dir between real and symlink."""
531 import shutil
532 while not stop_swapping.is_set():
533 swap_active.set()
534 # Replace real shard with symlink
535 try:
536 if shard_dir.exists() and not shard_dir.is_symlink():
537 shutil.rmtree(shard_dir)
538 shard_dir.symlink_to(attacker)
539 time.sleep(0.0005)
540 # Restore real shard
541 if shard_dir.is_symlink():
542 shard_dir.unlink()
543 shard_dir.mkdir(exist_ok=True)
544 except OSError:
545 pass
546
547 swapper = threading.Thread(target=swap_shard, daemon=True)
548 swapper.start()
549 swap_active.wait(timeout=1.0)
550
551 write_errors = 0
552 write_successes = 0
553 for _ in range(50):
554 try:
555 write_object(repo, oid, content)
556 write_successes += 1
557 except (ValueError, OSError, SystemExit):
558 write_errors += 1
559
560 stop_swapping.set()
561 swapper.join(timeout=2.0)
562
563 # The attacker directory must remain empty regardless of outcome.
564 attacker_files = list(attacker.rglob("*"))
565 if attacker_files:
566 errors.append(
567 f"Data leaked to attacker dir: {[str(f) for f in attacker_files]}"
568 )
569
570 assert not errors, "\n".join(errors)
571 # Sanity: at least some operations completed (either succeeded or were blocked).
572 assert write_successes + write_errors == 50
573
574
575 # ---------------------------------------------------------------------------
576 # Integration: end-to-end CLI commands with symlinked .muse/
577 # ---------------------------------------------------------------------------
578
579
580 class TestCLIWithSymlinkedMuse:
581 def test_muse_status_rejects_symlinked_muse(self, tmp_path: pathlib.Path) -> None:
582 """muse status must fail when .muse/ is a symlink."""
583 real_muse = tmp_path / "real_muse"
584 real_muse.mkdir()
585 for sub in ("objects", "commits", "snapshots", "refs/heads", "tags"):
586 (real_muse / sub).mkdir(parents=True)
587 (real_muse / "HEAD").write_text("ref: refs/heads/main\n")
588 (real_muse / "repo.json").write_text('{"repo_id": "test"}')
589
590 repo = tmp_path / "repo"
591 repo.mkdir()
592 (repo / ".muse").symlink_to(real_muse)
593
594 runner = CliRunner()
595 # find_repo_root won't find a real .muse/ → should exit non-zero
596 result = runner.invoke(None, ["status"], env={"MUSE_REPO_ROOT": str(repo)})
597 assert result.exit_code != 0
598
599 def test_muse_status_accepts_real_muse(self, tmp_path: pathlib.Path) -> None:
600 """muse status does not reject a real .muse/ directory as a symlink."""
601 repo = _make_real_repo(tmp_path)
602 (repo / ".muse" / "config.toml").write_text(
603 "[core]\nauthor = \"test\"\n"
604 )
605 (repo / ".muse" / "refs" / "heads" / "main").write_text("")
606 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/main\n")
607
608 runner = CliRunner()
609 result = runner.invoke(
610 None, ["status"],
611 env={"MUSE_REPO_ROOT": str(repo)},
612 )
613 # Must not complain about symlinks on a real .muse/.
614 assert "symbolic link" not in result.output.lower()
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago