Skip to content

Commit 5ae7ff5

Browse files
mnriemCopilot
andauthored
fix: skip recovered files during refresh_managed overwrite check (#2918) (#2919)
_is_managed() in install_shared_infra now consults manifest.is_recovered() before treating a hash-matching file as managed. Files marked recovered (pre-existing on disk, not installed by Spec Kit) are no longer overwritten by integration use/switch even when their hash matches the manifest entry. This closes the gap documented in the manifest API: callers using refresh_managed MUST check is_recovered first. Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 902b986 commit 5ae7ff5

2 files changed

Lines changed: 41 additions & 0 deletions

File tree

src/specify_cli/shared_infra.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,8 @@ def _is_managed(rel: str, dst: Path) -> bool:
313313
expected = prior_hashes.get(rel)
314314
if not expected or not dst.is_file() or dst.is_symlink():
315315
return False
316+
if manifest.is_recovered(rel):
317+
return False
316318
try:
317319
return _sha256(dst) == expected
318320
except OSError:

tests/integrations/test_integration_subcommand.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1918,6 +1918,45 @@ def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
19181918
assert "/speckit.plan" in updated
19191919
assert "/speckit-plan" not in updated
19201920

1921+
def test_switch_preserves_recovered_files(self, tmp_path):
1922+
"""Regression for #2918: files marked recovered in the manifest are not overwritten.
1923+
1924+
When a file already exists on disk before init and is recorded with
1925+
``recovered=True``, ``integration use``/``switch`` must not treat it as
1926+
managed even when the on-disk hash matches the manifest hash.
1927+
"""
1928+
import hashlib
1929+
1930+
project = _init_project(tmp_path, "claude")
1931+
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
1932+
assert shared_script.is_file()
1933+
1934+
# Simulate a team-customized file that was recorded as recovered:
1935+
# write custom content, then update the manifest to record its hash
1936+
# with the recovered flag set.
1937+
custom_bytes = b"#!/usr/bin/env bash\n# team custom workflow\nexit 0\n"
1938+
shared_script.write_bytes(custom_bytes)
1939+
1940+
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
1941+
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
1942+
rel = ".specify/scripts/bash/setup-tasks.sh"
1943+
manifest_data["files"][rel] = hashlib.sha256(custom_bytes).hexdigest()
1944+
manifest_data.setdefault("recovered_files", []).append(rel)
1945+
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
1946+
1947+
old_cwd = os.getcwd()
1948+
try:
1949+
os.chdir(project)
1950+
result = runner.invoke(app, [
1951+
"integration", "switch", "copilot",
1952+
"--script", "sh",
1953+
], catch_exceptions=False)
1954+
finally:
1955+
os.chdir(old_cwd)
1956+
assert result.exit_code == 0
1957+
# Recovered file must NOT be overwritten — team content preserved.
1958+
assert shared_script.read_bytes() == custom_bytes
1959+
19211960
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
19221961
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
19231962

0 commit comments

Comments
 (0)