From a3bbb705085bb1475929ef0dc26e403e6e75dfc8 Mon Sep 17 00:00:00 2001 From: Ryan Yin Date: Sat, 5 Sep 2026 13:23:46 +0800 Subject: [PATCH] fix(agents): preserve existing rules during symlink installation --- agents/README.md | 3 ++- agents/install-rules.py | 21 +++++++++++++--- agents/test_install_rules.py | 46 ++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 agents/test_install_rules.py diff --git a/agents/README.md b/agents/README.md index e1f61390..d75f596b 100644 --- a/agents/README.md +++ b/agents/README.md @@ -39,7 +39,8 @@ Behavior: - Each target is handled independently. - Missing destination directories are skipped. -- Existing destination file/symlink is replaced with a symlink to this repo source file. +- Existing regular files are preserved as `.bak` backups (numbered when a backup already exists). +- Destination links are replaced atomically; a failed link creation leaves the destination intact. The installer links only `AGENTS.md`; it does not install permission configuration, skills, or CLIs. The repository-root `AGENTS.md` contains guidance for this Nix configuration repository. It is not diff --git a/agents/install-rules.py b/agents/install-rules.py index 54c91995..bf898224 100644 --- a/agents/install-rules.py +++ b/agents/install-rules.py @@ -2,6 +2,7 @@ import os import sys +import tempfile from pathlib import Path @@ -12,10 +13,24 @@ def install_one(target_dir: Path, source_file: Path, target_name: str) -> None: target_file = target_dir / target_name - if target_file.exists() or target_file.is_symlink(): - target_file.unlink() + if target_file.is_file() and not target_file.is_symlink(): + # Preserve the old inode without reading its contents or replacing an earlier backup. + index = 0 + while True: + suffix = ".bak" if index == 0 else f".bak.{index}" + backup = target_file.with_name(target_file.name + suffix) + try: + os.link(target_file, backup) + break + except FileExistsError: + index += 1 + print(f"backed up {target_file} -> {backup}") - target_file.symlink_to(source_file) + # Create the link on the same filesystem before atomically replacing the destination. + with tempfile.TemporaryDirectory(prefix=".install-rules-", dir=target_dir) as staging: + link = Path(staging) / target_name + link.symlink_to(source_file) + link.replace(target_file) print(f"linked {target_file} -> {source_file}") diff --git a/agents/test_install_rules.py b/agents/test_install_rules.py new file mode 100644 index 00000000..2f1c41cf --- /dev/null +++ b/agents/test_install_rules.py @@ -0,0 +1,46 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +spec = importlib.util.spec_from_file_location( + "install_rules", Path(__file__).with_name("install-rules.py") +) +installer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(installer) + + +class InstallRulesTests(unittest.TestCase): + def test_existing_file_is_backed_up_without_overwriting_backup(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.md" + source.write_text("global rules") + target = root / "AGENTS.md" + target.write_text("personal rules") + backup = root / "AGENTS.md.bak" + backup.write_text("older rules") + installer.install_one(root, source, target.name) + self.assertEqual(target.resolve(), source) + self.assertEqual(backup.read_text(), "older rules") + self.assertEqual((root / "AGENTS.md.bak.1").read_text(), "personal rules") + + def test_failed_link_creation_preserves_existing_file(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source.md" + source.write_text("global rules") + target = root / "AGENTS.md" + target.write_text("personal rules") + with patch.object(Path, "symlink_to", side_effect=OSError("link failed")): + with self.assertRaises(OSError): + installer.install_one(root, source, target.name) + self.assertTrue(target.is_file()) + self.assertFalse(target.is_symlink()) + self.assertEqual(target.read_text(), "personal rules") + + +if __name__ == "__main__": + unittest.main()