22
33import errno
44import json
5+ import os
56import select
67import subprocess
78import sys
@@ -125,7 +126,9 @@ def recording_write(path: Path, payload: dict[str, Any]) -> None:
125126 events .append (f"write:{ 'locked' if held else 'unlocked' } " )
126127 real_write (path , payload )
127128
128- monkeypatch .setattr (global_registry , "exclusive_cross_runtime_file_lock" , recording_lock )
129+ monkeypatch .setattr (
130+ global_registry , "exclusive_cross_runtime_file_lock" , recording_lock
131+ )
129132 monkeypatch .setattr (global_registry , "_load_global_registry" , recording_load )
130133 monkeypatch .setattr (global_registry , "write_json" , recording_write )
131134
@@ -162,7 +165,9 @@ def denied_lock(path: Path, **kwargs: Any) -> Iterator[Path]:
162165 raise failure
163166 yield path
164167
165- monkeypatch .setattr (global_registry , "exclusive_cross_runtime_file_lock" , denied_lock )
168+ monkeypatch .setattr (
169+ global_registry , "exclusive_cross_runtime_file_lock" , denied_lock
170+ )
166171 result = sync_project_registry_to_global (
167172 registry_path = registry_path , runtime_root_override = str (runtime_root )
168173 )
@@ -191,7 +196,9 @@ def failed_lock(path: Path, **kwargs: Any) -> Iterator[Path]:
191196 raise failure
192197 yield path
193198
194- monkeypatch .setattr (global_registry , "exclusive_cross_runtime_file_lock" , failed_lock )
199+ monkeypatch .setattr (
200+ global_registry , "exclusive_cross_runtime_file_lock" , failed_lock
201+ )
195202 with pytest .raises (OSError ) as caught :
196203 sync_project_registry_to_global (
197204 registry_path = registry_path , runtime_root_override = str (runtime_root )
@@ -213,7 +220,9 @@ def recording_lock(path: Path, **kwargs: Any) -> Iterator[Path]:
213220 acquired .append (path )
214221 yield path
215222
216- monkeypatch .setattr (global_registry , "exclusive_cross_runtime_file_lock" , recording_lock )
223+ monkeypatch .setattr (
224+ global_registry , "exclusive_cross_runtime_file_lock" , recording_lock
225+ )
217226
218227 result = sync_project_registry_to_global (
219228 registry_path = registry_path ,
@@ -270,7 +279,9 @@ def recording_write(path: Path, payload: dict[str, Any]) -> None:
270279 events .append (f"backup:{ 'locked' if held else 'unlocked' } " )
271280 real_write (path , payload )
272281
273- monkeypatch .setattr (global_registry , "exclusive_cross_runtime_file_lock" , recording_lock )
282+ monkeypatch .setattr (
283+ global_registry , "exclusive_cross_runtime_file_lock" , recording_lock
284+ )
274285 monkeypatch .setattr (global_registry , "_load_global_registry" , recording_load )
275286 monkeypatch .setattr (global_registry , "write_json" , recording_write )
276287
@@ -663,6 +674,111 @@ def test_project_uninstall_archives_only_selected_state_in_a_shared_directory(
663674 assert [goal ["id" ] for goal in global_goals ] == ["goal-beta" ]
664675
665676
677+ @pytest .mark .parametrize ("shared_target" , [False , True ])
678+ def test_project_uninstall_preserves_retained_state_through_an_external_symlink (
679+ tmp_path : Path ,
680+ shared_target : bool ,
681+ ) -> None :
682+ runtime_root = tmp_path / "runtime"
683+ registry_path = _project_registry (tmp_path , "alpha" , runtime_root )
684+ project_root = registry_path .parents [1 ]
685+ state_dir = project_root / "selected-state"
686+ alias_dir = project_root / "aliases"
687+ state_dir .mkdir ()
688+ alias_dir .mkdir ()
689+ selected_state = state_dir / "goal-alpha.md"
690+ selected_state .write_text ("# alpha state\n " , encoding = "utf-8" )
691+ retained_target = selected_state if shared_target else state_dir / "goal-beta.md"
692+ if not shared_target :
693+ retained_target .write_text ("# beta state\n " , encoding = "utf-8" )
694+ retained_alias = alias_dir / "goal-beta.md"
695+ try :
696+ retained_alias .symlink_to (Path (".." ) / state_dir .name / retained_target .name )
697+ except OSError as exc :
698+ pytest .skip (f"file symlinks are unavailable: { exc } " )
699+
700+ registry = json .loads (registry_path .read_text (encoding = "utf-8" ))
701+ registry ["goals" ][0 ]["state_file" ] = "selected-state/goal-alpha.md"
702+ registry ["goals" ].append (
703+ {
704+ ** registry ["goals" ][0 ],
705+ "id" : "goal-beta" ,
706+ "state_file" : "aliases/goal-beta.md" ,
707+ }
708+ )
709+ registry_path .write_text (json .dumps (registry ), encoding = "utf-8" )
710+ sync_project_registry_to_global (
711+ registry_path = registry_path ,
712+ runtime_root_override = str (runtime_root ),
713+ dry_run = False ,
714+ )
715+ global_path = global_registry_path (runtime_root )
716+ registry_before_preview = registry_path .read_bytes ()
717+ global_before_preview = global_path .read_bytes ()
718+ expected_retained = retained_alias .read_bytes ()
719+
720+ def run_cli (* arguments : str ) -> dict [str , Any ]:
721+ completed = subprocess .run (
722+ [
723+ sys .executable ,
724+ "-m" ,
725+ "loopx.cli" ,
726+ "--format" ,
727+ "json" ,
728+ "--registry" ,
729+ str (registry_path ),
730+ "--runtime-root" ,
731+ str (runtime_root ),
732+ "uninstall-project" ,
733+ "--goal-id" ,
734+ "goal-alpha" ,
735+ "--archive-state" ,
736+ * arguments ,
737+ ],
738+ cwd = Path (__file__ ).resolve ().parents [1 ],
739+ env = {** os .environ , "LOOPX_USAGE_PING" : "0" },
740+ capture_output = True ,
741+ text = True ,
742+ timeout = 60 ,
743+ check = False ,
744+ )
745+ assert completed .returncode == 0 , completed .stderr
746+ return json .loads (completed .stdout )
747+
748+ preview = run_cli ()
749+
750+ expected_preview_action = (
751+ "kept-shared-state-file" if shared_target else "would-move-state-file"
752+ )
753+ assert preview ["state_actions" ][0 ]["action" ] == expected_preview_action
754+ assert preview ["state_actions" ][0 ]["retained_goal_ids" ] == ["goal-beta" ]
755+ assert registry_path .read_bytes () == registry_before_preview
756+ assert global_path .read_bytes () == global_before_preview
757+ assert retained_alias .is_symlink ()
758+ assert retained_alias .read_bytes () == expected_retained
759+
760+ result = run_cli ("--execute" )
761+
762+ expected_action = "kept-shared-state-file" if shared_target else "moved-state-file"
763+ state_action = result ["state_actions" ][0 ]
764+ assert state_action ["action" ] == expected_action
765+ assert state_action ["retained_goal_ids" ] == ["goal-beta" ]
766+ assert retained_alias .is_symlink ()
767+ assert retained_alias .read_bytes () == expected_retained
768+ if shared_target :
769+ assert selected_state .read_bytes () == expected_retained
770+ else :
771+ assert not selected_state .exists ()
772+ archived = Path (state_action ["archive_path" ])
773+ assert (archived / selected_state .name ).read_text (
774+ encoding = "utf-8"
775+ ) == "# alpha state\n "
776+ local_goals = json .loads (registry_path .read_text (encoding = "utf-8" ))["goals" ]
777+ assert [goal ["id" ] for goal in local_goals ] == ["goal-beta" ]
778+ global_goals = json .loads (global_path .read_text (encoding = "utf-8" ))["goals" ]
779+ assert [goal ["id" ] for goal in global_goals ] == ["goal-beta" ]
780+
781+
666782def test_project_uninstall_keeps_a_state_file_registered_by_a_retained_goal (
667783 tmp_path : Path ,
668784) -> None :
0 commit comments