feat(system): S85: Drone git fix/sync/smart-sync — merge on divergence instead of rebase + STATUS update

Co-Authored-By: @devpulse <devpulse@aipass>
This commit is contained in:
AIOSAI
2026-04-10 10:53:49 -07:00
co-authored by @devpulse
parent 040611d098
commit 600dfbccd4
5 changed files with 185 additions and 90 deletions
@@ -31,27 +31,62 @@ def sync_main() -> dict:
repo_root = find_repo_root()
try:
result = subprocess.run(
checkout = subprocess.run(
["git", "checkout", "main"],
capture_output=True, text=True, cwd=str(repo_root),
)
if result.returncode != 0:
msg = f"Failed to checkout main: {result.stderr.strip()}"
if checkout.returncode != 0:
msg = f"Failed to checkout main: {checkout.stderr.strip()}"
logger.error(msg)
return {"success": False, "message": msg, "stdout": result.stdout}
return {"success": False, "message": msg, "stdout": checkout.stdout}
result = subprocess.run(
["git", "pull", "--rebase"],
# Fetch first to get latest remote state
fetch = subprocess.run(
["git", "fetch", "origin"],
capture_output=True, text=True, cwd=str(repo_root),
)
if result.returncode != 0:
msg = f"Failed to pull: {result.stderr.strip()}"
if fetch.returncode != 0:
msg = f"Failed to fetch: {fetch.stderr.strip()}"
logger.error(msg)
return {"success": False, "message": msg, "stdout": result.stdout}
return {"success": False, "message": msg, "stdout": ""}
stdout = result.stdout.strip()
msg = f"Synced main: {stdout}"
json_handler.log_operation("sync_main", {"result": stdout})
# Check divergence to choose strategy
rev_list = subprocess.run(
["git", "rev-list", "--left-right", "--count", "main...origin/main"],
capture_output=True, text=True, cwd=str(repo_root),
)
ahead, behind = 0, 0
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:
# Diverged — merge instead of rebase
result = subprocess.run(
["git", "merge", "origin/main", "--no-edit"],
capture_output=True, text=True, cwd=str(repo_root),
)
if result.returncode != 0:
msg = f"Merge conflict (ahead={ahead}, behind={behind}): {result.stderr.strip()}"
logger.error(msg)
return {"success": False, "message": msg, "stdout": result.stdout}
stdout = result.stdout.strip()
msg = f"Synced main via merge (was ahead={ahead}, behind={behind}): {stdout}"
else:
# Normal — pull with rebase
result = subprocess.run(
["git", "pull", "--rebase"],
capture_output=True, text=True, cwd=str(repo_root),
)
if result.returncode != 0:
msg = f"Failed to pull: {result.stderr.strip()}"
logger.error(msg)
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})
logger.info(msg)
return {"success": True, "message": msg, "stdout": stdout}
@@ -77,21 +77,56 @@ def fix_git_state(caller: str) -> dict:
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"],
# Check 3: Diverged from origin — fetch + merge
fetch = subprocess.run(
["git", "fetch", "origin"],
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)
if fetch.returncode != 0:
actions.append(f"Fetch failed: {fetch.stderr.strip()}")
logger.error("fix_git_state: fetch failed: %s", fetch.stderr.strip())
else:
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:
merge = subprocess.run(
["git", "merge", "origin/main", "--no-edit"],
capture_output=True, text=True, cwd=str(repo_root),
)
if merge.returncode == 0:
actions.append(
f"Merged origin/main (was ahead={ahead}, behind={behind})"
)
logger.info("fix_git_state: merged origin/main ahead=%d behind=%d", ahead, behind)
else:
# Report conflict files
diff = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=U"],
capture_output=True, text=True, cwd=str(repo_root),
)
conflict_files = diff.stdout.strip().splitlines() if diff.stdout.strip() else []
# Abort the failed merge
subprocess.run(
["git", "merge", "--abort"],
capture_output=True, text=True, cwd=str(repo_root),
)
if conflict_files:
actions.append(
f"Merge conflict (ahead={ahead}, behind={behind}). "
f"Conflicting files: {', '.join(conflict_files)}"
)
else:
actions.append(
f"Merge failed (ahead={ahead}, behind={behind}): "
f"{merge.stderr.strip()}"
)
logger.warning("fix_git_state: merge conflict ahead=%d behind=%d", ahead, behind)
# Check 4: Dirty index with no intent
cached = subprocess.run(
@@ -68,30 +68,62 @@ def smart_sync(caller: str) -> dict:
result["ahead"] = ahead
result["behind"] = behind
# Step 3: Rebase if behind
# Step 3: Sync 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"],
if ahead > 0:
# Diverged — merge instead of rebase
merge = subprocess.run(
["git", "merge", "origin/main", "--no-edit"],
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
if merge.returncode != 0:
# Merge conflict — abort and report
diff = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=U"],
capture_output=True, text=True, cwd=str(repo_root),
)
conflict_files = diff.stdout.strip().splitlines() if diff.stdout.strip() else []
subprocess.run(
["git", "merge", "--abort"],
capture_output=True, text=True, cwd=str(repo_root),
)
files_msg = f" Conflicting files: {', '.join(conflict_files)}" if conflict_files else ""
result["message"] = (
f"Merge conflict (ahead={ahead}, behind={behind}). "
f"Merge aborted.{files_msg}"
)
logger.error(result["message"])
return result
result["rebased"] = True
result["success"] = True
result["message"] = (
f"Rebased onto origin/main (was {behind} behind, {ahead} ahead)"
)
result["rebased"] = False
result["merged"] = True
result["success"] = True
result["message"] = (
f"Merged origin/main (was {ahead} ahead, {behind} behind)"
)
else:
# Only behind — rebase is safe
rebase = subprocess.run(
["git", "rebase", "origin/main"],
capture_output=True, text=True, cwd=str(repo_root),
)
if rebase.returncode != 0:
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)"
)
else:
result["success"] = True
result["message"] = "Already up to date"
+23 -15
View File
@@ -243,12 +243,12 @@ class TestSmartSyncBehind:
assert result["behind"] == 3
class TestSmartSyncDivergedRebaseSuccess:
"""smart_sync should rebase when diverged (behind > 0) and rebase succeeds."""
class TestSmartSyncDivergedMergeSuccess:
"""smart_sync should merge when diverged (ahead > 0 AND behind > 0)."""
@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(
def test_smart_sync_diverged_merge_ok(
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
) -> None:
mock_root.return_value = tmp_path
@@ -261,8 +261,8 @@ class TestSmartSyncDivergedRebaseSuccess:
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"
elif cmd[:2] == ["git", "merge"]:
proc.stdout = "Merge made by the 'ort' strategy.\n"
else:
proc.stdout = ""
return proc
@@ -272,17 +272,18 @@ class TestSmartSyncDivergedRebaseSuccess:
result = smart_sync("devpulse")
assert result["success"] is True
assert result["rebased"] is True
assert result.get("merged") is True
assert result["rebased"] is False
assert result["ahead"] == 2
assert result["behind"] == 5
class TestSmartSyncRebaseConflict:
"""smart_sync should abort rebase on conflict and return error."""
class TestSmartSyncMergeConflict:
"""smart_sync should abort merge on conflict when diverged."""
@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(
def test_smart_sync_merge_conflict(
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
) -> None:
mock_root.return_value = tmp_path
@@ -296,11 +297,14 @@ class TestSmartSyncRebaseConflict:
elif cmd[:3] == ["git", "rev-list", "--left-right"]:
proc.returncode = 0
proc.stdout = "1\t2\n"
elif cmd[:3] == ["git", "rebase", "origin/main"]:
elif cmd[:2] == ["git", "merge"] and "--abort" not in cmd:
proc.returncode = 1
proc.stdout = ""
proc.stderr = "CONFLICT"
elif cmd[:3] == ["git", "rebase", "--abort"]:
proc.stderr = "CONFLICT (content): Merge conflict"
elif cmd[:2] == ["git", "diff"]:
proc.returncode = 0
proc.stdout = "src/file1.py\nsrc/file2.py\n"
elif ["--abort"] == cmd[-1:]:
proc.returncode = 0
proc.stdout = ""
else:
@@ -396,11 +400,11 @@ class TestFixDetachedHead:
class TestFixDiverged:
"""fix_git_state should report divergence and suggest smart-sync."""
"""fix_git_state should fetch and merge when diverged."""
@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(
def test_fix_diverged_merges(
self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path
) -> None:
mock_root.return_value = tmp_path
@@ -414,8 +418,12 @@ class TestFixDiverged:
proc.stdout = ""
if cmd[:3] == ["git", "symbolic-ref", "-q"]:
proc.stdout = "refs/heads/main\n"
elif cmd[:3] == ["git", "fetch", "origin"]:
proc.stdout = ""
elif cmd[:3] == ["git", "rev-list", "--left-right"]:
proc.stdout = "3\t2\n" # diverged
elif cmd[:2] == ["git", "merge"]:
proc.stdout = "Merge made by the 'ort' strategy.\n"
elif cmd[:3] == ["git", "diff", "--cached"]:
proc.stdout = ""
return proc
@@ -425,7 +433,7 @@ class TestFixDiverged:
result = fix_git_state("devpulse")
assert result["success"] is True
assert any("smart-sync" in a for a in result["actions_taken"])
assert any("merged" in a.lower() for a in result["actions_taken"])
class TestFixCleanState:
+15 -30
View File
@@ -283,19 +283,14 @@ class TestSyncHandler:
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
mock_checkout = MagicMock()
mock_checkout.returncode = 0
mock_checkout.stdout = "Switched to branch 'main'"
mock_checkout.stderr = ""
mock_pull = MagicMock()
mock_pull.returncode = 0
mock_pull.stdout = "Already up to date."
mock_pull.stderr = ""
mock_checkout = MagicMock(returncode=0, stdout="Switched to branch 'main'", stderr="")
mock_fetch = MagicMock(returncode=0, stdout="", stderr="")
mock_rev_list = MagicMock(returncode=0, stdout="0\t0\n", stderr="")
mock_pull = MagicMock(returncode=0, stdout="Already up to date.", stderr="")
with patch(
"aipass.drone.apps.handlers.git.sync_handler.subprocess.run",
side_effect=[mock_checkout, mock_pull],
side_effect=[mock_checkout, mock_fetch, mock_rev_list, mock_pull],
):
result = sync_main()
@@ -328,19 +323,14 @@ class TestSyncHandler:
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
mock_checkout = MagicMock()
mock_checkout.returncode = 0
mock_checkout.stdout = ""
mock_checkout.stderr = ""
mock_pull = MagicMock()
mock_pull.returncode = 1
mock_pull.stdout = ""
mock_pull.stderr = "fatal: unable to access remote"
mock_checkout = MagicMock(returncode=0, stdout="", stderr="")
mock_fetch = MagicMock(returncode=0, stdout="", stderr="")
mock_rev_list = MagicMock(returncode=0, stdout="0\t1\n", stderr="")
mock_pull = MagicMock(returncode=1, stdout="", stderr="fatal: unable to access remote")
with patch(
"aipass.drone.apps.handlers.git.sync_handler.subprocess.run",
side_effect=[mock_checkout, mock_pull],
side_effect=[mock_checkout, mock_fetch, mock_rev_list, mock_pull],
):
result = sync_main()
@@ -543,19 +533,14 @@ class TestGitModuleRouting:
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
mock_checkout = MagicMock()
mock_checkout.returncode = 0
mock_checkout.stdout = ""
mock_checkout.stderr = ""
mock_pull = MagicMock()
mock_pull.returncode = 0
mock_pull.stdout = "Already up to date."
mock_pull.stderr = ""
mock_checkout = MagicMock(returncode=0, stdout="", stderr="")
mock_fetch = MagicMock(returncode=0, stdout="", stderr="")
mock_rev_list = MagicMock(returncode=0, stdout="0\t0\n", stderr="")
mock_pull = MagicMock(returncode=0, stdout="Already up to date.", stderr="")
with patch(
"aipass.drone.apps.handlers.git.sync_handler.subprocess.run",
side_effect=[mock_checkout, mock_pull],
side_effect=[mock_checkout, mock_fetch, mock_rev_list, mock_pull],
):
result = handle_command("sync")