Skip to content

Commit 9930834

Browse files
jawwad-aliclaude
andauthored
fix(init): don't block on confirmation for 'init --here' without a TTY (#3236)
* fix(init): don't block on confirmation for 'init --here' without a TTY When 'specify init --here' targets a non-empty directory without --force, it called typer.confirm() unconditionally. In a non-interactive session (no TTY -- CI, piped, agent) there is no input, so the prompt reads EOF and aborts unhelpfully (or blocks), with no actionable message. The named-project path already fails fast and points to --force; --here was the inconsistent outlier. Guard the confirmation with the existing _stdin_is_interactive() helper: when non-interactive, print a clear 'directory not empty; re-run with --force' error and exit 1 instead of prompting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(init): honor piped confirmation for 'init --here'; only fail-fast on empty stdin The first version of this fix short-circuited on '_stdin_is_interactive()' (isatty) before typer.confirm, which broke 'init --here' when confirmation is piped (e.g. 'echo y | specify init --here' / CliRunner input='y\n') -- a non-TTY pipe with valid input was wrongly rejected, regressing test_init_here_without_force_preserves_shared_infra. Instead, call typer.confirm normally (piped 'y'/'n' is honored) and catch the Abort/EOFError it raises only when stdin is empty, converting that to the actionable '--force' guidance. This keeps the UX win for the no-input case without rejecting piped input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(init): distinguish interactive cancel from no-input; defer merge warning Address Copilot review on the --here non-empty path: (1) treat typer.Abort during an interactive confirm (e.g. Ctrl+C) as a normal cancellation (exit 0), and only emit the '--force' guidance + exit 1 when there is no TTY (empty stdin / EOF) -- no longer conflating the two; (2) move the 'will be merged / may overwrite' warning so it only shows when actually proceeding (force) or folded into the confirmation prompt, not on the fail-fast path where nothing is merged. Piped confirmation (e.g. 'echo y | specify init --here') is still honored, which is why the prompt is attempted rather than refused outright when non-interactive -- the existing test_init_here_without_force_preserves_shared_infra pipes 'y' and must succeed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(init): fail fast on non-interactive --here instead of prompting Per Copilot review: do not call typer.confirm when stdin is not a TTY -- an open-but-idle non-TTY stdin (CI/agent) could block on the prompt. When the directory is non-empty and --force is not given, fail fast with '--force' guidance unless an interactive terminal is present. Interactive confirm still offers the merge-but-preserve path (distinct from --force, which overwrites); a Ctrl+C there is treated as a normal cancellation (exit 0). The merge/overwrite warning is only printed when actually proceeding, not on the fail-fast path. Updated the preserve-merge E2E test to simulate an interactive terminal so it exercises the confirm path (non-interactive sessions now require --force). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(init): honor piped y/n for 'init --here', error only on no-input Per maintainer review: restore the second-revision shape. Calling typer.confirm normally keeps 'echo y | specify init --here' reaching the non-destructive preserve-merge path (and piped 'n' cancels with exit 0). Only when no confirmation input is available at all (closed/empty stdin -> typer.Abort/EOFError) is it converted into the actionable error that points at --force. This drops the _stdin_is_interactive fail-fast that broke the common piped-confirm idiom and made preserve-merge interactive-only. The preserve test no longer needs to monkeypatch _stdin_is_interactive - it passes on the real contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(init): preserve interactive-cancel semantics; fold merge risk into the prompt Two review-driven refinements to the 'init --here' non-empty confirm, keeping the maintainer-endorsed control flow (piped y/n honored; non-interactive EOF → actionable --force error): 1. typer.confirm raises typer.Abort for BOTH an interactive Ctrl+C and an EOF on closed/empty stdin. Catching it unconditionally reported 'no confirmation input available, use --force' and exited 1 even when the user cancelled at a real TTY. Branch on _stdin_is_interactive(): a TTY cancel is a normal exit 0 ('Operation cancelled'); only non-interactive EOF becomes the --force error. 2. Fold the merge-risk warning into the confirmation question instead of printing it unconditionally beforehand, so the EOF/no-input path (which exits without changing anything) no longer prints a misleading 'will be merged' line first. Adds test_init_here_interactive_cancel_exits_zero (fails before: exit 1 with --force; passes after: exit 0, 'cancelled', pre-existing file untouched). The non-interactive EOF and piped-y preserve-merge tests are unchanged and still pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 801ff88 commit 9930834

2 files changed

Lines changed: 93 additions & 6 deletions

File tree

src/specify_cli/commands/init.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,16 +220,45 @@ def init(
220220
console.print(
221221
f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)"
222222
)
223-
console.print(
224-
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
225-
)
226223
if force:
224+
# Proceeding: the merge/overwrite warning is accurate here.
225+
console.print(
226+
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
227+
)
227228
console.print(
228229
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
229230
)
230231
else:
231-
response = typer.confirm("Do you want to continue?")
232-
if not response:
232+
# Fold the merge risk into the confirmation prompt rather than
233+
# printing it unconditionally first: on the EOF/no-input path
234+
# below the command exits without changing anything, so a
235+
# standalone "will be merged" line would mislead. Interactive
236+
# users still see the risk as part of the question.
237+
#
238+
# Call typer.confirm normally so piped y/n is honored — e.g.
239+
# `echo y | specify init --here` keeps reaching the
240+
# non-destructive preserve-merge path.
241+
try:
242+
proceed = typer.confirm(
243+
"Template files will be merged with existing content "
244+
"and may overwrite existing files. Do you want to continue?"
245+
)
246+
except (typer.Abort, EOFError):
247+
# typer.confirm raises Abort for BOTH an interactive Ctrl+C
248+
# and an EOF on closed/empty stdin. Distinguish them: a real
249+
# TTY cancellation is a normal exit (0, "cancelled"), while a
250+
# missing-input EOF (non-interactive) becomes an actionable
251+
# error pointing at --force.
252+
if _stdin_is_interactive():
253+
console.print("[yellow]Operation cancelled[/yellow]")
254+
raise typer.Exit(0) from None
255+
console.print(
256+
"[red]Error:[/red] Current directory is not empty and no "
257+
"confirmation input is available. Re-run with "
258+
"[bold]--force[/bold] to merge into it."
259+
)
260+
raise typer.Exit(1) from None
261+
if not proceed:
233262
console.print("[yellow]Operation cancelled[/yellow]")
234263
raise typer.Exit(0)
235264
else:

tests/integrations/test_cli.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,63 @@ def fail_select(*_args, **_kwargs):
115115
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
116116
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
117117

118+
def test_init_here_nonempty_noninteractive_errors_with_force_guidance(self, tmp_path):
119+
"""`init --here` on a non-empty directory with no confirmation input (empty
120+
stdin) must fail fast with guidance to use --force, instead of the bare
121+
'Aborted.' from an EOF on typer.confirm. CliRunner with no `input=` provides
122+
empty stdin, so typer.confirm raises Abort, which the command converts to the
123+
actionable error."""
124+
from typer.testing import CliRunner
125+
from specify_cli import app
126+
127+
project = tmp_path / "nonempty-here"
128+
project.mkdir()
129+
(project / "existing.txt").write_text("keep me", encoding="utf-8")
130+
old_cwd = os.getcwd()
131+
try:
132+
os.chdir(project)
133+
result = CliRunner().invoke(app, [
134+
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
135+
], catch_exceptions=False)
136+
finally:
137+
os.chdir(old_cwd)
138+
139+
assert result.exit_code == 1, result.output
140+
assert "--force" in result.output
141+
# Aborted before scaffolding: the pre-existing file is untouched.
142+
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
143+
144+
def test_init_here_interactive_cancel_exits_zero(self, tmp_path, monkeypatch):
145+
"""An interactive Ctrl+C at the merge confirmation (typer.Abort on a TTY)
146+
is a normal cancellation — exit 0, "cancelled" — NOT the missing-input
147+
--force error, which is reserved for non-interactive EOF. Guards the
148+
regression where Abort was caught unconditionally and every cancel became
149+
an exit-1 --force error."""
150+
from typer.testing import CliRunner
151+
from specify_cli import app
152+
import specify_cli.commands.init as init_mod
153+
154+
# Simulate an interactive terminal so the Abort is treated as a cancel.
155+
monkeypatch.setattr(init_mod, "_stdin_is_interactive", lambda: True)
156+
157+
project = tmp_path / "cancel-here"
158+
project.mkdir()
159+
(project / "existing.txt").write_text("keep me", encoding="utf-8")
160+
old_cwd = os.getcwd()
161+
try:
162+
os.chdir(project)
163+
# No input → typer.confirm raises Abort (stands in for Ctrl+C).
164+
result = CliRunner().invoke(app, [
165+
"init", "--here", "--integration", "copilot", "--script", "sh", "--ignore-agent-tools",
166+
], catch_exceptions=False)
167+
finally:
168+
os.chdir(old_cwd)
169+
170+
assert result.exit_code == 0, result.output
171+
assert "cancelled" in result.output.lower()
172+
assert "--force" not in result.output # not the missing-input error
173+
assert (project / "existing.txt").read_text(encoding="utf-8") == "keep me"
174+
118175
def test_integration_copilot_auto_promotes(self, tmp_path):
119176
from typer.testing import CliRunner
120177
from specify_cli import app
@@ -835,7 +892,8 @@ def test_init_here_force_overwrites_shared_infra(self, tmp_path):
835892
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
836893

837894
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
838-
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
895+
"""E2E: confirming the merge with piped "y" (no --force) preserves
896+
existing shared infra files (unlike --force, which overwrites them)."""
839897
from typer.testing import CliRunner
840898
from specify_cli import app
841899

0 commit comments

Comments
 (0)