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:
@@ -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}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user