fix(init): split --auto-mine from --yes; show file-count estimate before mine prompt

Reviewer feedback on the previous commit flagged two real problems:

1. Overloading --yes to also auto-mine was a silent behaviour change for
   scripted callers. Today --yes only auto-accepts entities — making it
   ALSO trigger a multi-minute ChromaDB write breaks every script that
   currently runs `mempalace init --yes <dir>` for the fast non-interactive
   entity path. Add a separate `--auto-mine` flag instead. Combinations:

     mempalace init --yes <dir>              # entities auto, STILL prompt mine
     mempalace init --auto-mine <dir>        # prompt entities, skip mine prompt
     mempalace init --yes --auto-mine <dir>  # fully non-interactive

   --yes behaviour is now identical to pre-PR.

2. The mine prompt was firing without telling the user how big the job
   was. On a real corpus mine takes minutes-to-tens-of-minutes; hitting
   Enter on default-Y with no size cue is a footgun. Show a one-line
   estimate computed from scan_project (the same walk we hand into mine)
   BEFORE the prompt:

     ~423 files (~12 MB) would be mined into this palace.
     Mine this directory now? [Y/n]

   The estimate uses a single corpus walk: scan_project's output is
   passed into mine() via a new optional files= kwarg, so we never walk
   the tree twice.

Tests: replaced the old "--yes auto-mines" assertion with a regression
guard that --yes alone STILL prompts; added coverage for --auto-mine
alone, --yes --auto-mine together, and the pre-prompt estimate line.
This commit is contained in:
Igor Lins e Silva
2026-04-24 19:23:38 -03:00
parent f13b9a46a2
commit 23d534f8f3
4 changed files with 187 additions and 38 deletions
+69 -18
View File
@@ -159,44 +159,83 @@ def cmd_init(args):
# Pass 4: offer to run mine immediately. The directory just had its
# rooms + entities set up, so 99% of users will mine next anyway —
# asking here removes the "remember to type the next command" friction.
# `--yes` skips the prompt and auto-mines (non-interactive path).
# `--auto-mine` skips the prompt and mines automatically; `--yes` is
# SCOPED to entity auto-accept and does NOT imply mining.
_maybe_run_mine_after_init(args, cfg)
def _format_size_mb(num_bytes: int) -> str:
"""Render a byte count as a human-readable size for the mine estimate.
< 1 MB rounds up to ``<1 MB`` so users never see a misleading ``0 MB``
on small projects. Otherwise reports an integer megabyte count.
"""
if num_bytes <= 0:
return "<1 MB"
mb = num_bytes / (1024 * 1024)
if mb < 1:
return "<1 MB"
return f"{mb:.0f} MB"
def _maybe_run_mine_after_init(args, cfg) -> None:
"""Prompt the user to mine the directory just initialised, or auto-mine
when ``--yes`` was passed. Extracted so the prompt path is unit-testable.
when ``--auto-mine`` was passed. Extracted so the prompt path is
unit-testable.
Behaviour matrix:
- default (no flags) — prompt, default Yes, mine in-process if accepted
- ``--yes`` — entity auto-accept only; STILL prompts for the mine step
- ``--auto-mine`` — skip the mine prompt and mine directly
- ``--yes --auto-mine`` — fully non-interactive
Mine errors are surfaced (not swallowed): a failing mine exits with a
non-zero status via :func:`sys.exit` so downstream scripts can see it.
The pre-scan that produces the file-count estimate is reused as the
mine input so we never walk the corpus twice.
"""
from .miner import mine, scan_project
project_dir = args.dir
auto = bool(getattr(args, "yes", False))
auto_mine = bool(getattr(args, "auto_mine", False))
# Pre-scan so the user knows roughly what they're agreeing to before
# the prompt. The scan is fast and we'd run it inside mine() anyway.
# Single corpus walk: this scan feeds BOTH the "what would be mined"
# estimate the user sees in the prompt AND the file list mine() will
# process. We pass the result into mine() via the `files` kwarg so it
# doesn't re-walk the tree.
try:
files = scan_project(project_dir)
file_count = len(files)
scanned_files = scan_project(project_dir)
file_count = len(scanned_files)
total_bytes = 0
for fp in scanned_files:
try:
total_bytes += fp.stat().st_size
except OSError:
# Skip files that vanished between scan and stat — mine()
# will skip them too.
continue
size_str = _format_size_mb(total_bytes)
except Exception:
scanned_files = None
file_count = None
size_str = None
if auto:
print("\n Auto-mining (--yes).")
else:
scope = (
f" (~{file_count} files in scope)"
if isinstance(file_count, int) and file_count > 0
else ""
)
print(f"\n Ready to mine{scope}.")
# Show the scope estimate BEFORE the prompt so the user knows what
# they are agreeing to. On a real corpus mine takes minutes; hitting
# Enter on a default-Y prompt with no size cue is a footgun.
if isinstance(file_count, int):
if size_str:
print(f" ~{file_count} files (~{size_str}) would be mined into this palace.\n")
else:
print(f" ~{file_count} files would be mined into this palace.\n")
if not auto_mine:
try:
answer = input(" Mine this directory now? [Y/n] ").strip().lower()
except EOFError:
# Non-interactive stdin (e.g. piped) — treat like decline so
# we don't block. User can re-run with --yes to opt in.
# we don't block. User can re-run with --auto-mine to opt in.
answer = "n"
if answer not in ("", "y", "yes"):
print(f"\n Skipped. Run `mempalace mine {project_dir}` when ready.")
@@ -204,7 +243,11 @@ def _maybe_run_mine_after_init(args, cfg) -> None:
palace_path = cfg.palace_path
try:
mine(project_dir=project_dir, palace_path=palace_path)
mine(
project_dir=project_dir,
palace_path=palace_path,
files=scanned_files,
)
except KeyboardInterrupt:
# mine() handles its own SIGINT summary + sys.exit(130); re-raise
# any KeyboardInterrupt that escapes (shouldn't happen) so the
@@ -643,6 +686,14 @@ def main():
action="store_true",
help="Auto-accept all detected entities (non-interactive)",
)
p_init.add_argument(
"--auto-mine",
action="store_true",
help=(
"Skip the post-init mine prompt and run mine automatically. "
"Combine with --yes for a fully non-interactive setup."
),
)
p_init.add_argument(
"--lang",
default=None,
+15 -6
View File
@@ -981,8 +981,16 @@ def mine(
dry_run: bool = False,
respect_gitignore: bool = True,
include_ignored: list = None,
files: list = None,
):
"""Mine a project directory into the palace."""
"""Mine a project directory into the palace.
``files`` may optionally be a pre-scanned list of file paths from
:func:`scan_project`. When provided, the corpus walk is skipped — the
caller (e.g. ``init`` showing a file-count estimate before the mine
prompt) avoids walking the tree twice. When ``None`` (the default),
``mine`` walks the tree itself just like before.
"""
project_path = Path(project_dir).expanduser().resolve()
config = load_config(project_dir)
@@ -990,11 +998,12 @@ def mine(
wing = wing_override or config["wing"]
rooms = config.get("rooms", [{"name": "general", "description": "All project files"}])
files = scan_project(
project_dir,
respect_gitignore=respect_gitignore,
include_ignored=include_ignored,
)
if files is None:
files = scan_project(
project_dir,
respect_gitignore=respect_gitignore,
include_ignored=include_ignored,
)
if limit > 0:
files = files[:limit]