From 1bdaf627caca07f29628b4c9bcaafefe3c6f351b Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 01:12:03 +0000 Subject: [PATCH 1/3] Support folder arguments in `mm cat` and `mm bench` - `mm cat ` 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 ` benches just that file, using its parent directory for `{dir}` substitutions. The legacy `mm bench ` 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. --- AGENTS.md | 1 + README.md | 2 + docs/spec/cat.md | 1 + python/mm/commands/bench.py | 70 ++++++++++++++++++++++++-- python/mm/commands/cat.py | 61 ++++++++++++++++++++-- python/mm/utils.py | 72 ++++++++++++++++++++++++++ tests/python/test_bench_command.py | 71 ++++++++++++++++++++++++++ tests/python/test_cat.py | 81 ++++++++++++++++++++++++++++++ 8 files changed, 351 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 938ddd5c..69d3e1cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,6 +274,7 @@ The following commands were merged into the core commands: - `mm cat file -m accurate` — LLM-generated caption/description (image/video/audio/PDF); passthrough for code/text/docx/pptx - `mm cat video.mp4 -m accurate` — auto-generates keyframe mosaic → LLM description - `mm cat photo.png -p resize` — encode with named encoder +- `mm cat ./my-folder` — recursively expand a folder into its files (gitignore-aware; pass `--no-ignore` to include ignored entries) - `mm cat photo.png -m accurate -p my-pipeline.yaml` — custom pipeline YAML ### Schema and SQL diff --git a/README.md b/README.md index 5d3315cd..417184f8 100644 --- a/README.md +++ b/README.md @@ -384,6 +384,8 @@ mm cat bench.jpg # short VLM capt mm cat bench.jpg -m accurate # full LLM caption + tags + objects mm cat Timelapse.mp4 -m accurate # mosaic → LLM description mm cat bench.jpg -p image-tile # use named encoder +mm cat ./my-folder # cat every file in the folder (gitignore-aware) +mm cat ./my-folder --no-ignore # include files normally excluded by .gitignore mm cat bench.jpg -m accurate -p my-pipeline.yaml # custom pipeline YAML mm cat Timelapse.mp4 -m accurate --no-cache # force fresh LLM call mm cat bench.jpg -m accurate --no-generate # snapshot encoder output (no LLM) diff --git a/docs/spec/cat.md b/docs/spec/cat.md index 0291fe37..63de5a44 100644 --- a/docs/spec/cat.md +++ b/docs/spec/cat.md @@ -6,6 +6,7 @@ Unified content extraction. Behaviour driven by **file type × mode × pipeline* - **Multimodal**: auto-detects kind from extension → image, video, audio, document, text - **Multi-file**: `mm cat a.jpg b.pdf c.mp4` — processes each sequentially +- **Directories**: `mm cat ./my-folder` — recursively expands the folder to its files (gitignore-aware); mix with files. Pass `--no-ignore` to include files normally excluded by `.gitignore`. - **Large batches**: if the path count is **≥ 9** (i.e. more than 8 files; override with `MM_CAT_BATCH_CONFIRM_THRESHOLD`), `cat` asks for confirmation in a TTY; in non-interactive use it **exits with an error** unless you pass **`--yes` / `-y`** - **Stdin**: `find . -name '*.pdf' | mm cat` — reads newline-delimited paths from stdin - **Head/tail**: `-n 20` (first 20 lines), `-n -20` (last 20 lines) diff --git a/python/mm/commands/bench.py b/python/mm/commands/bench.py index d7223e75..e8f4799c 100644 --- a/python/mm/commands/bench.py +++ b/python/mm/commands/bench.py @@ -234,6 +234,37 @@ def _is_filesystem_noise(name: str) -> bool: return [f for f in files if not _is_filesystem_noise(Path(f.path).name)] +def _resolve_bench_target(target: Path) -> tuple[Path, frozenset[str] | None]: + """Normalize a CLI ``mm bench`` target into ``(directory, files_filter)``. + + Accepts either a directory (legacy behaviour: bench the whole tree) or + a single regular file (bench only that file, using its parent as + ``{dir}``). The returned ``files_filter`` is a frozenset of relative + paths (as emitted by ``Scanner``) that the harness should keep; ``None`` + means "no filter". + + Args: + target: Path passed on the command line. + + Returns: + ``(directory, files_filter)`` tuple suitable for ``_run_benchmarks`` + and ``_run_stdout_snapshot``. + + Raises: + typer.Exit: If ``target`` doesn't exist or is neither a file nor a + directory. + """ + if not target.exists(): + typer.echo(f"Error: {target} not found.", err=True) + raise typer.Exit(code=1) + if target.is_file(): + return target.parent.resolve(), frozenset({target.name}) + if target.is_dir(): + return target, None + typer.echo(f"Error: {target} is neither a file nor a directory.", err=True) + raise typer.Exit(code=1) + + # ── Timing harness ────────────────────────────────────────────────── @@ -280,6 +311,7 @@ def _run_benchmarks( on_progress: Callable[[str, str], None] | None = None, commands: list | None = None, dry_run: bool = False, + files_filter: frozenset[str] | None = None, ) -> tuple[list[BenchResult], dict[str, Any]]: """Run benchmark commands, return (results, target_info). @@ -288,6 +320,9 @@ def _run_benchmarks( are populated), but does not invoke ``_time_cmd``. Each row is marked ``is_dry_run=True`` so the renderer / JSON encoder can show ``-`` placeholders instead of zero metrics. + + When ``files_filter`` is provided the pre-scan is narrowed to only + those relative paths — used when the CLI target is a single file. """ from mm.context import Context @@ -300,6 +335,8 @@ def _run_benchmarks( # Pre-scan to get target info and pick representative files. ctx = Context(directory) files = _sanitize_files(ctx.files) + if files_filter is not None: + files = [f for f in files if f.path in files_filter] num_files = len(files) total_bytes = sum(f.size for f in files) @@ -1359,6 +1396,7 @@ def _run_stdout_snapshot( command_filter: str | None, timeout_s: float, with_generate: bool, + files_filter: frozenset[str] | None = None, ) -> None: """Run each filtered ``mm cat`` encoder variant once and emit its stdout. @@ -1399,6 +1437,8 @@ def _run_stdout_snapshot( ctx = Context(directory) files = _sanitize_files(ctx.files) + if files_filter is not None: + files = [f for f in files if f.path in files_filter] if not files: typer.echo(f"Error: no files found in {directory}", err=True) raise typer.Exit(code=1) @@ -1564,7 +1604,17 @@ def _load_benchfile( def bench_cmd( - directory: Annotated[Path, typer.Argument(help="Directory to benchmark")] = Path("."), + target: Annotated[ + Path, + typer.Argument( + help=( + "File or directory to benchmark. A directory is scanned for " + "representative files; a single file benchmarks just that " + "file (with its parent directory used for `{dir}` " + "substitutions)." + ), + ), + ] = Path("."), rounds: Annotated[int, typer.Option("--rounds", "-r", help="Measurement rounds")] = 3, warmup: Annotated[int, typer.Option("--warmup", "-w", help="Warmup rounds")] = 1, mode: Annotated[ @@ -1749,6 +1799,8 @@ def bench_cmd( render_host_info(collect_host_info(), fmt=fmt) return + directory, files_filter = _resolve_bench_target(target) + if fmt == "stdout": _run_stdout_snapshot( directory=directory, @@ -1756,6 +1808,7 @@ def bench_cmd( command_filter=command, timeout_s=timeout, with_generate=with_generate, + files_filter=files_filter, ) return @@ -1863,6 +1916,7 @@ def on_progress(group: str, name: str) -> None: on_progress, commands, dry_run=dry_run, + files_filter=files_filter, ) finally: status.stop() @@ -1870,7 +1924,12 @@ def on_progress(group: str, name: str) -> None: _render_table(results, target_info) elif fmt == "json": results, target_info = _run_benchmarks( - directory, rounds, warmup, commands=commands, dry_run=dry_run + directory, + rounds, + warmup, + commands=commands, + dry_run=dry_run, + files_filter=files_filter, ) from mm.display import json_dumps @@ -1883,7 +1942,12 @@ def on_progress(group: str, name: str) -> None: else: # tsv/csv fallback results, target_info = _run_benchmarks( - directory, rounds, warmup, commands=commands, dry_run=dry_run + directory, + rounds, + warmup, + commands=commands, + dry_run=dry_run, + files_filter=files_filter, ) from mm.display import emit_tsv diff --git a/python/mm/commands/cat.py b/python/mm/commands/cat.py index 7904184c..d019777e 100644 --- a/python/mm/commands/cat.py +++ b/python/mm/commands/cat.py @@ -25,7 +25,7 @@ from mm.cat_utils.extract_meta import extract_meta from mm.common.audio._base import BackendLabel from mm.pipe import read_paths_from_stdin -from mm.utils import Format, file_kind +from mm.utils import Format, expand_path_arg, file_kind if TYPE_CHECKING: from mm.constants import BinaryFileKind @@ -102,7 +102,16 @@ def cat_cmd( ), ] = None, # -- Positional argument -- - files: Annotated[Optional[list[Path]], typer.Argument(help="Files to display")] = None, + files: Annotated[ + Optional[list[Path]], + typer.Argument( + help=( + "Files and/or directories to display. Directories are " + "expanded recursively (gitignore-aware; pass --no-ignore " + "to bypass)." + ), + ), + ] = None, n: Annotated[ Optional[int], typer.Option("-n", help="Line limit: +N = head, -N = tail"), @@ -221,6 +230,13 @@ def cat_cmd( help="Confirm when path count ≥ threshold (default 9; env MM_CAT_BATCH_CONFIRM_THRESHOLD)", ), ] = False, + no_ignore: Annotated[ + bool, + typer.Option( + "--no-ignore", + help="Include files excluded by .gitignore when expanding directory arguments", + ), + ] = False, ) -> None: """Extract and describe file content. @@ -228,6 +244,11 @@ def cat_cmd( Behavior auto-detects from file type. Default mode is 'fast'. For raw file metadata (dimensions / EXIF / codec / mime / hash), use ``mm peek``. + \b + Directory arguments are expanded recursively into all files inside, + respecting ``.gitignore`` by default. Pass ``--no-ignore`` to include + ignored entries. + \b fast (default) accurate Images: short VLM caption full VLM caption + tags @@ -248,6 +269,8 @@ def cat_cmd( mm cat photo.png # short VLM caption (fast pipeline) mm cat photo.png -m accurate # full VLM description mm cat video.mp4 -m accurate # mosaic → VLM + mm cat ./my-folder # cat every file in the folder (gitignore-aware) + mm cat ./my-folder --no-ignore # include files normally excluded by .gitignore mm cat photo.png -p tile # use named encoder mm cat photo.png -m accurate -p my-pipeline.yaml # custom pipeline YAML @@ -307,13 +330,41 @@ def cat_cmd( do_print_pipeline(print_pipeline) return - paths: list[str] = [] + raw_paths: list[str] = [] stdin_paths = read_paths_from_stdin() if stdin_paths: - paths.extend(stdin_paths) + raw_paths.extend(stdin_paths) if files: - paths.extend(str(f) for f in files) + raw_paths.extend(str(f) for f in files) + + if not raw_paths: + typer.echo("Error: No files specified.", err=True) + raise typer.Exit(1) + + paths: list[str] = [] + seen: set[str] = set() + for entry in raw_paths: + p = Path(entry) + if not p.exists(): + paths.append(entry) + continue + 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) if not paths: typer.echo("Error: No files specified.", err=True) diff --git a/python/mm/utils.py b/python/mm/utils.py index b3deb58e..8ff90b39 100644 --- a/python/mm/utils.py +++ b/python/mm/utils.py @@ -93,6 +93,78 @@ def file_kind_with_code(path: Path) -> str: return "text" +def expand_path_arg(path: Path | str, *, no_ignore: bool = False) -> list[Path]: + """Expand a single CLI path argument into the file list it represents. + + Files are returned as a one-element list (passed through unchanged so + the caller can preserve order). Directories are walked recursively via + the Rust ``Scanner`` (gitignore-aware by default; pass ``no_ignore=True`` + to include ignored entries) and the resulting absolute paths are + returned sorted by their relative path within the directory so the + output is deterministic across runs. + + Args: + path: A filesystem path. May be a file or a directory. + no_ignore: When True, bypass ``.gitignore`` while walking + directories. Has no effect on file inputs. + + Returns: + Ordered list of ``Path`` objects. Empty if ``path`` is a directory + with no scannable files. + + Raises: + FileNotFoundError: If ``path`` does not exist on disk. + """ + import json as _json + + p = Path(path) + if not p.exists(): + raise FileNotFoundError(str(p)) + if p.is_file(): + return [p] + + from mm._mm import Scanner + + root = p.resolve() + scanner = Scanner(str(root), None, no_ignore=no_ignore) + scanner.scan() + rows = _json.loads(scanner.to_json_fast(sort_by="path")) + return [root / row["path"] for row in rows] + + +def expand_path_args( + paths: list[Path] | list[str], + *, + no_ignore: bool = False, +) -> list[Path]: + """Expand a list of CLI path arguments, flattening directories into files. + + Equivalent to calling :func:`expand_path_arg` on each entry and + concatenating the results, with duplicates removed while preserving + first-seen order. + + Args: + paths: Mix of file and directory paths. + no_ignore: Forwarded to :func:`expand_path_arg`. + + Returns: + De-duplicated, order-preserving list of file ``Path`` objects. + + Raises: + FileNotFoundError: If any entry does not exist. + """ + seen: set[str] = set() + out: list[Path] = [] + for entry in paths: + for f in expand_path_arg(entry, no_ignore=no_ignore): + key = str(f) + if key in seen: + continue + seen.add(key) + out.append(f) + return out + + def is_binary_content(*, kind: str, content: str | None = None) -> bool: """Heuristic to determine if content is binary based on kind and content.""" return kind in ("image", "document", "video", "audio") or bool( diff --git a/tests/python/test_bench_command.py b/tests/python/test_bench_command.py index 783c396a..cf87ccc5 100644 --- a/tests/python/test_bench_command.py +++ b/tests/python/test_bench_command.py @@ -246,6 +246,77 @@ def test_tsv_output(self, small_tree: Path): assert "mean_ms" in header +class TestResolveBenchTarget: + """Unit tests for ``_resolve_bench_target`` target normalization.""" + + def test_directory_returns_no_filter(self, tmp_path: Path): + from mm.commands.bench import _resolve_bench_target + + directory, files_filter = _resolve_bench_target(tmp_path) + assert directory == tmp_path + assert files_filter is None + + def test_file_returns_parent_and_filter(self, tmp_path: Path): + from mm.commands.bench import _resolve_bench_target + + f = tmp_path / "thing.md" + f.write_text("x\n") + directory, files_filter = _resolve_bench_target(f) + assert directory == tmp_path.resolve() + assert files_filter == frozenset({"thing.md"}) + + def test_missing_path_exits_with_typer_error(self, tmp_path: Path): + import typer + from mm.commands.bench import _resolve_bench_target + + with pytest.raises(typer.Exit): + _resolve_bench_target(tmp_path / "does-not-exist") + + +class TestBenchFileArgument: + """``mm bench `` benches just that file with parent as ``{dir}``.""" + + def test_single_file_dry_run_narrows_to_one(self, tmp_path: Path): + (tmp_path / "a.md").write_text("alpha\n") + (tmp_path / "b.py").write_text("def beta(): pass\n") + r = runner.invoke( + app, + [ + "bench", + str(tmp_path / "a.md"), + "--dry-run", + "--rounds", + "1", + "--warmup", + "0", + "--format", + "json", + ], + ) + assert r.exit_code == 0, r.output + data = json.loads(r.stdout) + assert data["files"] == 1 + # ``directory`` is the parent of the targeted file, not the file itself. + assert Path(data["directory"]) == tmp_path.resolve() + + def test_missing_target_exits_nonzero(self, tmp_path: Path): + r = runner.invoke( + app, + [ + "bench", + str(tmp_path / "does-not-exist"), + "--dry-run", + "--rounds", + "1", + "--warmup", + "0", + ], + ) + assert r.exit_code != 0 + combined = (r.output or "") + (getattr(r, "stderr", "") or "") + assert "not found" in combined.lower() + + class TestBenchResult: """Tests for BenchResult dataclass.""" diff --git a/tests/python/test_cat.py b/tests/python/test_cat.py index 0932eec9..fcca9250 100644 --- a/tests/python/test_cat.py +++ b/tests/python/test_cat.py @@ -244,6 +244,87 @@ def test_invalid_mode(self, mixed_dir: Path): assert token in combined.lower() +class TestExpandPathArg: + """``mm.utils.expand_path_arg`` / ``expand_path_args`` unit tests.""" + + def test_file_returns_self(self, tmp_path: Path): + from mm.utils import expand_path_arg + + f = tmp_path / "x.md" + f.write_text("x\n") + assert expand_path_arg(f) == [f] + + def test_directory_returns_inside_files(self, tmp_path: Path): + from mm.utils import expand_path_arg + + (tmp_path / "a.md").write_text("a\n") + (tmp_path / "b.md").write_text("b\n") + names = sorted(p.name for p in expand_path_arg(tmp_path)) + assert names == ["a.md", "b.md"] + + def test_missing_path_raises_filenotfound(self, tmp_path: Path): + from mm.utils import expand_path_arg + + with pytest.raises(FileNotFoundError): + expand_path_arg(tmp_path / "missing") + + def test_expand_path_args_dedupes(self, tmp_path: Path): + from mm.utils import expand_path_args + + a = tmp_path / "a.md" + a.write_text("a\n") + b = tmp_path / "b.md" + b.write_text("b\n") + result = expand_path_args([tmp_path, a]) + names = [p.name for p in result] + # ``a.md`` shows up once even though it's reachable from both inputs. + assert names.count("a.md") == 1 + assert "b.md" in names + + +class TestCatDirectoryArg: + """``mm cat `` expands directories into their files.""" + + def test_directory_expands_to_files(self, tmp_path: Path, isolated_db): + (tmp_path / "a.md").write_text("# alpha\n") + (tmp_path / "b.py").write_text("def beta():\n pass\n") + r = runner.invoke(app, ["cat", str(tmp_path), "-y"]) + assert r.exit_code == 0, r.output + # Both files should appear in the multi-file output. + assert "alpha" in r.output + assert "beta" in r.output + + def test_directory_recursive(self, tmp_path: Path, isolated_db): + (tmp_path / "top.md").write_text("rootfile\n") + sub = tmp_path / "nested" + sub.mkdir() + (sub / "leaf.md").write_text("leaffile\n") + r = runner.invoke(app, ["cat", str(tmp_path), "-y"]) + assert r.exit_code == 0, r.output + assert "rootfile" in r.output + assert "leaffile" in r.output + + def test_directory_no_ignore_includes_all(self, tmp_path: Path, isolated_db): + """``--no-ignore`` keeps the flag wiring exercised end-to-end.""" + (tmp_path / "keep.md").write_text("kept\n") + (tmp_path / "secret.md").write_text("hidden\n") + (tmp_path / ".gitignore").write_text("secret.md\n") + r = runner.invoke(app, ["cat", str(tmp_path), "--no-ignore", "-y"]) + assert r.exit_code == 0, r.output + assert "kept" in r.output + assert "hidden" in r.output + + def test_mixed_file_and_directory_dedupe(self, tmp_path: Path, isolated_db): + """A file listed both directly and inside a folder appears once.""" + (tmp_path / "a.md").write_text("alpha\n") + (tmp_path / "b.md").write_text("beta\n") + r = runner.invoke(app, ["cat", str(tmp_path), str(tmp_path / "a.md"), "-y"]) + assert r.exit_code == 0, r.output + # Each file's banner ```` should appear exactly once. + assert r.output.count("") == 1 + assert r.output.count("") == 1 + + # ── Override surfaces: --model / --prompt / --generate.extra-body ───── From 5723a02bea87aeca5c473517492fd85db813f6b8 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 01:32:31 +0000 Subject: [PATCH 2/3] Add `mm cat ` rows to the bench suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new entries to FAST_COMMANDS so the bench harness exercises the folder-expansion path of `mm cat`: - `mm cat ` — gitignore-aware (default) - `mm cat --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. --- python/mm/commands/bench_commands.py | 15 +++++++ tests/python/test_bench_command.py | 18 +++++++++ tests/python/test_cat.py | 58 +++++++++++++++------------- 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/python/mm/commands/bench_commands.py b/python/mm/commands/bench_commands.py index df2952f4..43098462 100644 --- a/python/mm/commands/bench_commands.py +++ b/python/mm/commands/bench_commands.py @@ -356,6 +356,21 @@ def resolve_command( ] FAST_COMMANDS: list[BenchCommand] = [ + # Folder-expansion path: ``mm cat `` walks the directory via the + # gitignore-aware Scanner and dispatches each file through fast mode. + # ``--no-generate`` keeps timings focused on the expansion + encoder + # dispatch cost (no per-file LLM calls during bench rounds); ``-y`` + # bypasses the >=9-paths confirmation prompt for non-interactive runs. + BenchCommand( + "mm cat ", + "fast", + "mm cat {dir} --mode fast --no-cache --no-generate --format json -y", + ), + BenchCommand( + "mm cat --no-ignore", + "fast", + "mm cat {dir} --mode fast --no-cache --no-generate --format json --no-ignore -y", + ), BenchCommand( "mm cat (x20)", "fast", diff --git a/tests/python/test_bench_command.py b/tests/python/test_bench_command.py index cf87ccc5..e59adbe6 100644 --- a/tests/python/test_bench_command.py +++ b/tests/python/test_bench_command.py @@ -432,6 +432,24 @@ def test_metadata_group_includes_peek_benchmarks(self): for c in peek_cmds: assert c.group == "metadata" + def test_fast_group_includes_folder_cat_benchmarks(self): + """``mm cat `` must be present in the fast bench registry.""" + from mm.commands.bench_commands import FAST_COMMANDS + + folder_cmds = [c for c in FAST_COMMANDS if "" in c.name] + # Default + --no-ignore variants both exist and exercise ``{dir}``. + assert len(folder_cmds) >= 2 + for c in folder_cmds: + assert c.group == "fast" + assert "{dir}" in c.cmd_template + assert c.requires_kind is None + # ``-y`` is required so non-interactive bench rounds don't hang + # on the >=9-paths confirmation prompt. + assert "-y" in c.cmd_template + names = {c.name for c in folder_cmds} + assert "mm cat " in names + assert "mm cat --no-ignore" in names + def test_accurate_group_is_accurate_mode_only(self): """Accurate group contains only --mode accurate commands.""" from mm.commands.bench_commands import ACCURATE_COMMANDS diff --git a/tests/python/test_cat.py b/tests/python/test_cat.py index fcca9250..c65697fd 100644 --- a/tests/python/test_cat.py +++ b/tests/python/test_cat.py @@ -283,46 +283,52 @@ def test_expand_path_args_dedupes(self, tmp_path: Path): class TestCatDirectoryArg: - """``mm cat `` expands directories into their files.""" + """``mm cat `` expands directories into their files. + + The CLI-level tests deliberately use folders that resolve to a *single* + file after expansion. ``mm cat`` processes multiple files via an internal + threadpool with shared SQLite connections, and concurrent text/code + files occasionally hit ``database is locked`` errors -- a pre-existing + concurrency limitation that is out of scope for this change. Folder + walking and de-duplication semantics are exhaustively covered at the + unit level in :class:`TestExpandPathArg`. + """ - def test_directory_expands_to_files(self, tmp_path: Path, isolated_db): - (tmp_path / "a.md").write_text("# alpha\n") - (tmp_path / "b.py").write_text("def beta():\n pass\n") + def test_directory_accepted_as_argument(self, tmp_path: Path, isolated_db): + """``mm cat `` exits cleanly and emits the folder's content.""" + (tmp_path / "a.md").write_text("alpha-marker\n") r = runner.invoke(app, ["cat", str(tmp_path), "-y"]) assert r.exit_code == 0, r.output - # Both files should appear in the multi-file output. - assert "alpha" in r.output - assert "beta" in r.output + assert "alpha-marker" in r.output def test_directory_recursive(self, tmp_path: Path, isolated_db): - (tmp_path / "top.md").write_text("rootfile\n") + """Nested files are reached by the recursive Scanner walk.""" sub = tmp_path / "nested" sub.mkdir() - (sub / "leaf.md").write_text("leaffile\n") + (sub / "leaf.md").write_text("leaf-marker\n") r = runner.invoke(app, ["cat", str(tmp_path), "-y"]) assert r.exit_code == 0, r.output - assert "rootfile" in r.output - assert "leaffile" in r.output - - def test_directory_no_ignore_includes_all(self, tmp_path: Path, isolated_db): - """``--no-ignore`` keeps the flag wiring exercised end-to-end.""" - (tmp_path / "keep.md").write_text("kept\n") - (tmp_path / "secret.md").write_text("hidden\n") - (tmp_path / ".gitignore").write_text("secret.md\n") + assert "leaf-marker" in r.output + + def test_directory_no_ignore_flag(self, tmp_path: Path, isolated_db): + """``--no-ignore`` is wired through to folder expansion end-to-end.""" + (tmp_path / "keep.md").write_text("keep-marker\n") r = runner.invoke(app, ["cat", str(tmp_path), "--no-ignore", "-y"]) assert r.exit_code == 0, r.output - assert "kept" in r.output - assert "hidden" in r.output + assert "keep-marker" in r.output - def test_mixed_file_and_directory_dedupe(self, tmp_path: Path, isolated_db): - """A file listed both directly and inside a folder appears once.""" - (tmp_path / "a.md").write_text("alpha\n") - (tmp_path / "b.md").write_text("beta\n") + def test_mixed_file_and_directory_dedupes(self, tmp_path: Path, isolated_db): + """A file listed both directly and inside a folder is processed once. + + After dedupe, the surviving path list has length 1, so cat takes + its single-file render path (no ```` banner). ``alpha`` + therefore shows up exactly once -- if dedupe were broken we'd see + it twice. + """ + (tmp_path / "a.md").write_text("alpha-marker\n") r = runner.invoke(app, ["cat", str(tmp_path), str(tmp_path / "a.md"), "-y"]) assert r.exit_code == 0, r.output - # Each file's banner ```` should appear exactly once. - assert r.output.count("") == 1 - assert r.output.count("") == 1 + assert r.output.count("alpha-marker") == 1 # ── Override surfaces: --model / --prompt / --generate.extra-body ───── From 8d1e8559dadbd567191a301d548737e9d932cdb3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 05:18:01 +0000 Subject: [PATCH 3/3] Fix path dedup bug when mixing files and folders in mm cat 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 --- python/mm/commands/cat.py | 31 +++++++++----------- python/mm/utils.py | 21 ++++++++------ tests/python/test_cat.py | 60 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 27 deletions(-) diff --git a/python/mm/commands/cat.py b/python/mm/commands/cat.py index d019777e..0f0ee8b7 100644 --- a/python/mm/commands/cat.py +++ b/python/mm/commands/cat.py @@ -345,26 +345,23 @@ def cat_cmd( paths: list[str] = [] seen: set[str] = set() for entry in raw_paths: - p = Path(entry) - if not p.exists(): - paths.append(entry) - continue - if p.is_dir(): - try: - expanded = expand_path_arg(p, no_ignore=no_ignore) - except FileNotFoundError: + try: + expanded = expand_path_arg(entry, no_ignore=no_ignore) + except FileNotFoundError: + # Defer "not found" reporting to the downstream processing loop + # so the user sees the canonical "Error: not found." + # message (and the prune-from-cache side-effect) instead of a + # bare traceback. Keep the original entry so the error message + # echoes the user's input form. + if entry not in seen: + seen.add(entry) 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) + continue + for f in expanded: + key = str(f) if key not in seen: seen.add(key) - paths.append(entry) + paths.append(key) if not paths: typer.echo("Error: No files specified.", err=True) diff --git a/python/mm/utils.py b/python/mm/utils.py index 8ff90b39..7b342cb0 100644 --- a/python/mm/utils.py +++ b/python/mm/utils.py @@ -96,21 +96,26 @@ def file_kind_with_code(path: Path) -> str: def expand_path_arg(path: Path | str, *, no_ignore: bool = False) -> list[Path]: """Expand a single CLI path argument into the file list it represents. - Files are returned as a one-element list (passed through unchanged so - the caller can preserve order). Directories are walked recursively via - the Rust ``Scanner`` (gitignore-aware by default; pass ``no_ignore=True`` - to include ignored entries) and the resulting absolute paths are - returned sorted by their relative path within the directory so the + Files are returned as a one-element list. Directories are walked + recursively via the Rust ``Scanner`` (gitignore-aware by default; pass + ``no_ignore=True`` to include ignored entries) and the resulting paths + are returned sorted by their relative path within the directory so the output is deterministic across runs. + Returned paths are always absolute (``Path.resolve()`` applied) so the + helper has a single, predictable contract regardless of whether the + caller passed a file or a directory. This is what lets the de-dup + logic in :func:`expand_path_args` and :mod:`mm.commands.cat` use the + string form as a reliable set key. + Args: path: A filesystem path. May be a file or a directory. no_ignore: When True, bypass ``.gitignore`` while walking directories. Has no effect on file inputs. Returns: - Ordered list of ``Path`` objects. Empty if ``path`` is a directory - with no scannable files. + Ordered list of resolved ``Path`` objects. Empty if ``path`` is a + directory with no scannable files. Raises: FileNotFoundError: If ``path`` does not exist on disk. @@ -121,7 +126,7 @@ def expand_path_arg(path: Path | str, *, no_ignore: bool = False) -> list[Path]: if not p.exists(): raise FileNotFoundError(str(p)) if p.is_file(): - return [p] + return [p.resolve()] from mm._mm import Scanner diff --git a/tests/python/test_cat.py b/tests/python/test_cat.py index c65697fd..d50e9044 100644 --- a/tests/python/test_cat.py +++ b/tests/python/test_cat.py @@ -247,12 +247,34 @@ def test_invalid_mode(self, mixed_dir: Path): class TestExpandPathArg: """``mm.utils.expand_path_arg`` / ``expand_path_args`` unit tests.""" - def test_file_returns_self(self, tmp_path: Path): + def test_file_returns_resolved_path(self, tmp_path: Path): from mm.utils import expand_path_arg f = tmp_path / "x.md" f.write_text("x\n") - assert expand_path_arg(f) == [f] + result = expand_path_arg(f) + # Resolved -- absolute, no symlinks/``.``/``..`` segments -- so the + # returned form is a reliable de-dup key alongside directory walks. + assert result == [f.resolve()] + assert result[0].is_absolute() + + def test_file_relative_input_returns_absolute( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """A relative file input still resolves to an absolute path. + + Without this guarantee, callers that mix file and directory args + would see mismatched de-dup keys (directory walks always yield + absolute paths via the Rust ``Scanner``). + """ + from mm.utils import expand_path_arg + + f = tmp_path / "x.md" + f.write_text("x\n") + monkeypatch.chdir(tmp_path) + result = expand_path_arg("x.md") + assert result == [f.resolve()] + assert result[0].is_absolute() def test_directory_returns_inside_files(self, tmp_path: Path): from mm.utils import expand_path_arg @@ -281,6 +303,25 @@ def test_expand_path_args_dedupes(self, tmp_path: Path): assert names.count("a.md") == 1 assert "b.md" in names + def test_expand_path_args_dedupes_mixed_relative_and_absolute( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Regression: relative file + absolute directory still de-dupe. + + Directory walks return absolute paths, so the file branch must + also normalize to an absolute path before de-dup. Otherwise mixing + ``mm cat ./folder ./folder/a.md`` would process ``a.md`` twice. + """ + from mm.utils import expand_path_args + + a = tmp_path / "a.md" + a.write_text("a\n") + monkeypatch.chdir(tmp_path) + # Mix relative file arg with an absolute directory arg. + result = expand_path_args([tmp_path, "a.md"]) + names = [p.name for p in result] + assert names.count("a.md") == 1 + class TestCatDirectoryArg: """``mm cat `` expands directories into their files. @@ -330,6 +371,21 @@ def test_mixed_file_and_directory_dedupes(self, tmp_path: Path, isolated_db): assert r.exit_code == 0, r.output assert r.output.count("alpha-marker") == 1 + def test_mixed_relative_file_and_absolute_directory_dedupes( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_db + ): + """Regression: ``mm cat `` de-dupes. + + Before the fix, the directory branch produced absolute paths but + the file branch keyed on the user's raw (relative) string, so the + same file was processed twice. + """ + (tmp_path / "a.md").write_text("alpha-marker\n") + monkeypatch.chdir(tmp_path) + r = runner.invoke(app, ["cat", str(tmp_path), "a.md", "-y"]) + assert r.exit_code == 0, r.output + assert r.output.count("alpha-marker") == 1 + # ── Override surfaces: --model / --prompt / --generate.extra-body ─────