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:
autocommit 2026-04-18 11:32:27 -07:00
parent 931c4d530a
commit 505362ed40
3 changed files with 241 additions and 18 deletions

View 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

View file

@ -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"

View file

@ -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,