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