diff --git a/src/auto_commit_service/git/conflict_resolution.py b/src/auto_commit_service/git/conflict_resolution.py new file mode 100644 index 0000000..7fff3b8 --- /dev/null +++ b/src/auto_commit_service/git/conflict_resolution.py @@ -0,0 +1,148 @@ +"""Auto-resolution strategies for common merge conflict classes.""" +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from .operations import GitError, _run_git_command + +logger = logging.getLogger(__name__) + +ResolutionSide = Literal["theirs", "ours"] + + +@dataclass(frozen=True) +class LockfileStrategy: + """How to auto-resolve a specific lockfile.""" + + filename: str + side: ResolutionSide + regen_command: tuple[str, ...] + regen_timeout_sec: int = 300 + + +# Ordered: most common first +LOCKFILE_STRATEGIES: tuple[LockfileStrategy, ...] = ( + LockfileStrategy("bun.lock", "theirs", ("bun", "install")), + LockfileStrategy("package-lock.json", "theirs", ("npm", "install", "--package-lock-only")), + LockfileStrategy("pnpm-lock.yaml", "theirs", ("pnpm", "install", "--lockfile-only")), + LockfileStrategy("yarn.lock", "theirs", ("yarn", "install", "--mode=update-lockfile")), + LockfileStrategy("Cargo.lock", "theirs", ("cargo", "generate-lockfile")), + LockfileStrategy("uv.lock", "theirs", ("uv", "lock")), + LockfileStrategy("poetry.lock", "theirs", ("poetry", "lock", "--no-update")), +) + +STRATEGY_BY_FILENAME: dict[str, LockfileStrategy] = { + s.filename: s for s in LOCKFILE_STRATEGIES +} + + +async def get_conflicted_files(repo_path: Path) -> list[str]: + """Return list of paths with unmerged entries.""" + stdout, _, _ = await _run_git_command( + "diff", "--name-only", "--diff-filter=U", cwd=repo_path, check=False + ) + return [line for line in stdout.split("\n") if line] + + +async def try_auto_resolve_lockfiles(repo_path: Path) -> dict: + """Attempt to auto-resolve conflicts if they are only in known lockfiles. + + Only acts when ALL conflicted files are known lockfiles (conservative). + If any regen fails, the function returns without partial commits — caller + falls through to abort. + + Returns dict: + - resolved: bool — all conflicts resolved and staged + - files_resolved: list[str] + - files_unresolvable: list[str] — non-lockfile conflicts + - regen_failed: list[str] — lockfiles where regen command failed + - error: str | None + """ + result: dict = { + "resolved": False, + "files_resolved": [], + "files_unresolvable": [], + "regen_failed": [], + "error": None, + } + + try: + conflicted = await get_conflicted_files(repo_path) + except Exception as e: + result["error"] = f"failed to list conflicted files: {e}" + return result + + if not conflicted: + result["resolved"] = True + return result + + # Classify: resolvable vs unresolvable + resolvable: list[tuple[str, LockfileStrategy]] = [] + for f in conflicted: + basename = Path(f).name + if basename in STRATEGY_BY_FILENAME: + resolvable.append((f, STRATEGY_BY_FILENAME[basename])) + else: + result["files_unresolvable"].append(f) + + if result["files_unresolvable"]: + logger.warning( + f"Non-lockfile conflicts in {repo_path}: " + f"{result['files_unresolvable']}, skipping auto-resolution" + ) + return result + + # Resolve each lockfile + for path, strategy in resolvable: + try: + await _run_git_command( + "checkout", f"--{strategy.side}", "--", path, cwd=repo_path + ) + except GitError as e: + result["regen_failed"].append(path) + logger.error(f"Git checkout --{strategy.side} failed for {path}: {e}") + continue + + proc = await asyncio.create_subprocess_exec( + *strategy.regen_command, + cwd=str(repo_path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + _, stderr_bytes = await asyncio.wait_for( + proc.communicate(), timeout=strategy.regen_timeout_sec + ) + except asyncio.TimeoutError: + proc.kill() + await proc.communicate() + result["regen_failed"].append(path) + logger.error(f"Regen timed out for {path}: {strategy.regen_command}") + continue + + if proc.returncode != 0: + result["regen_failed"].append(path) + stderr_text = stderr_bytes.decode()[:500] if stderr_bytes else "no stderr" + logger.error(f"Regen failed for {path}: {stderr_text}") + continue + + try: + await _run_git_command("add", "--", path, cwd=repo_path) + except GitError as e: + result["regen_failed"].append(path) + logger.error(f"Git add failed for {path} after regen: {e}") + continue + + result["files_resolved"].append(path) + logger.info( + f"Auto-resolved {path} via {strategy.side} + {strategy.regen_command[0]}" + ) + + result["resolved"] = ( + not result["files_unresolvable"] and not result["regen_failed"] + ) + return result diff --git a/src/auto_commit_service/git/operations.py b/src/auto_commit_service/git/operations.py index 6f2a760..8f96325 100644 --- a/src/auto_commit_service/git/operations.py +++ b/src/auto_commit_service/git/operations.py @@ -2,6 +2,7 @@ import asyncio import logging +import os from pathlib import Path from .repository import GitStatus, CommitResult, PushResult @@ -42,6 +43,7 @@ async def _run_git_command( cwd: Path, check: bool = True, stdin: bytes | None = None, + env: dict[str, str] | None = None, ) -> tuple[str, str, int]: """Run a git command asynchronously. @@ -54,7 +56,9 @@ async def _run_git_command( cwd: Working directory for the command check: Raise GitError on non-zero exit code stdin: Optional stdin data to send to the process + env: Optional env overrides merged onto os.environ """ + merged_env = {**os.environ, **(env or {})} # asyncio.create_subprocess_exec is safe - no shell, args passed directly create_process = asyncio.create_subprocess_exec proc = await create_process( @@ -64,6 +68,7 @@ async def _run_git_command( stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE if stdin is not None else None, + env=merged_env, ) stdout, stderr = await proc.communicate(input=stdin) stdout_str = stdout.decode().strip() @@ -361,6 +366,51 @@ async def git_push( ) +async def _abort_rebase_verified(repo_path: Path) -> bool: + """Run rebase --abort and verify the rebase state is cleared. + + Escalates to --quit + reset --hard ORIG_HEAD if abort doesn't clear state. + Returns True if repo is clean after recovery, False if unrecoverable. + """ + rebase_merge = repo_path / ".git" / "rebase-merge" + rebase_apply = repo_path / ".git" / "rebase-apply" + + await _run_git_command("rebase", "--abort", cwd=repo_path, check=False) + if not rebase_merge.exists() and not rebase_apply.exists(): + return True + + logger.warning(f"rebase --abort didn't clear state in {repo_path}, escalating to --quit") + await _run_git_command("rebase", "--quit", cwd=repo_path, check=False) + if not rebase_merge.exists() and not rebase_apply.exists(): + return True + + logger.error(f"rebase --quit also failed in {repo_path}, resetting to ORIG_HEAD") + await _run_git_command("reset", "--hard", "ORIG_HEAD", cwd=repo_path, check=False) + return not rebase_merge.exists() and not rebase_apply.exists() + + +async def pre_cycle_recover(repo_path: Path) -> dict: + """Detect and recover from orphan rebase/merge state left by a prior cycle. + + Returns dict: {was_stuck: bool, recovered: bool, state: str} + """ + rebase_merge = repo_path / ".git" / "rebase-merge" + rebase_apply = repo_path / ".git" / "rebase-apply" + merge_head = repo_path / ".git" / "MERGE_HEAD" + + if rebase_merge.exists() or rebase_apply.exists(): + logger.warning(f"Orphan rebase detected in {repo_path}, attempting recovery") + recovered = await _abort_rebase_verified(repo_path) + return {"was_stuck": True, "recovered": recovered, "state": "rebase"} + + if merge_head.exists(): + logger.warning(f"Orphan merge detected in {repo_path}, aborting") + await _run_git_command("merge", "--abort", cwd=repo_path, check=False) + return {"was_stuck": True, "recovered": not merge_head.exists(), "state": "merge"} + + return {"was_stuck": False, "recovered": False, "state": "clean"} + + async def git_pull_rebase( repo_path: Path, remote: str = "origin", @@ -377,8 +427,31 @@ async def git_pull_rebase( if returncode != 0: if "conflict" in stderr.lower() or "CONFLICT" in stderr: - # Abort the rebase - await _run_git_command("rebase", "--abort", cwd=repo_path, check=False) + from .conflict_resolution import try_auto_resolve_lockfiles + + resolution = await try_auto_resolve_lockfiles(repo_path) + if resolution["resolved"]: + _, cont_stderr, cont_rc = await _run_git_command( + "rebase", "--continue", + cwd=repo_path, check=False, + env={"GIT_EDITOR": "true"}, + ) + if cont_rc == 0: + logger.info( + f"Auto-resolved lockfile conflicts in {repo_path}: " + f"{resolution['files_resolved']}" + ) + return True + logger.warning( + f"rebase --continue failed after auto-resolution: {cont_stderr}" + ) + + if not await _abort_rebase_verified(repo_path): + raise GitError( + f"Rebase conflict and abort failed — repo stuck in {repo_path}", + stderr=stderr, + returncode=returncode, + ) raise MergeConflictError( f"Merge conflict during rebase: {stderr}", stderr=stderr, @@ -426,20 +499,28 @@ async def pre_cycle_sync( ) -> dict: """Sync repo with remote before a commit cycle. - Sequence: fetch -> check if behind -> skip if dirty -> pull --rebase. + Sequence: recover orphan state -> fetch -> check if behind -> skip if dirty -> pull --rebase. Never stashes — dirty trees are left alone and committed normally. All failures are non-fatal (logged and returned in result dict). - Returns dict with keys: fetched, pulled, behind_count, skipped_dirty, error + Returns dict with keys: fetched, pulled, behind_count, skipped_dirty, recovered, error """ result = { "fetched": False, "pulled": False, "behind_count": 0, "skipped_dirty": False, + "recovered": False, "error": None, } + recovery = await pre_cycle_recover(repo_path) + if recovery["was_stuck"]: + result["recovered"] = recovery["recovered"] + if not recovery["recovered"]: + result["error"] = f"orphan_{recovery['state']}_unrecovered" + return result + # Step 1: Fetch if not await git_fetch(repo_path, remote): result["error"] = "fetch_failed" diff --git a/src/auto_commit_service/pipeline/stages/push.py b/src/auto_commit_service/pipeline/stages/push.py index 4f68671..2840833 100644 --- a/src/auto_commit_service/pipeline/stages/push.py +++ b/src/auto_commit_service/pipeline/stages/push.py @@ -5,9 +5,14 @@ Sixth stage in the pipeline. Pushes commits to remote with retry logic. import logging import subprocess +from pathlib import Path from lilith_pipeline_framework import PipelineStage, StageResult, StageStatus +from ...git.operations import ( + MergeConflictError as GitMergeConflictError, + git_pull_rebase, +) from ..context import AutoCommitPipelineContext from ..models import MergeConflictError, PushRejectedError @@ -297,20 +302,9 @@ class PushCommitStage(PipelineStage): """ remote_branch = self._get_remote_branch(repo_path, remote, branch) try: - result = subprocess.run( - ["git", "pull", "--rebase", remote, remote_branch], - cwd=repo_path, - capture_output=True, - text=True, - check=True, - ) - except subprocess.CalledProcessError as e: - error_msg = e.stderr.lower() if e.stderr else "" - - if "conflict" in error_msg or "merge" in error_msg: - raise MergeConflictError(f"Merge conflict during rebase: {e.stderr}") - - raise Exception(f"Pull-rebase failed: {e.stderr}") + await git_pull_rebase(Path(repo_path), remote, remote_branch) + except GitMergeConflictError as e: + raise MergeConflictError(str(e)) from e async def _setup_remote_if_missing( self,