Skip to content

Commit 3970855

Browse files
authored
fix: --force now overwrites shared infra files during init and upgrade (#2320)
* fix: --force now overwrites shared infra files during init and upgrade _install_shared_infra() previously skipped all existing files under .specify/scripts/ and .specify/templates/, regardless of --force. This meant users could never receive upstream fixes to shared scripts or templates after initial project setup. Changes: - Add force parameter to _install_shared_infra(); when True, existing files are overwritten with the latest bundled versions - Wire force=True through specify init --here --force and specify integration upgrade --force call sites - Replace hidden logging.warning with visible console output listing skipped files and suggesting --force - Fix contradictory upgrade docs that claimed --force updated shared infra (it didn't) and warned about overwrites (they didn't happen) - Add 6 tests: unit tests for skip/overwrite/warning behavior, plus end-to-end CLI tests for both --force and non-force paths Fixes #2319 * fix: improve skip warning to suggest specific commands Address review feedback: the generic '--force' suggestion was misleading when _install_shared_infra is called from integration install/switch (which don't have a --force for shared infra). Now points users to the specific commands that can refresh shared infra: 'specify init --here --force' or 'specify integration upgrade --force'.
1 parent f612e1a commit 3970855

3 files changed

Lines changed: 156 additions & 27 deletions

File tree

docs/upgrade.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ When Spec Kit releases new features (like new slash commands or updated template
5353
Running `specify init --here --force` will update:
5454

5555
-**Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)
56-
-**Script files** (`.specify/scripts/`)
57-
-**Template files** (`.specify/templates/`)
56+
-**Script files** (`.specify/scripts/`)**only with `--force`**; without it, only missing files are added
57+
-**Template files** (`.specify/templates/`)**only with `--force`**; without it, only missing files are added
5858
-**Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**
5959

6060
### What stays safe?
@@ -94,7 +94,9 @@ Template files will be merged with existing content and may overwrite existing f
9494
Proceed? [y/N]
9595
```
9696

97-
With `--force`, it skips the confirmation and proceeds immediately.
97+
With `--force`, it skips the confirmation and proceeds immediately. It also **overwrites shared infrastructure files** (`.specify/scripts/` and `.specify/templates/`) with the latest versions from the installed Spec Kit release.
98+
99+
Without `--force`, shared infrastructure files that already exist are skipped — the CLI will print a warning listing the skipped files so you know which ones were not updated.
98100

99101
**Important: Your `specs/` directory is always safe.** The `--force` flag only affects template files (commands, scripts, templates, memory). Your feature specifications, plans, and tasks in `specs/` are never included in upgrade packages and cannot be overwritten.
100102

@@ -126,13 +128,14 @@ Or use git to restore it:
126128
git restore .specify/memory/constitution.md
127129
```
128130

129-
### 2. Custom template modifications
131+
### 2. Custom script or template modifications
130132

131-
If you customized any templates in `.specify/templates/`, the upgrade will overwrite them. Back them up first:
133+
If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first:
132134

133135
```bash
134-
# Back up custom templates
136+
# Back up custom templates and scripts
135137
cp -r .specify/templates .specify/templates-backup
138+
cp -r .specify/scripts .specify/scripts-backup
136139

137140
# After upgrade, merge your changes back manually
138141
```

src/specify_cli/__init__.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -722,12 +722,18 @@ def _install_shared_infra(
722722
project_path: Path,
723723
script_type: str,
724724
tracker: StepTracker | None = None,
725+
force: bool = False,
725726
) -> bool:
726727
"""Install shared infrastructure files into *project_path*.
727728
728729
Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
729730
bundled core_pack or source checkout. Tracks all installed files
730731
in ``speckit.manifest.json``.
732+
733+
When *force* is ``True``, existing files are overwritten with the
734+
latest bundled versions. When ``False`` (default), only missing
735+
files are added and existing ones are skipped.
736+
731737
Returns ``True`` on success.
732738
"""
733739
from .integrations.manifest import IntegrationManifest
@@ -752,12 +758,11 @@ def _install_shared_infra(
752758
if variant_src.is_dir():
753759
dest_variant = dest_scripts / variant_dir
754760
dest_variant.mkdir(parents=True, exist_ok=True)
755-
# Merge without overwriting — only add files that don't exist yet
756761
for src_path in variant_src.rglob("*"):
757762
if src_path.is_file():
758763
rel_path = src_path.relative_to(variant_src)
759764
dst_path = dest_variant / rel_path
760-
if dst_path.exists():
765+
if dst_path.exists() and not force:
761766
skipped_files.append(str(dst_path.relative_to(project_path)))
762767
else:
763768
dst_path.parent.mkdir(parents=True, exist_ok=True)
@@ -778,18 +783,23 @@ def _install_shared_infra(
778783
for f in templates_src.iterdir():
779784
if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
780785
dst = dest_templates / f.name
781-
if dst.exists():
786+
if dst.exists() and not force:
782787
skipped_files.append(str(dst.relative_to(project_path)))
783788
else:
784789
shutil.copy2(f, dst)
785790
rel = dst.relative_to(project_path).as_posix()
786791
manifest.record_existing(rel)
787792

788793
if skipped_files:
789-
import logging
790-
logging.getLogger(__name__).warning(
791-
"The following shared files already exist and were not overwritten:\n%s",
792-
"\n".join(f" {f}" for f in skipped_files),
794+
console.print(
795+
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
796+
)
797+
for f in skipped_files:
798+
console.print(f" {f}")
799+
console.print(
800+
"To refresh shared infrastructure, run "
801+
"[cyan]specify init --here --force[/cyan] or "
802+
"[cyan]specify integration upgrade --force[/cyan]."
793803
)
794804

795805
manifest.save()
@@ -1279,7 +1289,7 @@ def init(
12791289

12801290
# Install shared infrastructure (scripts, templates)
12811291
tracker.start("shared-infra")
1282-
_install_shared_infra(project_path, selected_script, tracker=tracker)
1292+
_install_shared_infra(project_path, selected_script, tracker=tracker, force=force)
12831293
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
12841294

12851295
ensure_constitution_from_template(project_path, tracker=tracker)
@@ -2446,9 +2456,8 @@ def integration_upgrade(
24462456

24472457
selected_script = _resolve_script_type(project_root, script)
24482458

2449-
# Ensure shared infrastructure is present (safe to run unconditionally;
2450-
# _install_shared_infra merges missing files without overwriting).
2451-
_install_shared_infra(project_root, selected_script)
2459+
# Ensure shared infrastructure is up to date; --force overwrites existing files.
2460+
_install_shared_infra(project_root, selected_script, force=force)
24522461
if os.name != "nt":
24532462
ensure_executable_scripts(project_root)
24542463

tests/integrations/test_cli.py

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -173,13 +173,43 @@ def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
173173
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
174174
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
175175

176-
def test_shared_infra_skips_existing_files(self, tmp_path):
177-
"""Pre-existing shared files are not overwritten by _install_shared_infra."""
178-
from typer.testing import CliRunner
179-
from specify_cli import app
176+
def test_shared_infra_skips_existing_files_without_force(self, tmp_path):
177+
"""Pre-existing shared files are not overwritten without --force."""
178+
from specify_cli import _install_shared_infra
180179

181180
project = tmp_path / "skip-test"
182181
project.mkdir()
182+
(project / ".specify").mkdir()
183+
184+
# Pre-create a shared script with custom content
185+
scripts_dir = project / ".specify" / "scripts" / "bash"
186+
scripts_dir.mkdir(parents=True)
187+
custom_content = "# user-modified common.sh\n"
188+
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
189+
190+
# Pre-create a shared template with custom content
191+
templates_dir = project / ".specify" / "templates"
192+
templates_dir.mkdir(parents=True)
193+
custom_template = "# user-modified spec-template\n"
194+
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
195+
196+
_install_shared_infra(project, "sh", force=False)
197+
198+
# User's files should be preserved (not overwritten)
199+
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
200+
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
201+
202+
# Other shared files should still be installed
203+
assert (scripts_dir / "setup-plan.sh").exists()
204+
assert (templates_dir / "plan-template.md").exists()
205+
206+
def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path):
207+
"""Pre-existing shared files ARE overwritten when force=True."""
208+
from specify_cli import _install_shared_infra
209+
210+
project = tmp_path / "force-test"
211+
project.mkdir()
212+
(project / ".specify").mkdir()
183213

184214
# Pre-create a shared script with custom content
185215
scripts_dir = project / ".specify" / "scripts" / "bash"
@@ -193,6 +223,67 @@ def test_shared_infra_skips_existing_files(self, tmp_path):
193223
custom_template = "# user-modified spec-template\n"
194224
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
195225

226+
_install_shared_infra(project, "sh", force=True)
227+
228+
# Files should be overwritten with bundled versions
229+
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
230+
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template
231+
232+
# Other shared files should also be installed
233+
assert (scripts_dir / "setup-plan.sh").exists()
234+
assert (templates_dir / "plan-template.md").exists()
235+
236+
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
237+
"""Console warning is displayed when files are skipped."""
238+
from specify_cli import _install_shared_infra
239+
240+
project = tmp_path / "warn-test"
241+
project.mkdir()
242+
(project / ".specify").mkdir()
243+
244+
scripts_dir = project / ".specify" / "scripts" / "bash"
245+
scripts_dir.mkdir(parents=True)
246+
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
247+
248+
_install_shared_infra(project, "sh", force=False)
249+
250+
captured = capsys.readouterr()
251+
assert "already exist and were not updated" in captured.out
252+
assert "specify init --here --force" in captured.out
253+
# Rich may wrap long lines; normalize whitespace for the second command
254+
normalized = " ".join(captured.out.split())
255+
assert "specify integration upgrade --force" in normalized
256+
257+
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
258+
"""No skip warning when force=True (all files overwritten)."""
259+
from specify_cli import _install_shared_infra
260+
261+
project = tmp_path / "no-warn-test"
262+
project.mkdir()
263+
(project / ".specify").mkdir()
264+
265+
scripts_dir = project / ".specify" / "scripts" / "bash"
266+
scripts_dir.mkdir(parents=True)
267+
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
268+
269+
_install_shared_infra(project, "sh", force=True)
270+
271+
captured = capsys.readouterr()
272+
assert "already exist and were not updated" not in captured.out
273+
274+
def test_init_here_force_overwrites_shared_infra(self, tmp_path):
275+
"""E2E: specify init --here --force overwrites shared infra files."""
276+
from typer.testing import CliRunner
277+
from specify_cli import app
278+
279+
project = tmp_path / "e2e-force"
280+
project.mkdir()
281+
282+
scripts_dir = project / ".specify" / "scripts" / "bash"
283+
scripts_dir.mkdir(parents=True)
284+
custom_content = "# user-modified common.sh\n"
285+
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
286+
196287
old_cwd = os.getcwd()
197288
try:
198289
os.chdir(project)
@@ -207,14 +298,40 @@ def test_shared_infra_skips_existing_files(self, tmp_path):
207298
os.chdir(old_cwd)
208299

209300
assert result.exit_code == 0
301+
# --force should overwrite the custom file
302+
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
210303

211-
# User's files should be preserved
212-
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
213-
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
304+
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
305+
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
306+
from typer.testing import CliRunner
307+
from specify_cli import app
214308

215-
# Other shared files should still be installed
216-
assert (scripts_dir / "setup-plan.sh").exists()
217-
assert (templates_dir / "plan-template.md").exists()
309+
project = tmp_path / "e2e-no-force"
310+
project.mkdir()
311+
312+
scripts_dir = project / ".specify" / "scripts" / "bash"
313+
scripts_dir.mkdir(parents=True)
314+
custom_content = "# user-modified common.sh\n"
315+
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
316+
317+
old_cwd = os.getcwd()
318+
try:
319+
os.chdir(project)
320+
runner = CliRunner()
321+
result = runner.invoke(app, [
322+
"init", "--here",
323+
"--integration", "copilot",
324+
"--script", "sh",
325+
"--no-git",
326+
], input="y\n", catch_exceptions=False)
327+
finally:
328+
os.chdir(old_cwd)
329+
330+
assert result.exit_code == 0
331+
# Without --force, custom file should be preserved
332+
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
333+
# Warning about skipped files should appear
334+
assert "not updated" in result.output
218335

219336

220337
class TestForceExistingDirectory:

0 commit comments

Comments
 (0)