feat(system): feat(drone): add merge, smart-sync, and fix plugins to devpulse_ops (#149)
Co-authored-by: @devpulse <devpulse@aipass>
This commit is contained in:
+52
-1
@@ -163,9 +163,60 @@ elif command == "system-pr":
|
||||
- **Dry-run mode:** `drone @git system-pr --dry-run "description"` shows what would be staged and the PR description without actually creating anything. Good for reviewing before committing.
|
||||
- **Selective staging:** `drone @git system-pr --include "src/aipass/backup/ src/aipass/drone/" "description"` to stage only specific branch directories. Useful when not all changes should go in one PR.
|
||||
- **PR template:** Auto-generate the PR body from git diff stats, listing which branches were modified and how many files/lines per branch.
|
||||
- **Merge plugin:** `drone @git merge 147` to merge a PR from the CLI. Would use `gh pr merge 147 --squash`. Same auth gate.
|
||||
- **Patrick mode:** `drone @git system-pr --author patrick "description"` changes the Co-Authored-By to Patrick's name. Requires a separate auth check (env var, config file, or interactive prompt).
|
||||
|
||||
## Phase 2 Plugins (dispatched to drone)
|
||||
|
||||
### merge_plugin.py — `drone @git merge <PR#>`
|
||||
- Squash-merges a PR via gh cli, auto-syncs local main after
|
||||
- Same auth gate as system-pr
|
||||
|
||||
### sync_plugin.py — `drone @git smart-sync`
|
||||
- Detects diverged local/origin main, rebases cleanly
|
||||
- Aborts and reports on conflict instead of leaving a mess
|
||||
|
||||
### fix_plugin.py — `drone @git fix`
|
||||
- Emergency button: detects stuck rebase, detached HEAD, diverged state, dirty index
|
||||
- Auto-resolves common broken states, reports what it did
|
||||
|
||||
## Phase 3: Git Snapshot Cache (Patrick's design)
|
||||
|
||||
**IMPORTANT: This is NOT a plugin.** Plugins are devpulse-only privileged extensions. The snapshot cache protects ALL git operations for ALL branches — it's a handler. Lives in `drone/apps/handlers/git/snapshot_handler.py` alongside pr_handler, lock_handler, sync_handler. Both `pr_handler.py` and the devpulse plugins call it before any destructive action.
|
||||
|
||||
### snapshot_handler.py — safety net for all git operations
|
||||
|
||||
**Concept:** Before every git action (PR, merge, rebase), the affected files get copied to a local snapshot folder. Rolling history of ~10 snapshots. When the 11th comes in, the oldest rolls off — but not deleted, rolls to the backup system (like plans and deleted branches do).
|
||||
|
||||
**Why:** We lost files in the past due to git operations gone wrong. The drone git workflow was built partly for this reason. But even with safe workflows, things can go sideways — a rebase drops a file, a merge conflict resolution loses changes, a force-push overwrites work. The snapshot cache means files are never truly lost.
|
||||
|
||||
**Design:**
|
||||
```
|
||||
drone/apps/plugins/devpulse_ops/
|
||||
├── snapshot_plugin.py # Snapshot before action, restore on demand
|
||||
```
|
||||
|
||||
Each branch gets a `.git_snapshots/` directory (or a central one in the repo root):
|
||||
```
|
||||
.git_snapshots/
|
||||
├── 001_2026-03-30T14:00_system-pr/ # Snapshot of files before PR #148
|
||||
│ ├── manifest.json # What files, what action, timestamp
|
||||
│ └── files/ # Actual file copies
|
||||
├── 002_2026-03-30T14:30_merge-148/ # Snapshot before merge
|
||||
│ ├── manifest.json
|
||||
│ └── files/
|
||||
└── ...up to 010 (rolling)
|
||||
```
|
||||
|
||||
**Integration:** Every other plugin calls `snapshot_plugin.take_snapshot(action, files)` before doing anything destructive. The snapshot happens silently — no user interaction needed.
|
||||
|
||||
**Rolloff:** When snapshot 11 arrives, snapshot 1 gets moved to the backup system (same pattern as deleted branches and closed plans — they don't delete, they archive).
|
||||
|
||||
**Restore:** `drone @git restore <snapshot_id>` or `drone @git restore latest` to recover files from a snapshot. Shows what would be restored before doing it.
|
||||
|
||||
**Patrick's words:** "Even if there were multiple errors where files got lost and the temp file got overridden, at least we have the previous versions. When you make a PR, the files that you've included get cached to a temporary folder. They don't delete, they just roll off somewhere. So there's always like — what happened to those files? They were there last week. We look at the backup system, we see the trail."
|
||||
|
||||
**Not building now** — this is Phase 3, after merge/sync/fix are proven. But the architecture should accommodate it from the start.
|
||||
|
||||
## Relationships
|
||||
- **Related DPLANs:** DPLAN-0086 (Patrick's git workflow cheat sheet — this solves several of his pain points)
|
||||
- **Related FPLANs:** None yet — drone will create one after reviewing this design
|
||||
|
||||
@@ -76,6 +76,36 @@
|
||||
"file": "apps/plugins/devpulse_ops/pr_plugin.py",
|
||||
"standard": "trigger",
|
||||
"reason": "create_system_pr is a privileged operation — trigger integration deferred until trigger branch supports plugin events."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/devpulse_ops/merge_plugin.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/devpulse_ops/merge_plugin.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Plugin imports lock_handler.find_repo_root() — drone-internal plugin, same branch."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/devpulse_ops/sync_plugin.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/devpulse_ops/sync_plugin.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Plugin imports lock_handler.find_repo_root() — drone-internal plugin, same branch."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/devpulse_ops/fix_plugin.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure."
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/devpulse_ops/fix_plugin.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Plugin imports lock_handler.find_repo_root() — drone-internal plugin, same branch."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -28,7 +28,7 @@ DRONE_MODULE = {
|
||||
"description": "Git workflow — PR, status, sync, lock management",
|
||||
}
|
||||
|
||||
_COMMANDS = ("pr", "status", "sync", "lock", "unlock", "system-pr")
|
||||
_COMMANDS = ("pr", "status", "sync", "lock", "unlock", "system-pr", "merge", "smart-sync", "fix")
|
||||
|
||||
|
||||
def _detect_branch_dir() -> tuple[str, Path] | None:
|
||||
@@ -83,6 +83,12 @@ def handle_command(command: str | None = None, args: list[str] | None = None) ->
|
||||
|
||||
if command == "system-pr":
|
||||
return _handle_system_pr(args)
|
||||
if command == "merge":
|
||||
return _handle_merge(args)
|
||||
if command == "smart-sync":
|
||||
return _handle_smart_sync(args)
|
||||
if command == "fix":
|
||||
return _handle_fix(args)
|
||||
if command == "pr":
|
||||
return _handle_pr(args)
|
||||
if command == "status":
|
||||
@@ -149,6 +155,129 @@ def _handle_system_pr(args: list[str]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _handle_merge(args: list[str]) -> dict:
|
||||
"""Handle the merge subcommand (devpulse-only)."""
|
||||
if not args:
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": "Usage: drone @git merge <PR#>",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
pr_number = args[0]
|
||||
|
||||
try:
|
||||
from aipass.drone.apps.plugins.devpulse_ops.auth import verify_caller
|
||||
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
|
||||
except ImportError as exc:
|
||||
logger.error("Failed to import devpulse_ops merge plugin: %s", exc)
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": f"devpulse_ops plugin not available: {exc}",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
try:
|
||||
caller = verify_caller()
|
||||
except PermissionError as exc:
|
||||
logger.error("merge authorization failed: %s", exc)
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": str(exc),
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
result = merge_pr(pr_number, caller)
|
||||
|
||||
if result["success"]:
|
||||
return {
|
||||
"stdout": result["message"],
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
}
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": result["message"],
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
|
||||
def _handle_smart_sync(args: list[str]) -> dict:
|
||||
"""Handle the smart-sync subcommand (devpulse-only)."""
|
||||
try:
|
||||
from aipass.drone.apps.plugins.devpulse_ops.auth import verify_caller
|
||||
from aipass.drone.apps.plugins.devpulse_ops.sync_plugin import smart_sync
|
||||
except ImportError as exc:
|
||||
logger.error("Failed to import devpulse_ops sync plugin: %s", exc)
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": f"devpulse_ops plugin not available: {exc}",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
try:
|
||||
caller = verify_caller()
|
||||
except PermissionError as exc:
|
||||
logger.error("smart-sync authorization failed: %s", exc)
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": str(exc),
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
result = smart_sync(caller)
|
||||
|
||||
if result["success"]:
|
||||
return {
|
||||
"stdout": result["message"],
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
}
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": result["message"],
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
|
||||
def _handle_fix(args: list[str]) -> dict:
|
||||
"""Handle the fix subcommand (devpulse-only)."""
|
||||
try:
|
||||
from aipass.drone.apps.plugins.devpulse_ops.auth import verify_caller
|
||||
from aipass.drone.apps.plugins.devpulse_ops.fix_plugin import fix_git_state
|
||||
except ImportError as exc:
|
||||
logger.error("Failed to import devpulse_ops fix plugin: %s", exc)
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": f"devpulse_ops plugin not available: {exc}",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
try:
|
||||
caller = verify_caller()
|
||||
except PermissionError as exc:
|
||||
logger.error("fix authorization failed: %s", exc)
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": str(exc),
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
result = fix_git_state(caller)
|
||||
|
||||
if result["success"]:
|
||||
return {
|
||||
"stdout": result["message"],
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
}
|
||||
return {
|
||||
"stdout": "",
|
||||
"stderr": result["message"],
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
|
||||
def _handle_pr(args: list[str]) -> dict:
|
||||
"""Handle the PR subcommand."""
|
||||
if not args:
|
||||
@@ -296,6 +425,21 @@ def get_help(command: str | None = None) -> str:
|
||||
" Stages all tracked changes, creates a disposable feature branch,\n"
|
||||
" and opens a PR. Requires devpulse passport authorization.\n"
|
||||
)
|
||||
if command == "merge":
|
||||
return (
|
||||
"git merge <PR#> — Squash-merge a PR and sync local main (devpulse only)\n"
|
||||
" Runs gh pr merge --squash --delete-branch, then git pull --rebase.\n"
|
||||
)
|
||||
if command == "smart-sync":
|
||||
return (
|
||||
"git smart-sync — Fetch origin and rebase if behind (devpulse only)\n"
|
||||
" Detects divergence and rebases safely; aborts on conflict.\n"
|
||||
)
|
||||
if command == "fix":
|
||||
return (
|
||||
"git fix — Detect and fix common broken git states (devpulse only)\n"
|
||||
" Fixes stuck rebases, detached HEAD, diverged branches, dirty index.\n"
|
||||
)
|
||||
|
||||
return (
|
||||
"git — Git workflow: PR, status, sync, lock management\n"
|
||||
@@ -303,6 +447,9 @@ def get_help(command: str | None = None) -> str:
|
||||
"Commands:\n"
|
||||
" pr <description> Create a PR with scoped changes\n"
|
||||
" system-pr <desc> Create a system-wide PR (devpulse only)\n"
|
||||
" merge <PR#> Squash-merge a PR (devpulse only)\n"
|
||||
" smart-sync Fetch + rebase if behind (devpulse only)\n"
|
||||
" fix Fix broken git states (devpulse only)\n"
|
||||
" status Show git status for your branch\n"
|
||||
" sync Checkout main and pull\n"
|
||||
" lock Check lock status\n"
|
||||
@@ -325,8 +472,11 @@ def get_introspective() -> str:
|
||||
" plugins/devpulse_ops/\n"
|
||||
" - auth.py (verify_caller — passport-based authorization)\n"
|
||||
" - pr_plugin.py (create_system_pr — system-wide PR workflow)\n"
|
||||
" - merge_plugin.py (merge_pr — squash-merge PR + sync)\n"
|
||||
" - sync_plugin.py (smart_sync — fetch + rebase if behind)\n"
|
||||
" - fix_plugin.py (fix_git_state — detect/fix broken states)\n"
|
||||
"\n"
|
||||
"Commands: pr, system-pr, status, sync, lock, unlock\n"
|
||||
"Commands: pr, system-pr, merge, smart-sync, fix, status, sync, lock, unlock\n"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: fix_plugin.py
|
||||
# Description: Detect and fix common broken git states
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-30
|
||||
# Modified: 2026-03-30
|
||||
# =============================================
|
||||
|
||||
"""Detect and fix common broken git states.
|
||||
|
||||
Runs a sequence of checks for stuck rebases, detached HEAD, diverged
|
||||
branches, and dirty index, applying automatic fixes where safe.
|
||||
Only authorized callers (verified via :mod:`auth`) may invoke this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
|
||||
|
||||
|
||||
def fix_git_state(caller: str) -> dict:
|
||||
"""Detect and fix common broken git states.
|
||||
|
||||
Checks are run in sequence; multiple fixes can happen in one call.
|
||||
|
||||
Args:
|
||||
caller: The verified caller name (e.g. ``"devpulse"``).
|
||||
|
||||
Returns:
|
||||
Dict with success, actions_taken (list[str]), and message.
|
||||
"""
|
||||
repo_root = find_repo_root()
|
||||
git_dir = repo_root / ".git"
|
||||
actions: list[str] = []
|
||||
|
||||
result: dict = {
|
||||
"success": False,
|
||||
"actions_taken": actions,
|
||||
"message": "",
|
||||
}
|
||||
|
||||
try:
|
||||
# Check 1: Stuck in rebase
|
||||
rebase_merge = git_dir / "rebase-merge"
|
||||
rebase_apply = git_dir / "rebase-apply"
|
||||
if rebase_merge.exists() or rebase_apply.exists():
|
||||
abort = subprocess.run(
|
||||
["git", "rebase", "--abort"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if abort.returncode == 0:
|
||||
actions.append("Aborted stuck rebase")
|
||||
logger.info("fix_git_state: aborted stuck rebase")
|
||||
else:
|
||||
actions.append(f"Failed to abort rebase: {abort.stderr.strip()}")
|
||||
logger.error("fix_git_state: rebase abort failed: %s", abort.stderr.strip())
|
||||
|
||||
# Check 2: Detached HEAD
|
||||
sym_ref = subprocess.run(
|
||||
["git", "symbolic-ref", "-q", "HEAD"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if sym_ref.returncode != 0:
|
||||
checkout = subprocess.run(
|
||||
["git", "checkout", "main"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if checkout.returncode == 0:
|
||||
actions.append("Checked out main (was detached HEAD)")
|
||||
logger.info("fix_git_state: checked out main from detached HEAD")
|
||||
else:
|
||||
actions.append(f"Failed to checkout main: {checkout.stderr.strip()}")
|
||||
logger.error("fix_git_state: checkout main failed: %s", checkout.stderr.strip())
|
||||
|
||||
# Check 3: Diverged from origin
|
||||
rev_list = subprocess.run(
|
||||
["git", "rev-list", "--left-right", "--count", "main...origin/main"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if rev_list.returncode == 0:
|
||||
parts = rev_list.stdout.strip().split()
|
||||
ahead = int(parts[0]) if len(parts) >= 1 else 0
|
||||
behind = int(parts[1]) if len(parts) >= 2 else 0
|
||||
if ahead > 0 and behind > 0:
|
||||
actions.append(
|
||||
f"Diverged from origin (ahead={ahead}, behind={behind}). "
|
||||
"Run: drone @git smart-sync"
|
||||
)
|
||||
logger.warning("fix_git_state: diverged ahead=%d behind=%d", ahead, behind)
|
||||
|
||||
# Check 4: Dirty index with no intent
|
||||
cached = subprocess.run(
|
||||
["git", "diff", "--cached", "--name-only"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if cached.returncode == 0 and cached.stdout.strip():
|
||||
staged_files = cached.stdout.strip().splitlines()
|
||||
reset = subprocess.run(
|
||||
["git", "reset", "HEAD"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if reset.returncode == 0:
|
||||
actions.append(f"Unstaged {len(staged_files)} file(s) from index")
|
||||
logger.info("fix_git_state: unstaged %d files", len(staged_files))
|
||||
else:
|
||||
actions.append(f"Failed to reset index: {reset.stderr.strip()}")
|
||||
logger.error("fix_git_state: reset failed: %s", reset.stderr.strip())
|
||||
|
||||
result["success"] = True
|
||||
if actions:
|
||||
result["message"] = f"Fixed {len(actions)} issue(s): {'; '.join(actions)}"
|
||||
else:
|
||||
result["message"] = "Git state is clean — nothing to fix"
|
||||
|
||||
json_handler.log_operation(
|
||||
"fix_git_state",
|
||||
{
|
||||
"caller": caller,
|
||||
"actions_taken": actions,
|
||||
},
|
||||
)
|
||||
logger.info(result["message"])
|
||||
return result
|
||||
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
result["message"] = f"Fix git state error: {exc}"
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
@@ -0,0 +1,99 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: merge_plugin.py
|
||||
# Description: Squash-merge a PR and sync local main
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-30
|
||||
# Modified: 2026-03-30
|
||||
# =============================================
|
||||
|
||||
"""Squash-merge a PR and sync local main.
|
||||
|
||||
Squash-merges the given PR number via ``gh``, deletes the remote branch,
|
||||
pulls to sync local main, and returns the merge commit hash and PR title.
|
||||
Only authorized callers (verified via :mod:`auth`) may invoke this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
|
||||
|
||||
|
||||
def merge_pr(pr_number: str, caller: str) -> dict:
|
||||
"""Squash-merge a PR and sync local main.
|
||||
|
||||
Args:
|
||||
pr_number: The PR number to merge (e.g. ``"42"``).
|
||||
caller: The verified caller name (e.g. ``"devpulse"``).
|
||||
|
||||
Returns:
|
||||
Dict with success, pr_number, title, merge_commit, and message.
|
||||
"""
|
||||
repo_root = find_repo_root()
|
||||
|
||||
result: dict = {
|
||||
"success": False,
|
||||
"pr_number": pr_number,
|
||||
"title": "",
|
||||
"merge_commit": "",
|
||||
"message": "",
|
||||
}
|
||||
|
||||
try:
|
||||
# Step 1: Squash-merge the PR
|
||||
merge = subprocess.run(
|
||||
["gh", "pr", "merge", pr_number, "--squash", "--delete-branch"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if merge.returncode != 0:
|
||||
result["message"] = f"Merge failed: {merge.stderr.strip()}"
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
# Step 2: Sync local main
|
||||
pull = subprocess.run(
|
||||
["git", "pull", "--rebase"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if pull.returncode != 0:
|
||||
result["message"] = f"Pull after merge failed: {pull.stderr.strip()}"
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
# Step 3: Get the merge commit hash
|
||||
rev = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
merge_commit = rev.stdout.strip() if rev.returncode == 0 else "unknown"
|
||||
|
||||
# Step 4: Get the PR title
|
||||
title_proc = subprocess.run(
|
||||
["gh", "pr", "view", pr_number, "--json", "title", "--jq", ".title"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
title = title_proc.stdout.strip() if title_proc.returncode == 0 else "unknown"
|
||||
|
||||
result["success"] = True
|
||||
result["title"] = title
|
||||
result["merge_commit"] = merge_commit
|
||||
result["message"] = f"PR #{pr_number} merged: {title} ({merge_commit[:8]})"
|
||||
json_handler.log_operation(
|
||||
"merge_pr",
|
||||
{
|
||||
"caller": caller,
|
||||
"pr_number": pr_number,
|
||||
"title": title,
|
||||
"merge_commit": merge_commit,
|
||||
},
|
||||
)
|
||||
logger.info(result["message"])
|
||||
return result
|
||||
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
result["message"] = f"Merge workflow error: {exc}"
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
@@ -0,0 +1,114 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: sync_plugin.py
|
||||
# Description: Smart sync — fetch, detect divergence, rebase if needed
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-30
|
||||
# Modified: 2026-03-30
|
||||
# =============================================
|
||||
|
||||
"""Smart sync — fetch, detect divergence, rebase if needed.
|
||||
|
||||
Fetches the latest remote state, checks whether local main has diverged
|
||||
from ``origin/main``, and rebases if behind. Aborts cleanly on conflict.
|
||||
Only authorized callers (verified via :mod:`auth`) may invoke this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
|
||||
|
||||
|
||||
def smart_sync(caller: str) -> dict:
|
||||
"""Fetch origin and rebase local main if behind.
|
||||
|
||||
Args:
|
||||
caller: The verified caller name (e.g. ``"devpulse"``).
|
||||
|
||||
Returns:
|
||||
Dict with success, ahead, behind, rebased, and message.
|
||||
"""
|
||||
repo_root = find_repo_root()
|
||||
|
||||
result: dict = {
|
||||
"success": False,
|
||||
"ahead": 0,
|
||||
"behind": 0,
|
||||
"rebased": False,
|
||||
"message": "",
|
||||
}
|
||||
|
||||
try:
|
||||
# Step 1: Fetch origin
|
||||
fetch = subprocess.run(
|
||||
["git", "fetch", "origin"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
result["message"] = f"Fetch failed: {fetch.stderr.strip()}"
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
# Step 2: Check divergence
|
||||
rev_list = subprocess.run(
|
||||
["git", "rev-list", "--left-right", "--count", "main...origin/main"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if rev_list.returncode != 0:
|
||||
result["message"] = f"Divergence check failed: {rev_list.stderr.strip()}"
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
parts = rev_list.stdout.strip().split()
|
||||
ahead = int(parts[0]) if len(parts) >= 1 else 0
|
||||
behind = int(parts[1]) if len(parts) >= 2 else 0
|
||||
result["ahead"] = ahead
|
||||
result["behind"] = behind
|
||||
|
||||
# Step 3: Rebase if behind
|
||||
if behind > 0:
|
||||
rebase = subprocess.run(
|
||||
["git", "rebase", "origin/main"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
if rebase.returncode != 0:
|
||||
# Conflict — abort rebase
|
||||
subprocess.run(
|
||||
["git", "rebase", "--abort"],
|
||||
capture_output=True, text=True, cwd=str(repo_root),
|
||||
)
|
||||
result["message"] = (
|
||||
f"Rebase conflict (ahead={ahead}, behind={behind}). "
|
||||
"Rebase aborted. Manual resolution required."
|
||||
)
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
|
||||
result["rebased"] = True
|
||||
result["success"] = True
|
||||
result["message"] = (
|
||||
f"Rebased onto origin/main (was {behind} behind, {ahead} ahead)"
|
||||
)
|
||||
else:
|
||||
result["success"] = True
|
||||
result["message"] = "Already up to date"
|
||||
|
||||
json_handler.log_operation(
|
||||
"smart_sync",
|
||||
{
|
||||
"caller": caller,
|
||||
"ahead": ahead,
|
||||
"behind": behind,
|
||||
"rebased": result["rebased"],
|
||||
},
|
||||
)
|
||||
logger.info(result["message"])
|
||||
return result
|
||||
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
result["message"] = f"Smart sync error: {exc}"
|
||||
logger.error(result["message"])
|
||||
return result
|
||||
@@ -0,0 +1,623 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_devpulse_plugins.py
|
||||
# Description: Tests for devpulse_ops plugins — merge, smart-sync, fix
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-30
|
||||
# Modified: 2026-03-30
|
||||
# =============================================
|
||||
|
||||
"""Tests for devpulse_ops plugins — merge, smart-sync, fix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
|
||||
from aipass.drone.apps.plugins.devpulse_ops.sync_plugin import smart_sync
|
||||
from aipass.drone.apps.plugins.devpulse_ops.fix_plugin import fix_git_state
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Fixtures
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def devpulse_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Create a temp directory with a devpulse passport."""
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(
|
||||
json.dumps({
|
||||
"branch_info": {"branch_name": "devpulse"},
|
||||
"identity": {"name": "devpulse"},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seedgo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Create a temp directory with a seedgo passport (unauthorized)."""
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(
|
||||
json.dumps({
|
||||
"branch_info": {"branch_name": "seedgo"},
|
||||
"identity": {"name": "seedgo"},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Create a temp repo root with AIPASS_REGISTRY.json and .git dir."""
|
||||
registry = tmp_path / "AIPASS_REGISTRY.json"
|
||||
registry.write_text("{}", encoding="utf-8")
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. Auth denial for each command
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestAuthDenialMerge:
|
||||
"""merge command should deny unauthorized callers."""
|
||||
|
||||
def test_merge_unauthorized(self, seedgo_dir: Path) -> None:
|
||||
from aipass.drone.apps.modules.git_module import handle_command
|
||||
|
||||
result = handle_command("merge", ["42"])
|
||||
assert result["exit_code"] == 1
|
||||
assert "not authorized" in result["stderr"]
|
||||
|
||||
|
||||
class TestAuthDenialSmartSync:
|
||||
"""smart-sync command should deny unauthorized callers."""
|
||||
|
||||
def test_smart_sync_unauthorized(self, seedgo_dir: Path) -> None:
|
||||
from aipass.drone.apps.modules.git_module import handle_command
|
||||
|
||||
result = handle_command("smart-sync", [])
|
||||
assert result["exit_code"] == 1
|
||||
assert "not authorized" in result["stderr"]
|
||||
|
||||
|
||||
class TestAuthDenialFix:
|
||||
"""fix command should deny unauthorized callers."""
|
||||
|
||||
def test_fix_unauthorized(self, seedgo_dir: Path) -> None:
|
||||
from aipass.drone.apps.modules.git_module import handle_command
|
||||
|
||||
result = handle_command("fix", [])
|
||||
assert result["exit_code"] == 1
|
||||
assert "not authorized" in result["stderr"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. merge_pr tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestMergePrHappyPath:
|
||||
"""merge_pr should squash-merge, pull, and return commit + title."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run")
|
||||
def test_merge_pr_success(
|
||||
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_root.return_value = tmp_path
|
||||
|
||||
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stderr = ""
|
||||
if cmd[:3] == ["gh", "pr", "merge"]:
|
||||
proc.stdout = "Merged\n"
|
||||
elif cmd[:2] == ["git", "pull"]:
|
||||
proc.stdout = "Already up to date.\n"
|
||||
elif cmd[:3] == ["git", "rev-parse", "HEAD"]:
|
||||
proc.stdout = "abc1234def5678\n"
|
||||
elif cmd[:3] == ["gh", "pr", "view"]:
|
||||
proc.stdout = "feat: awesome feature\n"
|
||||
else:
|
||||
proc.stdout = ""
|
||||
return proc
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
result = merge_pr("42", "devpulse")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["pr_number"] == "42"
|
||||
assert result["title"] == "feat: awesome feature"
|
||||
assert result["merge_commit"] == "abc1234def5678"
|
||||
assert "42" in result["message"]
|
||||
|
||||
|
||||
class TestMergePrFailure:
|
||||
"""merge_pr should return error when gh pr merge fails."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run")
|
||||
def test_merge_pr_merge_fails(
|
||||
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_root.return_value = tmp_path
|
||||
|
||||
proc = MagicMock()
|
||||
proc.returncode = 1
|
||||
proc.stderr = "PR is not mergeable"
|
||||
proc.stdout = ""
|
||||
mock_run.return_value = proc
|
||||
|
||||
result = merge_pr("99", "devpulse")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Merge failed" in result["message"]
|
||||
assert "not mergeable" in result["message"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. smart_sync tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSmartSyncUpToDate:
|
||||
"""smart_sync should report up-to-date when not behind."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run")
|
||||
def test_smart_sync_up_to_date(
|
||||
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_root.return_value = tmp_path
|
||||
|
||||
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stderr = ""
|
||||
if cmd[:3] == ["git", "fetch", "origin"]:
|
||||
proc.stdout = ""
|
||||
elif cmd[:3] == ["git", "rev-list", "--left-right"]:
|
||||
proc.stdout = "0\t0\n"
|
||||
else:
|
||||
proc.stdout = ""
|
||||
return proc
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
result = smart_sync("devpulse")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["rebased"] is False
|
||||
assert "up to date" in result["message"]
|
||||
|
||||
|
||||
class TestSmartSyncBehind:
|
||||
"""smart_sync should rebase when behind origin."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run")
|
||||
def test_smart_sync_behind_rebase_success(
|
||||
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_root.return_value = tmp_path
|
||||
|
||||
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stderr = ""
|
||||
if cmd[:3] == ["git", "fetch", "origin"]:
|
||||
proc.stdout = ""
|
||||
elif cmd[:3] == ["git", "rev-list", "--left-right"]:
|
||||
proc.stdout = "0\t3\n"
|
||||
elif cmd[:3] == ["git", "rebase", "origin/main"]:
|
||||
proc.stdout = "Successfully rebased\n"
|
||||
else:
|
||||
proc.stdout = ""
|
||||
return proc
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
result = smart_sync("devpulse")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["rebased"] is True
|
||||
assert result["behind"] == 3
|
||||
|
||||
|
||||
class TestSmartSyncDivergedRebaseSuccess:
|
||||
"""smart_sync should rebase when diverged (behind > 0) and rebase succeeds."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run")
|
||||
def test_smart_sync_diverged_rebase_ok(
|
||||
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_root.return_value = tmp_path
|
||||
|
||||
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stderr = ""
|
||||
if cmd[:3] == ["git", "fetch", "origin"]:
|
||||
proc.stdout = ""
|
||||
elif cmd[:3] == ["git", "rev-list", "--left-right"]:
|
||||
proc.stdout = "2\t5\n"
|
||||
elif cmd[:3] == ["git", "rebase", "origin/main"]:
|
||||
proc.stdout = "Successfully rebased\n"
|
||||
else:
|
||||
proc.stdout = ""
|
||||
return proc
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
result = smart_sync("devpulse")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["rebased"] is True
|
||||
assert result["ahead"] == 2
|
||||
assert result["behind"] == 5
|
||||
|
||||
|
||||
class TestSmartSyncRebaseConflict:
|
||||
"""smart_sync should abort rebase on conflict and return error."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run")
|
||||
def test_smart_sync_rebase_conflict(
|
||||
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_root.return_value = tmp_path
|
||||
|
||||
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.stderr = ""
|
||||
if cmd[:3] == ["git", "fetch", "origin"]:
|
||||
proc.returncode = 0
|
||||
proc.stdout = ""
|
||||
elif cmd[:3] == ["git", "rev-list", "--left-right"]:
|
||||
proc.returncode = 0
|
||||
proc.stdout = "1\t2\n"
|
||||
elif cmd[:3] == ["git", "rebase", "origin/main"]:
|
||||
proc.returncode = 1
|
||||
proc.stdout = ""
|
||||
proc.stderr = "CONFLICT"
|
||||
elif cmd[:3] == ["git", "rebase", "--abort"]:
|
||||
proc.returncode = 0
|
||||
proc.stdout = ""
|
||||
else:
|
||||
proc.returncode = 0
|
||||
proc.stdout = ""
|
||||
return proc
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
result = smart_sync("devpulse")
|
||||
|
||||
assert result["success"] is False
|
||||
assert "conflict" in result["message"].lower()
|
||||
assert result["rebased"] is False
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. fix_git_state tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestFixStuckRebase:
|
||||
"""fix_git_state should abort a stuck rebase."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run")
|
||||
def test_fix_stuck_rebase(
|
||||
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_root.return_value = tmp_path
|
||||
# Create .git/rebase-merge to simulate stuck rebase
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
(git_dir / "rebase-merge").mkdir()
|
||||
|
||||
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stderr = ""
|
||||
proc.stdout = ""
|
||||
if cmd[:3] == ["git", "symbolic-ref", "-q"]:
|
||||
proc.stdout = "refs/heads/main\n"
|
||||
elif cmd[:3] == ["git", "rev-list", "--left-right"]:
|
||||
proc.stdout = "0\t0\n"
|
||||
elif cmd[:3] == ["git", "diff", "--cached"]:
|
||||
proc.stdout = ""
|
||||
return proc
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
result = fix_git_state("devpulse")
|
||||
|
||||
assert result["success"] is True
|
||||
assert any("rebase" in a.lower() for a in result["actions_taken"])
|
||||
|
||||
|
||||
class TestFixDetachedHead:
|
||||
"""fix_git_state should checkout main on detached HEAD."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run")
|
||||
def test_fix_detached_head(
|
||||
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_root.return_value = tmp_path
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
|
||||
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.stderr = ""
|
||||
proc.stdout = ""
|
||||
if cmd[:3] == ["git", "symbolic-ref", "-q"]:
|
||||
proc.returncode = 1 # detached HEAD
|
||||
elif cmd[:3] == ["git", "checkout", "main"]:
|
||||
proc.returncode = 0
|
||||
elif cmd[:3] == ["git", "rev-list", "--left-right"]:
|
||||
proc.returncode = 0
|
||||
proc.stdout = "0\t0\n"
|
||||
elif cmd[:3] == ["git", "diff", "--cached"]:
|
||||
proc.returncode = 0
|
||||
proc.stdout = ""
|
||||
else:
|
||||
proc.returncode = 0
|
||||
return proc
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
result = fix_git_state("devpulse")
|
||||
|
||||
assert result["success"] is True
|
||||
assert any("detached" in a.lower() for a in result["actions_taken"])
|
||||
|
||||
|
||||
class TestFixDiverged:
|
||||
"""fix_git_state should report divergence and suggest smart-sync."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run")
|
||||
def test_fix_diverged_suggests_sync(
|
||||
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_root.return_value = tmp_path
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
|
||||
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stderr = ""
|
||||
proc.stdout = ""
|
||||
if cmd[:3] == ["git", "symbolic-ref", "-q"]:
|
||||
proc.stdout = "refs/heads/main\n"
|
||||
elif cmd[:3] == ["git", "rev-list", "--left-right"]:
|
||||
proc.stdout = "3\t2\n" # diverged
|
||||
elif cmd[:3] == ["git", "diff", "--cached"]:
|
||||
proc.stdout = ""
|
||||
return proc
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
result = fix_git_state("devpulse")
|
||||
|
||||
assert result["success"] is True
|
||||
assert any("smart-sync" in a for a in result["actions_taken"])
|
||||
|
||||
|
||||
class TestFixCleanState:
|
||||
"""fix_git_state should report nothing to fix when clean."""
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run")
|
||||
def test_fix_clean_state(
|
||||
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
mock_root.return_value = tmp_path
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
|
||||
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stderr = ""
|
||||
proc.stdout = ""
|
||||
if cmd[:3] == ["git", "symbolic-ref", "-q"]:
|
||||
proc.stdout = "refs/heads/main\n"
|
||||
elif cmd[:3] == ["git", "rev-list", "--left-right"]:
|
||||
proc.stdout = "0\t0\n"
|
||||
elif cmd[:3] == ["git", "diff", "--cached"]:
|
||||
proc.stdout = ""
|
||||
return proc
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
result = fix_git_state("devpulse")
|
||||
|
||||
assert result["success"] is True
|
||||
assert len(result["actions_taken"]) == 0
|
||||
assert "nothing to fix" in result["message"].lower()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. Routing tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestGitModuleRouting:
|
||||
"""Test that git_module routes merge, smart-sync, fix correctly."""
|
||||
|
||||
def test_merge_in_commands(self) -> None:
|
||||
from aipass.drone.apps.modules.git_module import _COMMANDS
|
||||
|
||||
assert "merge" in _COMMANDS
|
||||
|
||||
def test_smart_sync_in_commands(self) -> None:
|
||||
from aipass.drone.apps.modules.git_module import _COMMANDS
|
||||
|
||||
assert "smart-sync" in _COMMANDS
|
||||
|
||||
def test_fix_in_commands(self) -> None:
|
||||
from aipass.drone.apps.modules.git_module import _COMMANDS
|
||||
|
||||
assert "fix" in _COMMANDS
|
||||
|
||||
def test_get_help_includes_merge(self) -> None:
|
||||
from aipass.drone.apps.modules.git_module import get_help
|
||||
|
||||
assert "merge" in get_help()
|
||||
|
||||
def test_get_help_includes_smart_sync(self) -> None:
|
||||
from aipass.drone.apps.modules.git_module import get_help
|
||||
|
||||
assert "smart-sync" in get_help()
|
||||
|
||||
def test_get_help_includes_fix(self) -> None:
|
||||
from aipass.drone.apps.modules.git_module import get_help
|
||||
|
||||
assert "fix" in get_help()
|
||||
|
||||
def test_get_help_merge_specific(self) -> None:
|
||||
from aipass.drone.apps.modules.git_module import get_help
|
||||
|
||||
help_text = get_help("merge")
|
||||
assert "squash" in help_text.lower() or "Squash" in help_text
|
||||
|
||||
def test_get_help_smart_sync_specific(self) -> None:
|
||||
from aipass.drone.apps.modules.git_module import get_help
|
||||
|
||||
help_text = get_help("smart-sync")
|
||||
assert "rebase" in help_text.lower()
|
||||
|
||||
def test_get_help_fix_specific(self) -> None:
|
||||
from aipass.drone.apps.modules.git_module import get_help
|
||||
|
||||
help_text = get_help("fix")
|
||||
assert "broken" in help_text.lower() or "fix" in help_text.lower()
|
||||
|
||||
def test_get_introspective_includes_plugins(self) -> None:
|
||||
from aipass.drone.apps.modules.git_module import get_introspective
|
||||
|
||||
intro = get_introspective()
|
||||
assert "merge_plugin" in intro
|
||||
assert "sync_plugin" in intro
|
||||
assert "fix_plugin" in intro
|
||||
|
||||
def test_handle_merge_no_args(self) -> None:
|
||||
from aipass.drone.apps.modules.git_module import handle_command
|
||||
|
||||
result = handle_command("merge", [])
|
||||
assert result["exit_code"] == 1
|
||||
assert "Usage" in result["stderr"]
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.auth.verify_caller")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run")
|
||||
def test_handle_merge_routes_correctly(
|
||||
self,
|
||||
mock_run: MagicMock,
|
||||
mock_root: MagicMock,
|
||||
mock_verify: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from aipass.drone.apps.modules.git_module import handle_command
|
||||
|
||||
mock_verify.return_value = "devpulse"
|
||||
mock_root.return_value = tmp_path
|
||||
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stderr = ""
|
||||
proc.stdout = "ok\n"
|
||||
mock_run.return_value = proc
|
||||
|
||||
result = handle_command("merge", ["42"])
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.auth.verify_caller")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run")
|
||||
def test_handle_smart_sync_routes_correctly(
|
||||
self,
|
||||
mock_run: MagicMock,
|
||||
mock_root: MagicMock,
|
||||
mock_verify: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from aipass.drone.apps.modules.git_module import handle_command
|
||||
|
||||
mock_verify.return_value = "devpulse"
|
||||
mock_root.return_value = tmp_path
|
||||
|
||||
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stderr = ""
|
||||
if cmd[:3] == ["git", "rev-list", "--left-right"]:
|
||||
proc.stdout = "0\t0\n"
|
||||
else:
|
||||
proc.stdout = ""
|
||||
return proc
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
result = handle_command("smart-sync", [])
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.auth.verify_caller")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root")
|
||||
@patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run")
|
||||
def test_handle_fix_routes_correctly(
|
||||
self,
|
||||
mock_run: MagicMock,
|
||||
mock_root: MagicMock,
|
||||
mock_verify: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from aipass.drone.apps.modules.git_module import handle_command
|
||||
|
||||
mock_verify.return_value = "devpulse"
|
||||
mock_root.return_value = tmp_path
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
|
||||
def side_effect(cmd: list[str], **kwargs: object) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stderr = ""
|
||||
proc.stdout = ""
|
||||
if cmd[:3] == ["git", "symbolic-ref", "-q"]:
|
||||
proc.stdout = "refs/heads/main\n"
|
||||
elif cmd[:3] == ["git", "rev-list", "--left-right"]:
|
||||
proc.stdout = "0\t0\n"
|
||||
elif cmd[:3] == ["git", "diff", "--cached"]:
|
||||
proc.stdout = ""
|
||||
return proc
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
result = handle_command("fix", [])
|
||||
assert result["exit_code"] == 0
|
||||
Reference in New Issue
Block a user