diff --git a/README.md b/README.md index d0d2834..654319c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Developer control plane CLI — sync and manage developer tooling configurations - **Self-updating binary** — Single executable with automatic updates from GitHub releases - **Background config sync** — Optional hourly pull via launchd (macOS) or cron (Linux), with desktop notifications when updates land - **One-shot install** — Optional `install.sh` env vars to install devctl, run `ai-kit setup`, and register background sync in one run -- **Backup before overwrite** — Snapshots targets before applying changes; old backups are pruned automatically (keeps last 3 per repo by default) +- **Backup before overwrite** — One run-level snapshot of protocol targets before applying; restores that snapshot if apply fails; old run backups are pruned (keeps last 3 runs per repo by default) ## High-Level Design (HLD) @@ -47,9 +47,9 @@ Developer control plane CLI — sync and manage developer tooling configurations | **CLI** | ai-kit | setup, sync, update, install/uninstall-background-sync, status, doctor | | **CLI** | devspace / local | Domain stubs (planned) | | **Core** | repo_manager | Clone/pull Git repos, URL → slug | -| **Core** | protocol_engine | Parse `protocol.yaml`, execute file_sync, etc. | +| **Core** | protocol_engine | Parse `protocol.yaml`, execute `file_sync` / `symlink_sync`, etc. | | **Core** | versioning | Persist repo metadata in `state.json` | -| **Core** | backup | Snapshot target before overwrite | +| **Core** | backup | One run-level snapshot before apply; restore on failure; prune old runs | | **Core** | updater | Self-update binary from GitHub releases | | **Core** | config_sync | Check managed repos for new pushes, pull and re-apply, notify | | **Core** | background_sync | Install/uninstall launchd (macOS) or cron (Linux) for hourly sync | @@ -65,8 +65,9 @@ User: devctl ai-kit setup --repo https://github.com/org/configs │ │ │ │ │ ├──► repo_manager.clone_or_pull() │ │ ├──► protocol_engine.apply_protocols() - │ │ │ ├──► backup.backup_target() - │ │ │ └──► file_sync (merge copy) +│ │ │ ├──► backup.backup_apply_run() (once) +│ │ │ ├──► file_sync (merge copy) + │ │ │ └──► symlink_sync (shared skills/commands) │ │ └──► versioning.register_repo() │ │ │ └── check wi-devctl releases @@ -161,7 +162,10 @@ protocols: source: .cursor target: ~/.cursor obligations: [rules/security.json] - recommendations: [skills/debugging.md] + - name: cursor-skills + type: symlink_sync + source: .common/skills + target: ~/.cursor/skills ``` 2. Run setup: @@ -176,18 +180,19 @@ devctl ai-kit setup --repo https://github.com/your-org/ai-configs devctl ai-kit install-background-sync ``` -4. Repo is cloned to `~/.devctl/repos/`, configs are merged into `~/.cursor`. +4. Repo is cloned to `~/.devctl/repos/`. Vendor config is merge-copied (`file_sync`); shared skills/commands are symlinked (`symlink_sync`). ## Protocol Reference | Field | Description | |-------|-------------| +| `type` | `file_sync` (merge copy) or `symlink_sync` (directory symlink to source) | | `source` | Path in repo (relative to root) | | `target` | Local path (`~` expanded) | | `obligations` | Required files under target (reported if missing) | | `recommendations` | Optional files (reported if missing) | -`protocol.yaml` or `protocol.yml` must live at the **root** of the repo you sync from. +Declare `file_sync` entries before `symlink_sync` entries. `protocol.yaml` or `protocol.yml` must live at the **root** of the repo you sync from. ## Domains & Use Cases @@ -201,11 +206,11 @@ A **domain** is a grouped set of CLI commands for a specific use case. Each doma ### How use cases work -All domains share the same flow: clone repo → parse `protocol.yaml` → apply protocols → track state. The protocol engine supports multiple types (currently `file_sync`; extensible to `env_sync`, `script_run`, etc.). Domain-specific logic sits on top of this core. +All domains share the same flow: clone repo → parse `protocol.yaml` → apply protocols → track state. The protocol engine supports `file_sync` and `symlink_sync` (extensible to `env_sync`, `script_run`, etc.). Domain-specific logic sits on top of this core. | Use case | Domain | What it does | Example | |----------|--------|---------------|---------| -| **AI configs** | ai-kit | Sync `.cursor` rules/skills to `~/.cursor` | Cursor rules, agent skills | +| **AI configs** | ai-kit | Sync vendor config via `file_sync`; shared skills/commands via `symlink_sync` | Cursor rules, shared agent skills | | **Dev environments** | devspace | Define containers/VMs, start Docker/Podman | Dev containers, Colima setup | | **Local tooling** | local | Sync env vars, run setup scripts | `.env` files, dev daemons | | **Security** | (new domain) | Scan for secrets, enforce policies | Pre-commit hooks, policy configs | @@ -232,7 +237,7 @@ Fork the repo and add domains for your org — no hardcoded URLs; each domain wo | `DEVCTL_UPDATE_CHECK_INTERVAL_HOURS` | Hours between auto-update checks (default: 24) | | `DEVCTL_CONFIG_SYNC_INTERVAL_MINUTES` | Minutes between ai-kit config sync rate-limit checks; overrides `DEVCTL_CONFIG_SYNC_INTERVAL_HOURS` when set (fractional ok) | | `DEVCTL_CONFIG_SYNC_INTERVAL_HOURS` | Hours between config sync checks when minutes unset (default: 1; fractional ok) | -| `DEVCTL_BACKUP_RETENTION_COUNT` | Number of config backups to keep per repo slug (default: 3). Set to `0` to disable pruning | +| `DEVCTL_BACKUP_RETENTION_COUNT` | Number of **run-level** config backups to keep per repo slug (default: 3). Set to `0` to disable pruning | | `DEVCTL_BACKUP_RETENTION_DISABLED` | Set to `1` to keep all backups (no automatic pruning) | | `DEVCTL_AI_KIT_REPO` | *(install.sh only)* If set, run `ai-kit setup` after binary install | | `DEVCTL_AI_KIT_BACKGROUND_SYNC` | *(install.sh only)* Set to `1` to run `install-background-sync` after setup | @@ -323,7 +328,7 @@ Run `pytest` from the repo root (uses `pythonpath = ["src"]` in `pyproject.toml` | Area | File | What it covers | |------|------|----------------| -| **Protocols** | `tests/test_protocol_engine.py` | Load YAML/YML, validation errors, `file_sync`, merge behavior, **obligations / recommendations** present vs missing, unknown type, missing source, `apply_protocols` across multiple protocols | +| **Protocols** | `tests/test_protocol_engine.py` | Load YAML/YML, validation errors, `file_sync`, `symlink_sync`, merge behavior, **obligations / recommendations** present vs missing, unknown type, missing source, `apply_protocols` across multiple protocols | | **Updater** | `tests/test_updater.py` | Manifest / version comparison, platform key shape, `perform_update` rate limit and force paths (no real download) | | **Config sync** | `tests/test_config_sync.py` | `perform_config_sync`: no repos, rate limit, pull + notify, skip bad path / no remote updates | | **CLI** | `tests/test_cli.py` | `list`, `ai-kit sync`, `update-cli`, `--version`, `devspace`/`local` help (`DEVCTL_SKIP_AUTO_UPDATE=1`, isolated `HOME`) | @@ -331,7 +336,7 @@ Run `pytest` from the repo root (uses `pythonpath = ["src"]` in `pyproject.toml` | **Background install** | `tests/test_background_sync.py` | launchd plist write (mocked `launchctl`), missing binary, uninstall when absent | | **Notifications** | `tests/test_notify.py` | `DEVCTL_SKIP_NOTIFY`, macOS `osascript` path | | **Repos** | `tests/test_repo_manager.py` | URL → slug, `fetch_and_has_updates` (mocked git) | -| **Backups** | `tests/test_backup.py` | Backup snapshots, retention pruning, dry-run | +| **Backups** | `tests/test_backup.py` | Run-level snapshots, restore, retention pruning, dry-run | | **SSL** | `tests/test_ssl_certs.py` | certifi CA bundle configuration for HTTPS | End-to-end **git clone**, **auto-update binary replace**, and **real launchd/cron** are not run in CI (use a manual machine or staging for those). diff --git a/examples/protocol.yaml b/examples/protocol.yaml index 89462e7..e61a709 100644 --- a/examples/protocol.yaml +++ b/examples/protocol.yaml @@ -2,6 +2,11 @@ # Place this file in the ROOT of the repo you want to sync from. # Then run: devctl ai-kit setup --repo # Repo URL can be HTTPS (https://github.com/org/repo) or SSH (git@github.com:org/repo.git) +# +# Protocol types: +# file_sync — merge-copy vendor-specific config into the target directory +# symlink_sync — ensure target is a symlink to source (shared skills/commands) +# Declare all file_sync entries before symlink_sync entries. version: v1 @@ -17,3 +22,12 @@ protocols: recommendations: - skills/debugging.md + - name: cursor-skills + type: symlink_sync + source: .common/skills + target: ~/.cursor/skills + + - name: cursor-commands + type: symlink_sync + source: .common/commands + target: ~/.cursor/commands diff --git a/src/devctl/cli/ai_kit.py b/src/devctl/cli/ai_kit.py index 348a7ed..5e67801 100644 --- a/src/devctl/cli/ai_kit.py +++ b/src/devctl/cli/ai_kit.py @@ -5,7 +5,7 @@ import click -from devctl.core.protocol_engine import apply_protocols, load_protocols +from devctl.core.protocol_engine import apply_protocols, check_symlink_integrity, load_protocols from devctl.core.repo_manager import clone_or_pull, get_repo_path, url_to_slug from devctl.core.versioning import list_repos, register_repo from devctl.utils.logging import log_verbose @@ -217,8 +217,11 @@ def status(repo_url: str | None) -> None: target = expand_path(p.target) missing_obl = [target / r for r in p.obligations if not (target / r).exists()] missing_rec = [target / r for r in p.recommendations if not (target / r).exists()] - if missing_obl or missing_rec: + link_issue = check_symlink_integrity(p, path) + if missing_obl or missing_rec or link_issue: click.echo(f" protocol {p.name}:") + if link_issue: + click.echo(f" symlink: {link_issue}") if missing_obl: click.echo(f" drift (obligations): {[str(x) for x in missing_obl]}") if missing_rec: @@ -261,6 +264,11 @@ def doctor(repo_url: str | None) -> None: log_verbose(f"Validating {slug}") _, protocols = load_protocols(path) for p in protocols: + link_issue = check_symlink_integrity(p, path) + if link_issue: + click.echo(f"{slug}: {link_issue}") + click.echo(f" Fix: run 'devctl ai-kit update --repo {info.get('url', '?')}'") + issues += 1 target = expand_path(p.target) for r in p.obligations: full = target / r diff --git a/src/devctl/core/backup.py b/src/devctl/core/backup.py index c7db9e2..d59db03 100644 --- a/src/devctl/core/backup.py +++ b/src/devctl/core/backup.py @@ -1,17 +1,27 @@ -"""Backup before overwrite.""" +"""Backup before overwrite — one snapshot per apply run.""" +from __future__ import annotations + +import json import os import re import shutil import time from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import UTC, datetime from pathlib import Path from devctl.utils.logging import ProgressBar, log_status, log_verbose from devctl.utils.shell import get_backups_dir +_RUN_NAME = re.compile(r"^(.+)-run-(\d{8}T\d+Z)$") +# Legacy per-protocol backups: - without "-run-" +_LEGACY_BACKUP_NAME = re.compile(r"^(.+)-(\d{8}T\d+Z)$") +_DEFAULT_RETENTION_COUNT = 3 +_MANIFEST_NAME = "manifest.json" +_SYMLINK_META = "SYMLINK_TARGET" + def _format_size(size_bytes: int) -> str: """Format bytes as human-readable size.""" @@ -23,10 +33,12 @@ def _format_size(size_bytes: int) -> str: def _get_dir_size(path: Path) -> int: - """Get total size of directory in bytes.""" + """Get total size of directory in bytes (follows symlinks for size estimate).""" total = 0 try: for entry in path.rglob("*"): + if entry.is_symlink(): + continue if entry.is_file(): total += entry.stat().st_size except (OSError, PermissionError): @@ -38,6 +50,8 @@ def _copytree_with_progress( src: Path, dst: Path, total_size: int, + *, + symlinks: bool = False, ) -> None: """Copy directory tree with progress bar.""" progress = ProgressBar(total_size, desc="Copying") @@ -51,11 +65,18 @@ def copy_with_progress(src_file: str, dst_file: str) -> None: except OSError: pass - shutil.copytree(src, dst, copy_function=copy_with_progress) + shutil.copytree( + src, + dst, + symlinks=symlinks, + copy_function=copy_with_progress, + ) progress.finish() -_BACKUP_TIMESTAMP_SUFFIX = re.compile(r"^(.+)-(\d{8}T\d{6}Z)$") -_DEFAULT_RETENTION_COUNT = 3 + +def _backup_timestamp() -> str: + """UTC timestamp with second precision (YYYYMMDDTHHMMSSZ).""" + return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") def _backup_retention_count(env: Mapping[str, str] | None = None) -> int | None: @@ -75,23 +96,213 @@ def _backup_retention_count(env: Mapping[str, str] | None = None) -> int | None: return count -def _parse_backup_name(name: str) -> tuple[str, str] | None: - """Parse '-' backup directory names.""" - match = _BACKUP_TIMESTAMP_SUFFIX.match(name) +def _parse_run_backup_name(name: str) -> tuple[str, str] | None: + """Parse '-run-' backup directory names. + + Also accepts older names that included microseconds in the timestamp. + """ + match = _RUN_NAME.match(name) if not match: return None return match.group(1), match.group(2) +def _parse_legacy_backup_name(name: str) -> tuple[str, str] | None: + """Parse legacy '-' names (not run backups).""" + if "-run-" in name: + return None + match = _LEGACY_BACKUP_NAME.match(name) + if not match: + return None + return match.group(1), match.group(2) + + +def _parse_backup_name(name: str) -> tuple[str, str] | None: + """Parse run or legacy backup directory names. Prefer run format.""" + parsed = _parse_run_backup_name(name) + if parsed: + return parsed + return _parse_legacy_backup_name(name) + + +def _path_is_under(child: Path, parent: Path) -> bool: + """Return True if child is the same as parent or a descendant.""" + try: + child.resolve().relative_to(parent.resolve()) + return True + except (ValueError, OSError): + return False + + +def dedupe_backup_targets(targets: Sequence[Path]) -> list[Path]: + """Drop targets nested under another target (ancestor covers children).""" + # Resolve for comparison; keep original Path objects for existence checks + unique: list[Path] = [] + seen: set[str] = set() + for t in targets: + key = str(t) + if key in seen: + continue + seen.add(key) + unique.append(t) + + # Prefer shorter / ancestor paths: sort by path string length then lexicographically + ordered = sorted(unique, key=lambda p: (len(str(p.resolve())), str(p.resolve()))) + kept: list[Path] = [] + for path in ordered: + if any(_path_is_under(path, ancestor) for ancestor in kept): + continue + kept.append(path) + return kept + + +def _remove_path(path: Path) -> None: + """Remove a file, symlink, or directory tree at path.""" + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + + +def _write_entry(target_path: Path, entry_dir: Path) -> str: + """Snapshot one target into entry_dir. Returns kind: dir|file|symlink.""" + entry_dir.mkdir(parents=True, exist_ok=True) + if target_path.is_symlink(): + link_dest = os.readlink(target_path) + (entry_dir / _SYMLINK_META).write_text(link_dest, encoding="utf-8") + return "symlink" + if target_path.is_dir(): + size = _get_dir_size(target_path) + # Copy symlink nodes as links so broken nested links do not fail the run snapshot + content = entry_dir / "content" + _copytree_with_progress(target_path, content, size, symlinks=True) + return "dir" + shutil.copy2(target_path, entry_dir / target_path.name) + return "file" + + +def backup_apply_run(targets: Sequence[Path], slug: str) -> Path | None: + """Snapshot unique protocol targets once into ~/.devctl/backups/-run-/. + + Returns the run backup directory, or None if nothing existed to snapshot. + Nested targets under an ancestor path are skipped (ancestor covers them). + """ + to_backup = [ + t + for t in dedupe_backup_targets(list(targets)) + if t.exists() or t.is_symlink() + ] + if not to_backup: + return None + + backups_dir = get_backups_dir() + backups_dir.mkdir(parents=True, exist_ok=True) + # Second-precision names: if a run already exists this second, wait for the next. + run_dir: Path | None = None + for _ in range(3): + timestamp = _backup_timestamp() + candidate = backups_dir / f"{slug}-run-{timestamp}" + try: + candidate.mkdir(parents=True, exist_ok=False) + run_dir = candidate + break + except FileExistsError: + time.sleep(1) + if run_dir is None: + raise RuntimeError(f"Could not create unique run backup dir for slug {slug}") + + log_status(f"Run backup: snapshotting {len(to_backup)} target(s)...") + start = time.monotonic() + manifest: list[dict[str, str]] = [] + + for i, target_path in enumerate(to_backup): + entry_id = str(i) + entry_dir = run_dir / entry_id + log_verbose(f"Run backup entry {entry_id}: {target_path}") + kind = _write_entry(target_path, entry_dir) + manifest.append( + { + "path": str(target_path), + "entry": entry_id, + "kind": kind, + } + ) + + (run_dir / _MANIFEST_NAME).write_text( + json.dumps({"targets": manifest}, indent=2) + "\n", + encoding="utf-8", + ) + elapsed = time.monotonic() - start + log_status(f"Run backup complete ({elapsed:.1f}s) → {run_dir.name}") + log_verbose(f"Run backup saved to {run_dir}") + return run_dir + + +def restore_apply_run(run_dir: Path) -> None: + """Restore all targets recorded in a run backup directory.""" + manifest_path = run_dir / _MANIFEST_NAME + if not manifest_path.exists(): + raise FileNotFoundError(f"Run backup manifest missing: {manifest_path}") + + data = json.loads(manifest_path.read_text(encoding="utf-8")) + targets = data.get("targets") or [] + if not isinstance(targets, list): + raise ValueError(f"Invalid run backup manifest: {manifest_path}") + + log_status(f"Restoring run backup {run_dir.name} ({len(targets)} target(s))...") + for item in targets: + if not isinstance(item, dict): + continue + path_str = item.get("path") + entry_id = item.get("entry") + kind = item.get("kind") + if not path_str or entry_id is None or not kind: + continue + target_path = Path(path_str) + entry_dir = run_dir / str(entry_id) + if not entry_dir.exists(): + log_verbose(f"Skipping missing entry {entry_id} for {target_path}") + continue + + if target_path.exists() or target_path.is_symlink(): + _remove_path(target_path) + + target_path.parent.mkdir(parents=True, exist_ok=True) + + if kind == "symlink": + link_dest = (entry_dir / _SYMLINK_META).read_text(encoding="utf-8") + target_path.symlink_to(link_dest) + elif kind == "dir": + content = entry_dir / "content" + if content.exists(): + shutil.copytree(content, target_path, symlinks=True) + else: + # Backward-compatible: entry dir is the tree itself + shutil.copytree(entry_dir, target_path, symlinks=True) + elif kind == "file": + files = [p for p in entry_dir.iterdir() if p.is_file()] + if not files: + raise FileNotFoundError(f"No file snapshot in {entry_dir}") + shutil.copy2(files[0], target_path) + else: + raise ValueError(f"Unknown backup kind {kind!r} for {target_path}") + + log_status("Restore complete") + + def prune_backups( slug: str | None = None, env: Mapping[str, str] | None = None, dry_run: bool = False, ) -> list[Path]: - """Delete old backups beyond the retention limit. + """Delete old run backups beyond the retention limit; remove legacy non-run backups. - Keeps the newest DEVCTL_BACKUP_RETENTION_COUNT backups per slug (default: 3). - Set DEVCTL_BACKUP_RETENTION_DISABLED=1 or DEVCTL_BACKUP_RETENTION_COUNT=0 to skip pruning. + Keeps the newest DEVCTL_BACKUP_RETENTION_COUNT **run** backups per slug (default: 3). + Legacy `-` folders (without `-run-`) for that slug are always pruned. + Set DEVCTL_BACKUP_RETENTION_DISABLED=1 or DEVCTL_BACKUP_RETENTION_COUNT=0 to skip + run retention pruning (legacy cleanup still runs when retention is enabled). Returns paths that were deleted (or would be deleted when dry_run=True). """ @@ -103,23 +314,38 @@ def prune_backups( if not backups_dir.exists(): return [] - by_slug: dict[str, list[tuple[str, Path]]] = defaultdict(list) + runs_by_slug: dict[str, list[tuple[str, Path]]] = defaultdict(list) + legacy: list[Path] = [] + for entry in backups_dir.iterdir(): if not entry.is_dir(): continue - parsed = _parse_backup_name(entry.name) - if not parsed: - continue - entry_slug, timestamp = parsed - if slug is not None and entry_slug != slug: + run_parsed = _parse_run_backup_name(entry.name) + if run_parsed: + entry_slug, timestamp = run_parsed + if slug is not None and entry_slug != slug: + continue + runs_by_slug[entry_slug].append((timestamp, entry)) continue - by_slug[entry_slug].append((timestamp, entry)) + legacy_parsed = _parse_legacy_backup_name(entry.name) + if legacy_parsed: + entry_slug, _ts = legacy_parsed + if slug is not None and entry_slug != slug: + continue + legacy.append(entry) deleted: list[Path] = [] - for _entry_slug, entries in by_slug.items(): + + for path in legacy: + log_verbose(f"Pruning legacy backup: {path}") + if not dry_run: + shutil.rmtree(path) + deleted.append(path) + + for _entry_slug, entries in runs_by_slug.items(): entries.sort(key=lambda item: item[0], reverse=True) for _, path in entries[retention:]: - log_verbose(f"Pruning old backup: {path}") + log_verbose(f"Pruning old run backup: {path}") if not dry_run: shutil.rmtree(path) deleted.append(path) @@ -128,31 +354,8 @@ def prune_backups( def backup_target(target_path: Path, slug: str) -> Path | None: - """Backup existing target to ~/.devctl/backups/-/. Returns backup path or None if nothing to backup.""" - if not target_path.exists(): - return None - - backups_dir = get_backups_dir() - backups_dir.mkdir(parents=True, exist_ok=True) + """Backup a single target via a one-entry run snapshot (compatibility helper). - timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") - backup_path = backups_dir / f"{slug}-{timestamp}" - - if target_path.is_dir(): - size = _get_dir_size(target_path) - size_str = _format_size(size) - log_status(f"Backing up {target_path} ({size_str})...") - start = time.monotonic() - _copytree_with_progress(target_path, backup_path, size) - elapsed = time.monotonic() - start - log_status(f"Backup complete ({elapsed:.1f}s) → {backup_path.name}") - else: - log_status(f"Backing up {target_path}...") - start = time.monotonic() - backup_path.mkdir(parents=True, exist_ok=True) - shutil.copy2(target_path, backup_path / target_path.name) - elapsed = time.monotonic() - start - log_status(f"Backup complete ({elapsed:.1f}s)") - - log_verbose(f"Backup saved to {backup_path}") - return backup_path + Prefer backup_apply_run for protocol applies. + """ + return backup_apply_run([target_path], slug) diff --git a/src/devctl/core/protocol_engine.py b/src/devctl/core/protocol_engine.py index 8b85c31..5719c03 100644 --- a/src/devctl/core/protocol_engine.py +++ b/src/devctl/core/protocol_engine.py @@ -1,12 +1,13 @@ """Protocol parsing and execution.""" +from __future__ import annotations + +import os +import shutil from dataclasses import dataclass from pathlib import Path -from typing import Any - -import yaml -from devctl.core.backup import backup_target, prune_backups +from devctl.core.backup import backup_apply_run, prune_backups, restore_apply_run from devctl.utils.logging import log_status, log_verbose from devctl.utils.shell import expand_path from devctl.utils.yaml_loader import load_yaml @@ -76,22 +77,59 @@ def load_protocols(repo_path: Path) -> tuple[str, list[Protocol]]: return version, protocols +def _remove_path(path: Path) -> None: + """Remove a file, symlink, or directory tree at path.""" + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + + +def _clear_conflicting_targets(source_path: Path, target_path: Path) -> None: + """Remove target paths that would block a merge copy (symlinks, type mismatches).""" + if source_path.is_file(): + if target_path.is_symlink() or (target_path.exists() and target_path.is_dir()): + log_verbose(f"Removing conflicting target before file sync: {target_path}") + _remove_path(target_path) + return + + if not source_path.is_dir(): + return + + for child in source_path.iterdir(): + dest = target_path / child.name + if not (dest.exists() or dest.is_symlink()): + continue + # Symlink at dest always conflicts with copying real content into that name + if dest.is_symlink(): + log_verbose(f"Removing symlink before file_sync merge: {dest}") + _remove_path(dest) + continue + # Source dir vs target file (or vice versa) + if child.is_dir() and dest.is_file(): + log_verbose(f"Removing file blocking directory sync: {dest}") + _remove_path(dest) + elif child.is_file() and dest.is_dir() and not dest.is_symlink(): + log_verbose(f"Removing directory blocking file sync: {dest}") + _remove_path(dest) + + def _file_sync( source_path: Path, target_path: Path, - slug: str, - do_backup: bool = True, ) -> tuple[list[str], list[str]]: - """Execute file_sync: merge source into target. Never deletes existing files/folders in target.""" - if do_backup and target_path.exists(): - log_verbose(f"Backing up existing {target_path}") - backup_target(target_path, slug) - + """Execute file_sync: merge source into target. Never deletes unrelated existing files.""" target_path.parent.mkdir(parents=True, exist_ok=True) - if source_path.is_dir(): - import shutil + if target_path.is_symlink(): + log_verbose(f"Removing symlink target before file_sync: {target_path}") + _remove_path(target_path) + + _clear_conflicting_targets(source_path, target_path) + if source_path.is_dir(): log_status(f"Syncing {source_path.name}/ → {target_path}") log_verbose(f"Merging directory {source_path} -> {target_path} (existing files preserved)") shutil.copytree(source_path, target_path, dirs_exist_ok=True) @@ -100,21 +138,80 @@ def _file_sync( log_status(f"Syncing {source_path.name} → {target_path}") log_verbose(f"Copying file {source_path} -> {target_path}") target_path.parent.mkdir(parents=True, exist_ok=True) - import shutil - shutil.copy2(source_path, target_path) log_status("Sync complete") return [], [] +def _symlink_points_to(target_path: Path, expected: Path) -> bool: + """Return True if target_path is a symlink whose destination resolves to expected.""" + if not target_path.is_symlink(): + return False + try: + return target_path.resolve() == expected.resolve() + except OSError: + return False + + +def _symlink_sync( + source_path: Path, + target_path: Path, +) -> tuple[list[str], list[str]]: + """Ensure target_path is a symlink to source_path (absolute). Idempotent; migrates real dirs.""" + expected = source_path.resolve() + + if _symlink_points_to(target_path, expected): + log_verbose(f"Symlink already correct: {target_path} -> {expected}") + log_status(f"Link ok {target_path.name}/ → {expected}") + return [], [] + + exists_or_link = target_path.exists() or target_path.is_symlink() + if exists_or_link: + log_verbose(f"Removing existing path at {target_path}") + _remove_path(target_path) + + target_path.parent.mkdir(parents=True, exist_ok=True) + log_status(f"Linking {target_path} → {expected}") + target_path.symlink_to(expected, target_is_directory=source_path.is_dir()) + log_status("Link complete") + return [], [] + + +def check_symlink_integrity( + protocol: Protocol, + repo_path: Path, +) -> str | None: + """For symlink_sync protocols, return an error message if the link is missing/wrong; else None.""" + if protocol.type != "symlink_sync": + return None + source_path = (repo_path / protocol.source).resolve() + target_path = expand_path(protocol.target) + if not target_path.is_symlink(): + if target_path.exists(): + return f"{target_path} exists but is not a symlink (expected -> {source_path})" + return f"{target_path} missing (expected symlink -> {source_path})" + if not _symlink_points_to(target_path, source_path): + try: + current = os.readlink(target_path) + except OSError: + current = "(unreadable)" + return f"{target_path} points to {current}, expected {source_path}" + return None + + def execute_protocol( protocol: Protocol, repo_path: Path, slug: str, do_backup: bool = True, ) -> tuple[list[str], list[str]]: - """Execute a single protocol. Returns (missing_obligations, missing_recommendations).""" + """Execute a single protocol. Returns (missing_obligations, missing_recommendations). + + do_backup is accepted for API compatibility but ignored: apply_protocols takes a + single run-level snapshot before executing protocols. + """ + del slug, do_backup # run-level backup owns snapshots source_path = (repo_path / protocol.source).resolve() target_path = expand_path(protocol.target) @@ -122,7 +219,9 @@ def execute_protocol( raise FileNotFoundError(f"Source not found: {source_path}") if protocol.type == "file_sync": - missing_obl, missing_rec = _file_sync(source_path, target_path, slug, do_backup) + missing_obl, missing_rec = _file_sync(source_path, target_path) + elif protocol.type == "symlink_sync": + missing_obl, missing_rec = _symlink_sync(source_path, target_path) else: raise ValueError(f"Unknown protocol type: {protocol.type}") @@ -146,19 +245,39 @@ def apply_protocols( slug: str, do_backup: bool = True, ) -> tuple[str, list[Protocol], list[str], list[str]]: - """Load and apply all protocols. Returns (version, protocols, missing_obligations, missing_recommendations).""" + """Load and apply all protocols. Returns (version, protocols, missing_obligations, missing_recommendations). + + When do_backup is True, takes one run-level snapshot of all protocol targets before + applying. On failure, restores that snapshot and re-raises. On success, prunes old runs. + """ version, protocols = load_protocols(repo_path) log_status(f"Applying {len(protocols)} protocol(s)...") log_verbose(f"Applying {len(protocols)} protocol(s)") all_missing_obl: list[str] = [] all_missing_rec: list[str] = [] - for i, protocol in enumerate(protocols, 1): - log_status(f"[{i}/{len(protocols)}] Protocol '{protocol.name}' ({protocol.type})") - log_verbose(f"Executing protocol '{protocol.name}' ({protocol.type}): {protocol.source} -> {protocol.target}") - obl, rec = execute_protocol(protocol, repo_path, slug, do_backup) - all_missing_obl.extend(obl) - all_missing_rec.extend(rec) + run_dir = None + if do_backup: + targets = [expand_path(p.target) for p in protocols] + run_dir = backup_apply_run(targets, slug) + + try: + for i, protocol in enumerate(protocols, 1): + log_status(f"[{i}/{len(protocols)}] Protocol '{protocol.name}' ({protocol.type})") + log_verbose( + f"Executing protocol '{protocol.name}' ({protocol.type}): " + f"{protocol.source} -> {protocol.target}" + ) + obl, rec = execute_protocol(protocol, repo_path, slug, do_backup=False) + all_missing_obl.extend(obl) + all_missing_rec.extend(rec) + except Exception: + if run_dir is not None: + try: + restore_apply_run(run_dir) + except Exception as restore_err: + log_status(f"Restore after failure also failed: {restore_err}") + raise if do_backup: prune_backups(slug) diff --git a/tests/test_backup.py b/tests/test_backup.py index 0c64533..71e23f2 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -1,4 +1,4 @@ -"""Tests for backup retention.""" +"""Tests for run-level backup and retention.""" from pathlib import Path @@ -15,32 +15,66 @@ def backups_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: return backups_dir -def _make_backup(backups_dir: Path, slug: str, timestamp: str) -> Path: +def _make_run_backup(backups_dir: Path, slug: str, timestamp: str) -> Path: + path = backups_dir / f"{slug}-run-{timestamp}" + path.mkdir() + (path / "marker.txt").write_text(timestamp) + return path + + +def _make_legacy_backup(backups_dir: Path, slug: str, timestamp: str) -> Path: path = backups_dir / f"{slug}-{timestamp}" path.mkdir() (path / "marker.txt").write_text(timestamp) return path -def test_parse_backup_name() -> None: - assert backup._parse_backup_name("org-repo-20260323T053124Z") == ( +def test_backup_timestamp_is_second_precision() -> None: + ts = backup._backup_timestamp() + assert len(ts) == 16 # YYYYMMDDTHHMMSSZ + assert ts.endswith("Z") + assert "T" in ts + + assert backup._parse_run_backup_name("org-repo-run-20260323T053124Z") == ( "org-repo", "20260323T053124Z", ) - assert backup._parse_backup_name("WorkIndia-Private-wi-ai-collab-kit-20260713T060758Z") == ( + assert backup._parse_run_backup_name( + "WorkIndia-Private-wi-ai-collab-kit-run-20260713T060758Z" + ) == ( "WorkIndia-Private-wi-ai-collab-kit", "20260713T060758Z", ) + assert backup._parse_backup_name("org-repo-20260323T053124Z") == ( + "org-repo", + "20260323T053124Z", + ) assert backup._parse_backup_name("not-a-backup") is None + assert backup._parse_run_backup_name("org-repo-20260323T053124Z") is None + +def test_dedupe_backup_targets(tmp_path: Path) -> None: + parent = tmp_path / "cursor" + child = parent / "skills" + other = tmp_path / "claude" + parent.mkdir() + child.mkdir(parents=True) + other.mkdir() -def test_prune_backups_keeps_newest_per_slug(backups_home: Path) -> None: + kept = backup.dedupe_backup_targets([child, parent, other, parent]) + assert parent in kept + assert other in kept + assert child not in kept + assert len(kept) == 2 + + +def test_prune_backups_keeps_newest_runs_per_slug(backups_home: Path) -> None: slug = "org-repo" - oldest = _make_backup(backups_home, slug, "20260101T000000Z") - second_oldest = _make_backup(backups_home, slug, "20260102T000000Z") - keep_3 = _make_backup(backups_home, slug, "20260103T000000Z") - keep_4 = _make_backup(backups_home, slug, "20260104T000000Z") - newest = _make_backup(backups_home, slug, "20260105T000000Z") + oldest = _make_run_backup(backups_home, slug, "20260101T000000Z") + second_oldest = _make_run_backup(backups_home, slug, "20260102T000000Z") + keep_3 = _make_run_backup(backups_home, slug, "20260103T000000Z") + keep_4 = _make_run_backup(backups_home, slug, "20260104T000000Z") + newest = _make_run_backup(backups_home, slug, "20260105T000000Z") deleted = backup.prune_backups(slug=slug, env={"DEVCTL_BACKUP_RETENTION_COUNT": "3"}) @@ -53,11 +87,24 @@ def test_prune_backups_keeps_newest_per_slug(backups_home: Path) -> None: assert not second_oldest.exists() +def test_prune_backups_removes_legacy(backups_home: Path) -> None: + slug = "org-repo" + legacy = _make_legacy_backup(backups_home, slug, "20260101T000000Z") + run = _make_run_backup(backups_home, slug, "20260102T000000Z") + + deleted = backup.prune_backups(slug=slug, env={"DEVCTL_BACKUP_RETENTION_COUNT": "3"}) + + assert legacy in deleted + assert not legacy.exists() + assert run.exists() + assert run not in deleted + + def test_prune_backups_respects_slug_filter(backups_home: Path) -> None: - old_a = _make_backup(backups_home, "repo-a", "20260101T000000Z") - old_b = _make_backup(backups_home, "repo-b", "20260101T000000Z") - _make_backup(backups_home, "repo-a", "20260102T000000Z") - _make_backup(backups_home, "repo-b", "20260102T000000Z") + old_a = _make_run_backup(backups_home, "repo-a", "20260101T000000Z") + old_b = _make_run_backup(backups_home, "repo-b", "20260101T000000Z") + _make_run_backup(backups_home, "repo-a", "20260102T000000Z") + _make_run_backup(backups_home, "repo-b", "20260102T000000Z") deleted = backup.prune_backups(slug="repo-a", env={"DEVCTL_BACKUP_RETENTION_COUNT": "1"}) @@ -67,8 +114,8 @@ def test_prune_backups_respects_slug_filter(backups_home: Path) -> None: def test_prune_backups_disabled(backups_home: Path) -> None: - old = _make_backup(backups_home, "org-repo", "20260101T000000Z") - _make_backup(backups_home, "org-repo", "20260102T000000Z") + old = _make_run_backup(backups_home, "org-repo", "20260101T000000Z") + _make_run_backup(backups_home, "org-repo", "20260102T000000Z") deleted = backup.prune_backups(env={"DEVCTL_BACKUP_RETENTION_DISABLED": "1"}) @@ -77,8 +124,8 @@ def test_prune_backups_disabled(backups_home: Path) -> None: def test_prune_backups_dry_run(backups_home: Path) -> None: - old = _make_backup(backups_home, "org-repo", "20260101T000000Z") - _make_backup(backups_home, "org-repo", "20260102T000000Z") + old = _make_run_backup(backups_home, "org-repo", "20260101T000000Z") + _make_run_backup(backups_home, "org-repo", "20260102T000000Z") deleted = backup.prune_backups( env={"DEVCTL_BACKUP_RETENTION_COUNT": "1"}, @@ -89,7 +136,88 @@ def test_prune_backups_dry_run(backups_home: Path) -> None: assert old.exists() -def test_backup_target_creates_snapshot(backups_home: Path, tmp_path: Path) -> None: +def test_backup_apply_run_single_manifest(backups_home: Path, tmp_path: Path) -> None: + cursor = tmp_path / "cursor" + skills = cursor / "skills" + claude = tmp_path / "claude" + cursor.mkdir() + skills.mkdir() + (skills / "a.md").write_text("skill") + claude.mkdir() + (claude / "x.md").write_text("x") + + result = backup.backup_apply_run([cursor, skills, claude], "org-repo") + + assert result is not None + assert result.name.startswith("org-repo-run-") + assert (result / "manifest.json").exists() + import json + + manifest = json.loads((result / "manifest.json").read_text()) + paths = {t["path"] for t in manifest["targets"]} + assert str(cursor) in paths + assert str(claude) in paths + assert str(skills) not in paths # nested under cursor + assert len(manifest["targets"]) == 2 + + +def test_backup_apply_run_nested_symlink_not_followed( + backups_home: Path, tmp_path: Path +) -> None: + root = tmp_path / "cursor" + root.mkdir() + missing = tmp_path / "does-not-exist" + link = root / "skills" + link.symlink_to(missing) + + result = backup.backup_apply_run([root], "org-repo") + + assert result is not None + content = result / "0" / "content" + assert (content / "skills").is_symlink() + assert os_readlink(content / "skills") == str(missing) + + +def os_readlink(path: Path) -> str: + import os + + return os.readlink(path) + + +def test_restore_apply_run_restores_dir_file_symlink( + backups_home: Path, tmp_path: Path +) -> None: + real = tmp_path / "real-skills" + real.mkdir() + (real / "s.md").write_text("shared") + + cursor = tmp_path / "cursor" + cursor.mkdir() + (cursor / "rules.md").write_text("rule") + skills_link = tmp_path / "skills-link" + skills_link.symlink_to(real) + alone = tmp_path / "alone.txt" + alone.write_text("file") + + run_dir = backup.backup_apply_run([cursor, skills_link, alone], "org-repo") + assert run_dir is not None + + # Mutate live paths + (cursor / "rules.md").write_text("changed") + skills_link.unlink() + skills_link.mkdir() + (skills_link / "junk").write_text("x") + alone.write_text("changed") + + backup.restore_apply_run(run_dir) + + assert (cursor / "rules.md").read_text() == "rule" + assert skills_link.is_symlink() + assert skills_link.resolve() == real.resolve() + assert alone.read_text() == "file" + + +def test_backup_target_creates_run_snapshot(backups_home: Path, tmp_path: Path) -> None: target = tmp_path / "target" target.mkdir() (target / "rules").mkdir() @@ -98,11 +226,26 @@ def test_backup_target_creates_snapshot(backups_home: Path, tmp_path: Path) -> N result = backup.backup_target(target, "org-repo") assert result is not None - assert result.exists() - assert (result / "rules" / "a.md").read_text() == "rule" + assert "-run-" in result.name + assert (result / "0" / "content" / "rules" / "a.md").read_text() == "rule" + + +def test_backup_target_symlink_records_link_only(backups_home: Path, tmp_path: Path) -> None: + """Symlink backup stores link metadata without deep-copying the resolved tree.""" + real = tmp_path / "real" + real.mkdir() + (real / "big.txt").write_text("content") + link = tmp_path / "link" + link.symlink_to(real) + + result = backup.backup_target(link, "org-repo") + + assert result is not None + assert (result / "0" / "SYMLINK_TARGET").read_text(encoding="utf-8") == str(real) + assert not (result / "0" / "big.txt").exists() -def test_apply_protocols_prunes_old_backups( +def test_apply_protocols_one_run_backup_and_prunes( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -114,7 +257,7 @@ def test_apply_protocols_prunes_old_backups( slug = "test-slug" for ts in ("20260101T000000Z", "20260102T000000Z", "20260103T000000Z", "20260104T000000Z"): - _make_backup(backups_dir, slug, ts) + _make_run_backup(backups_dir, slug, ts) repo = tmp_path / "repo" (repo / "src").mkdir(parents=True) @@ -137,6 +280,6 @@ def test_apply_protocols_prunes_old_backups( remaining = sorted(p.name for p in backups_dir.iterdir() if p.is_dir()) assert len(remaining) == 3 - assert "test-slug-20260101T000000Z" not in remaining - assert "test-slug-20260102T000000Z" not in remaining - assert any(name.startswith("test-slug-2026") and name.endswith("Z") for name in remaining) + assert all("-run-" in name for name in remaining) + assert f"{slug}-run-20260101T000000Z" not in remaining + assert f"{slug}-run-20260102T000000Z" not in remaining diff --git a/tests/test_protocol_engine.py b/tests/test_protocol_engine.py index 62258f7..0e8381f 100644 --- a/tests/test_protocol_engine.py +++ b/tests/test_protocol_engine.py @@ -258,3 +258,295 @@ def test_apply_protocols_aggregates_missing(tmp_path: Path) -> None: assert len(miss_o) == 2 assert any("missing1.txt" in m for m in miss_o) assert any("missing2.txt" in m for m in miss_o) + + +def test_execute_protocol_symlink_sync_create(tmp_path: Path) -> None: + """symlink_sync creates a directory symlink to source.""" + source = tmp_path / "common" / "skills" + source.mkdir(parents=True) + (source / "demo").mkdir() + (source / "demo" / "SKILL.md").write_text("hi") + target = tmp_path / "home" / "skills" + + protocol = Protocol( + name="skills", + type="symlink_sync", + source="common/skills", + target=str(target), + obligations=[], + recommendations=[], + ) + missing_obl, missing_rec = execute_protocol(protocol, tmp_path, "test", do_backup=False) + + assert target.is_symlink() + assert target.resolve() == source.resolve() + assert (target / "demo" / "SKILL.md").read_text() == "hi" + assert missing_obl == [] + assert missing_rec == [] + + +def test_execute_protocol_symlink_sync_idempotent(tmp_path: Path) -> None: + """Re-applying symlink_sync when already correct is a no-op.""" + source = tmp_path / "common" / "skills" + source.mkdir(parents=True) + target = tmp_path / "home" / "skills" + target.parent.mkdir(parents=True) + target.symlink_to(source.resolve()) + + protocol = Protocol( + name="skills", + type="symlink_sync", + source="common/skills", + target=str(target), + obligations=[], + recommendations=[], + ) + execute_protocol(protocol, tmp_path, "test", do_backup=False) + execute_protocol(protocol, tmp_path, "test", do_backup=False) + + assert target.is_symlink() + assert target.resolve() == source.resolve() + + +def test_execute_protocol_symlink_sync_replaces_real_dir(tmp_path: Path) -> None: + """symlink_sync replaces a prior real directory (migration from file_sync).""" + source = tmp_path / "common" / "skills" + source.mkdir(parents=True) + (source / "from_common.txt").write_text("common") + + target = tmp_path / "home" / "skills" + target.mkdir(parents=True) + (target / "old_copy.txt").write_text("stale") + + protocol = Protocol( + name="skills", + type="symlink_sync", + source="common/skills", + target=str(target), + obligations=[], + recommendations=[], + ) + execute_protocol(protocol, tmp_path, "test", do_backup=False) + + assert target.is_symlink() + assert target.resolve() == source.resolve() + assert (target / "from_common.txt").read_text() == "common" + assert not (target / "old_copy.txt").exists() + + +def test_execute_protocol_symlink_sync_repairs_broken_link(tmp_path: Path) -> None: + """symlink_sync replaces a broken symlink.""" + source = tmp_path / "common" / "skills" + source.mkdir(parents=True) + + target = tmp_path / "home" / "skills" + target.parent.mkdir(parents=True) + target.symlink_to(tmp_path / "does-not-exist") + + protocol = Protocol( + name="skills", + type="symlink_sync", + source="common/skills", + target=str(target), + obligations=[], + recommendations=[], + ) + execute_protocol(protocol, tmp_path, "test", do_backup=False) + + assert target.is_symlink() + assert target.resolve() == source.resolve() + + +def test_execute_protocol_symlink_sync_retargets_wrong_link(tmp_path: Path) -> None: + """symlink_sync replaces a symlink that points at the wrong path.""" + source = tmp_path / "common" / "skills" + source.mkdir(parents=True) + wrong = tmp_path / "other" + wrong.mkdir() + + target = tmp_path / "home" / "skills" + target.parent.mkdir(parents=True) + target.symlink_to(wrong.resolve()) + + protocol = Protocol( + name="skills", + type="symlink_sync", + source="common/skills", + target=str(target), + obligations=[], + recommendations=[], + ) + execute_protocol(protocol, tmp_path, "test", do_backup=False) + + assert target.is_symlink() + assert target.resolve() == source.resolve() + + +def test_check_symlink_integrity(tmp_path: Path) -> None: + """check_symlink_integrity reports missing, wrong, and ok states.""" + from devctl.core.protocol_engine import check_symlink_integrity + + source = tmp_path / "common" / "skills" + source.mkdir(parents=True) + target = tmp_path / "home" / "skills" + protocol = Protocol( + name="skills", + type="symlink_sync", + source="common/skills", + target=str(target), + obligations=[], + recommendations=[], + ) + + assert check_symlink_integrity(protocol, tmp_path) is not None + + target.parent.mkdir(parents=True) + target.mkdir() + assert "not a symlink" in (check_symlink_integrity(protocol, tmp_path) or "") + + target.rmdir() + target.symlink_to(source.resolve()) + assert check_symlink_integrity(protocol, tmp_path) is None + + file_sync = Protocol( + name="c", + type="file_sync", + source="common/skills", + target=str(tmp_path / "out"), + obligations=[], + recommendations=[], + ) + assert check_symlink_integrity(file_sync, tmp_path) is None + + +def test_file_sync_replaces_child_symlink(tmp_path: Path) -> None: + """file_sync removes a conflicting child symlink then copies real content.""" + source = tmp_path / "repo" / "vendor" + (source / "skills").mkdir(parents=True) + (source / "skills" / "from_repo.md").write_text("repo") + + target = tmp_path / "home" / "vendor" + target.mkdir(parents=True) + missing = tmp_path / "gone" + (target / "skills").symlink_to(missing) + + protocol = Protocol( + name="vendor", + type="file_sync", + source="vendor", + target=str(target), + obligations=[], + recommendations=[], + ) + execute_protocol(protocol, tmp_path / "repo", "test", do_backup=False) + + skills = target / "skills" + assert not skills.is_symlink() + assert skills.is_dir() + assert (skills / "from_repo.md").read_text() == "repo" + + +def test_apply_protocols_restores_on_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Mid-apply failure restores targets from the run-level snapshot.""" + from devctl.core import backup + from devctl.core.protocol_engine import apply_protocols, execute_protocol + + backups_dir = tmp_path / "backups" + backups_dir.mkdir() + monkeypatch.setattr(backup, "get_backups_dir", lambda: backups_dir) + + t1 = tmp_path / "t1" + t2 = tmp_path / "t2" + t1.mkdir() + t2.mkdir() + (t1 / "keep.txt").write_text("original1") + (t2 / "keep.txt").write_text("original2") + + repo = tmp_path / "repo" + (repo / "s1").mkdir(parents=True) + (repo / "s2").mkdir(parents=True) + (repo / "s1" / "keep.txt").write_text("new1") + (repo / "s2" / "keep.txt").write_text("new2") + (repo / "protocol.yaml").write_text( + f""" +version: v1 +protocols: + - name: one + type: file_sync + source: s1 + target: {t1} + - name: two + type: file_sync + source: s2 + target: {t2} +""" + ) + + real_execute = execute_protocol + calls = {"n": 0} + + def flaky_execute(protocol, repo_path, slug, do_backup=True): + calls["n"] += 1 + if calls["n"] == 2: + raise RuntimeError("boom") + return real_execute(protocol, repo_path, slug, do_backup=do_backup) + + monkeypatch.setattr( + "devctl.core.protocol_engine.execute_protocol", + flaky_execute, + ) + + with pytest.raises(RuntimeError, match="boom"): + apply_protocols(repo, "slug", do_backup=True) + + assert (t1 / "keep.txt").read_text() == "original1" + assert (t2 / "keep.txt").read_text() == "original2" + + +def test_apply_protocols_multi_protocol_one_run_backup( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Multiple protocols produce a single run backup folder.""" + from devctl.core import backup + from devctl.core.protocol_engine import apply_protocols + + backups_dir = tmp_path / "backups" + backups_dir.mkdir() + monkeypatch.setattr(backup, "get_backups_dir", lambda: backups_dir) + + t1 = tmp_path / "t1" + t2 = tmp_path / "t2" + t1.mkdir() + t2.mkdir() + (t1 / "a.txt").write_text("a") + (t2 / "b.txt").write_text("b") + + repo = tmp_path / "repo" + (repo / "s1").mkdir(parents=True) + (repo / "s2").mkdir(parents=True) + (repo / "s1" / "a.txt").write_text("A") + (repo / "s2" / "b.txt").write_text("B") + (repo / "protocol.yaml").write_text( + f""" +version: v1 +protocols: + - name: one + type: file_sync + source: s1 + target: {t1} + - name: two + type: file_sync + source: s2 + target: {t2} +""" + ) + + apply_protocols(repo, "slug", do_backup=True) + + runs = [p for p in backups_dir.iterdir() if p.is_dir()] + assert len(runs) == 1 + assert "-run-" in runs[0].name