Genuinely empty (0-byte) file is silently untrackable -- misclassified as an empty-directory sentinel
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)
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:
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:
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
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 separationmuse/cli/commands/code_stage.py(6 sites) --run_add's deleted-dir detection, dir/symbol counting, verbose labelingmuse/cli/commands/status.py(4 sites) -- staged/unstaged bucketingmuse/cli/commands/mv.py(2 sites) -- rename detectionmuse/cli/commands/diff.py(1 site) -- diff renderingmuse/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:
- Adding an explicit discriminator to
StagedEntry(e.g."kind": "file" | "dir"), bumping the stage schema version. - Updating every one of the ~19 call sites above to check the new
field instead of
object_id == EMPTY_DIR_OID. - Deciding what happens to already-staged entries written under the
old schema (no
kindfield) -- almost certainly: absence ofkinddefaults 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. - 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 xresults inempty.mdpresent in the resulting commit's manifest.- All existing empty-directory tests (
test_directories_feature.pyand 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_SENTINELshould only match the (now correct, schema-aware) discriminator checks, not raw object_id comparisons used to infer file-vs-directory.
Fixed in sha256:47e5542341c5a936597b3974abf274d75185c50c3b89917c21d8face06fa6691 (shipped as muse 0.2.1rc10, part of the #104 roadmap fix).
Added an explicit kind: "file" | "dir" discriminator to StagedEntry (stage schema v4) so a genuinely empty file is never inferred to be a directory sentinel from object_id == EMPTY_DIR_OID alone. Verified end-to-end against the real installed 0.2.1rc10 CLI: touch empty.md && muse code add . && muse commit now correctly includes the file in the manifest and it never reappears as untracked.
Tracked under parent roadmap ticket #104 (comprehensive fix for the empty-directory sentinel design flaw all three of these — plus one already-shipped fix — are symptoms of). Not closing this individually; #104 defines the multi-phase plan and the actual acceptance gate for considering this whole bug family resolved.