# A genuinely empty (0-byte) file is silently untrackable via `muse code add` -- misclassified as a directory sentinel ## Severity High. Systemic, not edge-case: affects any code-domain repo, any time a user tries to track a 0-byte file. Silent -- no error, no warning, `muse code add` reports the file as staged ("new file"), but it never reaches a commit's manifest and reverts to untracked immediately after. ## Background Found while unblocking a real user's repo (`~/thoughts`) that also hit an unrelated, now-fixed bug (a stale directory-sentinel-clobber -- see the commit that shipped `0.2.1rc8`). After that fix landed, the same file *still* wouldn't commit. Root cause turned out to be completely independent and far more general. ## Repro (fresh repo, zero history, first commit) ```bash muse init touch brand-new-empty.md muse code add . --json # reports: {"path": "brand-new-empty.md", "mode": "new file"}, staged=3 muse commit -m "track empty file" --json # files_changed.added = 2, not 3 -- brand-new-empty.md silently excluded muse status --json # untracked: ["brand-new-empty.md"] ``` No prior directory history involved. No ghost entries. Just a 0-byte file, staged, committed, gone. ## Root cause `muse/plugins/code/stage.py`: ```python EMPTY_DIR_OID: str = blob_id(b"") ``` The sentinel value used to mark "this stage entry represents a tracked *empty directory*, not a file" is defined as the SHA-256 content hash of zero bytes. But the content hash of a genuinely empty *file* is the exact same value -- `blob_id(b"")` is deterministic and content-only; it cannot know or care whether the zero bytes came from "a file with no content" or "a marker for an empty directory." Both produce the identical hash. Every place in the codebase that classifies a stage entry as "directory" vs. "file" does so by checking `object_id == EMPTY_DIR_OID` (or the equivalent `_DIR_SENTINEL` alias) -- there is no other discriminator. The critical failure point, `muse/plugins/code/plugin.py::snapshot()`'s stage branch: ```python for rel_path, entry in stage.items(): if entry["object_id"] == _DIR_SENTINEL: continue # sentinel entries go into directories, not files ... staged_manifest[rel_path] = entry["object_id"] ``` A staged empty file has `object_id == EMPTY_DIR_OID` (correctly, that *is* its content hash) and `mode == "A"` -- indistinguishable, by this check, from a staged empty-directory sentinel. It hits `continue` and is silently excluded from `staged_manifest` forever. ## Blast radius -- every call site making this same assumption ```bash grep -rn "== _DIR_SENTINEL\|== EMPTY_DIR_OID\|!= EMPTY_DIR_OID\|!= _DIR_SENTINEL" muse/ --include="*.py" ``` At least 19 call sites across 6 files, all trusting object_id equality alone to mean "this is a directory, not a file": - `muse/plugins/code/plugin.py` (7 sites) -- snapshot(), workdir walk, dimension separation - `muse/cli/commands/code_stage.py` (6 sites) -- `run_add`'s deleted-dir detection, dir/symbol counting, verbose labeling - `muse/cli/commands/status.py` (4 sites) -- staged/unstaged bucketing - `muse/cli/commands/mv.py` (2 sites) -- rename detection - `muse/cli/commands/diff.py` (1 site) -- diff rendering - `muse/plugins/code/stage.py` (1 site, legacy sentinel migration) Every one of these needs to agree on the fix simultaneously -- a half-migrated fix (e.g. only `plugin.py` updated) would just move the misclassification to a different command instead of eliminating it. ## Why this needs a real design, not a quick patch The stage entry schema (`StagedEntry`, version 3 per `stage.json`'s `"version":3` field) currently has exactly `object_id`, `mode`, `staged_at`. There's no field that independently says "this is a directory sentinel" without inferring it from the object_id's value. Fixing this properly means: 1. Adding an explicit discriminator to `StagedEntry` (e.g. `"kind": "file" | "dir"`), bumping the stage schema version. 2. Updating every one of the ~19 call sites above to check the new field instead of `object_id == EMPTY_DIR_OID`. 3. Deciding what happens to *already-staged* entries written under the old schema (no `kind` field) -- almost certainly: absence of `kind` defaults to inferring from context (was it written via the directory-staging code path or the file-staging code path) at read-time, migrated to the explicit field on next write. Per this workspace's no-legacy-shims convention, this should be a clean one-time migration, not a permanent dual-path. 4. A regression test for the exact repro above, plus one for every existing empty-directory test to confirm they still pass with the new discriminator (there's real risk of breaking the empty-directory feature while fixing the empty-file bug, since they're now sharing one schema field instead of one overloaded hash value). ## Acceptance criteria - `touch empty.md && muse code add . && muse commit -m x` results in `empty.md` present in the resulting commit's manifest. - All existing empty-directory tests (`test_directories_feature.py` and friends) still pass -- directories must not regress while fixing files. - A new regression test for the exact fresh-repo repro above. - Every one of the ~19 call sites audited and updated consistently; a follow-up grep for `== EMPTY_DIR_OID` / `== _DIR_SENTINEL` should only match the (now correct, schema-aware) discriminator checks, not raw object_id comparisons used to infer file-vs-directory.