muse status misclassifies directories containing only ignored files as untracked
muse status misclassifies directories containing only ignored files as untracked
Background
Found live while auditing ~/ecosystem for stray untracked files. After adding
.claude/scheduled_tasks.lock to .museignore, muse status continued to
report untracked directory: .claude/ even though muse check-ignore .claude/scheduled_tasks.lock confirms the file is correctly matched and
ignored, and muse diff --json reports zero changes (has_changes: false).
Two commands disagree about the same working tree because they use different
code paths — status goes through the code plugin's drift() method, diff
does not.
Root cause — traced to muse/plugins/code/plugin.py's drift() method
for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False):
...
on_disk_dirs.add(cur_rel) # every dir os.walk visits, unconditionally
dirnames[:] = sorted(
d for d in dirnames
if d not in _ALWAYS_IGNORE_DIRS
and not is_ignored(f"{cur_rel}/{d}/_" if cur_rel else f"{d}/_", patterns)
...
)
for fname in sorted(filenames):
...
if is_ignored(rel, patterns):
continue # file excluded from workdir_files
workdir_files[rel] = ...
...
dirs_with_workdir_files: set[str] = set() # derived from workdir_files only
for f in workdir_files:
...
empty_on_disk_dirs: set[str] = on_disk_dirs - dirs_with_workdir_files
...
dir_added = sorted(empty_on_disk_dirs - committed_empty_dirs - staged_added_dirs)
A directory is only pruned from the walk (excluded from on_disk_dirs) if the
directory name itself matches an ignore pattern (e.g. __pycache__/). A
file-level pattern like .claude/scheduled_tasks.lock doesn't prune the
directory — os.walk still descends into .claude/, adds it to
on_disk_dirs, then correctly excludes the one file inside from
workdir_files. Since dirs_with_workdir_files is derived only from
workdir_files, .claude never lands in it. The subtraction
on_disk_dirs - dirs_with_workdir_files then classifies .claude/ as an
empty on-disk directory — even though it's not empty, its only content is
legitimately ignored — and it flows into dir_added, which muse status
renders as untracked directory: .claude/.
Confirmed via isolated reproduction, not just read from source:
$ mkdir onlyignored && echo junk > onlyignored/lockfile.txt
$ echo '[global]\npatterns = ["onlyignored/lockfile.txt"]' > .museignore
$ muse status --json | jq '.untracked'
["onlyignored/"]
$ muse diff --json | jq '.has_changes'
false
Goal
A directory whose contents are entirely composed of individually-ignored files
must never be reported as an untracked/empty directory by muse status. The
"empty on-disk directory" classification (used for muse code add's empty-dir
tracking feature) must mean zero files at all, ignored or not — not "zero
non-ignored files."
"Done" means: status's untracked-directory reporting and diff's
change-detection agree on every case — a directory containing only ignored
content is invisible to both, exactly like a directory containing zero files
whose parent is itself ignored.
Out of Scope
- Any change to
muse diff's own logic — it already handles this correctly; this issue is scoped entirely todrift()'s directory classification. - The pre-existing empty-directory-tracking feature's semantics for genuinely empty directories (zero files, no ignore involved) — unaffected, not touched.
Phases
Phase 1 — Red: reproduce with a real test
STATUSDIR_01— Directory containing only a file matched by a file-level (not directory-level) ignore pattern:muse status'suntrackedlist must not contain that directory. Must fail against current code (reproducing the exact.claude//onlyignored/case above) and pass after the fix.STATUSDIR_02— Same setup, but assertmuse diff --jsonandmuse status --jsonagree: ifdiff.has_changesisfalse,status's untracked list must be empty for that directory too. Pins the cross-command consistency directly, not just one command's output.STATUSDIR_03— Regression guard: a genuinely empty directory (zero files, no ignore pattern involved) must still be correctly reported as an untracked/addable empty directory — confirms the fix doesn't break the existing empty-dir-tracking feature.STATUSDIR_04— A directory with a mix of one ignored file and one real (non-ignored) file: must appear inuntrackedonly because of the real file, andmuse code addon it must stage only the real file.
Deliverable: all four tests exist and STATUSDIR_01/02 fail against
current code (red) before any Phase 2 code changes.
Phase 2 — Fix: distinguish "zero files" from "zero non-ignored files"
- Track a new set — directories containing any file encountered during
the walk, ignored or not (populated before the
is_ignoredcheck, not after) — separately fromdirs_with_workdir_files. empty_on_disk_dirsmust subtract this new "has any file" set, not justdirs_with_workdir_files, so a directory with only ignored content is excluded from the empty-dir candidate set entirely.- No change to directory-name-level ignore pruning (the
dirnames[:] = ...filter) — that mechanism is correct and unrelated to this bug.
Deliverable: STATUSDIR_01 through STATUSDIR_04 all green.
Phase 3 — Regression sweep
- Full run of the existing directory-tracking test files (
test_status_ directory_tracking.py,test_directory_tracking.py,test_dir_lifecycle.py,test_directories_feature.py) — zero new failures. muse code test --jsonfrom themuserepo root for downstream impact.
Deliverable: full regression pass, zero new failures.
Acceptance Criteria
muse statusnever reports a directory as untracked/empty when its only contents are individually ignored files.muse statusandmuse diffagree on working-tree cleanliness for every case in Phases 1–3's tests.- Genuinely empty directories (no ignore involved) are still correctly tracked as addable — the pre-existing feature is unaffected.
- All
STATUSDIR_01–04pass; full existing directory-tracking test suite remains green.
Implementation Order
Phase 1 → Phase 2 → Phase 3. TDD by the book — red tests written and confirmed failing before any fix code.