diff --git a/src/aipass/drone/apps/handlers/git/sync_handler.py b/src/aipass/drone/apps/handlers/git/sync_handler.py index a1354fb1..b5835040 100644 --- a/src/aipass/drone/apps/handlers/git/sync_handler.py +++ b/src/aipass/drone/apps/handlers/git/sync_handler.py @@ -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} 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 86637a66..9a83c6f2 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py @@ -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( diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py index 7a0c07a3..cb19b960 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py @@ -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" diff --git a/src/aipass/drone/tests/test_devpulse_plugins.py b/src/aipass/drone/tests/test_devpulse_plugins.py index 30c3d72b..8ba7194b 100644 --- a/src/aipass/drone/tests/test_devpulse_plugins.py +++ b/src/aipass/drone/tests/test_devpulse_plugins.py @@ -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: diff --git a/src/aipass/drone/tests/test_git_module.py b/src/aipass/drone/tests/test_git_module.py index 44960f5f..f65a8364 100644 --- a/src/aipass/drone/tests/test_git_module.py +++ b/src/aipass/drone/tests/test_git_module.py @@ -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")