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
48 changes: 42 additions & 6 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,8 @@ def _install_shared_infra(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
"""Install shared infrastructure files into *project_path*.

Expand All @@ -776,9 +778,23 @@ def _install_shared_infra(
placeholders using *invoke_separator* (``"."`` for markdown agents,
``"-"`` for skills agents).

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.
Overwrite policy:

* ``force=True`` — overwrite every existing file (still skips symlinks
to avoid following links outside the project root).
* ``refresh_managed=True`` — overwrite only files whose on-disk hash
still matches the previously recorded manifest hash (i.e. unmodified
files installed by spec-kit). Files with diverging hashes are
treated as user customizations and preserved with a warning.
* Default — only add missing files; existing ones are skipped.

*refresh_hint* — caller-supplied rich-text fragment shown after the
"Preserved customized files" warning to tell the user which flag/command
they should re-run with to overwrite their customizations. Each caller
passes the flag that's actually valid in its CLI surface (e.g.
``--refresh-shared-infra`` for ``integration switch``,
``--force`` for ``init``/``integration upgrade``). When ``None``, no
remediation hint is printed for customizations.

Returns ``True`` on success.
"""
Expand All @@ -791,6 +807,8 @@ def _install_shared_infra(
console=console,
force=force,
invoke_separator=invoke_separator,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)


Expand All @@ -800,6 +818,8 @@ def _install_shared_infra_or_exit(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
try:
return _install_shared_infra(
Expand All @@ -808,6 +828,8 @@ def _install_shared_infra_or_exit(
tracker=tracker,
force=force,
invoke_separator=invoke_separator,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)
except (ValueError, OSError) as exc:
console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}")
Expand Down Expand Up @@ -2578,7 +2600,8 @@ def integration_uninstall(
def integration_switch(
target: str = typer.Argument(help="Integration key to switch to"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"),
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
Comment thread
mnriem marked this conversation as resolved.
):
"""Switch from the current integration to a different one."""
Expand Down Expand Up @@ -2749,14 +2772,27 @@ def integration_switch(
target_integration, current, target, integration_options
)

# Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting).
# Refresh shared infrastructure to the current CLI version. Switching
# integrations is exactly when stale vendored shared scripts (e.g.
# update-agent-context.sh that pre-dates the target integration's
# supported-agent list) would silently break the new integration.
#
# Use refresh_managed=True so only files that match their previously
# recorded hash are overwritten — user customizations are detected via
# hash divergence and preserved with a warning. Pass
# --refresh-shared-infra to overwrite customizations as well. See #2293.
_install_shared_infra_or_exit(
project_root,
selected_script,
force=refresh_shared_infra,
refresh_managed=True,
invoke_separator=_invoke_separator_for_integration(
target_integration, current, target, parsed_options
),
refresh_hint=(
"To overwrite customizations, re-run with "
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
),
)
Comment thread
Quratulain-bilal marked this conversation as resolved.
if os.name != "nt":
ensure_executable_scripts(project_root)
Expand Down
79 changes: 69 additions & 10 deletions src/specify_cli/shared_infra.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,51 @@ def install_shared_infra(
console: Any,
force: bool = False,
invoke_separator: str = ".",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
"""Install shared scripts and templates into *project_path*."""
"""Install shared scripts and templates into *project_path*.

When ``refresh_managed`` is True, files whose on-disk hash still matches
the previously recorded manifest hash are overwritten with the bundled
version. Files whose hash diverges are treated as user customizations and
preserved with a warning. ``force=True`` overwrites everything regardless.
``refresh_hint`` is shown after the customization warning to tell the user
which flag would overwrite their customizations.
"""
from .integrations.manifest import _sha256

manifest = load_speckit_manifest(project_path, version=version, console=console)
prior_hashes = dict(manifest.files)

def _is_managed(rel: str, dst: Path) -> bool:
expected = prior_hashes.get(rel)
if not expected or not dst.is_file() or dst.is_symlink():
return False
try:
return _sha256(dst) == expected
except OSError:
return False

skipped_files: list[str] = []
preserved_user_files: list[str] = []
planned_copies: list[tuple[Path, str, bytes, int]] = []
planned_templates: list[tuple[Path, str, str]] = []

def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
if not dst.exists():
return True, None
if force:
return True, None
if refresh_managed:
if _is_managed(rel, dst):
return True, None
if rel in prior_hashes:
return False, "preserved"
return False, "skip"
return False, "skip"

scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root)
if scripts_src.is_dir():
dest_scripts = project_path / ".specify" / "scripts"
Expand All @@ -265,12 +303,16 @@ def install_shared_infra(
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
_ensure_safe_shared_destination(project_path, dst_path, parent_must_exist=False)
if dst_path.exists() and not force:
skipped_files.append(dst_path.relative_to(project_path).as_posix())
rel = dst_path.relative_to(project_path).as_posix()
write, bucket = _decide_overwrite(rel, dst_path)
if not write:
if bucket == "preserved":
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
continue

_ensure_safe_shared_directory(project_path, dst_path.parent)
rel = dst_path.relative_to(project_path).as_posix()
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))

templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
Expand All @@ -283,13 +325,17 @@ def install_shared_infra(

dst = dest_templates / src.name
_ensure_safe_shared_destination(project_path, dst)
if dst.exists() and not force:
skipped_files.append(dst.relative_to(project_path).as_posix())
rel = dst.relative_to(project_path).as_posix()
write, bucket = _decide_overwrite(rel, dst)
if not write:
if bucket == "preserved":
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
continue

content = src.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
rel = dst.relative_to(project_path).as_posix()
planned_templates.append((dst, rel, content))

for dst_path, rel, content, mode in planned_copies:
Expand All @@ -307,11 +353,24 @@ def install_shared_infra(
)
for path in skipped_files:
console.print(f" {path}")
if refresh_hint:
console.print(refresh_hint)
else:
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
)

if preserved_user_files:
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
f"[yellow]⚠[/yellow] Preserved {len(preserved_user_files)} customized shared "
"infrastructure file(s) (hash differs from previous install):"
)
for path in preserved_user_files:
console.print(f" {path}")
if refresh_hint:
console.print(refresh_hint)

manifest.save()
return True
2 changes: 2 additions & 0 deletions templates/constitution-template.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@


# [PROJECT_NAME] Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->

Expand Down
146 changes: 146 additions & 0 deletions tests/integrations/test_integration_subcommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,152 @@ def test_switch_preserves_shared_infra(self, tmp_path):
assert shared_script.exists()
assert shared_script.read_text(encoding="utf-8") == shared_content

def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
"""Regression for #2293: stale managed shared scripts get refreshed on switch."""
import hashlib

project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
bundled_bytes = shared_script.read_bytes()

# Simulate a stale vendored script: write truncated content as bytes
# (write_text would translate \n→\r\n on Windows and break the hash)
# and update the speckit manifest hash so the stale copy is treated
# as "managed" (installed by spec-kit, not a user customization).
stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n"
shared_script.write_bytes(stale_bytes)

manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_data["files"][".specify/scripts/bash/common.sh"] = (
hashlib.sha256(stale_bytes).hexdigest()
)
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")

old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0

# Stale managed file should be replaced by the bundled version
assert shared_script.read_bytes() == bundled_bytes

def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
"""User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"

# User customization: append bytes but do NOT update manifest hash,
# so on-disk hash diverges from the recorded one.
original = shared_script.read_bytes()
custom_bytes = original + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)

old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert shared_script.read_bytes() == custom_bytes
assert "Preserved" in result.output

def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
bundled_bytes = shared_script.read_bytes()

# User customization (hash diverges from manifest)
custom_bytes = bundled_bytes + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)

old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
"--refresh-shared-infra",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Customization is overwritten with the bundled version
assert shared_script.read_bytes() == bundled_bytes

def test_switch_skips_symlinked_parent_directory(self, tmp_path):
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.

Copilot follow-up on #2375: leaf-only symlink check let writes escape
when an *ancestor* directory was symlinked outside the project root.
"""
import sys
if sys.platform.startswith("win"):
import pytest as _pytest
_pytest.skip("Symlink creation typically requires admin on Windows")

project = _init_project(tmp_path, "claude")
bash_dir = project / ".specify" / "scripts" / "bash"
outside = tmp_path / "outside"
outside.mkdir()
for child in bash_dir.iterdir():
child.rename(outside / child.name)
bash_dir.rmdir()
bash_dir.symlink_to(outside, target_is_directory=True)
sentinel = (outside / "common.sh").read_bytes()

old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Symlinked tree reported, not written through.
assert "symlink" in result.output.lower()
# Outside dir contents unchanged.
assert (outside / "common.sh").read_bytes() == sentinel

def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path):
"""--force (uninstall semantics) must NOT overwrite shared-infra customizations.

Regression: ensures the decoupling of --force and --refresh-shared-infra.
"""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
bundled_bytes = shared_script.read_bytes()

custom_bytes = bundled_bytes + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)

old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
"--force",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# --force alone preserves the customization
assert shared_script.read_bytes() == custom_bytes

def test_switch_from_nothing(self, tmp_path):
"""Switch when no integration is installed should just install the target."""
project = tmp_path / "bare"
Expand Down