gabriel / muse public
Closed #102 Bug
filed by gabriel human · 13 hours ago

Genuinely empty (0-byte) file is silently untrackable -- misclassified as an empty-directory sentinel

0 Anchors
Blast radius
Churn 30d
0 Proposals

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.
Activity2
gabriel opened this issue 13 hours ago
gabriel 12 hours ago

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.

gabriel 11 hours ago

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.

closed this issue 11 hours ago