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:
AIPass
2026-03-30 16:18:07 -07:00
committed by GitHub
co-authored by @devpulse
parent 4b6b3cf665
commit c9b7feaf0c
7 changed files with 1203 additions and 3 deletions
@@ -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
+30
View File
@@ -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": {
+152 -2
View File
@@ -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