Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 28 additions & 1 deletion tests/integrations/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading