From caa8952e6e9dada8d579d3cb56edaa1778b1a78b Mon Sep 17 00:00:00 2001 From: Duang777 Date: Sun, 11 Oct 2026 15:51:55 +0800 Subject: [PATCH 1/2] fix: avoid recursive project state archive Project uninstall moved state_file.parent as a directory. When the state file lived at the project root or under .loopx, the archive destination was inside that source directory and shutil refused or recursively copied it. Detect nested archive destinations and move only the recorded state file in that case. This preserves unrelated project files and the project registry while retaining whole-directory archives for isolated state directories. Signed-off-by: Duang777 --- loopx/project_uninstall.py | 17 +++++ .../project_registry_io_manifest_v1.json | 4 +- ...est_global_registry_write_serialization.py | 62 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/loopx/project_uninstall.py b/loopx/project_uninstall.py index 58f3334fc7..0dc40c408b 100644 --- a/loopx/project_uninstall.py +++ b/loopx/project_uninstall.py @@ -194,6 +194,23 @@ def _archive_state_directory( "archived only the selected state file" ), } + destination_is_nested = destination.resolve().is_relative_to(state_dir_resolved) + if destination_is_nested: + if not dry_run: + destination.mkdir(parents=True, exist_ok=True) + shutil.move(str(state_file), str(destination / state_file.name)) + return { + "goal_id": goal_id, + "state_file": str(state_file), + "state_dir": str(state_dir), + "archive_path": str(destination), + "action": "would-move-state-file" if dry_run else "moved-state-file", + "archived": not dry_run, + "warning": ( + "state directory contains the archive destination; " + "only the recorded state file was archived" + ), + } if not dry_run: destination.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(state_dir), str(destination)) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 830d604cda..4bef08f003 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -2127,7 +2127,7 @@ }, { "site": "loopx/project_uninstall.py::.uninstall_project::codec_read:load_registry#1", - "line": 327, + "line": 344, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -2135,7 +2135,7 @@ }, { "site": "loopx/project_uninstall.py::.uninstall_project::codec_transaction:project_registry_transaction#1", - "line": 437, + "line": 454, "column": 18, "kind": "codec_transaction", "api": "project_registry_transaction", diff --git a/tests/test_global_registry_write_serialization.py b/tests/test_global_registry_write_serialization.py index ff08e2fc2e..a5e05e4985 100644 --- a/tests/test_global_registry_write_serialization.py +++ b/tests/test_global_registry_write_serialization.py @@ -538,6 +538,68 @@ def backup_then_commit_concurrent_goal( assert [goal.get("id") for goal in remaining] == ["goal-beta"] +@pytest.mark.parametrize( + "state_file_record", + ["ACTIVE_GOAL_STATE.md", ".loopx/ACTIVE_GOAL_STATE.md"], +) +def test_project_uninstall_archives_only_the_state_file_when_archive_is_nested( + tmp_path: Path, + state_file_record: str, +) -> None: + runtime_root = tmp_path / "runtime" + registry_path = _project_registry(tmp_path, "alpha", runtime_root) + project_root = registry_path.parents[1] + original_state_file = project_root / "ACTIVE_GOAL_STATE.md" + state_file = project_root / state_file_record + if state_file != original_state_file: + original_state_file.replace(state_file) + registry = json.loads(registry_path.read_text(encoding="utf-8")) + registry["goals"][0]["state_file"] = state_file_record + registry_path.write_text(json.dumps(registry), encoding="utf-8") + sentinel = project_root / "project-sentinel.txt" + sentinel.write_text("keep\n", encoding="utf-8") + sync_project_registry_to_global( + registry_path=registry_path, + runtime_root_override=str(runtime_root), + dry_run=False, + ) + + preview = uninstall_project( + registry_path=registry_path, + runtime_root_override=str(runtime_root), + goal_ids=["goal-alpha"], + archive_state=True, + remove_empty_registry=False, + execute=False, + ) + + preview_action = preview["state_actions"][0] + assert preview_action["action"] == "would-move-state-file" + assert "only the recorded state file" in preview_action["warning"] + assert state_file.read_text(encoding="utf-8") == "# state\n" + assert not (registry_path.parent / "archived-project-state").exists() + + result = uninstall_project( + registry_path=registry_path, + runtime_root_override=str(runtime_root), + goal_ids=["goal-alpha"], + archive_state=True, + remove_empty_registry=False, + execute=True, + ) + + action = result["state_actions"][0] + archive_path = Path(action["archive_path"]) + assert action["action"] == "moved-state-file" + assert "only the recorded state file" in action["warning"] + assert [path.relative_to(archive_path) for path in archive_path.rglob("*")] == [ + Path("ACTIVE_GOAL_STATE.md") + ] + assert not state_file.exists() + assert sentinel.read_text(encoding="utf-8") == "keep\n" + assert json.loads(registry_path.read_text(encoding="utf-8"))["goals"] == [] + + _HOLD_CANONICAL_WRITER_GUARD = """ import {once} from "node:events"; import {mkdir} from "node:fs/promises"; From c3cc23f7f9329b834f11dd09741ec063f8dc2dfd Mon Sep 17 00:00:00 2001 From: Duang777 Date: Mon, 12 Oct 2026 02:00:23 +0800 Subject: [PATCH 2/2] fix: preserve project state through archive symlinks The prior fix only compared resolved destination paths, so a symlink inside the state directory could hide lexical nesting and trigger a whole-directory move. Check both physical containment and resolved lexical ancestors, with CLI regression coverage for project-root and .loopx state files. Signed-off-by: Duang777 --- loopx/project_uninstall.py | 8 +- .../project_registry_io_manifest_v1.json | 4 +- ...est_global_registry_write_serialization.py | 90 +++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/loopx/project_uninstall.py b/loopx/project_uninstall.py index 0dc40c408b..b3273faaff 100644 --- a/loopx/project_uninstall.py +++ b/loopx/project_uninstall.py @@ -1,6 +1,7 @@ from __future__ import annotations from contextlib import ExitStack +import os import shutil from pathlib import Path from typing import Any @@ -194,7 +195,12 @@ def _archive_state_directory( "archived only the selected state file" ), } - destination_is_nested = destination.resolve().is_relative_to(state_dir_resolved) + destination_path = Path(os.path.abspath(destination)) + destination_is_nested = destination.resolve().is_relative_to( + state_dir_resolved + ) or any( + parent.resolve() == state_dir_resolved for parent in destination_path.parents + ) if destination_is_nested: if not dry_run: destination.mkdir(parents=True, exist_ok=True) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 4bef08f003..6a5aa75de2 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -2127,7 +2127,7 @@ }, { "site": "loopx/project_uninstall.py::.uninstall_project::codec_read:load_registry#1", - "line": 344, + "line": 350, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -2135,7 +2135,7 @@ }, { "site": "loopx/project_uninstall.py::.uninstall_project::codec_transaction:project_registry_transaction#1", - "line": 454, + "line": 460, "column": 18, "kind": "codec_transaction", "api": "project_registry_transaction", diff --git a/tests/test_global_registry_write_serialization.py b/tests/test_global_registry_write_serialization.py index a5e05e4985..ac3c0e94e7 100644 --- a/tests/test_global_registry_write_serialization.py +++ b/tests/test_global_registry_write_serialization.py @@ -600,6 +600,96 @@ def test_project_uninstall_archives_only_the_state_file_when_archive_is_nested( assert json.loads(registry_path.read_text(encoding="utf-8"))["goals"] == [] +@pytest.mark.parametrize( + "state_file_record", + ["ACTIVE_GOAL_STATE.md", ".loopx/ACTIVE_GOAL_STATE.md"], +) +def test_project_uninstall_archives_only_state_through_outbound_archive_symlink( + tmp_path: Path, + state_file_record: str, +) -> None: + runtime_root = tmp_path / "runtime" + registry_path = _project_registry(tmp_path, "alpha", runtime_root) + project_root = registry_path.parents[1] + original_state_file = project_root / "ACTIVE_GOAL_STATE.md" + state_file = project_root / state_file_record + if state_file != original_state_file: + original_state_file.replace(state_file) + registry = json.loads(registry_path.read_text(encoding="utf-8")) + registry["goals"][0]["state_file"] = state_file_record + registry_path.write_text(json.dumps(registry), encoding="utf-8") + sentinel = project_root / "project-sentinel.txt" + sentinel.write_text("keep\n", encoding="utf-8") + sync_project_registry_to_global( + registry_path=registry_path, + runtime_root_override=str(runtime_root), + dry_run=False, + ) + global_path = global_registry_path(runtime_root) + external_archive = tmp_path / "external-archive" + external_archive.mkdir() + archive_link = registry_path.parent / "archived-project-state" + try: + archive_link.symlink_to(external_archive, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks are unavailable: {exc}") + + registry_before = registry_path.read_bytes() + global_before = global_path.read_bytes() + + def run_cli(*arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "--format", + "json", + "--registry", + str(registry_path), + "--runtime-root", + str(runtime_root), + "uninstall-project", + "--goal-id", + "goal-alpha", + "--archive-state", + *arguments, + ], + cwd=Path(__file__).resolve().parents[1], + env={**os.environ, "LOOPX_USAGE_PING": "0"}, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + preview = run_cli() + assert preview.returncode == 0, preview.stderr + preview_payload = json.loads(preview.stdout) + assert registry_path.read_bytes() == registry_before + assert global_path.read_bytes() == global_before + + execute = run_cli("--execute") + assert execute.returncode == 0, execute.stdout + execute.stderr + result = json.loads(execute.stdout) + + preview_action = preview_payload["state_actions"][0] + assert preview_action["action"] == "would-move-state-file" + assert "only the recorded state file" in preview_action["warning"] + action = result["state_actions"][0] + archive_path = Path(action["archive_path"]) + assert action["action"] == "moved-state-file" + assert "only the recorded state file" in action["warning"] + assert archive_path.is_relative_to(archive_link) + assert archive_path.resolve().is_relative_to(external_archive.resolve()) + assert (archive_path / state_file.name).read_text(encoding="utf-8") == "# state\n" + assert archive_link.is_symlink() + assert not state_file.exists() + assert sentinel.read_text(encoding="utf-8") == "keep\n" + assert json.loads(registry_path.read_text(encoding="utf-8"))["goals"] == [] + assert json.loads(global_path.read_text(encoding="utf-8"))["goals"] == [] + + _HOLD_CANONICAL_WRITER_GUARD = """ import {once} from "node:events"; import {mkdir} from "node:fs/promises";