fix(cli,mine): shell-quote project_dir in resume hints

The "Skipped. Run mempalace mine <dir>" hint after declining the init
prompt and the "Re-run mempalace mine <dir> to resume" hint after a
Ctrl-C interruption both interpolated project_dir without shell-quoting.
A path containing spaces or metacharacters produced a copy-paste-broken
command.

Both spots now use shlex.quote(project_dir). Adds regression tests
covering each hint with a path that contains a space.
This commit is contained in:
Igor Lins e Silva
2026-04-25 01:10:17 -03:00
parent 23d534f8f3
commit 8faf0042b5
4 changed files with 50 additions and 2 deletions
+1 -1
View File
@@ -238,7 +238,7 @@ def _maybe_run_mine_after_init(args, cfg) -> None:
# we don't block. User can re-run with --auto-mine to opt in. # we don't block. User can re-run with --auto-mine to opt in.
answer = "n" answer = "n"
if answer not in ("", "y", "yes"): if answer not in ("", "y", "yes"):
print(f"\n Skipped. Run `mempalace mine {project_dir}` when ready.") print(f"\n Skipped. Run `mempalace mine {shlex.quote(project_dir)}` when ready.")
return return
palace_path = cfg.palace_path palace_path = cfg.palace_path
+2 -1
View File
@@ -9,6 +9,7 @@ Stores verbatim chunks as drawers. No summaries. Ever.
import os import os
import sys import sys
import shlex
import hashlib import hashlib
import fnmatch import fnmatch
from pathlib import Path from pathlib import Path
@@ -1103,7 +1104,7 @@ def mine(
print(f" drawers_filed: {total_drawers}") print(f" drawers_filed: {total_drawers}")
print(f" last_file: {last_file or '<none>'}") print(f" last_file: {last_file or '<none>'}")
print( print(
f"\n Re-run `mempalace mine {project_dir}` to resume — " f"\n Re-run `mempalace mine {shlex.quote(project_dir)}` to resume — "
"already-filed drawers are\n upserted idempotently and will not duplicate.\n" "already-filed drawers are\n upserted idempotently and will not duplicate.\n"
) )
sys.exit(130) sys.exit(130)
+23
View File
@@ -281,6 +281,29 @@ def test_maybe_run_mine_yes_and_auto_mine_fully_noninteractive(tmp_path):
mock_mine.assert_called_once() mock_mine.assert_called_once()
def test_maybe_run_mine_decline_quotes_path_with_spaces(tmp_path, capsys):
"""The resume hint must shell-quote the project dir so paths with
spaces / metacharacters produce a copy-paste-safe command."""
from mempalace.cli import _maybe_run_mine_after_init
spaced_dir = tmp_path / "my project dir"
spaced_dir.mkdir()
args = argparse.Namespace(dir=str(spaced_dir), yes=False, auto_mine=False)
cfg = _fake_cfg(tmp_path)
with (
patch("mempalace.miner.mine"),
patch("mempalace.miner.scan_project", return_value=[]),
patch("builtins.input", return_value="n"),
):
_maybe_run_mine_after_init(args, cfg)
out = capsys.readouterr().out
# shlex.quote wraps paths with spaces in single quotes.
assert f"mempalace mine '{spaced_dir}'" in out
# And the bare unquoted form is NOT printed (would break paste).
assert f"mempalace mine {spaced_dir} " not in out
assert f"mempalace mine {spaced_dir}`" not in out
def test_maybe_run_mine_eof_on_stdin_treated_as_decline(tmp_path, capsys): def test_maybe_run_mine_eof_on_stdin_treated_as_decline(tmp_path, capsys):
"""Piped / non-interactive stdin (EOFError) declines without crashing.""" """Piped / non-interactive stdin (EOFError) declines without crashing."""
from mempalace.cli import _maybe_run_mine_after_init from mempalace.cli import _maybe_run_mine_after_init
+24
View File
@@ -653,6 +653,30 @@ def test_mine_keyboard_interrupt_prints_summary_and_exits_130(tmp_path, capsys):
assert "upserted idempotently" in out assert "upserted idempotently" in out
def test_mine_keyboard_interrupt_quotes_path_with_spaces_in_resume_hint(tmp_path, capsys):
"""Resume hint must shell-quote the project dir so a path containing
spaces / metacharacters yields a copy-paste-safe `mempalace mine ...`
command. Otherwise users on a path like "My Project" hit a broken
invocation when they re-run after Ctrl-C."""
import pytest
from unittest.mock import patch
project_root = tmp_path / "my project"
project_root.mkdir()
_make_minable_project(project_root, n_files=2)
palace_path = project_root / "palace"
def fake_process_file(*args, **kwargs):
raise KeyboardInterrupt
with patch("mempalace.miner.process_file", side_effect=fake_process_file):
with pytest.raises(SystemExit):
mine(str(project_root), str(palace_path))
out = capsys.readouterr().out
assert f"mempalace mine '{project_root}'" in out
def test_mine_cleans_up_pid_file_on_interrupt(tmp_path): def test_mine_cleans_up_pid_file_on_interrupt(tmp_path):
"""Our own PID entry in mine.pid is removed in the finally clause.""" """Our own PID entry in mine.pid is removed in the finally clause."""
import pytest import pytest