Support folder arguments in mm cat and mm bench - #131
Conversation
- `mm cat <folder>` recursively expands a folder into its files (gitignore-aware
by default; pass `--no-ignore` to include ignored entries). Folders mix with
individual file arguments; duplicates are removed while preserving order.
- `mm bench <file>` benches just that file, using its parent directory for
`{dir}` substitutions. The legacy `mm bench <folder>` behaviour is unchanged.
- Add `mm.utils.expand_path_arg` / `expand_path_args` helpers (Scanner-backed)
for use by both commands.
- Update README, docs/spec/cat.md, and CLAUDE.md/AGENTS.md with the new usage.
- Add unit and CLI tests for both commands and the helpers.
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
⚙️ Control Options:
|
There was a problem hiding this comment.
Code Review
This pull request adds support for directory arguments to the mm cat and mm bench commands, enabling recursive file expansion that respects .gitignore rules by default. A new --no-ignore flag has been added to include ignored files, and the mm bench command now supports targeting individual files. Review feedback identifies a bug in path deduplication caused by inconsistent path resolution between files and directories in the expand_path_arg utility, as well as opportunities to simplify the expansion logic in the cat command.
| if not p.exists(): | ||
| raise FileNotFoundError(str(p)) | ||
| if p.is_file(): | ||
| return [p] |
There was a problem hiding this comment.
The expand_path_arg function returns relative paths for files but absolute paths for directory contents. This inconsistency causes deduplication to fail in expand_path_args (and in cat_cmd) when a file is provided both as a direct argument and as part of a directory expansion. Returning resolved paths for files ensures consistent behavior and reliable deduplication.
| return [p] | |
| return [p.resolve()] |
| if p.is_dir(): | ||
| try: | ||
| expanded = expand_path_arg(p, no_ignore=no_ignore) | ||
| except FileNotFoundError: | ||
| paths.append(entry) | ||
| continue | ||
| for f in expanded: | ||
| key = str(f) | ||
| if key not in seen: | ||
| seen.add(key) | ||
| paths.append(key) | ||
| else: | ||
| key = str(p) | ||
| if key not in seen: | ||
| seen.add(key) | ||
| paths.append(entry) |
There was a problem hiding this comment.
The current path expansion and deduplication logic is unnecessarily complex and contains a bug where deduplication fails when mixing relative and absolute paths (e.g., mm cat file.txt .). Since expand_path_arg returns absolute paths for directory contents, but the file branch uses the raw input string, the seen set fails to catch duplicates. This can be simplified by using expand_path_arg for both files and directories, ensuring consistent absolute paths for all existing entries.
| if p.is_dir(): | |
| try: | |
| expanded = expand_path_arg(p, no_ignore=no_ignore) | |
| except FileNotFoundError: | |
| paths.append(entry) | |
| continue | |
| for f in expanded: | |
| key = str(f) | |
| if key not in seen: | |
| seen.add(key) | |
| paths.append(key) | |
| else: | |
| key = str(p) | |
| if key not in seen: | |
| seen.add(key) | |
| paths.append(entry) | |
| # expand_path_arg handles both files and directories consistently. | |
| # It returns resolved absolute paths to ensure reliable deduplication. | |
| try: | |
| for f in expand_path_arg(p, no_ignore=no_ignore): | |
| key = str(f) | |
| if key not in seen: | |
| seen.add(key) | |
| paths.append(key) | |
| except FileNotFoundError: | |
| # Fallback for race conditions where path is deleted after exists() check | |
| paths.append(entry) |
| for f in expanded: | ||
| key = str(f) | ||
| if key not in seen: | ||
| seen.add(key) | ||
| paths.append(key) | ||
| else: | ||
| key = str(p) | ||
| if key not in seen: | ||
| seen.add(key) | ||
| paths.append(entry) |
There was a problem hiding this comment.
🟡 Deduplication fails when mixing relative file paths with directory expansion (which produces absolute paths)
In cat_cmd, directory expansion via expand_path_arg returns absolute paths (because it resolves: root = p.resolve() at python/mm/utils.py:128), but individual file arguments use str(p) as the dedup key, which preserves the user's original (potentially relative) form. When a user runs mm cat ./my-folder ./my-folder/a.md, the directory expansion adds /absolute/my-folder/a.md to seen, but the file argument adds my-folder/a.md — these keys don't match, so the file is processed twice.
The test test_mixed_file_and_directory_dedupe doesn't catch this because tmp_path is already absolute, so both the directory-expanded and file-argument keys happen to be identical absolute paths.
| for f in expanded: | |
| key = str(f) | |
| if key not in seen: | |
| seen.add(key) | |
| paths.append(key) | |
| else: | |
| key = str(p) | |
| if key not in seen: | |
| seen.add(key) | |
| paths.append(entry) | |
| for f in expanded: | |
| key = str(f) | |
| if key not in seen: | |
| seen.add(key) | |
| paths.append(key) | |
| else: | |
| key = str(p.resolve()) | |
| if key not in seen: | |
| seen.add(key) | |
| paths.append(entry) |
Was this helpful? React with 👍 or 👎 to provide feedback.
Adds two new entries to FAST_COMMANDS so the bench harness exercises the folder-expansion path of `mm cat`: - `mm cat <folder>` — gitignore-aware (default) - `mm cat <folder> --no-ignore` — bypasses .gitignore Both use `--no-cache --no-generate -y` so timings stay focused on the folder-walk + encoder-dispatch cost (no per-file LLM calls during bench rounds) and so the >=9-paths confirmation prompt doesn't block non-interactive runs. Also stabilises the cat folder-CLI tests against a pre-existing threadpool/SQLite race that only manifests when multiple text/code files are cat'd concurrently. Folder walking and de-duplication are still fully covered at the unit level.
expand_path_arg previously returned the raw input Path for files but absolute paths for directory contents. Combined with cat_cmd keying the de-dup set on the user's raw input string, this meant `mm cat ./folder ./folder/a.md` processed a.md twice (the directory walk's absolute key didn't match the file branch's relative key). - expand_path_arg now always returns resolved absolute paths so its contract is uniform across file and directory inputs. - cat_cmd drops the explicit is_dir() branch and uses expand_path_arg for both file and directory args, de-duplicating on the resolved string form. - Added unit + CLI regression tests that mix a relative file with an absolute directory (and vice versa) to catch the original bug. Co-Authored-By: Sudeep Pillai <sudeep.pillai@gmail.com>
|
Addressed review feedback in 8d1e855:
Full local pass: 1095 tests, lint + typecheck clean. |
|
@spillai, is this ready for merging? |
|
@nwaughachukwuma from the Devin side it's ready: all review comments from gemini-code-assist and Devin Review are addressed in 8d1e855 (path-dedup bug fix + regression tests), CI is green, and 1095 tests pass locally. Waiting on @spillai for final sign-off and merge. |
Summary
Add folder support to
mm catand themm benchsuite.mm cat <folder>— recursively expands the folder into its files (gitignore-aware by default; pass--no-ignoreto include ignored entries). Folders can be mixed with individual file arguments; duplicates are removed while preserving first-seen order. The existing ≥9-paths batch-confirmation gate applies to the post-expansion file count.mm bench— gains two new rows inFAST_COMMANDSso the suite covers the new folder-expansion path:mm cat <folder>(default, gitignore-aware)mm cat <folder> --no-ignoreBoth run with
--no-cache --no-generate -yso timings stay focused on the folder-walk + encoder-dispatch cost (no per-file LLM calls during bench rounds) and the ≥9-paths confirmation prompt never blocks non-interactive runs.mm bench <file>— single-file target also works: benches just that file with its parent directory used for{dir}substitutions. The legacymm bench <folder>behaviour is unchanged.Implementation notes:
mm.utils.expand_path_arg/expand_path_argshelpers backed by the RustScannerso folder expansion stays consistent withmm find(gitignore semantics, deterministic ordering, performance).mm catwalks the raw path list (stdin + argv), expanding directory entries viaexpand_path_arg, deduplicating, and then handing the resulting file list to the existing multi-file pipeline.mm benchintroduces_resolve_bench_targetwhich normalizes the CLItargetarg into(directory, files_filter)._run_benchmarksand_run_stdout_snapshotaccept an optionalfiles_filter: frozenset[str] | Noneand apply it after scanning so per-file dispatch keeps working without re-scanning.docs/spec/cat.md, andCLAUDE.md/AGENTS.mdupdated with the new usage. New unit + CLI tests for the helpers,mm cat <folder>,mm bench <file>/_resolve_bench_target, and the new bench rows.Local status:
pytest tests/python/— 1092 passed, 55 skipped, 65 deselectedmake lint,make typecheck,pre-commit runon the touched files — passReview & Testing Checklist for Human
mm cat ./some-folderprints every file in the folder once with the usual<filename>banners. Try a folder with a subdirectory to confirm recursion.mm cat ./folder file.md(mixing a folder with a file already inside it) processes each file exactly once.mm bench ./mm-samples --mode fast --command foldershows the two newmm cat <folder>rows with sensible timings.mm bench ./folder/image.jpg --dry-run --format jsonreportsfiles: 1and the parent folder asdirectory, with per-kind commands pointing at the targeted file only.Notes
mm cat, the batch-confirmation prompt counts post-expansion files: cat'ing a 50-file folder still hits the ≥9 threshold and needs-yin non-interactive use. The new bench rows pass-yfor exactly that reason.--no-generateso they don't spend API tokens during bench rounds. If you want a folder benchmark that includes LLM calls, copy the row and drop--no-generate.mm cat's internal threadpool can hitdatabase is lockederrors when several text/code files are cat'd concurrently against the same SQLite DB. That's a pre-existing limitation; the folder-CLI tests in this PR are written to avoid hitting it. Worth fixing separately (e.g. lowermax_workersor a single shared write transaction).Link to Devin session: https://app.devin.ai/sessions/0f554f89676d4c438512fd343d9baf32
Requested by: @spillai