diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index dd815b8c5d..cb63abcdfb 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -220,16 +220,34 @@ def init( console.print( f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)" ) - console.print( - "[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]" - ) if force: + # Proceeding: the merge/overwrite warning is accurate here. + console.print( + "[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]" + ) console.print( "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" ) else: - response = typer.confirm("Do you want to continue?") - if not response: + console.print( + "[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]" + ) + # Call typer.confirm normally so piped y/n is honored — e.g. + # `echo y | specify init --here` keeps reaching the + # non-destructive preserve-merge path. Only when no + # confirmation input is available at all (closed/empty stdin + # → EOF/Abort) do we convert it into an actionable error that + # points at --force, rather than blocking or failing opaquely. + try: + proceed = typer.confirm("Do you want to continue?") + except (typer.Abort, EOFError): + console.print( + "[red]Error:[/red] Current directory is not empty and no " + "confirmation input is available. Re-run with " + "[bold]--force[/bold] to merge into it." + ) + raise typer.Exit(1) from None + if not proceed: console.print("[yellow]Operation cancelled[/yellow]") raise typer.Exit(0) else: diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 25d4a7c16a..d23957206f 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -115,6 +115,32 @@ def fail_select(*_args, **_kwargs): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION + def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path): + """`init --here` on a non-empty directory with no confirmation input (empty + stdin) must fail fast with guidance to use --force, instead of the bare + 'Aborted.' from an EOF on typer.confirm. CliRunner with no `input=` provides + empty stdin, so typer.confirm raises Abort, which the command converts to the + actionable error.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "nonempty-here" + project.mkdir() + (project / "existing.txt").write_text("keep me", encoding="utf-8") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 1, result.output + assert "--force" in result.output + # Aborted before scaffolding: the pre-existing file is untouched. + assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me" + def test_integration_copilot_auto_promotes(self, tmp_path): from typer.testing import CliRunner from specify_cli import app @@ -835,7 +861,8 @@ def test_init_here_force_overwrites_shared_infra(self, tmp_path): assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content def test_init_here_without_force_preserves_shared_infra(self, tmp_path): - """E2E: specify init --here (no --force) preserves existing shared infra files.""" + """E2E: confirming the merge with piped "y" (no --force) preserves + existing shared infra files (unlike --force, which overwrites them).""" from typer.testing import CliRunner from specify_cli import app