feat(git): rebase recovery + lockfile auto-resolution
Task #1 + Task #2 combined: - _abort_rebase_verified: escalation ladder abort → quit → reset --hard ORIG_HEAD - pre_cycle_recover: detect orphan rebase/merge state at cycle start - Integrate into pre_cycle_sync: refuse to proceed if recovery fails - Consolidate push.py:_pull_rebase to delegate to operations.py:git_pull_rebase - conflict_resolution module with LOCKFILE_STRATEGIES (bun/npm/pnpm/yarn/cargo/uv/poetry) - try_auto_resolve_lockfiles: conservative — acts only when all conflicts are lockfiles - git_pull_rebase: attempt auto-resolution before falling back to abort
This commit is contained in:
parent
931c4d530a
commit
505362ed40
3 changed files with 241 additions and 18 deletions
148
src/auto_commit_service/git/conflict_resolution.py
Normal file
148
src/auto_commit_service/git/conflict_resolution.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue