Skip to content
Merged
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
15 changes: 9 additions & 6 deletions docs/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
```
Expand Down
31 changes: 20 additions & 11 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -714,12 +714,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
Expand All @@ -744,12 +750,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)
Expand All @@ -770,18 +775,23 @@ 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)
rel = dst.relative_to(project_path).as_posix()
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()
Expand Down Expand Up @@ -1272,7 +1282,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)
Expand Down Expand Up @@ -2297,9 +2307,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)

Expand Down
137 changes: 127 additions & 10 deletions tests/integrations/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,43 @@ def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
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"
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=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"
Expand All @@ -193,6 +223,67 @@ def test_shared_infra_skips_existing_files(self, tmp_path):
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)
Expand All @@ -207,14 +298,40 @@ def test_shared_infra_skips_existing_files(self, tmp_path):
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
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
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

# Other shared files should still be installed
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
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
# Warning about skipped files should appear
assert "not updated" in result.output


class TestForceExistingDirectory:
Expand Down
Loading