feat(drone): UX fixes batch: sync --autostash, fix --dry-run + help, mail view N by index (B1/B2/B3 from issue #360)

Co-Authored-By: @drone <drone@aipass>
This commit is contained in:
AIOSAI
2026-04-20 14:28:37 -07:00
co-authored by @drone
parent b30beb5d4a
commit 7860fdba13
4 changed files with 183 additions and 11 deletions
+38
View File
@@ -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
@@ -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}
+27 -7
View File
@@ -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 (
@@ -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"