gabriel / muse public
fix BREAKING task/muse-104-empty-dir-sentinel #1 / 1
AI Agent gabriel · 14 hours ago · Sep 22, 2026 · Diff

fix: eliminate EMPTY_DIR_OID/empty-file collision with explicit kind discriminator (muse#101,#102,#103,#104)

Root cause (muse#104): EMPTY_DIR_OID = blob_id(b"") is both the sentinel marking a tracked empty directory AND the legitimate content hash of a genuinely empty file -- every call site that inferred file-vs-directory from object_id == EMPTY_DIR_OID was ambiguous by construction.

Fix: add an explicit kind: "file" | "dir" field to StagedEntry, bump stage schema to v4 with a one-time best-effort migration for pre-v4 entries (infer from whether the path is an on-disk directory). All ~19 call sites across plugin.py, code_stage.py, status.py, mv.py, diff.py updated to check kind instead of object_id equality. Final grep audit: zero remaining object_id-equality-as-file-vs-dir comparisons anywhere.

muse#102 (empty file silently dropped from manifest): fixed by the kind discriminator alone -- an empty file now stays kind="file" and is never shunted into the directories bucket.

muse#103 (stuck directory sentinel, directory still on disk): code_stage.py's deleted_committed_dirs detection now also fires when a committed empty dir's path is still present but fully excluded by .museignore, not just when physically absent -- muse code add . auto-detects and stages the removal.

muse#101 (false 'deleted' status, path in both manifest and directories): bisected the real historical corruption source in ~/ecosystem/presentations/build-with-muse -- introducing commit 903cc80e ('Add Episode 03 script') transitioned a placeholder directory to a real file in the same commit that carries forward HEAD's committed empty dirs; the stale directory entry was never dropped. The already-shipped subtraction fix in plugin.py::snapshot() (0.2.1rc8) prevents this for new commits; this commit adds a locked-in regression test (TestManifestDirectoriesMutualExclusion) plus the kind-based fix that makes the underlying ambiguity structurally impossible going forward.

New consolidated reproduction suite: tests/test_empty_dir_sentinel_invariants.py (9 tests, one class per symptom). Full targeted regression run: 618 + 398 = 1016 tests across code_add, directories_feature, status, mv, diff, resolve, commit, merge_engine, cherry_pick, merge, pull, rebase suites -- zero regressions.

sha256:47e5542341c5a936597b3974abf274d75185c50c3b89917c21d8face06fa6691 sha
+27 ~23 −5 symbols
9 changed · 1207 in snapshot files
sha256:7b7b34123811c1b57350d5e14d2d12525bbe1267376bbad548c4deec702fa022 snapshot
+27
symbols added
~23
symbols modified
−5
symbols removed
9
files changed
1207
files in snapshot
0
dead code introduced
Semantic Changes 55 symbols
+ TestEmptyFileNotMisclassifiedAsDirSentinel class class TestEmptyFileNotMisclassifiedAsDirSentinel L55–139
+ test_empty_dir_sentinel_still_works_alongside_empty_files method method test_empty_dir_sentinel_still_works_alongside_empty_files L115–139
+ test_empty_file_is_staged_as_new_file_not_a_dir method method test_empty_file_is_staged_as_new_file_not_a_dir L63–73
+ test_empty_file_not_untracked_after_commit method method test_empty_file_not_untracked_after_commit L101–113
+ test_empty_file_survives_commit_in_manifest method method test_empty_file_survives_commit_in_manifest L75–99
+ TestManifestDirectoriesMutualExclusion class class TestManifestDirectoriesMutualExclusion L200–259
+ test_dir_to_file_and_file_to_dir_transitions_never_overlap method method test_dir_to_file_and_file_to_dir_transitions_never_overlap L231–259
+ test_fresh_commit_never_double_records_a_path method method test_fresh_commit_never_double_records_a_path L206–229
+ TestStageSchemaV4Migration class class TestStageSchemaV4Migration L267–316
+ test_legacy_entry_for_existing_dir_migrates_to_dir_kind method method test_legacy_entry_for_existing_dir_migrates_to_dir_kind L273–294
+ test_legacy_entry_for_nonexistent_path_migrates_to_file_kind method method test_legacy_entry_for_nonexistent_path_migrates_to_file_kind L296–316
+ TestStuckDirectorySentinelUntrack class class TestStuckDirectorySentinelUntrack L147–192
+ test_ignored_still_present_dir_is_auto_untracked method method test_ignored_still_present_dir_is_auto_untracked L154–192
+ _env function function _env L32–33
+ _run function function _run L36–38
+ cli variable variable cli L29–29
+ CliRunner import import CliRunner L26–26
+ StagedEntry import import StagedEntry L25–25
+ annotations import import annotations L18–18
+ json import import json L20–20
+ pathlib import import pathlib L21–21
+ pytest import import pytest L23–23
+ read_stage import import read_stage L25–25
+ write_stage import import write_stage L25–25
+ repo function function repo L42–47
+ runner variable variable runner L28–28
~ muse/cli/commands/diff.py .py 1 symbol removed, 2 symbols modified
EMPTY_DIR_OID import import EMPTY_DIR_OID L78–78
~ run
~ muse/cli/commands/mv.py .py 1 symbol modified
~ run
~ muse/cli/commands/status.py .py 1 symbol removed, 1 symbol modified
_DIR_SENTINEL import import _DIR_SENTINEL L92–92
~ muse/plugins/code/plugin.py .py 2 symbols removed, 9 symbols modified
_DIR_SENTINEL variable variable _DIR_SENTINEL L239–239
EMPTY_DIR_OID import import EMPTY_DIR_OID L129–129
~ pyproject.toml .toml 2 symbols modified
~ tests/test_cmd_code_add.py .py 1 symbol added, 1 symbol removed, 1 symbol modified
test_V7_stage_version_is_3_in_json method method test_V7_stage_version_is_3_in_json L655–665
+ test_V7_stage_version_is_4_in_json method method test_V7_stage_version_is_4_in_json L655–665
Files Changed
+1 ~8
1207 in snapshot
← Older Oldest on task/muse-104-empty-dir-sentinel
All commits
Newer → Latest on task/muse-104-empty-dir-sentinel

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:47e5542341c5a936597b3974abf274d75185c50c3b89917c21d8face06fa6691 --body "your comment"