fix(spawn): dry-run-default + path-based update engine — kill #636 branch-scrambler (TDPLAN-0006 P0+P1)
#636: drone @spawn update would scramble every branch's identity/memory in one command. On a branch created seconds earlier, --dry-run proposed 30 renames rotating identity dirs (apps->.trinity->.seedgo->.claude->.archive->.aipass), README->DASHBOARD, and deep-merged stale template into live .trinity/. Root cause: the CREATE path regenerated template-registry IDs in filesystem-walk order (!= the master's hand-crafted IDs), so content-hash + rename-detection saw a mismatch on a pristine branch. update --all would have destroyed all 13 citizens at once. P0 — safety by default: - update + repair are now dry-run by default; --apply required to write. Forgotten flag = safe preview-only no-op. --dry-run kept as alias. - doctor_fix.py repair suggestions emit the matching --apply form (+ aipass test_doctor_fix updated to the new contract). P1 — engine rebuild (update_ops.py v2.0): - Path-based named-managed-files model replaces whole-tree hash-diff + rename-detection. ID divergence is moot — IDs are no longer used. - .trinity/*, DASHBOARD.local.json, artifacts/birth_certificate.json, .seedgo/bypass.json = delivered on CREATE only, NEVER touched on update. - Old ID engine (change_detection.py, reconcile.py) + orphaned tests deleted. Verified on fresh sandbox: update --dry-run = 0 renames / 0 updates / 0 additions (create==update invariant); no-flag run = dry-run preview, filesystem byte-identical; 313 spawn tests green; seedgo 100% (all 36 standards). Closes #636. P2/P3/P4 (shared lib, seam, .recovery relocate) to follow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1ef1e2e89c
commit
ccc8d6a97b
+2
-1
@@ -125,4 +125,5 @@ branch_audits/
|
||||
claude_4_7_transition_notes.md
|
||||
README_ORIGINAL_DISABLED.md
|
||||
*.bak
|
||||
test/
|
||||
test/
|
||||
sandbox_test/
|
||||
|
||||
@@ -10,6 +10,28 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
|
||||
## [2026.W23] - 2026-06-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`drone @spawn update` no longer scrambles branches (#636, critical — TDPLAN-0006
|
||||
P0+P1).** The update engine compared a freshly-created branch against the class
|
||||
template by *content hash* with rename-detection, and because the CREATE path
|
||||
regenerated template-registry IDs in filesystem-walk order (≠ the master's
|
||||
hand-crafted IDs), a branch created seconds earlier produced **30 proposed renames**
|
||||
that rotated identity/memory dirs into each other
|
||||
(`apps→.trinity→.seedgo→.claude→.archive→.aipass`), turned `README` into
|
||||
`DASHBOARD`, and deep-merged stale template into live `.trinity/` memory —
|
||||
`update <class> --all` would have destroyed every citizen in one command. Rebuilt
|
||||
`update_ops.py` (v2.0) on an explicit **named-managed-files + path-based** model:
|
||||
`.trinity/*`, `DASHBOARD.local.json`, `artifacts/birth_certificate.json` and
|
||||
`.seedgo/bypass.json` are delivered on **create only** and never touched on update;
|
||||
the create==update invariant now yields **0 renames / 0 merges** on a fresh branch.
|
||||
The old ID-based engine (`change_detection.py`, `reconcile.py`) is deleted.
|
||||
- **Destructive spawn ops are now dry-run by default (TDPLAN-0006 P0).** `drone @spawn
|
||||
update` and `drone @spawn repair` preview by default and require an explicit
|
||||
`--apply` to write — forgetting a flag is now a safe no-op instead of irreversible
|
||||
damage (`--dry-run` kept as an alias). `aipass doctor` repair suggestions emit the
|
||||
matching `--apply` form.
|
||||
|
||||
### Added
|
||||
|
||||
- **Memory-pool auto-processing (TDPLAN-0005)** — dropped files in
|
||||
|
||||
@@ -83,7 +83,7 @@ def _build_pollution_items(agents: list, project: str) -> List[RemediationItem]:
|
||||
f"Registry pollution: {len(hit.locations)} copies of "
|
||||
f"{hit.agent_name} share registry_id {hit.registry_id}"
|
||||
),
|
||||
fix_command=f"drone @spawn repair @{project} --clean-pollution",
|
||||
fix_command=f"drone @spawn repair @{project} --clean-pollution --apply",
|
||||
)
|
||||
)
|
||||
return items
|
||||
@@ -104,7 +104,7 @@ def _build_placement_items(agents: list, project_root: Path, project: str) -> Li
|
||||
severity="warning",
|
||||
category="placement",
|
||||
description=f"Misplaced agent: {issue.agent_name} at {rel_path}",
|
||||
fix_command=f"drone @spawn repair @{project} --relocate {rel_path} {suggested}",
|
||||
fix_command=f"drone @spawn repair @{project} --relocate {rel_path} {suggested} --apply",
|
||||
)
|
||||
)
|
||||
return items
|
||||
@@ -122,7 +122,7 @@ def _build_registry_items(project_root: Path, agents: list, project: str) -> Lis
|
||||
severity="warning",
|
||||
category="registry",
|
||||
description=f"Registry {issue.problem}: {issue.branch_name} at {issue.registered_path}",
|
||||
fix_command=f"drone @spawn repair @{project} --dedup-registry",
|
||||
fix_command=f"drone @spawn repair @{project} --dedup-registry --apply",
|
||||
)
|
||||
)
|
||||
return items
|
||||
@@ -145,7 +145,7 @@ def generate_remediation(project_root: Path) -> List[RemediationItem]:
|
||||
severity="info",
|
||||
category="pyproject",
|
||||
description="Missing pyproject.toml",
|
||||
fix_command=f"drone @spawn repair @{project} --add-pyproject",
|
||||
fix_command=f"drone @spawn repair @{project} --add-pyproject --apply",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -156,7 +156,7 @@ def generate_remediation(project_root: Path) -> List[RemediationItem]:
|
||||
severity=severity,
|
||||
category="root_artifact",
|
||||
description=f"{hit.description}: {hit.name}/",
|
||||
fix_command=f"drone @spawn repair @{project} --relocate-root {hit.name}",
|
||||
fix_command=f"drone @spawn repair @{project} --relocate-root {hit.name} --apply",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -184,7 +184,8 @@ def format_text_report(items: List[RemediationItem], project_name: str) -> str:
|
||||
lines.append(f"[{item.severity.upper()}] {item.description}")
|
||||
lines.append(f" Fix: {item.fix_command}")
|
||||
lines.append("")
|
||||
lines.append(f"Preview all fixes: drone @spawn repair @{project_name} --dry-run")
|
||||
lines.append(f"Preview all fixes: drone @spawn repair @{project_name}")
|
||||
lines.append(f"Apply all fixes: drone @spawn repair @{project_name} --apply")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -248,10 +248,13 @@ class TestFormatTextReport:
|
||||
assert "drone @spawn repair @test --relocate a b" in result
|
||||
|
||||
def test_dry_run_hint(self) -> None:
|
||||
"""Report ends with dry-run suggestion."""
|
||||
"""Report shows preview (dry-run default) and explicit --apply hints."""
|
||||
items = [RemediationItem("info", "pyproject", "missing", "fix")]
|
||||
result = format_text_report(items, "myproj")
|
||||
assert "drone @spawn repair @myproj --dry-run" in result
|
||||
# Repair is dry-run by default now: preview form has no flag, apply form is explicit
|
||||
assert "Preview all fixes:" in result
|
||||
assert "drone @spawn repair @myproj" in result
|
||||
assert "drone @spawn repair @myproj --apply" in result
|
||||
|
||||
def test_critical_sorted_first(self) -> None:
|
||||
"""Critical items appear before warning and info."""
|
||||
|
||||
@@ -25,16 +25,6 @@
|
||||
"standard": "json_structure",
|
||||
"reason": "Pure string transformation — placeholder replacement engine, no file I/O"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/change_detection.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Pure data comparison — detects changes between registries, no writes"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/reconcile.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Pure analysis — compares filesystem vs metadata, no writes"
|
||||
},
|
||||
{
|
||||
"file": "log_structure",
|
||||
"standard": "log_structure",
|
||||
@@ -65,11 +55,6 @@
|
||||
"standard": "deep_nesting",
|
||||
"reason": "sync_templates() depth 4 — linear workflow loading owners, iterating managed files, checking hashes. Minimal branching, straightforward responsibility."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/reconcile.py",
|
||||
"standard": "deep_nesting",
|
||||
"reason": "reconcile_branch_state() depth 6 — three independent checks (tracked files, tracked dirs, untracked files) with iterdir loops and hash computation. Structure reflects algorithm necessity."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/sync_registry_ops.py",
|
||||
"standard": "deep_nesting",
|
||||
@@ -125,16 +110,6 @@
|
||||
"standard": "naming",
|
||||
"reason": "dest, source are local variables inside helper functions, not module-level constants. Path objects for template/branch comparison."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/reconcile.py",
|
||||
"standard": "naming",
|
||||
"reason": "full_path, entry are local variables inside reconcile_branch_state(), not module-level constants. Loop iteration variables."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/change_detection.py",
|
||||
"standard": "naming",
|
||||
"reason": "common_ids is a local variable inside detect_changes(), not a module-level constant. Set intersection of template and branch IDs."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/passport_ops.py",
|
||||
"standard": "naming",
|
||||
@@ -145,16 +120,6 @@
|
||||
"standard": "documentation",
|
||||
"reason": "delete_branch() has a docstring at line 45 — checker doesn't recognize it due to multi-line function signature spanning lines 40-44."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/reconcile.py",
|
||||
"standard": "documentation",
|
||||
"reason": "reconcile_branch_state() has a docstring at line 57 — checker doesn't recognize it due to multi-line function signature spanning lines 52-56."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/change_detection.py",
|
||||
"standard": "documentation",
|
||||
"reason": "detect_changes() has a docstring at line 29 — checker doesn't recognize it due to multi-line function signature spanning lines 24-28."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/passport_ops.py",
|
||||
"standard": "documentation",
|
||||
@@ -241,11 +206,26 @@
|
||||
"standard": "encapsulation",
|
||||
"reason": "Test file — tests must import handlers directly to test them in isolation. Handler imports inside test methods are intentional."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_update.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file — lives in tests/ by convention, not in the 3-layer app structure. Test files are exempt from layer architecture standard."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_update.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Test file — tests must import handlers directly to test them in isolation. Handler imports inside test methods are intentional."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/repair_ops.py",
|
||||
"standard": "windows_compat",
|
||||
"lines": [89],
|
||||
"reason": "fcntl import guarded by sys.platform != 'win32' check — lock_fd is None on Windows, so this line never executes."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/meta_ops.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "load_branch_meta() no longer called by P1 engine but has tests and may be reused by sync-registry or P2 shared library."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
+12
-11
@@ -56,12 +56,13 @@ drone @spawn passport @dirname --role "Observer" --purpose "Monitoring"
|
||||
|
||||
### Update
|
||||
|
||||
Update is **preview-only by default** — `--apply` required to execute changes.
|
||||
|
||||
```bash
|
||||
drone @spawn update @branch_name # Single branch (uses passport class)
|
||||
drone @spawn update builder --all # All builder-class branches
|
||||
drone @spawn update birthright --all # All birthright-class branches
|
||||
drone @spawn update @branch_name --dry-run # Preview changes
|
||||
drone @spawn update builder --all --dry-run # Preview batch update
|
||||
drone @spawn update @branch_name # Preview changes (dry-run default)
|
||||
drone @spawn update @branch_name --apply # Execute changes
|
||||
drone @spawn update builder --all --apply # All builder-class branches
|
||||
drone @spawn update @branch_name --dry-run # Explicit preview (same as default)
|
||||
```
|
||||
|
||||
### Delete
|
||||
@@ -79,12 +80,12 @@ drone @spawn sync-templates # Pull managed fi
|
||||
drone @spawn regenerate-registry # Regenerate builder template hashes
|
||||
drone @spawn regenerate-registry --all # All template classes
|
||||
|
||||
# Repair
|
||||
drone @spawn repair <project_path> # Scan project for structural issues
|
||||
drone @spawn repair <project_path> --dry-run # Preview only
|
||||
drone @spawn repair --relocate @branch src/pkg/branch # Move branch to new location
|
||||
drone @spawn repair --relocate @branch path --relocate-artifacts # Move branch + .chroma/ into it
|
||||
drone @spawn repair <project_path> --clean-pollution # Archive + remove duplicate dirs
|
||||
# Repair (preview-only by default — --apply required to execute)
|
||||
drone @spawn repair <project_path> # Preview structural issues (dry-run default)
|
||||
drone @spawn repair <project_path> --apply # Execute fixes
|
||||
drone @spawn repair --relocate @branch src/pkg/branch --apply # Move branch to new location
|
||||
drone @spawn repair --relocate @branch path --relocate-artifacts --apply # Move + .chroma/
|
||||
drone @spawn repair <project_path> --clean-pollution --apply # Archive + remove duplicate dirs
|
||||
```
|
||||
|
||||
### Introspection
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: change_detection.py
|
||||
# Description: Template change detection — template vs branch metadata
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-10
|
||||
# =============================================
|
||||
|
||||
"""Template change detection handler.
|
||||
|
||||
Compares the template registry (what the template currently defines) against
|
||||
branch metadata (what the branch currently has). Uses ID-based detection to
|
||||
identify additions, updates, renames, and pruned files.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHANGE DETECTION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def detect_changes(
|
||||
template_registry: dict,
|
||||
branch_meta: dict,
|
||||
branch_dir: Path,
|
||||
old_branch_tracking: dict | None = None,
|
||||
) -> dict:
|
||||
"""Detect changes between template registry and branch metadata.
|
||||
|
||||
Uses ID-based matching:
|
||||
- ID exists in both template and branch -> compare paths (rename?) and hashes (update?)
|
||||
- ID only in template -> addition (new template file)
|
||||
- ID only in branch -> pruned (removed from template)
|
||||
|
||||
Args:
|
||||
template_registry: Template registry dict (from .template_registry.json).
|
||||
branch_meta: Branch metadata dict (from .branch_meta.json).
|
||||
branch_dir: Path to the branch directory (for existence checks).
|
||||
old_branch_tracking: Snapshot of file_tracking from BEFORE metadata
|
||||
regeneration (Phase 0 pattern). Used for accurate rename detection.
|
||||
When None, falls back to current branch_meta tracking.
|
||||
|
||||
Returns:
|
||||
Dict with change categories::
|
||||
|
||||
{
|
||||
"additions": [{"file_id": ..., "template_path": ..., "hash": ...}, ...],
|
||||
"updates": [{"file_id": ..., "template_path": ..., "branch_path": ...,
|
||||
"template_hash": ..., "branch_hash": ...}, ...],
|
||||
"renames": [{"file_id": ..., "template_path": ..., "branch_path": ...,
|
||||
"old_name": ..., "new_name": ...}, ...],
|
||||
"pruned": [{"file_id": ..., "branch_path": ..., "name": ...}, ...],
|
||||
}
|
||||
"""
|
||||
branch_dir = Path(branch_dir)
|
||||
|
||||
additions: list[dict[str, Any]] = []
|
||||
updates: list[dict[str, Any]] = []
|
||||
renames: list[dict[str, Any]] = []
|
||||
pruned: list[dict[str, Any]] = []
|
||||
|
||||
# Extract tracking data from branch metadata
|
||||
branch_file_tracking = branch_meta.get("file_tracking", {})
|
||||
branch_dir_tracking = branch_meta.get("directory_tracking", {})
|
||||
|
||||
# Merge file and directory IDs from branch meta into a single set for lookup
|
||||
branch_ids = set(branch_file_tracking.keys()) | set(branch_dir_tracking.keys())
|
||||
|
||||
# Build template ID sets
|
||||
template_files = template_registry.get("files", {})
|
||||
template_dirs = template_registry.get("directories", {})
|
||||
template_ids = set(template_files.keys()) | set(template_dirs.keys())
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# 1. Detect ADDITIONS: IDs in template but not in branch
|
||||
# -----------------------------------------------------------------
|
||||
for file_id in template_ids - branch_ids:
|
||||
# Get template info (could be file or directory)
|
||||
t_info = template_files.get(file_id) or template_dirs.get(file_id)
|
||||
if not t_info:
|
||||
continue
|
||||
|
||||
template_path = t_info.get("path", t_info.get("current_name", ""))
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"file_id": file_id,
|
||||
"template_path": template_path,
|
||||
}
|
||||
|
||||
# Include hash for files (directories don't have hashes)
|
||||
if file_id in template_files:
|
||||
entry["hash"] = t_info.get("content_hash", "")
|
||||
|
||||
additions.append(entry)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# 2. Detect PRUNED: IDs in branch but not in template
|
||||
# -----------------------------------------------------------------
|
||||
for file_id in branch_ids - template_ids:
|
||||
b_info = branch_file_tracking.get(file_id) or branch_dir_tracking.get(file_id)
|
||||
if not b_info:
|
||||
continue
|
||||
|
||||
branch_path = b_info.get("current_path", "")
|
||||
name = b_info.get("current_name", "")
|
||||
|
||||
pruned.append(
|
||||
{
|
||||
"file_id": file_id,
|
||||
"branch_path": branch_path,
|
||||
"name": name,
|
||||
}
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# 3. Detect RENAMES and UPDATES: IDs in both template and branch
|
||||
# -----------------------------------------------------------------
|
||||
common_ids = template_ids & branch_ids
|
||||
|
||||
for file_id in common_ids:
|
||||
# Get info from both sides
|
||||
t_info = template_files.get(file_id) or template_dirs.get(file_id)
|
||||
b_info = branch_file_tracking.get(file_id) or branch_dir_tracking.get(file_id)
|
||||
|
||||
if not t_info or not b_info:
|
||||
continue
|
||||
|
||||
template_path = t_info.get("path", t_info.get("current_name", ""))
|
||||
branch_path = b_info.get("current_path", "")
|
||||
|
||||
# Check for RENAME: same ID but path changed in template
|
||||
# Use old tracking snapshot for comparison if available (Phase 0 pattern)
|
||||
if old_branch_tracking and file_id in old_branch_tracking:
|
||||
old_info = old_branch_tracking[file_id]
|
||||
old_template_name = old_info.get("template_name", "")
|
||||
if old_template_name and template_path != old_template_name:
|
||||
renames.append(
|
||||
{
|
||||
"file_id": file_id,
|
||||
"template_path": template_path,
|
||||
"branch_path": branch_path,
|
||||
"old_name": old_template_name,
|
||||
"new_name": template_path,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Fallback: compare against current branch meta
|
||||
branch_template_name = b_info.get("template_name", "")
|
||||
if branch_template_name and template_path != branch_template_name:
|
||||
renames.append(
|
||||
{
|
||||
"file_id": file_id,
|
||||
"template_path": template_path,
|
||||
"branch_path": branch_path,
|
||||
"old_name": branch_template_name,
|
||||
"new_name": template_path,
|
||||
}
|
||||
)
|
||||
|
||||
# Check for UPDATE: same ID, same location, content hash changed
|
||||
# Only applies to files (directories don't have content hashes)
|
||||
if file_id in template_files and file_id in branch_file_tracking:
|
||||
template_hash = t_info.get("content_hash", "")
|
||||
branch_hash = b_info.get("content_hash", "")
|
||||
|
||||
if template_hash and branch_hash and template_hash != branch_hash:
|
||||
updates.append(
|
||||
{
|
||||
"file_id": file_id,
|
||||
"template_path": template_path,
|
||||
"branch_path": branch_path,
|
||||
"template_hash": template_hash,
|
||||
"branch_hash": branch_hash,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"additions": additions,
|
||||
"updates": updates,
|
||||
"renames": renames,
|
||||
"pruned": pruned,
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: reconcile.py
|
||||
# Description: Branch state reconciliation — filesystem vs metadata
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-10
|
||||
# =============================================
|
||||
|
||||
"""Branch state reconciliation handler.
|
||||
|
||||
Compares what .branch_meta.json says should exist vs what actually exists
|
||||
on the filesystem. Identifies missing files, untracked files, hash
|
||||
mismatches, and missing directories.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
# Directories/files to skip during filesystem scans
|
||||
_SKIP_NAMES = {"__pycache__", ".git", ".branch_meta.json", ".template_registry.json"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _compute_hash(file_path: Path) -> str:
|
||||
"""Compute SHA-256 hash, first 12 hex chars."""
|
||||
try:
|
||||
sha256 = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
sha256.update(chunk)
|
||||
return sha256.hexdigest()[:12]
|
||||
except (IOError, PermissionError) as e:
|
||||
logger.warning("Failed to compute hash for %s: %s", file_path, e)
|
||||
return ""
|
||||
|
||||
|
||||
def _should_skip(name: str) -> bool:
|
||||
"""Check if a file or directory name should be skipped during scan."""
|
||||
return name in _SKIP_NAMES
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RECONCILIATION
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def reconcile_branch_state(
|
||||
branch_dir: Path,
|
||||
branch_meta: dict,
|
||||
trace: bool = False,
|
||||
) -> dict:
|
||||
"""Reconcile branch metadata with actual filesystem state.
|
||||
|
||||
Compares tracked files/directories in .branch_meta.json against what
|
||||
exists on disk within the branch directory.
|
||||
|
||||
Args:
|
||||
branch_dir: Path to the branch directory.
|
||||
branch_meta: Loaded branch metadata dict (from .branch_meta.json).
|
||||
trace: If True, enable verbose logging via prax.
|
||||
|
||||
Returns:
|
||||
Dict with reconciliation results::
|
||||
|
||||
{
|
||||
"missing_files": [{"file_id": ..., "path": ..., "template_name": ...}, ...],
|
||||
"untracked_files": [{"path": ..., "name": ...}, ...],
|
||||
"hash_mismatches": [{"file_id": ..., "path": ..., "tracked_hash": ..., "current_hash": ...}, ...],
|
||||
"missing_dirs": [{"dir_id": ..., "path": ..., "template_name": ...}, ...],
|
||||
"needs_update": bool,
|
||||
}
|
||||
"""
|
||||
branch_dir = Path(branch_dir)
|
||||
file_tracking = branch_meta.get("file_tracking", {})
|
||||
dir_tracking = branch_meta.get("directory_tracking", {})
|
||||
|
||||
missing_files: list[dict[str, Any]] = []
|
||||
untracked_files: list[dict[str, Any]] = []
|
||||
hash_mismatches: list[dict[str, Any]] = []
|
||||
missing_dirs: list[dict[str, Any]] = []
|
||||
|
||||
if trace:
|
||||
logger.info(f"[reconcile] Starting reconciliation for {branch_dir.name}")
|
||||
logger.info(f"[reconcile] Tracked files: {len(file_tracking)}, dirs: {len(dir_tracking)}")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Check 1: Tracked files — do they still exist on disk?
|
||||
# -------------------------------------------------------------------------
|
||||
for file_id, file_info in file_tracking.items():
|
||||
tracked_path = file_info.get("current_path", "")
|
||||
if not tracked_path:
|
||||
continue
|
||||
|
||||
full_path = branch_dir / tracked_path
|
||||
if not full_path.exists():
|
||||
entry = {
|
||||
"file_id": file_id,
|
||||
"path": tracked_path,
|
||||
"template_name": file_info.get("template_name", ""),
|
||||
}
|
||||
missing_files.append(entry)
|
||||
if trace:
|
||||
logger.info(f"[reconcile] MISSING: {file_id} -> {tracked_path}")
|
||||
elif full_path.is_file():
|
||||
# Check hash mismatch
|
||||
tracked_hash = file_info.get("content_hash", "")
|
||||
if tracked_hash:
|
||||
current_hash = _compute_hash(full_path)
|
||||
if current_hash and current_hash != tracked_hash:
|
||||
entry = {
|
||||
"file_id": file_id,
|
||||
"path": tracked_path,
|
||||
"tracked_hash": tracked_hash,
|
||||
"current_hash": current_hash,
|
||||
}
|
||||
hash_mismatches.append(entry)
|
||||
if trace:
|
||||
logger.info(
|
||||
f"[reconcile] HASH MISMATCH: {file_id} tracked={tracked_hash} current={current_hash}"
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Check 2: Tracked directories — do they still exist?
|
||||
# -------------------------------------------------------------------------
|
||||
for dir_id, dir_info in dir_tracking.items():
|
||||
tracked_path = dir_info.get("current_path", "")
|
||||
if not tracked_path:
|
||||
continue
|
||||
|
||||
full_path = branch_dir / tracked_path
|
||||
if not full_path.is_dir():
|
||||
entry = {
|
||||
"dir_id": dir_id,
|
||||
"path": tracked_path,
|
||||
"template_name": dir_info.get("template_name", ""),
|
||||
}
|
||||
missing_dirs.append(entry)
|
||||
if trace:
|
||||
logger.info(f"[reconcile] MISSING DIR: {dir_id} -> {tracked_path}")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Check 3: Untracked files — on disk but not in metadata
|
||||
# Only check within template-managed directories (tracked dir paths).
|
||||
# -------------------------------------------------------------------------
|
||||
tracked_paths = {info.get("current_path", "") for info in file_tracking.values()}
|
||||
tracked_dir_paths = {info.get("current_path", "") for info in dir_tracking.values()}
|
||||
|
||||
# Scan within tracked directories for untracked files
|
||||
for dir_path_str in tracked_dir_paths:
|
||||
if not dir_path_str:
|
||||
continue
|
||||
scan_dir = branch_dir / dir_path_str
|
||||
if not scan_dir.is_dir():
|
||||
continue
|
||||
|
||||
try:
|
||||
for item in scan_dir.iterdir():
|
||||
if _should_skip(item.name):
|
||||
continue
|
||||
if not item.is_file():
|
||||
continue
|
||||
|
||||
rel_path = str(item.relative_to(branch_dir))
|
||||
if rel_path not in tracked_paths:
|
||||
entry = {
|
||||
"path": rel_path,
|
||||
"name": item.name,
|
||||
}
|
||||
untracked_files.append(entry)
|
||||
if trace:
|
||||
logger.info(f"[reconcile] UNTRACKED: {rel_path}")
|
||||
except (PermissionError, OSError) as exc:
|
||||
if trace:
|
||||
logger.warning(f"[reconcile] Scan error in {dir_path_str}: {exc}")
|
||||
|
||||
# Also check root-level files
|
||||
try:
|
||||
for item in branch_dir.iterdir():
|
||||
if _should_skip(item.name):
|
||||
continue
|
||||
if not item.is_file():
|
||||
continue
|
||||
rel_path = str(item.relative_to(branch_dir))
|
||||
if rel_path not in tracked_paths:
|
||||
entry = {
|
||||
"path": rel_path,
|
||||
"name": item.name,
|
||||
}
|
||||
untracked_files.append(entry)
|
||||
if trace:
|
||||
logger.info(f"[reconcile] UNTRACKED (root): {rel_path}")
|
||||
except (PermissionError, OSError) as exc:
|
||||
if trace:
|
||||
logger.warning(f"[reconcile] Root scan error: {exc}")
|
||||
|
||||
needs_update = bool(missing_files or untracked_files or hash_mismatches or missing_dirs)
|
||||
|
||||
if trace:
|
||||
logger.info(
|
||||
f"[reconcile] Complete: missing={len(missing_files)}, "
|
||||
f"untracked={len(untracked_files)}, "
|
||||
f"hash_mismatches={len(hash_mismatches)}, "
|
||||
f"missing_dirs={len(missing_dirs)}, "
|
||||
f"needs_update={needs_update}"
|
||||
)
|
||||
|
||||
return {
|
||||
"missing_files": missing_files,
|
||||
"untracked_files": untracked_files,
|
||||
"hash_mismatches": hash_mismatches,
|
||||
"missing_dirs": missing_dirs,
|
||||
"needs_update": needs_update,
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: update_ops.py
|
||||
# Description: Update handler — implementation logic for branch updates
|
||||
# Version: 1.0.0
|
||||
# Description: Update handler — path-based template sync engine (P1 rewrite, TDPLAN-0006)
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-10
|
||||
# Modified: 2026-06-06
|
||||
# =============================================
|
||||
|
||||
"""Update handler implementation for branch lifecycle management.
|
||||
"""Update handler — path-based template sync engine.
|
||||
|
||||
Contains the core update logic: resolving branch paths, executing renames,
|
||||
additions, JSON updates, pruned file archival, and coordinating the full
|
||||
update workflow for single and all-branch modes.
|
||||
P1 rewrite (TDPLAN-0006, issue #636): replaces the broken ID-based
|
||||
change-detection engine with explicit template walking. No renames,
|
||||
no pruning, never touches identity/memory files.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
@@ -25,17 +24,39 @@ from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.spawn.apps.handlers.meta_ops import (
|
||||
generate_branch_meta,
|
||||
get_template_dir,
|
||||
load_branch_meta,
|
||||
load_template_registry,
|
||||
save_branch_meta,
|
||||
)
|
||||
from aipass.spawn.apps.handlers.reconcile import reconcile_branch_state
|
||||
from aipass.spawn.apps.handlers.change_detection import detect_changes
|
||||
from aipass.spawn.apps.handlers.json_ops import backup_json, deep_merge
|
||||
from aipass.spawn.apps.handlers.placeholders import build_replacements_dict, replace_placeholders
|
||||
from aipass.spawn.apps.handlers.registry import find_registry, load_registry, branches_as_list
|
||||
from aipass.spawn.apps.handlers.json import json_handler
|
||||
|
||||
_NEVER_UPDATE_PREFIXES = (".trinity/",)
|
||||
_NEVER_UPDATE_FILES = frozenset(
|
||||
{
|
||||
"DASHBOARD.local.json",
|
||||
"artifacts/birth_certificate.json",
|
||||
".seedgo/bypass.json",
|
||||
}
|
||||
)
|
||||
_SKIP_TRACKING = frozenset(
|
||||
{
|
||||
".spawn/.template_registry.json",
|
||||
".spawn/.branch_meta.json",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_never_update(resolved_path: str) -> bool:
|
||||
"""Check if a resolved path is in the create-only set."""
|
||||
if resolved_path in _NEVER_UPDATE_FILES:
|
||||
return True
|
||||
for prefix in _NEVER_UPDATE_PREFIXES:
|
||||
if resolved_path.startswith(prefix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUBLIC API
|
||||
@@ -43,220 +64,133 @@ from aipass.spawn.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def update_branch(branch_name: str, dry_run: bool = False, trace: bool = False) -> dict:
|
||||
"""Update a single branch from template.
|
||||
"""Update a single branch from its class template.
|
||||
|
||||
Workflow:
|
||||
1. Resolve branch path from AIPASS_REGISTRY.json
|
||||
2. Load template registry
|
||||
3. Load or generate .spawn/.branch_meta.json
|
||||
4. Pre-flight reconciliation
|
||||
5. Change detection
|
||||
6. If dry_run -> print summary, return
|
||||
7. Execute changes (renames, additions, JSON updates, pruned archival)
|
||||
8. Update branch metadata with new tracking state
|
||||
9. Post-flight reconciliation
|
||||
10. Return summary dict
|
||||
|
||||
Returns:
|
||||
Dict with update results including counts and errors.
|
||||
Path-based engine: walks the template directory, resolves placeholder
|
||||
paths, and for each file decides add/merge/skip. No renames, no pruning.
|
||||
Identity files (.trinity/*, DASHBOARD, birth_certificate, bypass.json)
|
||||
are never touched — create-only.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
counts = {
|
||||
"additions": 0,
|
||||
"renames": 0,
|
||||
"updates": 0,
|
||||
"pruned": 0,
|
||||
"skipped_py": 0,
|
||||
}
|
||||
counts = {"additions": 0, "renames": 0, "updates": 0, "pruned": 0, "skipped_py": 0}
|
||||
additions_detail: list[dict] = []
|
||||
updates_detail: list[dict] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Resolve branch path
|
||||
# ------------------------------------------------------------------
|
||||
branch_dir = _resolve_branch_path(branch_name)
|
||||
if branch_dir is None:
|
||||
return _result(branch_name, False, counts, [f"Branch '{branch_name}' not found in registry"], dry_run)
|
||||
|
||||
if not branch_dir.is_dir():
|
||||
return _result(branch_name, False, counts, [f"Branch directory does not exist: {branch_dir}"], dry_run)
|
||||
|
||||
if trace:
|
||||
logger.info(f"[update] Resolved {branch_name} -> {branch_dir}")
|
||||
logger.info("[update] Resolved %s -> %s", branch_name, branch_dir)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1b. Read citizen_class from passport
|
||||
# ------------------------------------------------------------------
|
||||
citizen_class = _read_citizen_class(branch_dir)
|
||||
if trace:
|
||||
logger.info(f"[update] Citizen class: {citizen_class}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Load template registry
|
||||
# ------------------------------------------------------------------
|
||||
template_dir = get_template_dir(citizen_class)
|
||||
template_registry = load_template_registry(template_dir)
|
||||
if template_registry is None:
|
||||
return _result(branch_name, False, counts, ["Failed to load template registry"], dry_run)
|
||||
|
||||
if not template_dir.is_dir():
|
||||
return _result(branch_name, False, counts, [f"Template directory not found: {template_dir}"], dry_run)
|
||||
|
||||
if trace:
|
||||
t_files = len(template_registry.get("files", {}))
|
||||
t_dirs = len(template_registry.get("directories", {}))
|
||||
logger.info(f"[update] Template registry: {t_files} files, {t_dirs} dirs")
|
||||
logger.info("[update] Citizen class: %s, template: %s", citizen_class, template_dir)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Load or generate branch metadata (first-time adoption)
|
||||
# ------------------------------------------------------------------
|
||||
branch_meta = load_branch_meta(branch_dir)
|
||||
first_time = branch_meta is None
|
||||
|
||||
# Phase 0: Snapshot old tracking BEFORE any metadata generation/sync
|
||||
# This preserves the old template_name values for rename detection
|
||||
old_branch_tracking: dict | None = None
|
||||
if branch_meta is not None:
|
||||
old_branch_tracking = copy.deepcopy(branch_meta.get("file_tracking", {}))
|
||||
|
||||
if first_time:
|
||||
if trace:
|
||||
logger.info("[update] No branch_meta found — generating initial metadata (adoption)")
|
||||
branch_meta = generate_branch_meta(branch_dir, template_registry)
|
||||
if not dry_run:
|
||||
save_branch_meta(branch_dir, branch_meta)
|
||||
if trace:
|
||||
logger.info("[update] Saved initial branch_meta.json")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Pre-flight reconciliation
|
||||
# ------------------------------------------------------------------
|
||||
recon = reconcile_branch_state(branch_dir, branch_meta, trace=trace)
|
||||
if trace:
|
||||
logger.info(
|
||||
f"[update] Pre-flight: missing={len(recon['missing_files'])}, "
|
||||
f"untracked={len(recon['untracked_files'])}, "
|
||||
f"hash_mismatches={len(recon['hash_mismatches'])}, "
|
||||
f"missing_dirs={len(recon['missing_dirs'])}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Change detection
|
||||
# ------------------------------------------------------------------
|
||||
changes = detect_changes(template_registry, branch_meta, branch_dir, old_branch_tracking=old_branch_tracking)
|
||||
|
||||
additions = changes.get("additions", [])
|
||||
updates_list = changes.get("updates", [])
|
||||
renames = changes.get("renames", [])
|
||||
pruned = changes.get("pruned", [])
|
||||
|
||||
if trace:
|
||||
logger.info(
|
||||
f"[update] Changes detected: additions={len(additions)}, "
|
||||
f"updates={len(updates_list)}, renames={len(renames)}, "
|
||||
f"pruned={len(pruned)}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Dry run — just report
|
||||
# ------------------------------------------------------------------
|
||||
if dry_run:
|
||||
counts["additions"] = len(additions)
|
||||
counts["renames"] = len(renames)
|
||||
counts["pruned"] = len(pruned)
|
||||
|
||||
# Count updates vs skipped .py files
|
||||
for upd in updates_list:
|
||||
tp = upd.get("template_path", "")
|
||||
if tp.endswith(".py"):
|
||||
counts["skipped_py"] += 1
|
||||
elif tp.endswith(".json"):
|
||||
counts["updates"] += 1
|
||||
|
||||
return _result(
|
||||
branch_name,
|
||||
True,
|
||||
counts,
|
||||
errors,
|
||||
dry_run,
|
||||
_additions_detail=additions,
|
||||
_updates_detail=updates_list,
|
||||
_renames_detail=renames,
|
||||
_pruned_detail=pruned,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Execute changes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Build placeholder replacements for this branch
|
||||
replacements = build_replacements_dict(branch_dir, branch_name)
|
||||
|
||||
# 7a. RENAMES
|
||||
for rename_info in renames:
|
||||
try:
|
||||
_execute_rename(branch_dir, rename_info, trace)
|
||||
counts["renames"] += 1
|
||||
except Exception as exc:
|
||||
msg = f"Rename failed for {rename_info.get('file_id')}: {exc}"
|
||||
errors.append(msg)
|
||||
logger.error(f"[update] {msg}")
|
||||
|
||||
# 7b. ADDITIONS
|
||||
for add_info in additions:
|
||||
try:
|
||||
_execute_addition(branch_dir, template_dir, add_info, replacements, template_registry, trace)
|
||||
# Walk template directories — create missing ones in branch
|
||||
for template_subdir in sorted(template_dir.rglob("*")):
|
||||
if not template_subdir.is_dir():
|
||||
continue
|
||||
if "__pycache__" in template_subdir.parts:
|
||||
continue
|
||||
rel_dir = template_subdir.relative_to(template_dir).as_posix()
|
||||
resolved_dir = replace_placeholders(rel_dir, replacements)
|
||||
if _is_never_update(resolved_dir + "/"):
|
||||
continue
|
||||
if resolved_dir in _SKIP_TRACKING:
|
||||
continue
|
||||
dest_dir = branch_dir / resolved_dir
|
||||
if not dest_dir.exists():
|
||||
if not dry_run:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
additions_detail.append({"template_path": resolved_dir, "type": "directory"})
|
||||
counts["additions"] += 1
|
||||
except Exception as exc:
|
||||
msg = f"Addition failed for {add_info.get('file_id')}: {exc}"
|
||||
errors.append(msg)
|
||||
logger.error(f"[update] {msg}")
|
||||
if trace:
|
||||
logger.info("[update] Added directory: %s", resolved_dir)
|
||||
|
||||
# 7c. JSON UPDATES (skip .py files, deep merge .json)
|
||||
for upd_info in updates_list:
|
||||
try:
|
||||
result = _execute_update(branch_dir, template_dir, upd_info, replacements, trace)
|
||||
# Walk template files — add/merge/skip per type
|
||||
for template_file in sorted(template_dir.rglob("*")):
|
||||
if not template_file.is_file():
|
||||
continue
|
||||
if "__pycache__" in template_file.parts:
|
||||
continue
|
||||
|
||||
rel_path = template_file.relative_to(template_dir).as_posix()
|
||||
|
||||
if rel_path in _SKIP_TRACKING:
|
||||
continue
|
||||
|
||||
resolved_path = replace_placeholders(rel_path, replacements)
|
||||
|
||||
if _is_never_update(resolved_path):
|
||||
if trace:
|
||||
logger.info("[update] SKIP (create-only): %s", resolved_path)
|
||||
continue
|
||||
|
||||
dest = branch_dir / resolved_path
|
||||
|
||||
if not dest.exists():
|
||||
# ADDITION — missing file from template
|
||||
if not dry_run:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
content = template_file.read_text(encoding="utf-8")
|
||||
content = replace_placeholders(content, replacements)
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
except (UnicodeDecodeError, UnicodeEncodeError) as enc_err:
|
||||
logger.warning("[update] Binary file, copying directly: %s (%s)", resolved_path, enc_err)
|
||||
shutil.copy2(template_file, dest)
|
||||
additions_detail.append({"template_path": resolved_path, "type": "file"})
|
||||
counts["additions"] += 1
|
||||
if trace:
|
||||
logger.info("[update] Added: %s", resolved_path)
|
||||
|
||||
elif dest.suffix == ".py":
|
||||
counts["skipped_py"] += 1
|
||||
updates_detail.append({"template_path": rel_path, "branch_path": resolved_path})
|
||||
if trace:
|
||||
logger.info("[update] SKIP .py: %s", resolved_path)
|
||||
|
||||
elif dest.suffix == ".json":
|
||||
result = _merge_json(template_file, dest, replacements, dry_run, trace)
|
||||
if result == "updated":
|
||||
counts["updates"] += 1
|
||||
elif result == "skipped_py":
|
||||
counts["skipped_py"] += 1
|
||||
except Exception as exc:
|
||||
msg = f"Update failed for {upd_info.get('file_id')}: {exc}"
|
||||
errors.append(msg)
|
||||
logger.error(f"[update] {msg}")
|
||||
updates_detail.append({"template_path": rel_path, "branch_path": resolved_path})
|
||||
elif result == "error":
|
||||
errors.append(f"JSON merge failed for {resolved_path}")
|
||||
|
||||
# 7d. PRUNED — archive, don't delete
|
||||
for prune_info in pruned:
|
||||
try:
|
||||
_execute_prune(branch_dir, prune_info, trace)
|
||||
counts["pruned"] += 1
|
||||
except Exception as exc:
|
||||
msg = f"Prune/archive failed for {prune_info.get('file_id')}: {exc}"
|
||||
errors.append(msg)
|
||||
logger.error(f"[update] {msg}")
|
||||
# Refresh branch metadata (informational tracking)
|
||||
if not dry_run:
|
||||
template_registry = load_template_registry(template_dir)
|
||||
if template_registry:
|
||||
updated_meta = generate_branch_meta(branch_dir, template_registry)
|
||||
updated_meta["metadata"]["last_updated"] = datetime.now().strftime("%Y-%m-%d")
|
||||
save_branch_meta(branch_dir, updated_meta)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. Refresh branch metadata
|
||||
# ------------------------------------------------------------------
|
||||
updated_meta = generate_branch_meta(branch_dir, template_registry)
|
||||
updated_meta["metadata"]["last_updated"] = datetime.now().strftime("%Y-%m-%d")
|
||||
save_branch_meta(branch_dir, updated_meta)
|
||||
|
||||
if trace:
|
||||
logger.info("[update] Branch metadata refreshed and saved")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. Post-flight reconciliation
|
||||
# ------------------------------------------------------------------
|
||||
post_recon = reconcile_branch_state(branch_dir, updated_meta, trace=trace)
|
||||
if trace:
|
||||
logger.info(
|
||||
f"[update] Post-flight: missing={len(post_recon['missing_files'])}, "
|
||||
f"untracked={len(post_recon['untracked_files'])}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 10. Return summary
|
||||
# ------------------------------------------------------------------
|
||||
success = len(errors) == 0
|
||||
if success:
|
||||
if success and not dry_run:
|
||||
json_handler.log_operation("update_executed", data={"branch": branch_name})
|
||||
return _result(branch_name, success, counts, errors, dry_run)
|
||||
|
||||
return _result(
|
||||
branch_name,
|
||||
success,
|
||||
counts,
|
||||
errors,
|
||||
dry_run,
|
||||
_additions_detail=additions_detail,
|
||||
_updates_detail=updates_detail,
|
||||
_renames_detail=[],
|
||||
_pruned_detail=[],
|
||||
)
|
||||
|
||||
|
||||
def update_all(dry_run: bool = False, trace: bool = False, citizen_class: str | None = None) -> list[dict]:
|
||||
@@ -280,30 +214,28 @@ def update_all(dry_run: bool = False, trace: bool = False, citizen_class: str |
|
||||
name = branch_entry.get("name", "")
|
||||
lower_name = name.lower()
|
||||
|
||||
# Skip spawn itself
|
||||
if lower_name == "spawn":
|
||||
if trace:
|
||||
logger.info("[update] Skipping spawn (self)")
|
||||
continue
|
||||
|
||||
# Check class filter if specified
|
||||
if citizen_class:
|
||||
branch_dir = _resolve_branch_path(lower_name)
|
||||
if branch_dir and branch_dir.is_dir():
|
||||
actual_class = _read_citizen_class(branch_dir)
|
||||
if actual_class != citizen_class:
|
||||
if trace:
|
||||
logger.info(f"[update] Skipping {name} (class={actual_class}, filter={citizen_class})")
|
||||
logger.info("[update] Skipping %s (class=%s, filter=%s)", name, actual_class, citizen_class)
|
||||
continue
|
||||
|
||||
if trace:
|
||||
logger.info(f"[update] Processing branch: {name}")
|
||||
logger.info("[update] Processing branch: %s", name)
|
||||
|
||||
try:
|
||||
result = update_branch(lower_name, dry_run=dry_run, trace=trace)
|
||||
results.append(result)
|
||||
except Exception as exc:
|
||||
logger.error(f"[update] Error updating {name}: {exc}")
|
||||
logger.error("[update] Error updating %s: %s", name, exc)
|
||||
results.append(
|
||||
{
|
||||
"branch": lower_name,
|
||||
@@ -322,16 +254,12 @@ def update_all(dry_run: bool = False, trace: bool = False, citizen_class: str |
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTERNAL EXECUTION FUNCTIONS
|
||||
# INTERNAL HELPERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _read_citizen_class(branch_dir: Path) -> str:
|
||||
"""Read citizen_class from a branch's passport.json.
|
||||
|
||||
Falls back to "builder" if passport doesn't exist, doesn't have
|
||||
the citizen_class field, or has an unknown class (e.g. "manager").
|
||||
"""
|
||||
"""Read citizen_class from a branch's passport.json."""
|
||||
from aipass.spawn.apps.handlers.class_registry import validate_class
|
||||
|
||||
passport_path = branch_dir / ".trinity" / "passport.json"
|
||||
@@ -342,21 +270,17 @@ def _read_citizen_class(branch_dir: Path) -> str:
|
||||
citizen_class = data.get("identity", {}).get("citizen_class", "builder")
|
||||
if not validate_class(citizen_class):
|
||||
logger.warning(
|
||||
f"[update] Unknown citizen_class '{citizen_class}' in {passport_path}, falling back to 'builder'"
|
||||
"[update] Unknown citizen_class '%s' in %s, falling back to 'builder'", citizen_class, passport_path
|
||||
)
|
||||
return "builder"
|
||||
return citizen_class
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"[update] Failed to read citizen_class from passport {passport_path}: {e}")
|
||||
logger.warning("[update] Failed to read citizen_class from passport %s: %s", passport_path, e)
|
||||
return "builder"
|
||||
|
||||
|
||||
def _resolve_branch_path(branch_name: str) -> Path | None:
|
||||
"""Resolve a branch name to its absolute directory path via the registry.
|
||||
|
||||
Tries both the exact name and common case variants.
|
||||
Registry paths are relative to registry parent (project root).
|
||||
"""
|
||||
"""Resolve a branch name to its absolute directory path via the registry."""
|
||||
registry_path = find_registry()
|
||||
project_root = registry_path.parent
|
||||
registry = load_registry(registry_path)
|
||||
@@ -371,188 +295,44 @@ def _resolve_branch_path(branch_name: str) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def _execute_rename(branch_dir: Path, rename_info: dict, trace: bool) -> None:
|
||||
"""Rename a file within the branch to match template's new path."""
|
||||
old_path = branch_dir / rename_info.get("branch_path", "")
|
||||
new_rel = rename_info.get("new_name", "") # This is the new template-relative path
|
||||
new_path = branch_dir / new_rel
|
||||
|
||||
if not old_path.exists():
|
||||
if trace:
|
||||
logger.info(f"[update] Rename: source not found, skipping: {old_path}")
|
||||
return
|
||||
|
||||
# Create parent dirs if needed
|
||||
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
old_path.rename(new_path)
|
||||
if trace:
|
||||
logger.info(f"[update] Renamed: {rename_info.get('branch_path')} -> {new_rel}")
|
||||
|
||||
|
||||
def _execute_addition(
|
||||
branch_dir: Path,
|
||||
template_dir: Path,
|
||||
add_info: dict,
|
||||
replacements: dict,
|
||||
_template_registry: dict,
|
||||
trace: bool,
|
||||
) -> None:
|
||||
"""Copy a new template file/directory to the branch with placeholder replacement."""
|
||||
template_path = add_info.get("template_path", "")
|
||||
file_id = add_info.get("file_id", "")
|
||||
|
||||
if not template_path:
|
||||
return
|
||||
|
||||
# Check if this is a directory addition (ID starts with 'd')
|
||||
if file_id.startswith("d"):
|
||||
dest = branch_dir / template_path
|
||||
# Apply placeholder replacement to path
|
||||
dest_str = replace_placeholders(str(dest), replacements)
|
||||
dest = Path(dest_str)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
if trace:
|
||||
logger.info(f"[update] Added directory: {template_path}")
|
||||
return
|
||||
|
||||
# File addition — read from template, apply placeholders, write to branch
|
||||
source = template_dir / template_path
|
||||
dest = branch_dir / template_path
|
||||
|
||||
# Apply placeholder replacement to destination path components
|
||||
dest_rel_str = replace_placeholders(template_path, replacements)
|
||||
dest = branch_dir / dest_rel_str
|
||||
|
||||
if not source.exists():
|
||||
if trace:
|
||||
logger.info(f"[update] Template source not found, skipping: {source}")
|
||||
return
|
||||
|
||||
# NEVER overwrite existing .py files — even during additions
|
||||
if dest.exists() and dest.suffix == ".py":
|
||||
if trace:
|
||||
logger.info(f"[update] SKIPPED existing .py file: {dest_rel_str}")
|
||||
return
|
||||
|
||||
# Create parent directories
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Don't overwrite any existing file — additions are for MISSING files only
|
||||
if dest.exists():
|
||||
if trace:
|
||||
logger.info(f"[update] SKIPPED existing file: {dest_rel_str}")
|
||||
return
|
||||
|
||||
try:
|
||||
content = source.read_text(encoding="utf-8")
|
||||
content = replace_placeholders(content, replacements)
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
except (UnicodeDecodeError, UnicodeEncodeError) as e:
|
||||
# Binary file — copy directly
|
||||
logger.warning(f"[update] Text read/write failed for {template_path}, falling back to binary copy: {e}")
|
||||
shutil.copy2(source, dest)
|
||||
|
||||
if trace:
|
||||
logger.info(f"[update] Added: {dest_rel_str}")
|
||||
|
||||
|
||||
def _execute_update(
|
||||
branch_dir: Path,
|
||||
template_dir: Path,
|
||||
upd_info: dict,
|
||||
def _merge_json(
|
||||
template_file: Path,
|
||||
dest: Path,
|
||||
replacements: dict,
|
||||
dry_run: bool,
|
||||
trace: bool,
|
||||
) -> str:
|
||||
"""Handle a file update from template.
|
||||
"""Deep-merge a template JSON file into the branch copy.
|
||||
|
||||
Returns:
|
||||
"updated" if file was merged, "skipped_py" if .py file, "skipped" otherwise.
|
||||
Returns "updated", "unchanged", or "error".
|
||||
"""
|
||||
template_path = upd_info.get("template_path", "")
|
||||
branch_path = upd_info.get("branch_path", "")
|
||||
try:
|
||||
template_content = template_file.read_text(encoding="utf-8")
|
||||
template_content = replace_placeholders(template_content, replacements)
|
||||
template_data = json.loads(template_content)
|
||||
|
||||
if not template_path or not branch_path:
|
||||
return "skipped"
|
||||
existing_text = dest.read_text(encoding="utf-8")
|
||||
existing_data = json.loads(existing_text)
|
||||
|
||||
# NEVER overwrite .py files
|
||||
if template_path.endswith(".py") or branch_path.endswith(".py"):
|
||||
if trace:
|
||||
logger.info(f"[update] SKIPPED .py file (manual review needed): {branch_path}")
|
||||
return "skipped_py"
|
||||
merged = deep_merge(template_data, existing_data)
|
||||
merged_text = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
# JSON files — deep merge
|
||||
if template_path.endswith(".json") and branch_path.endswith(".json"):
|
||||
source = template_dir / template_path
|
||||
dest = branch_dir / branch_path
|
||||
|
||||
if not source.exists() or not dest.exists():
|
||||
if merged_text == existing_text:
|
||||
if trace:
|
||||
logger.info(f"[update] Source or dest missing for JSON merge: {template_path}")
|
||||
return "skipped"
|
||||
logger.info("[update] JSON unchanged: %s", dest.name)
|
||||
return "unchanged"
|
||||
|
||||
try:
|
||||
# Backup existing file first
|
||||
if not dry_run:
|
||||
backup_json(dest)
|
||||
dest.write_text(merged_text, encoding="utf-8")
|
||||
|
||||
# Load both files
|
||||
template_content = source.read_text(encoding="utf-8")
|
||||
template_content = replace_placeholders(template_content, replacements)
|
||||
template_data = json.loads(template_content)
|
||||
|
||||
existing_data = json.loads(dest.read_text(encoding="utf-8"))
|
||||
|
||||
# Deep merge: template defines structure, existing fills values
|
||||
merged = deep_merge(template_data, existing_data)
|
||||
|
||||
# Write merged result
|
||||
dest.write_text(
|
||||
json.dumps(merged, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
if trace:
|
||||
logger.info(f"[update] JSON merged: {branch_path}")
|
||||
return "updated"
|
||||
|
||||
except (json.JSONDecodeError, IOError) as exc:
|
||||
logger.error(f"[update] JSON merge failed for {branch_path}: {exc}")
|
||||
return "skipped"
|
||||
|
||||
# Other file types — skip (don't overwrite)
|
||||
if trace:
|
||||
logger.info(f"[update] SKIPPED non-JSON non-py file: {branch_path}")
|
||||
return "skipped"
|
||||
|
||||
|
||||
def _execute_prune(branch_dir: Path, prune_info: dict, trace: bool) -> None:
|
||||
"""Archive a pruned file to .archive/ within the branch."""
|
||||
branch_path = prune_info.get("branch_path", "")
|
||||
if not branch_path:
|
||||
return
|
||||
|
||||
source = branch_dir / branch_path
|
||||
if not source.exists():
|
||||
if trace:
|
||||
logger.info(f"[update] Pruned file already gone: {branch_path}")
|
||||
return
|
||||
logger.info("[update] JSON merged: %s", dest.name)
|
||||
return "updated"
|
||||
|
||||
# Archive destination
|
||||
archive_dir = branch_dir / ".archive"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Use flat name with path separators replaced to avoid collisions
|
||||
archive_name = branch_path.replace("/", "__").replace("\\", "__")
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
archive_dest = archive_dir / f"{archive_name}.{timestamp}.pruned"
|
||||
|
||||
if source.is_file():
|
||||
shutil.move(str(source), str(archive_dest))
|
||||
elif source.is_dir():
|
||||
shutil.move(str(source), str(archive_dest))
|
||||
|
||||
if trace:
|
||||
logger.info(f"[update] Archived: {branch_path} -> .archive/{archive_dest.name}")
|
||||
except (json.JSONDecodeError, IOError) as exc:
|
||||
logger.error("[update] JSON merge failed for %s: %s", dest.name, exc)
|
||||
return "error"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -71,7 +71,8 @@ def print_introspection():
|
||||
console.print(" - metadata.py (get_branch_name, normalize_branch_name, detect_profile — branch identity)")
|
||||
console.print(" - placeholders.py (build_replacements_dict, validate_no_placeholders — template substitution)")
|
||||
console.print(
|
||||
" - file_ops.py (copy_template, rename_placeholder_paths, regenerate_template_registry, ensure_directory — filesystem ops)"
|
||||
" - file_ops.py (copy_template, rename_placeholder_paths,"
|
||||
" regenerate_template_registry, ensure_directory — filesystem ops)"
|
||||
)
|
||||
console.print(
|
||||
" - meta_ops.py (load_template_registry, generate_branch_meta, save_branch_meta — branch metadata)"
|
||||
@@ -80,7 +81,8 @@ def print_introspection():
|
||||
" - registry.py (find_registry, add_to_registry, get_next_citizen_number — AIPASS_REGISTRY management)"
|
||||
)
|
||||
console.print(
|
||||
" - class_registry.py (validate_class, get_default_class, get_available_classes, get_template_dir — citizen class lookup)"
|
||||
" - class_registry.py (validate_class, get_default_class,"
|
||||
" get_available_classes, get_template_dir — citizen class lookup)"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ def handle_repair(args: list[str]) -> int:
|
||||
_print_help()
|
||||
return 1
|
||||
|
||||
dry_run = "--dry-run" in args
|
||||
apply = "--apply" in args
|
||||
dry_run = not apply
|
||||
|
||||
if "--relocate" in args:
|
||||
return _handle_relocate(args, dry_run)
|
||||
@@ -101,7 +102,7 @@ def handle_repair(args: list[str]) -> int:
|
||||
|
||||
def _handle_relocate(args, dry_run):
|
||||
"""Handle --relocate @branch new/path [--relocate-artifacts]."""
|
||||
flags = {"--dry-run", "--relocate", "--relocate-artifacts"}
|
||||
flags = {"--dry-run", "--apply", "--relocate", "--relocate-artifacts"}
|
||||
positional = [a for a in args if a not in flags]
|
||||
relocate_artifacts = "--relocate-artifacts" in args
|
||||
|
||||
@@ -137,7 +138,7 @@ def _handle_relocate(args, dry_run):
|
||||
|
||||
def _handle_clean_pollution(args, dry_run):
|
||||
"""Handle --clean-pollution <project_path>."""
|
||||
flags = {"--dry-run", "--clean-pollution"}
|
||||
flags = {"--dry-run", "--apply", "--clean-pollution"}
|
||||
positional = [a for a in args if a not in flags]
|
||||
|
||||
if not positional:
|
||||
@@ -159,7 +160,7 @@ def _handle_clean_pollution(args, dry_run):
|
||||
|
||||
def _handle_scan(args, dry_run):
|
||||
"""Handle default scan mode — report structural issues."""
|
||||
flags = {"--dry-run"}
|
||||
flags = {"--dry-run", "--apply"}
|
||||
positional = [a for a in args if a not in flags]
|
||||
|
||||
if not positional:
|
||||
@@ -189,7 +190,8 @@ def _print_help():
|
||||
warning("Usage: drone @spawn repair <project_path> [options]")
|
||||
console.print()
|
||||
console.print(" [green]<project_path>[/green] Path to project root")
|
||||
console.print(" [green]--dry-run[/green] Preview changes without modifying")
|
||||
console.print(" [green]--apply[/green] Execute changes (default is preview-only)")
|
||||
console.print(" [green]--dry-run[/green] Preview changes without modifying [dim](default)[/dim]")
|
||||
console.print(" [green]--relocate[/green] @branch path Move a branch to a new location")
|
||||
console.print(
|
||||
" [green]--relocate-artifacts[/green] Move .chroma/ into branch (with --relocate, single-branch only)"
|
||||
|
||||
@@ -89,31 +89,33 @@ def handle_update(args: list[str]) -> int:
|
||||
"""
|
||||
# Intercept --help before processing (argparse has add_help=False)
|
||||
if "--help" in args or "-h" in args:
|
||||
warning("Usage: drone @spawn update <@branch|class --all> [--dry-run] [--trace]")
|
||||
warning("Usage: drone @spawn update <@branch|class --all> [--apply] [--dry-run] [--trace]")
|
||||
console.print()
|
||||
console.print(" [green]@branch[/green] Update a single branch (uses its own class)")
|
||||
console.print(" [green]builder --all[/green] Update all builder-class branches")
|
||||
console.print(" [green]birthright --all[/green] Update all birthright-class branches")
|
||||
console.print(" [green]--dry-run[/green] Preview changes without modifying files")
|
||||
console.print(" [green]--apply[/green] Execute changes (default is preview-only)")
|
||||
console.print(" [green]--dry-run[/green] Preview changes without modifying files [dim](default)[/dim]")
|
||||
console.print(" [green]--trace[/green] Enable verbose logging")
|
||||
return 0
|
||||
|
||||
if not args:
|
||||
warning("Usage: drone @spawn update <@branch|class --all> [--dry-run] [--trace]")
|
||||
warning("Usage: drone @spawn update <@branch|class --all> [--apply] [--dry-run] [--trace]")
|
||||
console.print()
|
||||
console.print(" [green]@branch[/green] Update a single branch (uses its own class)")
|
||||
console.print(" [green]builder --all[/green] Update all builder-class branches")
|
||||
console.print(" [green]birthright --all[/green] Update all birthright-class branches")
|
||||
console.print(" [green]--dry-run[/green] Preview changes without modifying files")
|
||||
console.print(" [green]--apply[/green] Execute changes (default is preview-only)")
|
||||
console.print(" [green]--dry-run[/green] Preview changes without modifying files [dim](default)[/dim]")
|
||||
console.print(" [green]--trace[/green] Enable verbose logging")
|
||||
return 1
|
||||
|
||||
from aipass.spawn.apps.modules.core import validate_class, get_available_classes
|
||||
|
||||
dry_run = "--dry-run" in args
|
||||
apply = "--apply" in args
|
||||
dry_run = not apply
|
||||
trace = "--trace" in args
|
||||
|
||||
# Filter out flags to find positional args
|
||||
positional = [a for a in args if not a.startswith("--")]
|
||||
|
||||
# Detect citizen class argument
|
||||
|
||||
@@ -38,8 +38,9 @@ def print_help():
|
||||
console.print()
|
||||
console.print(" [green]create[/green] [class] <path> Create a new branch from template")
|
||||
console.print(" [green]passport[/green] <@dirname> Grant birthright citizenship (minimal)")
|
||||
console.print(" [green]update[/green] <@branch> Update single branch (uses its class)")
|
||||
console.print(" [green]update[/green] <class> --all Update all branches of a class")
|
||||
console.print(" [green]update[/green] <@branch> Update single branch (preview-only by default)")
|
||||
console.print(" [green]update[/green] <@branch> --apply Update single branch (execute changes)")
|
||||
console.print(" [green]update[/green] <class> --all --apply Update all branches of a class")
|
||||
console.print(" [green]delete[/green] <@branch> Archive and deregister branch")
|
||||
console.print(" [green]sync-registry[/green] Repair registry against filesystem")
|
||||
console.print(" [green]sync-templates[/green] Pull managed files from source")
|
||||
@@ -60,7 +61,8 @@ def print_help():
|
||||
warning("--purpose", details="Agent purpose (brief)")
|
||||
warning("--template", details="Template class name (builder, birthright) or custom directory path")
|
||||
warning("--registry", details="Path to AIPASS_REGISTRY.json")
|
||||
warning("--dry-run", details="Preview changes without modifying files")
|
||||
warning("--apply", details="Execute changes (update/repair are preview-only by default)")
|
||||
warning("--dry-run", details="Preview changes without modifying files (default for update/repair)")
|
||||
warning("--trace", details="Enable verbose logging")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"last_updated": "2026-05-22",
|
||||
"last_updated": "2026-06-06",
|
||||
"description": "Template file tracking registry for ID-based updates"
|
||||
},
|
||||
"files": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
"""Tests for spawn handler modules: meta_ops, reconcile, change_detection, json_ops."""
|
||||
"""Tests for spawn handler modules: meta_ops, json_ops."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
@@ -313,303 +313,6 @@ class TestDeepMerge:
|
||||
assert result["name"] == "template_default"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# detect_changes tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDetectChanges:
|
||||
"""Tests for detect_changes()."""
|
||||
|
||||
def test_detects_additions(self, tmp_path):
|
||||
"""Files in template but not in branch should be additions."""
|
||||
from aipass.spawn.apps.handlers.change_detection import detect_changes
|
||||
|
||||
template_registry = {
|
||||
"files": {
|
||||
"f001": {"current_name": "old.txt", "path": "old.txt", "content_hash": "aaa111"},
|
||||
"f002": {"current_name": "new.txt", "path": "new.txt", "content_hash": "bbb222"},
|
||||
},
|
||||
"directories": {},
|
||||
}
|
||||
|
||||
branch_meta = {
|
||||
"file_tracking": {
|
||||
"f001": {
|
||||
"template_name": "old.txt",
|
||||
"current_name": "old.txt",
|
||||
"current_path": "old.txt",
|
||||
"content_hash": "aaa111",
|
||||
},
|
||||
},
|
||||
"directory_tracking": {},
|
||||
}
|
||||
|
||||
result = detect_changes(template_registry, branch_meta, tmp_path)
|
||||
|
||||
assert len(result["additions"]) == 1
|
||||
assert result["additions"][0]["file_id"] == "f002"
|
||||
assert result["additions"][0]["template_path"] == "new.txt"
|
||||
|
||||
def test_detects_pruned(self, tmp_path):
|
||||
"""Files in branch but not in template should be pruned."""
|
||||
from aipass.spawn.apps.handlers.change_detection import detect_changes
|
||||
|
||||
template_registry = {
|
||||
"files": {
|
||||
"f001": {"current_name": "kept.txt", "path": "kept.txt", "content_hash": "aaa"},
|
||||
},
|
||||
"directories": {},
|
||||
}
|
||||
|
||||
branch_meta = {
|
||||
"file_tracking": {
|
||||
"f001": {
|
||||
"template_name": "kept.txt",
|
||||
"current_name": "kept.txt",
|
||||
"current_path": "kept.txt",
|
||||
"content_hash": "aaa",
|
||||
},
|
||||
"f099": {
|
||||
"template_name": "removed.txt",
|
||||
"current_name": "removed.txt",
|
||||
"current_path": "removed.txt",
|
||||
"content_hash": "zzz",
|
||||
},
|
||||
},
|
||||
"directory_tracking": {},
|
||||
}
|
||||
|
||||
result = detect_changes(template_registry, branch_meta, tmp_path)
|
||||
|
||||
assert len(result["pruned"]) == 1
|
||||
assert result["pruned"][0]["file_id"] == "f099"
|
||||
|
||||
def test_detects_updates(self, tmp_path):
|
||||
"""Files with same ID but different hashes should be updates."""
|
||||
from aipass.spawn.apps.handlers.change_detection import detect_changes
|
||||
|
||||
template_registry = {
|
||||
"files": {
|
||||
"f001": {"current_name": "config.json", "path": "config.json", "content_hash": "newhash12345"},
|
||||
},
|
||||
"directories": {},
|
||||
}
|
||||
|
||||
branch_meta = {
|
||||
"file_tracking": {
|
||||
"f001": {
|
||||
"template_name": "config.json",
|
||||
"current_name": "config.json",
|
||||
"current_path": "config.json",
|
||||
"content_hash": "oldhash67890",
|
||||
},
|
||||
},
|
||||
"directory_tracking": {},
|
||||
}
|
||||
|
||||
result = detect_changes(template_registry, branch_meta, tmp_path)
|
||||
|
||||
assert len(result["updates"]) == 1
|
||||
assert result["updates"][0]["file_id"] == "f001"
|
||||
assert result["updates"][0]["template_hash"] == "newhash12345"
|
||||
assert result["updates"][0]["branch_hash"] == "oldhash67890"
|
||||
|
||||
def test_detects_renames(self, tmp_path):
|
||||
"""Files with same ID but different template paths should be renames."""
|
||||
from aipass.spawn.apps.handlers.change_detection import detect_changes
|
||||
|
||||
template_registry = {
|
||||
"files": {
|
||||
"f001": {
|
||||
"current_name": "new_name.txt",
|
||||
"path": "docs/new_name.txt",
|
||||
"content_hash": "samehash1234",
|
||||
},
|
||||
},
|
||||
"directories": {},
|
||||
}
|
||||
|
||||
branch_meta = {
|
||||
"file_tracking": {
|
||||
"f001": {
|
||||
"template_name": "docs/old_name.txt",
|
||||
"current_name": "old_name.txt",
|
||||
"current_path": "docs/old_name.txt",
|
||||
"content_hash": "samehash1234",
|
||||
},
|
||||
},
|
||||
"directory_tracking": {},
|
||||
}
|
||||
|
||||
result = detect_changes(template_registry, branch_meta, tmp_path)
|
||||
|
||||
assert len(result["renames"]) == 1
|
||||
assert result["renames"][0]["file_id"] == "f001"
|
||||
assert result["renames"][0]["old_name"] == "docs/old_name.txt"
|
||||
assert result["renames"][0]["new_name"] == "docs/new_name.txt"
|
||||
|
||||
def test_no_changes(self, tmp_path):
|
||||
"""Identical template and branch should produce no changes."""
|
||||
from aipass.spawn.apps.handlers.change_detection import detect_changes
|
||||
|
||||
template_registry = {
|
||||
"files": {
|
||||
"f001": {"current_name": "a.txt", "path": "a.txt", "content_hash": "abc123"},
|
||||
},
|
||||
"directories": {
|
||||
"d001": {"current_name": "apps", "path": "apps"},
|
||||
},
|
||||
}
|
||||
|
||||
branch_meta = {
|
||||
"file_tracking": {
|
||||
"f001": {
|
||||
"template_name": "a.txt",
|
||||
"current_name": "a.txt",
|
||||
"current_path": "a.txt",
|
||||
"content_hash": "abc123",
|
||||
},
|
||||
},
|
||||
"directory_tracking": {
|
||||
"d001": {
|
||||
"template_name": "apps",
|
||||
"current_name": "apps",
|
||||
"current_path": "apps",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result = detect_changes(template_registry, branch_meta, tmp_path)
|
||||
|
||||
assert result["additions"] == []
|
||||
assert result["updates"] == []
|
||||
assert result["renames"] == []
|
||||
assert result["pruned"] == []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# reconcile_branch_state tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestReconcileBranchState:
|
||||
"""Tests for reconcile_branch_state()."""
|
||||
|
||||
def test_detects_missing_files(self, tmp_path):
|
||||
"""Files tracked in meta but not on disk should be reported as missing."""
|
||||
from aipass.spawn.apps.handlers.reconcile import reconcile_branch_state
|
||||
|
||||
# Branch with no actual files
|
||||
branch_dir = tmp_path / "branch"
|
||||
branch_dir.mkdir()
|
||||
|
||||
branch_meta = {
|
||||
"file_tracking": {
|
||||
"f001": {
|
||||
"template_name": "README.md",
|
||||
"current_name": "README.md",
|
||||
"current_path": "README.md",
|
||||
"content_hash": "abc123def456",
|
||||
},
|
||||
},
|
||||
"directory_tracking": {},
|
||||
}
|
||||
|
||||
result = reconcile_branch_state(branch_dir, branch_meta)
|
||||
|
||||
assert result["needs_update"] is True
|
||||
assert len(result["missing_files"]) == 1
|
||||
assert result["missing_files"][0]["file_id"] == "f001"
|
||||
assert result["missing_files"][0]["path"] == "README.md"
|
||||
|
||||
def test_detects_hash_mismatches(self, tmp_path):
|
||||
"""Files with changed content should be reported as hash mismatches."""
|
||||
from aipass.spawn.apps.handlers.reconcile import reconcile_branch_state
|
||||
from aipass.spawn.apps.handlers.meta_ops import compute_file_hash
|
||||
|
||||
branch_dir = tmp_path / "branch"
|
||||
branch_dir.mkdir()
|
||||
|
||||
# Create file with known content
|
||||
readme = branch_dir / "README.md"
|
||||
readme.write_text("modified content", encoding="utf-8")
|
||||
actual_hash = compute_file_hash(readme)
|
||||
|
||||
branch_meta = {
|
||||
"file_tracking": {
|
||||
"f001": {
|
||||
"template_name": "README.md",
|
||||
"current_name": "README.md",
|
||||
"current_path": "README.md",
|
||||
"content_hash": "differenthash", # stale hash
|
||||
},
|
||||
},
|
||||
"directory_tracking": {},
|
||||
}
|
||||
|
||||
result = reconcile_branch_state(branch_dir, branch_meta)
|
||||
|
||||
assert result["needs_update"] is True
|
||||
assert len(result["hash_mismatches"]) == 1
|
||||
assert result["hash_mismatches"][0]["file_id"] == "f001"
|
||||
assert result["hash_mismatches"][0]["current_hash"] == actual_hash
|
||||
|
||||
def test_clean_state_no_update_needed(self, tmp_path):
|
||||
"""Consistent state should report needs_update=False."""
|
||||
from aipass.spawn.apps.handlers.reconcile import reconcile_branch_state
|
||||
from aipass.spawn.apps.handlers.meta_ops import compute_file_hash
|
||||
|
||||
branch_dir = tmp_path / "branch"
|
||||
branch_dir.mkdir()
|
||||
|
||||
readme = branch_dir / "README.md"
|
||||
readme.write_text("stable content", encoding="utf-8")
|
||||
actual_hash = compute_file_hash(readme)
|
||||
|
||||
branch_meta = {
|
||||
"file_tracking": {
|
||||
"f001": {
|
||||
"template_name": "README.md",
|
||||
"current_name": "README.md",
|
||||
"current_path": "README.md",
|
||||
"content_hash": actual_hash,
|
||||
},
|
||||
},
|
||||
"directory_tracking": {},
|
||||
}
|
||||
|
||||
result = reconcile_branch_state(branch_dir, branch_meta)
|
||||
|
||||
assert result["needs_update"] is False
|
||||
assert result["missing_files"] == []
|
||||
assert result["hash_mismatches"] == []
|
||||
|
||||
def test_detects_missing_directories(self, tmp_path):
|
||||
"""Tracked directories missing from disk should be reported."""
|
||||
from aipass.spawn.apps.handlers.reconcile import reconcile_branch_state
|
||||
|
||||
branch_dir = tmp_path / "branch"
|
||||
branch_dir.mkdir()
|
||||
|
||||
branch_meta = {
|
||||
"file_tracking": {},
|
||||
"directory_tracking": {
|
||||
"d001": {
|
||||
"template_name": "apps",
|
||||
"current_name": "apps",
|
||||
"current_path": "apps",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result = reconcile_branch_state(branch_dir, branch_meta)
|
||||
|
||||
assert result["needs_update"] is True
|
||||
assert len(result["missing_dirs"]) == 1
|
||||
assert result["missing_dirs"][0]["dir_id"] == "d001"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# backup_json tests
|
||||
# =============================================================================
|
||||
|
||||
@@ -133,6 +133,7 @@ def branch_dir(tmp_path):
|
||||
(branch / "tests" / "__init__.py").write_text("")
|
||||
(branch / ".archive").mkdir()
|
||||
(branch / "docs").mkdir()
|
||||
(branch / ".spawn").mkdir()
|
||||
|
||||
return branch
|
||||
|
||||
@@ -346,34 +347,13 @@ class TestUpdateBranch:
|
||||
assert "{{branchname}}" not in content
|
||||
assert "test_branch" in content
|
||||
|
||||
def test_pruned_files_archived(self, tmp_path, template_dir, branch_dir, mock_registry):
|
||||
"""Files removed from template should be archived, not deleted."""
|
||||
def test_extra_files_not_pruned(self, tmp_path, template_dir, branch_dir, mock_registry):
|
||||
"""P1 engine never prunes — extra branch files are left untouched."""
|
||||
from aipass.spawn.apps.handlers.update_ops import update_branch
|
||||
|
||||
# Create a file in the branch that's tracked in branch_meta but NOT in template
|
||||
extra_file = branch_dir / "old_config.json"
|
||||
extra_file.write_text(json.dumps({"old": True}))
|
||||
|
||||
# Create branch_meta that tracks this file
|
||||
spawn_dir = branch_dir / ".spawn"
|
||||
spawn_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Generate branch meta, then add the extra file tracking manually
|
||||
from aipass.spawn.apps.handlers.meta_ops import generate_branch_meta, save_branch_meta, compute_file_hash
|
||||
|
||||
reg_path = template_dir / ".spawn" / ".template_registry.json"
|
||||
template_registry = json.loads(reg_path.read_text())
|
||||
|
||||
meta = generate_branch_meta(branch_dir, template_registry)
|
||||
# Add the extra file with a unique ID that's NOT in the template
|
||||
meta["file_tracking"]["f_extra"] = {
|
||||
"template_name": "old_config.json",
|
||||
"current_name": "old_config.json",
|
||||
"current_path": "old_config.json",
|
||||
"content_hash": compute_file_hash(extra_file),
|
||||
}
|
||||
save_branch_meta(branch_dir, meta)
|
||||
|
||||
with (
|
||||
patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir),
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry),
|
||||
@@ -381,15 +361,86 @@ class TestUpdateBranch:
|
||||
result = update_branch("test_branch")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["pruned"] >= 1
|
||||
assert result["pruned"] == 0
|
||||
assert extra_file.exists()
|
||||
|
||||
# Original file should be gone from its original location
|
||||
assert not extra_file.exists()
|
||||
|
||||
# Should be in .archive/
|
||||
archive_dir = branch_dir / ".archive"
|
||||
archived_files = list(archive_dir.glob("old_config.json*"))
|
||||
assert len(archived_files) >= 1
|
||||
class TestNeverUpdateGuard:
|
||||
"""Tests for create-only file protection (P1 engine, TDPLAN-0006)."""
|
||||
|
||||
def test_trinity_files_never_touched(self, tmp_path, template_dir, branch_dir, mock_registry):
|
||||
"""Update must never modify .trinity/ files even when template has them."""
|
||||
from aipass.spawn.apps.handlers.update_ops import update_branch
|
||||
|
||||
# Add .trinity/ to template
|
||||
trinity_tpl = template_dir / ".trinity"
|
||||
trinity_tpl.mkdir(exist_ok=True)
|
||||
(trinity_tpl / "passport.json").write_text('{"identity": {"role": "template"}}')
|
||||
(trinity_tpl / "local.json").write_text('{"sessions": []}')
|
||||
|
||||
# Add .trinity/ to branch with different content
|
||||
trinity_branch = branch_dir / ".trinity"
|
||||
trinity_branch.mkdir(exist_ok=True)
|
||||
(trinity_branch / "passport.json").write_text('{"identity": {"role": "real_agent"}}')
|
||||
(trinity_branch / "local.json").write_text('{"sessions": [{"id": 1}]}')
|
||||
|
||||
passport_before = (trinity_branch / "passport.json").read_text()
|
||||
local_before = (trinity_branch / "local.json").read_text()
|
||||
|
||||
with (
|
||||
patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir),
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry),
|
||||
):
|
||||
result = update_branch("test_branch")
|
||||
|
||||
assert result["success"] is True
|
||||
assert (trinity_branch / "passport.json").read_text() == passport_before
|
||||
assert (trinity_branch / "local.json").read_text() == local_before
|
||||
|
||||
def test_dashboard_never_touched(self, tmp_path, template_dir, branch_dir, mock_registry):
|
||||
"""Update must never modify DASHBOARD.local.json even when template differs."""
|
||||
from aipass.spawn.apps.handlers.update_ops import update_branch
|
||||
|
||||
dashboard = branch_dir / "DASHBOARD.local.json"
|
||||
dashboard_before = dashboard.read_text()
|
||||
|
||||
with (
|
||||
patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir),
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry),
|
||||
):
|
||||
result = update_branch("test_branch")
|
||||
|
||||
assert result["success"] is True
|
||||
assert dashboard.read_text() == dashboard_before
|
||||
|
||||
def test_zero_renames_always(self, tmp_path, template_dir, branch_dir, mock_registry):
|
||||
"""P1 engine never proposes renames."""
|
||||
from aipass.spawn.apps.handlers.update_ops import update_branch
|
||||
|
||||
with (
|
||||
patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir),
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry),
|
||||
):
|
||||
result = update_branch("test_branch", dry_run=True)
|
||||
|
||||
assert result["renames"] == 0
|
||||
assert result.get("_renames_detail", []) == []
|
||||
|
||||
def test_create_update_invariant(self, tmp_path, template_dir, branch_dir, mock_registry):
|
||||
"""Fresh branch from template should show 0 changes on update."""
|
||||
from aipass.spawn.apps.handlers.update_ops import update_branch
|
||||
|
||||
with (
|
||||
patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir),
|
||||
patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry),
|
||||
):
|
||||
result = update_branch("test_branch", dry_run=True)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["additions"] == 0
|
||||
assert result["renames"] == 0
|
||||
assert result["updates"] == 0
|
||||
assert result["pruned"] == 0
|
||||
|
||||
|
||||
class TestUpdateAll:
|
||||
|
||||
Reference in New Issue
Block a user