mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-20 07:43:07 +08:00
fix(learning): add project registry maintenance
This commit is contained in:
committed by
Affaan Mustafa
parent
98bd517451
commit
bc519e5b8e
@@ -116,7 +116,13 @@ except(KeyError, TypeError, ValueError):
|
|||||||
# If cwd was provided in stdin, use it for project detection
|
# If cwd was provided in stdin, use it for project detection
|
||||||
if [ -n "$STDIN_CWD" ] && [ -d "$STDIN_CWD" ]; then
|
if [ -n "$STDIN_CWD" ] && [ -d "$STDIN_CWD" ]; then
|
||||||
_GIT_ROOT=$(git -C "$STDIN_CWD" rev-parse --show-toplevel 2>/dev/null || true)
|
_GIT_ROOT=$(git -C "$STDIN_CWD" rev-parse --show-toplevel 2>/dev/null || true)
|
||||||
export CLAUDE_PROJECT_DIR="${_GIT_ROOT:-$STDIN_CWD}"
|
if [ -n "$_GIT_ROOT" ]; then
|
||||||
|
export CLAUDE_PROJECT_DIR="$_GIT_ROOT"
|
||||||
|
unset CLV2_NO_PROJECT
|
||||||
|
else
|
||||||
|
unset CLAUDE_PROJECT_DIR
|
||||||
|
export CLV2_NO_PROJECT=1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -75,17 +75,43 @@ _clv2_normalize_remote_url() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_clv2_main_worktree_root() {
|
||||||
|
local root="$1"
|
||||||
|
[ -z "$root" ] && return 0
|
||||||
|
command -v git >/dev/null 2>&1 || return 0
|
||||||
|
|
||||||
|
git -C "$root" worktree list --porcelain 2>/dev/null | while IFS= read -r line; do
|
||||||
|
case "$line" in
|
||||||
|
worktree\ *)
|
||||||
|
printf '%s\n' "${line#worktree }"
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
_clv2_detect_project() {
|
_clv2_detect_project() {
|
||||||
local project_root=""
|
local project_root=""
|
||||||
local project_name=""
|
local project_name=""
|
||||||
local project_id=""
|
local project_id=""
|
||||||
local source_hint=""
|
local source_hint=""
|
||||||
|
|
||||||
|
if [ "${CLV2_NO_PROJECT:-0}" = "1" ]; then
|
||||||
|
_CLV2_PROJECT_ID="global"
|
||||||
|
_CLV2_PROJECT_NAME="global"
|
||||||
|
_CLV2_PROJECT_ROOT=""
|
||||||
|
_CLV2_PROJECT_DIR="${_CLV2_HOMUNCULUS_DIR}"
|
||||||
|
mkdir -p "$_CLV2_PROJECT_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
# 1. Try CLAUDE_PROJECT_DIR env var
|
# 1. Try CLAUDE_PROJECT_DIR env var
|
||||||
if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then
|
if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ] && command -v git &>/dev/null; then
|
||||||
project_root="$CLAUDE_PROJECT_DIR"
|
project_root=$(git -C "$CLAUDE_PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
|
||||||
|
if [ -n "$project_root" ]; then
|
||||||
source_hint="env"
|
source_hint="env"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# 2. Try git repo root from CWD (only if git is available)
|
# 2. Try git repo root from CWD (only if git is available)
|
||||||
if [ -z "$project_root" ] && command -v git &>/dev/null; then
|
if [ -z "$project_root" ] && command -v git &>/dev/null; then
|
||||||
@@ -101,6 +127,7 @@ _clv2_detect_project() {
|
|||||||
_CLV2_PROJECT_NAME="global"
|
_CLV2_PROJECT_NAME="global"
|
||||||
_CLV2_PROJECT_ROOT=""
|
_CLV2_PROJECT_ROOT=""
|
||||||
_CLV2_PROJECT_DIR="${_CLV2_HOMUNCULUS_DIR}"
|
_CLV2_PROJECT_DIR="${_CLV2_HOMUNCULUS_DIR}"
|
||||||
|
mkdir -p "$_CLV2_PROJECT_DIR"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -133,7 +160,14 @@ _clv2_detect_project() {
|
|||||||
normalized_remote=$(_clv2_normalize_remote_url "$remote_url")
|
normalized_remote=$(_clv2_normalize_remote_url "$remote_url")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local hash_input="${normalized_remote:-${remote_url:-$project_root}}"
|
local fallback_root="$project_root"
|
||||||
|
if [ -z "$remote_url" ]; then
|
||||||
|
local main_worktree_root
|
||||||
|
main_worktree_root=$(_clv2_main_worktree_root "$project_root")
|
||||||
|
[ -n "$main_worktree_root" ] && fallback_root="$main_worktree_root"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local hash_input="${normalized_remote:-${remote_url:-$fallback_root}}"
|
||||||
# Prefer Python for consistent SHA256 behavior across shells/platforms.
|
# Prefer Python for consistent SHA256 behavior across shells/platforms.
|
||||||
# Pass the value via env var and encode as UTF-8 inside Python so the hash
|
# Pass the value via env var and encode as UTF-8 inside Python so the hash
|
||||||
# is locale-independent (shells vary between UTF-8 / CP932 / CP1252, which
|
# is locale-independent (shells vary between UTF-8 / CP932 / CP1252, which
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -194,26 +195,64 @@ def _yaml_quote(value: str) -> str:
|
|||||||
# Project Detection (Python equivalent of detect-project.sh)
|
# Project Detection (Python equivalent of detect-project.sh)
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _git_repo_root(cwd: Optional[str] = None) -> Optional[str]:
|
||||||
|
args = ["git"]
|
||||||
|
if cwd:
|
||||||
|
args.extend(["-C", cwd])
|
||||||
|
args.extend(["rev-parse", "--show-toplevel"])
|
||||||
|
try:
|
||||||
|
result = subprocess.run(args, capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _main_worktree_root(project_root: str) -> str:
|
||||||
|
"""Return the main worktree root when project_root is a linked worktree."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
|
return project_root
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return project_root
|
||||||
|
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if line.startswith("worktree "):
|
||||||
|
main_root = line.split(" ", 1)[1].strip()
|
||||||
|
return main_root or project_root
|
||||||
|
return project_root
|
||||||
|
|
||||||
|
|
||||||
def detect_project() -> dict:
|
def detect_project() -> dict:
|
||||||
"""Detect current project context. Returns dict with id, name, root, project_dir."""
|
"""Detect current project context. Returns dict with id, name, root, project_dir."""
|
||||||
project_root = None
|
project_root = None
|
||||||
|
|
||||||
|
if os.environ.get("CLV2_NO_PROJECT") == "1":
|
||||||
|
return {
|
||||||
|
"id": "global",
|
||||||
|
"name": "global",
|
||||||
|
"root": "",
|
||||||
|
"project_dir": HOMUNCULUS_DIR,
|
||||||
|
"instincts_personal": GLOBAL_PERSONAL_DIR,
|
||||||
|
"instincts_inherited": GLOBAL_INHERITED_DIR,
|
||||||
|
"evolved_dir": GLOBAL_EVOLVED_DIR,
|
||||||
|
"observations_file": GLOBAL_OBSERVATIONS_FILE,
|
||||||
|
}
|
||||||
|
|
||||||
# 1. CLAUDE_PROJECT_DIR env var
|
# 1. CLAUDE_PROJECT_DIR env var
|
||||||
env_dir = os.environ.get("CLAUDE_PROJECT_DIR")
|
env_dir = os.environ.get("CLAUDE_PROJECT_DIR")
|
||||||
if env_dir and os.path.isdir(env_dir):
|
if env_dir and os.path.isdir(env_dir):
|
||||||
project_root = env_dir
|
project_root = _git_repo_root(env_dir)
|
||||||
|
|
||||||
# 2. git repo root
|
# 2. git repo root
|
||||||
if not project_root:
|
if not project_root:
|
||||||
try:
|
project_root = _git_repo_root()
|
||||||
result = subprocess.run(
|
|
||||||
["git", "rev-parse", "--show-toplevel"],
|
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
project_root = result.stdout.strip()
|
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Normalize: strip trailing slashes to keep basename and hash stable
|
# Normalize: strip trailing slashes to keep basename and hash stable
|
||||||
if project_root:
|
if project_root:
|
||||||
@@ -250,9 +289,10 @@ def detect_project() -> dict:
|
|||||||
if remote_url:
|
if remote_url:
|
||||||
remote_url = _strip_remote_credentials(remote_url)
|
remote_url = _strip_remote_credentials(remote_url)
|
||||||
|
|
||||||
|
fallback_root = _main_worktree_root(project_root) if not remote_url else project_root
|
||||||
legacy_hash_source = remote_url if remote_url else project_root
|
legacy_hash_source = remote_url if remote_url else project_root
|
||||||
normalized_remote = _normalize_remote_url(remote_url) if remote_url else ""
|
normalized_remote = _normalize_remote_url(remote_url) if remote_url else ""
|
||||||
hash_source = normalized_remote if normalized_remote else legacy_hash_source
|
hash_source = normalized_remote if normalized_remote else (remote_url if remote_url else fallback_root)
|
||||||
project_id = _project_hash(hash_source)
|
project_id = _project_hash(hash_source)
|
||||||
|
|
||||||
project_dir = PROJECTS_DIR / project_id
|
project_dir = PROJECTS_DIR / project_id
|
||||||
@@ -352,6 +392,26 @@ def load_registry() -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_registry(registry: dict) -> None:
|
||||||
|
"""Write the project registry atomically."""
|
||||||
|
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_file = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.tmp.{os.getpid()}"
|
||||||
|
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(registry, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp_file, REGISTRY_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_project_id(project_id: str) -> bool:
|
||||||
|
if not project_id or len(project_id) > 128:
|
||||||
|
return False
|
||||||
|
if "/" in project_id or "\\" in project_id or ".." in project_id:
|
||||||
|
return False
|
||||||
|
return bool(re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*$", project_id))
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Instinct Parser
|
# Instinct Parser
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@@ -436,6 +496,96 @@ def _load_instincts_from_dir(directory: Path, source_type: str, scope_label: str
|
|||||||
return instincts
|
return instincts
|
||||||
|
|
||||||
|
|
||||||
|
def _project_counts(project_id: str) -> dict:
|
||||||
|
project_dir = PROJECTS_DIR / project_id
|
||||||
|
personal_dir = project_dir / "instincts" / "personal"
|
||||||
|
inherited_dir = project_dir / "instincts" / "inherited"
|
||||||
|
observations_file = project_dir / "observations.jsonl"
|
||||||
|
|
||||||
|
personal_count = len(_load_instincts_from_dir(personal_dir, "personal", "project"))
|
||||||
|
inherited_count = len(_load_instincts_from_dir(inherited_dir, "inherited", "project"))
|
||||||
|
observations_count = 0
|
||||||
|
if observations_file.exists():
|
||||||
|
try:
|
||||||
|
with open(observations_file, encoding="utf-8") as f:
|
||||||
|
observations_count = sum(1 for _ in f)
|
||||||
|
except OSError:
|
||||||
|
observations_count = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"personal": personal_count,
|
||||||
|
"inherited": inherited_count,
|
||||||
|
"observations": observations_count,
|
||||||
|
"total": personal_count + inherited_count + observations_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_project_storage(project_id: str) -> None:
|
||||||
|
project_dir = PROJECTS_DIR / project_id
|
||||||
|
if project_dir.exists():
|
||||||
|
shutil.rmtree(project_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_instinct_ids(project_dir: Path, source_type: str) -> set[str]:
|
||||||
|
instinct_dir = project_dir / "instincts" / source_type
|
||||||
|
return {
|
||||||
|
inst.get("id")
|
||||||
|
for inst in _load_instincts_from_dir(instinct_dir, source_type, "project")
|
||||||
|
if inst.get("id")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_instinct_dir(from_dir: Path, into_dir: Path, existing_ids: set[str]) -> tuple[int, int]:
|
||||||
|
moved = 0
|
||||||
|
skipped = 0
|
||||||
|
if not from_dir.exists():
|
||||||
|
return moved, skipped
|
||||||
|
|
||||||
|
into_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for file_path in sorted(from_dir.iterdir()):
|
||||||
|
if not file_path.is_file() or file_path.suffix.lower() not in ALLOWED_INSTINCT_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
instincts = parse_instinct_file(file_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
instincts = []
|
||||||
|
instinct_ids = [inst.get("id") for inst in instincts if inst.get("id")]
|
||||||
|
if any(instinct_id in existing_ids for instinct_id in instinct_ids):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_path = into_dir / file_path.name
|
||||||
|
if target_path.exists():
|
||||||
|
target_path = into_dir / f"{file_path.stem}-{_project_hash(str(file_path))}{file_path.suffix}"
|
||||||
|
shutil.copy2(file_path, target_path)
|
||||||
|
existing_ids.update(instinct_ids)
|
||||||
|
moved += 1
|
||||||
|
|
||||||
|
return moved, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def _append_observations(from_project_dir: Path, into_project_dir: Path) -> int:
|
||||||
|
from_file = from_project_dir / "observations.jsonl"
|
||||||
|
if not from_file.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
into_file = into_project_dir / "observations.jsonl"
|
||||||
|
into_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
lines = from_file.read_text(encoding="utf-8").splitlines()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with open(into_file, "a", encoding="utf-8") as f:
|
||||||
|
for line in lines:
|
||||||
|
if line.strip():
|
||||||
|
f.write(line.rstrip("\n") + "\n")
|
||||||
|
return len([line for line in lines if line.strip()])
|
||||||
|
|
||||||
|
|
||||||
def load_all_instincts(project: dict, include_global: bool = True) -> list[dict]:
|
def load_all_instincts(project: dict, include_global: bool = True) -> list[dict]:
|
||||||
"""Load all instincts: project-scoped + global.
|
"""Load all instincts: project-scoped + global.
|
||||||
|
|
||||||
@@ -1180,7 +1330,14 @@ def _promote_auto(project: dict, force: bool, dry_run: bool) -> int:
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
def cmd_projects(args) -> int:
|
def cmd_projects(args) -> int:
|
||||||
"""List all known projects and their instinct counts."""
|
"""List or maintain known projects and their instinct counts."""
|
||||||
|
if getattr(args, "project_action", None) == "delete":
|
||||||
|
return _cmd_projects_delete(args)
|
||||||
|
if getattr(args, "project_action", None) == "merge":
|
||||||
|
return _cmd_projects_merge(args)
|
||||||
|
if getattr(args, "project_action", None) == "gc":
|
||||||
|
return _cmd_projects_gc(args)
|
||||||
|
|
||||||
registry = load_registry()
|
registry = load_registry()
|
||||||
|
|
||||||
if not registry:
|
if not registry:
|
||||||
@@ -1225,6 +1382,143 @@ def cmd_projects(args) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_projects_delete(args) -> int:
|
||||||
|
registry = load_registry()
|
||||||
|
project_id = args.project_id
|
||||||
|
|
||||||
|
if not _validate_project_id(project_id):
|
||||||
|
print(f"Invalid project ID: {project_id}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if project_id not in registry and not (PROJECTS_DIR / project_id).exists():
|
||||||
|
print(f"Project '{project_id}' not found.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
counts = _project_counts(project_id)
|
||||||
|
print(f"Project: {project_id}")
|
||||||
|
print(f" Instincts: {counts['personal']} personal, {counts['inherited']} inherited")
|
||||||
|
print(f" Observations: {counts['observations']} events")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"\n[DRY RUN] Would delete project '{project_id}' from registry and storage.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not args.force:
|
||||||
|
if counts["total"] > 0:
|
||||||
|
print("\nWarning: this project has instincts or observations.")
|
||||||
|
response = input(f"Delete project '{project_id}'? [y/N] ")
|
||||||
|
if response.lower() != "y":
|
||||||
|
print("Cancelled.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
registry.pop(project_id, None)
|
||||||
|
_write_registry(registry)
|
||||||
|
_remove_project_storage(project_id)
|
||||||
|
print(f"\nDeleted project '{project_id}'.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_projects_gc(args) -> int:
|
||||||
|
registry = load_registry()
|
||||||
|
candidates = [
|
||||||
|
project_id
|
||||||
|
for project_id in sorted(registry)
|
||||||
|
if _validate_project_id(project_id) and _project_counts(project_id)["total"] == 0
|
||||||
|
]
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
print("No zero-value project entries found.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Zero-value project entries: {len(candidates)}")
|
||||||
|
for project_id in candidates:
|
||||||
|
pinfo = registry.get(project_id, {})
|
||||||
|
print(f" - {pinfo.get('name', project_id)} [{project_id}]")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(f"\n[DRY RUN] Would delete {len(candidates)} project entr{'y' if len(candidates) == 1 else 'ies'}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not args.force:
|
||||||
|
response = input(f"\nDelete {len(candidates)} zero-value project entr{'y' if len(candidates) == 1 else 'ies'}? [y/N] ")
|
||||||
|
if response.lower() != "y":
|
||||||
|
print("Cancelled.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for project_id in candidates:
|
||||||
|
registry.pop(project_id, None)
|
||||||
|
_remove_project_storage(project_id)
|
||||||
|
_write_registry(registry)
|
||||||
|
print(f"\nDeleted {len(candidates)} zero-value project entr{'y' if len(candidates) == 1 else 'ies'}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_projects_merge(args) -> int:
|
||||||
|
from_id = args.from_id
|
||||||
|
into_id = args.into_id
|
||||||
|
|
||||||
|
if not _validate_project_id(from_id) or not _validate_project_id(into_id):
|
||||||
|
print("Invalid project ID.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if from_id == into_id:
|
||||||
|
print("Cannot merge a project into itself.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
registry = load_registry()
|
||||||
|
if from_id not in registry:
|
||||||
|
print(f"Source project '{from_id}' not found.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if into_id not in registry:
|
||||||
|
print(f"Destination project '{into_id}' not found.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
from_counts = _project_counts(from_id)
|
||||||
|
into_counts = _project_counts(into_id)
|
||||||
|
print(f"Merge: {from_id} -> {into_id}")
|
||||||
|
print(f" Source: {from_counts['personal']} personal, {from_counts['inherited']} inherited, {from_counts['observations']} observations")
|
||||||
|
print(f" Destination before merge: {into_counts['personal']} personal, {into_counts['inherited']} inherited, {into_counts['observations']} observations")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n[DRY RUN] Would merge source project into destination and remove source.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not args.force:
|
||||||
|
response = input(f"\nMerge '{from_id}' into '{into_id}' and remove source? [y/N] ")
|
||||||
|
if response.lower() != "y":
|
||||||
|
print("Cancelled.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
from_project_dir = PROJECTS_DIR / from_id
|
||||||
|
into_project_dir = PROJECTS_DIR / into_id
|
||||||
|
into_project_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
personal_existing = _project_instinct_ids(into_project_dir, "personal")
|
||||||
|
inherited_existing = _project_instinct_ids(into_project_dir, "inherited")
|
||||||
|
personal_moved, personal_skipped = _merge_instinct_dir(
|
||||||
|
from_project_dir / "instincts" / "personal",
|
||||||
|
into_project_dir / "instincts" / "personal",
|
||||||
|
personal_existing,
|
||||||
|
)
|
||||||
|
inherited_moved, inherited_skipped = _merge_instinct_dir(
|
||||||
|
from_project_dir / "instincts" / "inherited",
|
||||||
|
into_project_dir / "instincts" / "inherited",
|
||||||
|
inherited_existing,
|
||||||
|
)
|
||||||
|
observations_moved = _append_observations(from_project_dir, into_project_dir)
|
||||||
|
|
||||||
|
registry.pop(from_id, None)
|
||||||
|
destination = registry.get(into_id, {})
|
||||||
|
destination["last_seen"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
registry[into_id] = destination
|
||||||
|
_write_registry(registry)
|
||||||
|
_remove_project_storage(from_id)
|
||||||
|
|
||||||
|
print("\nMerged project registry entry.")
|
||||||
|
print(f" Moved instincts: {personal_moved + inherited_moved}")
|
||||||
|
print(f" Skipped duplicate instincts: {personal_skipped + inherited_skipped}")
|
||||||
|
print(f" Appended observations: {observations_moved}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Generate Evolved Structures
|
# Generate Evolved Structures
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@@ -1486,6 +1780,19 @@ def main() -> int:
|
|||||||
|
|
||||||
# Projects (new in v2.1)
|
# Projects (new in v2.1)
|
||||||
projects_parser = subparsers.add_parser('projects', help='List known projects and instinct counts')
|
projects_parser = subparsers.add_parser('projects', help='List known projects and instinct counts')
|
||||||
|
projects_subparsers = projects_parser.add_subparsers(dest='project_action')
|
||||||
|
projects_delete = projects_subparsers.add_parser('delete', help='Delete a project registry entry')
|
||||||
|
projects_delete.add_argument('project_id', help='Project ID to delete')
|
||||||
|
projects_delete.add_argument('--dry-run', action='store_true', help='Preview without deleting')
|
||||||
|
projects_delete.add_argument('--force', action='store_true', help='Skip confirmation')
|
||||||
|
projects_merge = projects_subparsers.add_parser('merge', help='Merge one project registry entry into another')
|
||||||
|
projects_merge.add_argument('from_id', help='Source project ID')
|
||||||
|
projects_merge.add_argument('into_id', help='Destination project ID')
|
||||||
|
projects_merge.add_argument('--dry-run', action='store_true', help='Preview without merging')
|
||||||
|
projects_merge.add_argument('--force', action='store_true', help='Skip confirmation')
|
||||||
|
projects_gc = projects_subparsers.add_parser('gc', help='Delete zero-value project registry entries')
|
||||||
|
projects_gc.add_argument('--dry-run', action='store_true', help='Preview without deleting')
|
||||||
|
projects_gc.add_argument('--force', action='store_true', help='Skip confirmation')
|
||||||
|
|
||||||
# Prune (pending instinct TTL)
|
# Prune (pending instinct TTL)
|
||||||
prune_parser = subparsers.add_parser('prune', help='Delete pending instincts older than TTL')
|
prune_parser = subparsers.add_parser('prune', help='Delete pending instincts older than TTL')
|
||||||
|
|||||||
@@ -181,29 +181,14 @@ test('detect-project.sh sets PROJECT_NAME and non-global PROJECT_ID for worktree
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a worktree-like directory with .git as a file
|
|
||||||
const worktreeDir = path.join(testDir, 'my-worktree');
|
const worktreeDir = path.join(testDir, 'my-worktree');
|
||||||
fs.mkdirSync(worktreeDir, { recursive: true });
|
execSync(`git worktree add "${worktreeDir}" -b feature/project-id`, {
|
||||||
|
cwd: mainRepo,
|
||||||
// Set up the worktree directory structure in the main repo
|
stdio: 'pipe'
|
||||||
const worktreesDir = path.join(mainRepo, '.git', 'worktrees', 'my-worktree');
|
});
|
||||||
fs.mkdirSync(worktreesDir, { recursive: true });
|
assert.ok(
|
||||||
|
fs.statSync(path.join(worktreeDir, '.git')).isFile(),
|
||||||
// Create the gitdir file and commondir in the worktree metadata
|
'linked worktree should expose .git as a file'
|
||||||
const mainGitDir = path.join(mainRepo, '.git');
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(worktreesDir, 'commondir'),
|
|
||||||
'../..\n'
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(worktreesDir, 'HEAD'),
|
|
||||||
fs.readFileSync(path.join(mainGitDir, 'HEAD'), 'utf8')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Write .git file in the worktree directory (this is what git worktree creates)
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(worktreeDir, '.git'),
|
|
||||||
`gitdir: ${worktreesDir}\n`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Source detect-project.sh from the worktree directory and capture results
|
// Source detect-project.sh from the worktree directory and capture results
|
||||||
@@ -248,6 +233,61 @@ test('detect-project.sh sets PROJECT_NAME and non-global PROJECT_ID for worktree
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('detect-project.sh uses the main worktree hash when no remote exists', () => {
|
||||||
|
const testDir = createTempDir();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mainRepo = path.join(testDir, 'main-repo');
|
||||||
|
const worktreeDir = path.join(testDir, 'feature-worktree');
|
||||||
|
const homeDir = path.join(testDir, 'home');
|
||||||
|
fs.mkdirSync(mainRepo, { recursive: true });
|
||||||
|
fs.mkdirSync(homeDir, { recursive: true });
|
||||||
|
execSync('git init', { cwd: mainRepo, stdio: 'pipe' });
|
||||||
|
execSync('git commit --allow-empty -m "init"', {
|
||||||
|
cwd: mainRepo,
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: 'Test',
|
||||||
|
GIT_AUTHOR_EMAIL: 'test@test.com',
|
||||||
|
GIT_COMMITTER_NAME: 'Test',
|
||||||
|
GIT_COMMITTER_EMAIL: 'test@test.com'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
execSync(`git worktree add "${worktreeDir}" -b feature/no-remote`, {
|
||||||
|
cwd: mainRepo,
|
||||||
|
stdio: 'pipe'
|
||||||
|
});
|
||||||
|
|
||||||
|
function detectId(targetDir) {
|
||||||
|
const script = `
|
||||||
|
export HOME="${toBashPath(homeDir)}"
|
||||||
|
export USERPROFILE="${toBashPath(homeDir)}"
|
||||||
|
export CLAUDE_PROJECT_DIR="${toBashPath(targetDir)}"
|
||||||
|
source "${toBashPath(detectProjectPath)}" >/dev/null
|
||||||
|
printf "%s" "$PROJECT_ID"
|
||||||
|
`;
|
||||||
|
return execFileSync('bash', ['-lc', script], {
|
||||||
|
cwd: targetDir,
|
||||||
|
timeout: 10000,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: toBashPath(homeDir),
|
||||||
|
USERPROFILE: toBashPath(homeDir),
|
||||||
|
CLAUDE_PROJECT_DIR: toBashPath(targetDir)
|
||||||
|
}
|
||||||
|
}).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainId = detectId(mainRepo);
|
||||||
|
const worktreeId = detectId(worktreeDir);
|
||||||
|
assert.ok(mainId && mainId !== 'global', 'main repo should get a project id');
|
||||||
|
assert.strictEqual(worktreeId, mainId, 'linked worktree should share the main worktree project id');
|
||||||
|
} finally {
|
||||||
|
cleanupDir(testDir);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────
|
||||||
// Summary
|
// Summary
|
||||||
// ──────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -3300,11 +3300,14 @@ async function runTests() {
|
|||||||
|
|
||||||
assert.strictEqual(result.code, 0, `observe.sh should exit successfully, stderr: ${result.stderr}`);
|
assert.strictEqual(result.code, 0, `observe.sh should exit successfully, stderr: ${result.stderr}`);
|
||||||
|
|
||||||
const projectsDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects');
|
const homunculusDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus');
|
||||||
const projectIds = fs.readdirSync(projectsDir);
|
const projectsDir = path.join(homunculusDir, 'projects');
|
||||||
assert.strictEqual(projectIds.length, 1, 'observe.sh should create one project-scoped observation directory');
|
assert.ok(
|
||||||
|
!fs.existsSync(projectsDir) || fs.readdirSync(projectsDir).length === 0,
|
||||||
|
'observe.sh should not create a project-scoped directory for a non-git cwd'
|
||||||
|
);
|
||||||
|
|
||||||
const observationsPath = path.join(projectsDir, projectIds[0], 'observations.jsonl');
|
const observationsPath = path.join(homunculusDir, 'observations.jsonl');
|
||||||
const observations = fs.readFileSync(observationsPath, 'utf8').trim().split('\n').filter(Boolean);
|
const observations = fs.readFileSync(observationsPath, 'utf8').trim().split('\n').filter(Boolean);
|
||||||
assert.ok(observations.length > 0, 'observe.sh should append at least one observation');
|
assert.ok(observations.length > 0, 'observe.sh should append at least one observation');
|
||||||
|
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ test('observe.sh resolves cwd to git root before setting CLAUDE_PROJECT_DIR', ()
|
|||||||
'observe.sh should resolve STDIN_CWD to git repo root'
|
'observe.sh should resolve STDIN_CWD to git repo root'
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
content.includes('${_GIT_ROOT:-$STDIN_CWD}'),
|
content.includes('export CLV2_NO_PROJECT=1'),
|
||||||
'observe.sh should fall back to raw cwd when git root is unavailable'
|
'observe.sh should mark non-git cwd payloads as global instead of registering raw cwd'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ test('observe.sh falls back to CLAUDE_HOOK_EVENT_NAME when no phase argument is
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('observe.sh keeps the raw cwd when the directory is not inside a git repo', () => {
|
test('observe.sh records non-git cwd payloads globally without project registry side effects', () => {
|
||||||
const testRoot = createTempDir();
|
const testRoot = createTempDir();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -262,12 +262,17 @@ test('observe.sh keeps the raw cwd when the directory is not inside a git repo',
|
|||||||
const result = runObserve({ homeDir, cwd: nonGitDir });
|
const result = runObserve({ homeDir, cwd: nonGitDir });
|
||||||
assert.strictEqual(result.status, 0, result.stderr);
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
|
||||||
const { metadata } = readSingleProjectMetadata(homeDir);
|
const homunculusDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus');
|
||||||
assert.strictEqual(
|
const projectsDir = path.join(homunculusDir, 'projects');
|
||||||
normalizeComparablePath(metadata.root),
|
const registryPath = path.join(homunculusDir, 'projects.json');
|
||||||
normalizeComparablePath(nonGitDir),
|
const observationsPath = path.join(homunculusDir, 'observations.jsonl');
|
||||||
'project metadata root should stay on the non-git cwd'
|
|
||||||
|
assert.ok(!fs.existsSync(registryPath), 'non-git cwd should not create projects.json');
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(projectsDir) || fs.readdirSync(projectsDir).length === 0,
|
||||||
|
'non-git cwd should not create project directories'
|
||||||
);
|
);
|
||||||
|
assert.ok(fs.existsSync(observationsPath), 'non-git cwd should still record a global observation');
|
||||||
} finally {
|
} finally {
|
||||||
cleanupDir(testRoot);
|
cleanupDir(testRoot);
|
||||||
}
|
}
|
||||||
|
|||||||
260
tests/scripts/instinct-cli-projects.test.js
Normal file
260
tests/scripts/instinct-cli-projects.test.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||||
|
const cliPath = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'skills',
|
||||||
|
'continuous-learning-v2',
|
||||||
|
'scripts',
|
||||||
|
'instinct-cli.py'
|
||||||
|
);
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
passed += 1;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ✗ ${name}`);
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTempDir() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-instinct-cli-projects-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupDir(dir) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(filePath, payload) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath) {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeInstinct(filePath, id, confidence = 0.9) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
filePath,
|
||||||
|
[
|
||||||
|
'---',
|
||||||
|
`id: ${id}`,
|
||||||
|
'trigger: "when repeated"',
|
||||||
|
`confidence: ${confidence}`,
|
||||||
|
'domain: workflow',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
`Action for ${id}.`,
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedProject(root, id, options = {}) {
|
||||||
|
const projectDir = path.join(root, 'projects', id);
|
||||||
|
const personalDir = path.join(projectDir, 'instincts', 'personal');
|
||||||
|
const inheritedDir = path.join(projectDir, 'instincts', 'inherited');
|
||||||
|
fs.mkdirSync(personalDir, { recursive: true });
|
||||||
|
fs.mkdirSync(inheritedDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const instinct of options.personal || []) {
|
||||||
|
writeInstinct(path.join(personalDir, `${instinct}.yaml`), instinct);
|
||||||
|
}
|
||||||
|
for (const instinct of options.inherited || []) {
|
||||||
|
writeInstinct(path.join(inheritedDir, `${instinct}.yaml`), instinct);
|
||||||
|
}
|
||||||
|
if (options.observations) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectDir, 'observations.jsonl'),
|
||||||
|
options.observations.map(row => JSON.stringify(row)).join('\n') + '\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectHash(value) {
|
||||||
|
return crypto.createHash('sha256').update(value).digest('hex').slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runGit(cwd, args) {
|
||||||
|
const result = spawnSync('git', args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
return result.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(root, args, options = {}) {
|
||||||
|
return spawnSync('python3', [cliPath, ...args], {
|
||||||
|
cwd: options.cwd || repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CLV2_HOMUNCULUS_DIR: root,
|
||||||
|
HOME: path.join(root, 'home'),
|
||||||
|
USERPROFILE: path.join(root, 'home'),
|
||||||
|
CLAUDE_PROJECT_DIR: '',
|
||||||
|
...(options.env || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Testing instinct-cli.py projects maintenance ===\n');
|
||||||
|
|
||||||
|
test('projects delete --dry-run preserves registry and project files', () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
try {
|
||||||
|
const registryPath = path.join(root, 'projects.json');
|
||||||
|
seedProject(root, 'alpha123', {
|
||||||
|
personal: ['keep-me'],
|
||||||
|
observations: [{ event: 'tool_complete' }],
|
||||||
|
});
|
||||||
|
writeJson(registryPath, {
|
||||||
|
alpha123: { name: 'alpha', root: '/repo/alpha', remote: '', last_seen: '2026-01-01T00:00:00Z' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runCli(root, ['projects', 'delete', 'alpha123', '--dry-run']);
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
assert.match(result.stdout, /would delete/i);
|
||||||
|
assert.ok(fs.existsSync(path.join(root, 'projects', 'alpha123')));
|
||||||
|
assert.ok(readJson(registryPath).alpha123);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(root);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projects delete --force removes registry entry and project directory', () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
try {
|
||||||
|
const registryPath = path.join(root, 'projects.json');
|
||||||
|
seedProject(root, 'alpha123', { personal: ['delete-me'] });
|
||||||
|
writeJson(registryPath, {
|
||||||
|
alpha123: { name: 'alpha', root: '/repo/alpha', remote: '', last_seen: '2026-01-01T00:00:00Z' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runCli(root, ['projects', 'delete', 'alpha123', '--force']);
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
assert.ok(!fs.existsSync(path.join(root, 'projects', 'alpha123')));
|
||||||
|
assert.ok(!readJson(registryPath).alpha123);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(root);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projects gc --force removes only zero-value project entries', () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
try {
|
||||||
|
const registryPath = path.join(root, 'projects.json');
|
||||||
|
seedProject(root, 'empty000');
|
||||||
|
seedProject(root, 'active999', { personal: ['active'] });
|
||||||
|
writeJson(registryPath, {
|
||||||
|
empty000: { name: 'empty', root: '/tmp/empty', remote: '', last_seen: '2026-01-01T00:00:00Z' },
|
||||||
|
active999: { name: 'active', root: '/repo/active', remote: '', last_seen: '2026-01-02T00:00:00Z' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runCli(root, ['projects', 'gc', '--force']);
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
const registry = readJson(registryPath);
|
||||||
|
assert.ok(!registry.empty000);
|
||||||
|
assert.ok(registry.active999);
|
||||||
|
assert.ok(!fs.existsSync(path.join(root, 'projects', 'empty000')));
|
||||||
|
assert.ok(fs.existsSync(path.join(root, 'projects', 'active999')));
|
||||||
|
} finally {
|
||||||
|
cleanupDir(root);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projects merge deduplicates instincts, appends observations, and removes source', () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
try {
|
||||||
|
const registryPath = path.join(root, 'projects.json');
|
||||||
|
seedProject(root, 'from111', {
|
||||||
|
personal: ['shared', 'from-only'],
|
||||||
|
observations: [{ event: 'from-event' }],
|
||||||
|
});
|
||||||
|
seedProject(root, 'into222', {
|
||||||
|
personal: ['shared', 'into-only'],
|
||||||
|
observations: [{ event: 'into-event' }],
|
||||||
|
});
|
||||||
|
writeJson(registryPath, {
|
||||||
|
from111: { name: 'from', root: '/repo/from', remote: '', last_seen: '2026-01-01T00:00:00Z' },
|
||||||
|
into222: { name: 'into', root: '/repo/into', remote: '', last_seen: '2026-01-02T00:00:00Z' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runCli(root, ['projects', 'merge', 'from111', 'into222', '--force']);
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
assert.ok(!fs.existsSync(path.join(root, 'projects', 'from111')));
|
||||||
|
assert.ok(!readJson(registryPath).from111);
|
||||||
|
assert.ok(readJson(registryPath).into222);
|
||||||
|
|
||||||
|
const intoPersonal = path.join(root, 'projects', 'into222', 'instincts', 'personal');
|
||||||
|
assert.ok(fs.existsSync(path.join(intoPersonal, 'shared.yaml')));
|
||||||
|
assert.ok(fs.existsSync(path.join(intoPersonal, 'from-only.yaml')));
|
||||||
|
assert.ok(fs.existsSync(path.join(intoPersonal, 'into-only.yaml')));
|
||||||
|
|
||||||
|
const observations = fs.readFileSync(
|
||||||
|
path.join(root, 'projects', 'into222', 'observations.jsonl'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
assert.match(observations, /from-event/);
|
||||||
|
assert.match(observations, /into-event/);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(root);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status migrates legacy no-remote linked worktree project dirs to main worktree id', () => {
|
||||||
|
const root = createTempDir();
|
||||||
|
const repoParent = createTempDir();
|
||||||
|
try {
|
||||||
|
const mainWorktree = path.join(repoParent, 'main');
|
||||||
|
const linkedWorktree = path.join(repoParent, 'linked');
|
||||||
|
fs.mkdirSync(mainWorktree, { recursive: true });
|
||||||
|
runGit(mainWorktree, ['init']);
|
||||||
|
runGit(mainWorktree, ['config', 'user.email', 'ecc@example.test']);
|
||||||
|
runGit(mainWorktree, ['config', 'user.name', 'ECC Test']);
|
||||||
|
fs.writeFileSync(path.join(mainWorktree, 'README.md'), 'test\n');
|
||||||
|
runGit(mainWorktree, ['add', 'README.md']);
|
||||||
|
runGit(mainWorktree, ['commit', '-m', 'init']);
|
||||||
|
runGit(mainWorktree, ['worktree', 'add', linkedWorktree]);
|
||||||
|
|
||||||
|
const mainRoot = runGit(mainWorktree, ['rev-parse', '--show-toplevel']);
|
||||||
|
const linkedRoot = runGit(linkedWorktree, ['rev-parse', '--show-toplevel']);
|
||||||
|
const oldLinkedId = projectHash(linkedRoot);
|
||||||
|
const mainId = projectHash(mainRoot);
|
||||||
|
seedProject(root, oldLinkedId, { personal: ['legacy-worktree'] });
|
||||||
|
|
||||||
|
const result = runCli(root, ['status'], { cwd: linkedRoot });
|
||||||
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
|
assert.ok(!fs.existsSync(path.join(root, 'projects', oldLinkedId)));
|
||||||
|
assert.ok(fs.existsSync(path.join(root, 'projects', mainId)));
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(root, 'projects', mainId, 'instincts', 'personal', 'legacy-worktree.yaml'))
|
||||||
|
);
|
||||||
|
assert.match(result.stdout, new RegExp(`\\(${mainId}\\)`));
|
||||||
|
} finally {
|
||||||
|
cleanupDir(root);
|
||||||
|
cleanupDir(repoParent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nPassed: ${passed}`);
|
||||||
|
console.log(`Failed: ${failed}`);
|
||||||
|
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
Reference in New Issue
Block a user