mirror of
https://github.com/github/spec-kit.git
synced 2026-04-03 03:03:09 +00:00
fix: robust unlink, fail-fast config validation, symlink tests
- uninstall() wraps path.unlink() in try/except OSError to avoid partial cleanup on race conditions or permission errors - setup() raises ValueError on missing config or folder instead of silently returning empty - Added 3 tests: symlink in check_modified, symlink skip/force in uninstall (47 total)
This commit is contained in:
@@ -114,10 +114,15 @@ class IntegrationBase(ABC):
|
|||||||
return created
|
return created
|
||||||
|
|
||||||
if not self.config:
|
if not self.config:
|
||||||
return created
|
raise ValueError(
|
||||||
|
f"{type(self).__name__}.config is not set; integration "
|
||||||
|
"subclasses must define a non-empty 'config' mapping."
|
||||||
|
)
|
||||||
folder = self.config.get("folder")
|
folder = self.config.get("folder")
|
||||||
if not folder:
|
if not folder:
|
||||||
return created
|
raise ValueError(
|
||||||
|
f"{type(self).__name__}.config is missing required 'folder' entry."
|
||||||
|
)
|
||||||
|
|
||||||
project_root_resolved = project_root.resolve()
|
project_root_resolved = project_root.resolve()
|
||||||
if manifest.project_root != project_root_resolved:
|
if manifest.project_root != project_root_resolved:
|
||||||
|
|||||||
@@ -175,7 +175,11 @@ class IntegrationManifest:
|
|||||||
if not force and _sha256(path) != expected_hash:
|
if not force and _sha256(path) != expected_hash:
|
||||||
skipped.append(path)
|
skipped.append(path)
|
||||||
continue
|
continue
|
||||||
path.unlink()
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError:
|
||||||
|
skipped.append(path)
|
||||||
|
continue
|
||||||
removed.append(path)
|
removed.append(path)
|
||||||
# Clean up empty parent directories up to project root
|
# Clean up empty parent directories up to project root
|
||||||
parent = path.parent
|
parent = path.parent
|
||||||
|
|||||||
@@ -233,6 +233,16 @@ class TestManifestCheckModified:
|
|||||||
(tmp_path / "f.txt").unlink()
|
(tmp_path / "f.txt").unlink()
|
||||||
assert m.check_modified() == []
|
assert m.check_modified() == []
|
||||||
|
|
||||||
|
def test_symlink_treated_as_modified(self, tmp_path):
|
||||||
|
"""A tracked file replaced with a symlink is reported as modified."""
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
m.record_file("f.txt", "original")
|
||||||
|
target = tmp_path / "target.txt"
|
||||||
|
target.write_text("target", encoding="utf-8")
|
||||||
|
(tmp_path / "f.txt").unlink()
|
||||||
|
(tmp_path / "f.txt").symlink_to(target)
|
||||||
|
assert m.check_modified() == ["f.txt"]
|
||||||
|
|
||||||
|
|
||||||
class TestManifestUninstall:
|
class TestManifestUninstall:
|
||||||
def test_removes_unmodified(self, tmp_path):
|
def test_removes_unmodified(self, tmp_path):
|
||||||
@@ -310,6 +320,36 @@ class TestManifestUninstall:
|
|||||||
assert (tmp_path / "a" / "b" / "other.txt").exists()
|
assert (tmp_path / "a" / "b" / "other.txt").exists()
|
||||||
assert (tmp_path / "a" / "b").is_dir()
|
assert (tmp_path / "a" / "b").is_dir()
|
||||||
|
|
||||||
|
def test_symlink_skipped_without_force(self, tmp_path):
|
||||||
|
"""A tracked file replaced with a symlink is skipped unless force."""
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
m.record_file("f.txt", "original")
|
||||||
|
m.save()
|
||||||
|
target = tmp_path / "target.txt"
|
||||||
|
target.write_text("target", encoding="utf-8")
|
||||||
|
(tmp_path / "f.txt").unlink()
|
||||||
|
(tmp_path / "f.txt").symlink_to(target)
|
||||||
|
|
||||||
|
removed, skipped = m.uninstall()
|
||||||
|
assert removed == []
|
||||||
|
assert len(skipped) == 1
|
||||||
|
assert (tmp_path / "f.txt").is_symlink() # still there
|
||||||
|
|
||||||
|
def test_symlink_removed_with_force(self, tmp_path):
|
||||||
|
"""A tracked file replaced with a symlink is removed with force."""
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
m.record_file("f.txt", "original")
|
||||||
|
m.save()
|
||||||
|
target = tmp_path / "target.txt"
|
||||||
|
target.write_text("target", encoding="utf-8")
|
||||||
|
(tmp_path / "f.txt").unlink()
|
||||||
|
(tmp_path / "f.txt").symlink_to(target)
|
||||||
|
|
||||||
|
removed, skipped = m.uninstall(force=True)
|
||||||
|
assert len(removed) == 1
|
||||||
|
assert not (tmp_path / "f.txt").exists()
|
||||||
|
assert target.exists() # target not deleted
|
||||||
|
|
||||||
|
|
||||||
class TestManifestPersistence:
|
class TestManifestPersistence:
|
||||||
def test_save_and_load_roundtrip(self, tmp_path):
|
def test_save_and_load_roundtrip(self, tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user