gabriel / muse public
Closed #76 Bug
filed by gabriel human · 108 days ago

muse status misclassifies directories containing only ignored files as untracked

0 Anchors
— Blast radius
— Churn 30d
0 Proposals

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 to drift()'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's untracked list 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 assert muse diff --json and muse status --json agree: if diff.has_changes is false, 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 in untracked only because of the real file, and muse code add on 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_ignored check, not after) — separately from dirs_with_workdir_files.
  • empty_on_disk_dirs must subtract this new "has any file" set, not just dirs_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 --json from the muse repo root for downstream impact.

Deliverable: full regression pass, zero new failures.


Acceptance Criteria

  • muse status never reports a directory as untracked/empty when its only contents are individually ignored files.
  • muse status and muse diff agree 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–04 pass; 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.

◎ Activity1
●
gabriel opened this issue 108 days ago
○
gabriel 2 days ago

Fixed, following your phases exactly.

Root cause — confirmed exactly as you traced it in CodePlugin.stage_status()'s os.walk loop: a directory is only pruned from the walk if the directory name itself matches an ignore pattern. A file-level pattern (e.g. onlyignored/lockfile.txt) doesn't prune the directory — os.walk descends into it, adds it to on_disk_dirs, then correctly excludes the one file from workdir_files. Since dirs_with_workdir_files is derived only from workdir_files, the directory never lands in it, and empty_on_disk_dirs = on_disk_dirs - dirs_with_workdir_files misclassified it as empty.

Fix (Phase 2, as scoped) — added dirs_with_any_file, populated for every regular file encountered during the walk (all ancestor path components, matching dirs_with_workdir_files's own prefix-walk semantics) before the is_ignored check. empty_on_disk_dirs now subtracts both dirs_with_workdir_files and dirs_with_any_file. No change to directory-name-level ignore pruning, per your explicit scope.

Tests (Phase 1, as scoped) — TestDirectoryWithOnlyIgnoredContent in tests/test_status_directory_tracking.py:

  • STATUSDIR_01 — directory with only an ignored file no longer appears in untracked. Confirmed genuine RED first.
  • STATUSDIR_02 — status/diff agreement, isolated by committing the ignore-pattern change first so it's not itself a pending change (matches your exact has_changes: false repro). Confirmed genuine RED first.
  • STATUSDIR_03 — genuinely empty directory still reported untracked (regression guard — already passed pre-fix, as expected).
  • STATUSDIR_04 — mixed ignored+real-file directory surfaces only via the real file's path, and muse code add stages only the real file (already passed pre-fix, as expected).

Live-verified your exact repro via muse-dev in a disposable sandbox: onlyignored/ disappeared from untracked after the fix, while a sibling genuinely-empty directory and a mixed directory both continued to behave correctly side by side.

Regression sweep (Phase 3, as scoped) — test_status_directory_tracking.py (14, including the 4 new tests), test_directory_tracking.py, test_dir_lifecycle.py, test_directories_feature.py, plus test_cmd_status.py and test_cmd_code_add.py for broader downstream coverage — all pass (285 passed), zero regressions.

Merged to dev → main, pushed to local/staging/production for both branches.

✓
closed this issue 2 days ago