Skip to content

Commit 5844eff

Browse files
committed
fix: preserve symlinked retained project state
Resolve the full retained state file before containment filtering so external aliases into the selected state directory remain readable. Add real CLI coverage for aliases to a distinct retained file and the selected shared file. Addresses review: "Compare the resolved full retained file before containment filtering" Signed-off-by: Duang777 <duangjl007@gmail.com>
1 parent 7923396 commit 5844eff

3 files changed

Lines changed: 130 additions & 11 deletions

File tree

‎loopx/project_uninstall.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
from .control_plane.runtime.time import now_local_iso, utc_timestamp
1717
from .file_lock import exclusive_cross_runtime_file_lock
1818
from .global_registry import GlobalRegistryReduction, mutate_global_registry
19-
from .paths import global_registry_path, resolve_runtime_root, select_default_runtime_root
19+
from .paths import (
20+
global_registry_path,
21+
resolve_runtime_root,
22+
select_default_runtime_root,
23+
)
2024
from .registry import read_json, registry_goals
2125
from .runtime import validate_goal_id_path_segment
2226

@@ -130,7 +134,7 @@ def _archive_state_directory(
130134
)
131135
)
132136
is not None
133-
and _path_is_within(retained_state.parent, state_dir_resolved)
137+
and _path_is_within(retained_state, state_dir_resolved)
134138
]
135139
shared_state_owners = [
136140
retained_goal_id
@@ -165,8 +169,7 @@ def _archive_state_directory(
165169
"action": "state-file-missing",
166170
"archived": False,
167171
"retained_goal_ids": sorted(
168-
retained_goal_id
169-
for retained_goal_id, _ in retained_state_owners
172+
retained_goal_id for retained_goal_id, _ in retained_state_owners
170173
),
171174
"warning": (
172175
"state directory is still registered by a retained goal; "

‎loopx/semantics/project_registry_io_manifest_v1.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2127,15 +2127,15 @@
21272127
},
21282128
{
21292129
"site": "loopx/project_uninstall.py::<module>.uninstall_project::codec_read:load_registry#1",
2130-
"line": 324,
2130+
"line": 327,
21312131
"column": 24,
21322132
"kind": "codec_read",
21332133
"api": "load_registry",
21342134
"classification": "codec_api"
21352135
},
21362136
{
21372137
"site": "loopx/project_uninstall.py::<module>.uninstall_project::codec_transaction:project_registry_transaction#1",
2138-
"line": 434,
2138+
"line": 437,
21392139
"column": 18,
21402140
"kind": "codec_transaction",
21412141
"api": "project_registry_transaction",

‎tests/test_global_registry_write_serialization.py‎

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import errno
44
import json
5+
import os
56
import select
67
import subprocess
78
import 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+
666782
def test_project_uninstall_keeps_a_state_file_registered_by_a_retained_goal(
667783
tmp_path: Path,
668784
) -> None:

0 commit comments

Comments
 (0)