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'.
This commit is contained in:
Manfred Riem
2026-04-22 16:40:41 -05:00
committed by GitHub
parent f612e1a30d
commit 3970855797
3 changed files with 156 additions and 27 deletions

View File

@@ -53,8 +53,8 @@ When Spec Kit releases new features (like new slash commands or updated template
Running `specify init --here --force` will update:
-**Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)
-**Script files** (`.specify/scripts/`)
-**Template files** (`.specify/templates/`)
-**Script files** (`.specify/scripts/`)**only with `--force`**; without it, only missing files are added
-**Template files** (`.specify/templates/`)**only with `--force`**; without it, only missing files are added
-**Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**
### What stays safe?
@@ -94,7 +94,9 @@ Template files will be merged with existing content and may overwrite existing f
Proceed? [y/N]
```
With `--force`, it skips the confirmation and proceeds immediately.
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.
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.
**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.
@@ -126,13 +128,14 @@ Or use git to restore it:
git restore .specify/memory/constitution.md
```
### 2. Custom template modifications
### 2. Custom script or template modifications
If you customized any templates in `.specify/templates/`, the upgrade will overwrite them. Back them up first:
If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first:
```bash
# Back up custom templates
# Back up custom templates and scripts
cp -r .specify/templates .specify/templates-backup
cp -r .specify/scripts .specify/scripts-backup
# After upgrade, merge your changes back manually
```

View File

@@ -722,12 +722,18 @@ def _install_shared_infra(
project_path: Path,
script_type: str,
tracker: StepTracker | None = None,
force: bool = False,
) -> bool:
"""Install shared infrastructure files into *project_path*.
Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
bundled core_pack or source checkout. Tracks all installed files
in ``speckit.manifest.json``.
When *force* is ``True``, existing files are overwritten with the
latest bundled versions. When ``False`` (default), only missing
files are added and existing ones are skipped.
Returns ``True`` on success.
"""
from .integrations.manifest import IntegrationManifest
@@ -752,12 +758,11 @@ def _install_shared_infra(
if variant_src.is_dir():
dest_variant = dest_scripts / variant_dir
dest_variant.mkdir(parents=True, exist_ok=True)
# Merge without overwriting — only add files that don't exist yet
for src_path in variant_src.rglob("*"):
if src_path.is_file():
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
if dst_path.exists():
if dst_path.exists() and not force:
skipped_files.append(str(dst_path.relative_to(project_path)))
else:
dst_path.parent.mkdir(parents=True, exist_ok=True)
@@ -778,7 +783,7 @@ def _install_shared_infra(
for f in templates_src.iterdir():
if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."):
dst = dest_templates / f.name
if dst.exists():
if dst.exists() and not force:
skipped_files.append(str(dst.relative_to(project_path)))
else:
shutil.copy2(f, dst)
@@ -786,10 +791,15 @@ def _install_shared_infra(
manifest.record_existing(rel)
if skipped_files:
import logging
logging.getLogger(__name__).warning(
"The following shared files already exist and were not overwritten:\n%s",
"\n".join(f" {f}" for f in skipped_files),
console.print(
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
)
for f in skipped_files:
console.print(f" {f}")
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
)
manifest.save()
@@ -1279,7 +1289,7 @@ def init(
# Install shared infrastructure (scripts, templates)
tracker.start("shared-infra")
_install_shared_infra(project_path, selected_script, tracker=tracker)
_install_shared_infra(project_path, selected_script, tracker=tracker, force=force)
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
ensure_constitution_from_template(project_path, tracker=tracker)
@@ -2446,9 +2456,8 @@ def integration_upgrade(
selected_script = _resolve_script_type(project_root, script)
# Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting).
_install_shared_infra(project_root, selected_script)
# Ensure shared infrastructure is up to date; --force overwrites existing files.
_install_shared_infra(project_root, selected_script, force=force)
if os.name != "nt":
ensure_executable_scripts(project_root)

View File

@@ -173,13 +173,13 @@ class TestInitIntegrationFlag:
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_shared_infra_skips_existing_files(self, tmp_path):
"""Pre-existing shared files are not overwritten by _install_shared_infra."""
from typer.testing import CliRunner
from specify_cli import app
def test_shared_infra_skips_existing_files_without_force(self, tmp_path):
"""Pre-existing shared files are not overwritten without --force."""
from specify_cli import _install_shared_infra
project = tmp_path / "skip-test"
project.mkdir()
(project / ".specify").mkdir()
# Pre-create a shared script with custom content
scripts_dir = project / ".specify" / "scripts" / "bash"
@@ -193,6 +193,97 @@ class TestInitIntegrationFlag:
custom_template = "# user-modified spec-template\n"
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
_install_shared_infra(project, "sh", force=False)
# User's files should be preserved (not overwritten)
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
# Other shared files should still be installed
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path):
"""Pre-existing shared files ARE overwritten when force=True."""
from specify_cli import _install_shared_infra
project = tmp_path / "force-test"
project.mkdir()
(project / ".specify").mkdir()
# Pre-create a shared script with custom content
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
custom_content = "# user-modified common.sh\n"
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
# Pre-create a shared template with custom content
templates_dir = project / ".specify" / "templates"
templates_dir.mkdir(parents=True)
custom_template = "# user-modified spec-template\n"
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
_install_shared_infra(project, "sh", force=True)
# Files should be overwritten with bundled versions
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template
# Other shared files should also be installed
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
"""Console warning is displayed when files are skipped."""
from specify_cli import _install_shared_infra
project = tmp_path / "warn-test"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
_install_shared_infra(project, "sh", force=False)
captured = capsys.readouterr()
assert "already exist and were not updated" in captured.out
assert "specify init --here --force" in captured.out
# Rich may wrap long lines; normalize whitespace for the second command
normalized = " ".join(captured.out.split())
assert "specify integration upgrade --force" in normalized
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
"""No skip warning when force=True (all files overwritten)."""
from specify_cli import _install_shared_infra
project = tmp_path / "no-warn-test"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
_install_shared_infra(project, "sh", force=True)
captured = capsys.readouterr()
assert "already exist and were not updated" not in captured.out
def test_init_here_force_overwrites_shared_infra(self, tmp_path):
"""E2E: specify init --here --force overwrites shared infra files."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "e2e-force"
project.mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
custom_content = "# user-modified common.sh\n"
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
@@ -207,14 +298,40 @@ class TestInitIntegrationFlag:
os.chdir(old_cwd)
assert result.exit_code == 0
# --force should overwrite the custom file
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
# User's files should be preserved
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "e2e-no-force"
project.mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
custom_content = "# user-modified common.sh\n"
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--no-git",
], input="y\n", catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Without --force, custom file should be preserved
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
# Other shared files should still be installed
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
# Warning about skipped files should appear
assert "not updated" in result.output
class TestForceExistingDirectory: