gabriel / muse public
issue-102-empty-file-misclassified-as-dir-sentinel.md markdown
128 lines 5.4 KB
Raw
sha256:3273bc9871dda86f13624c7c78da1af474afdaa4e5705b705b446938263aa651 docs: add local source-of-truth for muse#102 (empty file mi… Sonnet 5 1 day ago

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 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.
File History 1 commit
sha256:3273bc9871dda86f13624c7c78da1af474afdaa4e5705b705b446938263aa651 docs: add local source-of-truth for muse#102 (empty file mi… Sonnet 5 1 day ago