diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py index 815370ab..896d7e21 100644 --- a/src/aipass/drone/apps/drone.py +++ b/src/aipass/drone/apps/drone.py @@ -357,6 +357,40 @@ def _handle_custom_command(args: list[str]) -> int: return result.exit_code +def _read_inbox_message_id(inbox: Path, n: int) -> str | None: + """Return the ID of the Nth message (1-based) from inbox.json, or None.""" + import json as _json + + try: + data = _json.loads(inbox.read_text(encoding="utf-8")) + messages = data.get("messages", []) + if 1 <= n <= len(messages): + return messages[n - 1]["id"] + except Exception as exc: + logger.warning("Failed to resolve mail index %d: %s", n, exc) + return None + + +def _resolve_mail_index(n: int) -> str: + """Translate a 1-based inbox list index to a message ID. + + Walks up from CWD to find the branch's .ai_mail.local/inbox.json, + returns the ID of the Nth message (1-based). Falls back to str(n) + if the inbox cannot be found or index is out of range. + """ + cwd = Path.cwd() + for parent in [cwd] + list(cwd.parents): + if not (parent / ".trinity" / "passport.json").exists(): + continue + inbox = parent / ".ai_mail.local" / "inbox.json" + if inbox.exists(): + msg_id = _read_inbox_message_id(inbox, n) + if msg_id is not None: + return msg_id + break + return str(n) + + def _handle_target(args: List[str]) -> int: """Handle `drone @target command [args]` or `drone @target --help`.""" target = args[0] @@ -414,6 +448,10 @@ def _handle_target(args: List[str]) -> int: command = rest[0] cmd_args = rest[1:] + # B3: translate numeric inbox index to message ID for @ai_mail view N + if module_name == "ai_mail" and command == "view" and cmd_args and cmd_args[0].isdigit(): + cmd_args = [_resolve_mail_index(int(cmd_args[0]))] + cmd_args[1:] + # needs_interactive already computed above interactive = needs_interactive diff --git a/src/aipass/drone/apps/handlers/git/sync_handler.py b/src/aipass/drone/apps/handlers/git/sync_handler.py index 014b6caf..5cc85af9 100644 --- a/src/aipass/drone/apps/handlers/git/sync_handler.py +++ b/src/aipass/drone/apps/handlers/git/sync_handler.py @@ -22,13 +22,18 @@ from aipass.drone.apps.handlers.json import json_handler from aipass.drone.apps.handlers.git.lock_handler import find_repo_root -def sync_main() -> dict: +def sync_main(autostash: bool = False) -> dict: """Checkout main and pull latest changes. + Args: + autostash: If True, stash local changes before pull and restore after. + Use when sync fails with 'unstaged changes' error. + Returns: Dict with success (bool), message (str), and stdout (str). """ repo_root = find_repo_root() + stashed = False try: checkout = subprocess.run( @@ -42,6 +47,15 @@ def sync_main() -> dict: logger.error(msg) return {"success": False, "message": msg, "stdout": checkout.stdout} + if autostash: + stash = subprocess.run( + ["git", "stash"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + stashed = "No local changes to save" not in stash.stdout + # Fetch first to get latest remote state fetch = subprocess.run( ["git", "fetch", "origin"], @@ -52,6 +66,13 @@ def sync_main() -> dict: if fetch.returncode != 0: msg = f"Failed to fetch: {fetch.stderr.strip()}" logger.error(msg) + if stashed: + subprocess.run( + ["git", "stash", "pop"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) return {"success": False, "message": msg, "stdout": ""} # Check divergence to choose strategy @@ -78,6 +99,13 @@ def sync_main() -> dict: if result.returncode != 0: msg = f"Merge conflict (ahead={ahead}, behind={behind}): {result.stderr.strip()}" logger.error(msg) + if stashed: + subprocess.run( + ["git", "stash", "pop"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) return {"success": False, "message": msg, "stdout": result.stdout} stdout = result.stdout.strip() msg = f"Synced main via merge (was ahead={ahead}, behind={behind}): {stdout}" @@ -90,13 +118,39 @@ def sync_main() -> dict: cwd=str(repo_root), ) if result.returncode != 0: - msg = f"Failed to pull: {result.stderr.strip()}" + raw = result.stderr.strip() + msg = f"Failed to pull: {raw}" + if not autostash and ("unstaged changes" in raw or "uncommitted changes" in raw): + msg += "\n Tip: retry with 'drone @git sync --autostash' to stash local changes first" logger.error(msg) + if stashed: + subprocess.run( + ["git", "stash", "pop"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) return {"success": False, "message": msg, "stdout": result.stdout} stdout = result.stdout.strip() msg = f"Synced main: {stdout}" - json_handler.log_operation("sync_main", {"result": stdout, "ahead": ahead, "behind": behind}) + if stashed: + pop = subprocess.run( + ["git", "stash", "pop"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + if pop.returncode != 0: + logger.warning( + "sync_main: autostash pop failed (manual restore may be needed): %s", + pop.stderr.strip(), + ) + + json_handler.log_operation( + "sync_main", + {"result": stdout, "ahead": ahead, "behind": behind, "autostash": autostash}, + ) logger.info(msg) return {"success": True, "message": msg, "stdout": stdout} diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index a0bc65fd..075bb37b 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -94,7 +94,7 @@ def handle_command(command: str | None = None, args: list[str] | None = None) -> if command == "status": return _handle_status() if command == "sync": - return _handle_sync() + return _handle_sync(args) if command == "lock": return _handle_lock() if command == "unlock": @@ -263,7 +263,8 @@ def _handle_fix(args: list[str]) -> dict: "exit_code": 1, } - result = fix_git_state(caller) + dry_run = "--dry-run" in (args or []) + result = fix_git_state(caller, dry_run=dry_run) if result["success"]: return { @@ -336,9 +337,10 @@ def _handle_status() -> dict: } -def _handle_sync() -> dict: +def _handle_sync(args: list[str]) -> dict: """Handle the sync subcommand.""" - result = sync_handler.sync_main() + autostash = "--autostash" in args + result = sync_handler.sync_main(autostash=autostash) if result["success"]: return { @@ -404,7 +406,12 @@ def get_help(command: str | None = None) -> str: if command == "status": return "git status — Show git status filtered to your branch directory\n" if command == "sync": - return "git sync — Checkout main and pull latest changes\n" + return ( + "git sync [--autostash] — Checkout main and pull latest changes\n" + " Options:\n" + " --autostash Stash local changes before pull and restore after.\n" + " Use when sync fails with 'unstaged changes' error.\n" + ) if command == "lock": return "git lock — Check current lock status\n Shows lock holder, age, stale/orphan detection.\n" if command == "unlock": @@ -427,8 +434,21 @@ def get_help(command: str | None = None) -> str: ) 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" + "git fix [--dry-run] — Detect and fix common broken git states (devpulse only)\n" + "\n" + "Detected states and actions:\n" + " Stuck rebase .git/rebase-merge or rebase-apply exists\n" + " → git rebase --abort\n" + " Detached HEAD HEAD is not on a named branch\n" + " → git checkout main\n" + " Diverged local main and origin/main have diverged\n" + " → git fetch + git merge origin/main --no-edit\n" + " Dirty index files are staged but not committed\n" + " → git reset HEAD (unstages all)\n" + "\n" + "Options:\n" + " --dry-run Report detected states and proposed actions without\n" + " executing any fixes. Safe to run anytime.\n" ) return ( diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py index 7943c4fb..5039e27b 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py @@ -85,7 +85,64 @@ def _fix_divergence(repo_root: Path, actions: list[str]) -> None: logger.warning("fix_git_state: merge conflict ahead=%d behind=%d", ahead, behind) -def fix_git_state(caller: str) -> dict: +def _detect_only(repo_root: Path, git_dir: Path) -> dict: + """Run all fix checks and report detected states without executing any fixes.""" + detected: list[str] = [] + + if (git_dir / "rebase-merge").exists() or (git_dir / "rebase-apply").exists(): + detected.append("Stuck rebase → would run: git rebase --abort") + + sym_ref = subprocess.run( + ["git", "symbolic-ref", "-q", "HEAD"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + if sym_ref.returncode != 0: + detected.append("Detached HEAD → would run: git checkout main") + + fetch = subprocess.run( + ["git", "fetch", "origin"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + if fetch.returncode == 0: + 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: + detected.append( + f"Diverged from origin (ahead={ahead}, behind={behind})" + " → would run: git merge origin/main --no-edit" + ) + + 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(): + n = len(cached.stdout.strip().splitlines()) + detected.append(f"Dirty index ({n} staged file(s)) → would run: git reset HEAD") + + if detected: + msg = "Dry run — would fix:\n " + "\n ".join(detected) + else: + msg = "Dry run — git state looks clean, nothing to fix" + + return {"success": True, "actions_taken": [], "message": msg} + + +def fix_git_state(caller: str, dry_run: bool = False) -> dict: """Detect and fix common broken git states. Checks are run in sequence; multiple fixes can happen in one call. @@ -107,6 +164,9 @@ def fix_git_state(caller: str) -> dict: } try: + if dry_run: + return _detect_only(repo_root, git_dir) + # Check 1: Stuck in rebase rebase_merge = git_dir / "rebase-merge" rebase_apply = git_dir / "rebase-apply"