fix: fully clean project adoption artifacts

This commit is contained in:
thanhnv
2026-07-24 11:40:29 +07:00
parent 114340c719
commit e359989a74
5 changed files with 194 additions and 23 deletions
+132 -15
View File
@@ -27,6 +27,7 @@ from __future__ import annotations
import argparse
from contextlib import contextmanager
import hashlib
import json
import os
import re
@@ -359,6 +360,33 @@ def _load_json_or(path, default):
return default
def _safe_project_path(target, relative):
"""Resolve a manifest-owned relative path without allowing target escape."""
normalized = str(relative).replace("\\", "/").strip("/")
if not normalized or os.path.isabs(str(relative)):
return None
candidate = os.path.abspath(os.path.join(target, *normalized.split("/")))
try:
if os.path.commonpath((target, candidate)) != target:
return None
except ValueError:
return None
return candidate
def _file_sha256(path):
if not os.path.isfile(path) or os.path.islink(path):
return None
digest = hashlib.sha256()
try:
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
digest.update(chunk)
except OSError:
return None
return digest.hexdigest()
def _backup_once(path, backups):
if backups is None:
return
@@ -814,14 +842,35 @@ def cmd_init(args):
for path in previous_manifest.get("owned_files", [])
if isinstance(path, str)
}
previous_owned_hashes = {
str(path).replace("\\", "/"): digest
for path, digest in previous_manifest.get(
"owned_file_hashes", {}).items()
if isinstance(path, str) and isinstance(digest, str)
}
newly_owned_files = set()
level_removed = []
level_retained = []
if not apply_devkit:
devkit_owned_files = {
path for path in owned_files
if (path.startswith(".gitea/workflows/casan-") or
path.startswith("apps/"))
}
level_removed, level_retained = _remove_owned_project_artifacts(
target, devkit_owned_files, previous_owned_hashes)
for relative in level_removed:
owned_files.discard(relative)
previous_owned_hashes.pop(relative, None)
def created_add(p):
created.append(os.path.relpath(p, target))
def mark_owned_if_absent(p):
if not os.path.exists(p):
owned_files.add(
os.path.relpath(p, target).replace(os.sep, "/"))
relative = os.path.relpath(p, target).replace(os.sep, "/")
owned_files.add(relative)
newly_owned_files.add(relative)
# ── .casan/config.json ──
cfg_dir = os.path.join(target, ".casan")
@@ -969,24 +1018,41 @@ def cmd_init(args):
ci_src = os.path.join(dk, "templates", "gitea-workflow", "ci.yml")
ci_dst = os.path.join(target, ".gitea", "workflows", "casan-ci.yml")
if _copy_if_absent(ci_src, ci_dst, None, target, created):
owned_files.add(os.path.relpath(
ci_dst, target).replace(os.sep, "/"))
relative = os.path.relpath(
ci_dst, target).replace(os.sep, "/")
owned_files.add(relative)
newly_owned_files.add(relative)
level_extras.append(".gitea/workflows/casan-ci.yml")
dom_src = os.path.join(dk, "templates", "domain-pack")
dom_dst = os.path.join(target, "apps", project, "domain")
created_before_domain = len(created)
n = _copy_tree_missing(dom_src, dom_dst, target, created)
if n:
owned_files.update(
domain_files = {
path.replace(os.sep, "/")
for path in created[created_before_domain:])
for path in created[created_before_domain:]
}
owned_files.update(domain_files)
newly_owned_files.update(domain_files)
level_extras.append("apps/%s/domain (%d files)" % (project, n))
# ── manifest (so uninstall/verify know what init created) ──
owned_file_hashes = {
path: digest
for path, digest in previous_owned_hashes.items()
if path in owned_files
}
for relative in newly_owned_files:
owned_path = _safe_project_path(target, relative)
digest = _file_sha256(owned_path) if owned_path else None
if digest:
owned_file_hashes[relative] = digest
manifest = {
"manifest_version": 2,
"created": created,
"backups": [os.path.relpath(b, target) for b in backups],
"owned_files": sorted(owned_files),
"owned_file_hashes": dict(sorted(owned_file_hashes.items())),
"project_id": project,
"at": now_iso(),
}
@@ -1008,6 +1074,8 @@ def cmd_init(args):
"vscode_extension": vscode_install,
"legacy_migration": legacy_migration,
"level_extras": level_extras,
"level_removed": level_removed,
"level_retained": level_retained,
"note": ("harness NOT copied into repo (hybrid model); selected client hooks "
"MERGED and unselected CASAN hooks removed; run `casan doctor`"),
}
@@ -1300,6 +1368,54 @@ def _prune_empty_directory(path):
pass
def _prune_empty_parents(path, target):
parent = os.path.dirname(path)
while parent and parent != target:
try:
os.rmdir(parent)
except OSError:
break
parent = os.path.dirname(parent)
def _remove_owned_project_artifacts(target, owned_files, owned_file_hashes):
"""Remove safe standalone artifacts while preserving merged/user content."""
removed = []
retained = []
integration_owned_files = {
".claude/settings.json",
".codex/hooks.json",
".codex/config.toml",
".vscode/extensions.json",
".specify/.gitignore",
}
for relative in sorted(
owned_files, key=lambda item: (item.count("/"), item),
reverse=True):
if (relative in integration_owned_files or
relative.startswith(".casan/")):
continue
path = _safe_project_path(target, relative)
if not path:
retained.append("%s (invalid ownership path)" % relative)
continue
if not os.path.isfile(path) and not os.path.islink(path):
continue
expected_hash = owned_file_hashes.get(relative)
current_hash = _file_sha256(path)
casan_namespaced_workflow = (
relative.startswith(".gitea/workflows/casan-") and
relative.endswith((".yml", ".yaml")))
if casan_namespaced_workflow or (
expected_hash and current_hash == expected_hash):
_remove_owned_file(path, removed, target)
_prune_empty_parents(path, target)
else:
retained.append(
"%s (modified or legacy file; preserved)" % relative)
return removed, retained
def _uninstall_vscode_extension(enabled):
if not enabled:
return {
@@ -1349,6 +1465,11 @@ def cmd_uninstall(args):
for path in manifest.get("owned_files", [])
if isinstance(path, str)
}
owned_file_hashes = {
str(path).replace("\\", "/"): digest
for path, digest in manifest.get("owned_file_hashes", {}).items()
if isinstance(path, str) and isinstance(digest, str)
}
hook_changes = {}
integration_files = (
(".claude/settings.json", "claude_hook.py", "casan-hook.py"),
@@ -1379,7 +1500,9 @@ def cmd_uninstall(args):
vscode_path, "_casan_unused", "",
".vscode/extensions.json" in owned_files)
removed = []
removed, retained = _remove_owned_project_artifacts(
target, owned_files, owned_file_hashes)
for relative in (
".casan/config.json",
".casan/version.lock",
@@ -1403,14 +1526,8 @@ def cmd_uninstall(args):
".casan", ".claude", ".codex", ".vscode", ".specify"):
_prune_empty_directory(os.path.join(target, relative))
retained = []
if not args.purge and os.path.isdir(os.path.join(target, ".specify")):
retained.append(".specify/ runtime evidence (use --purge to remove)")
for item in manifest.get("created", []):
normalized = str(item).replace("\\", "/")
if (normalized.startswith(".gitea/") or
normalized.startswith("apps/")):
retained.append(normalized + " (may contain project changes)")
backups = manifest.get("backups", [])
if backups:
retained.append("%d .casan-bak backup(s)" % len(backups))
@@ -1448,8 +1565,8 @@ def main(argv=None):
"--vscode-install", choices=["auto", "yes", "no"], default="auto",
help=("install the local CASAN @casan VSIX when vscode-copilot is selected "
"(default auto: install when `code` is available)"))
pi.add_argument("--level", default="devkit",
help="packaging level to adopt: 1|core, 2|devkit (default), 3|platform (preview), 4|enterprise (refused)")
pi.add_argument("--level", default="core",
help="packaging level to adopt: 1|core (default), 2|devkit, 3|platform (preview), 4|enterprise (refused)")
pi.add_argument("--mode", choices=["observe", "enforce"], default="enforce",
help="agentic policy mode (default: enforce; use observe for a telemetry-only pilot)")
pi.add_argument("--integration-mode", dest="integration_mode",