Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions PyGitUp/git_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"""


__all__ = ['GitWrapper', 'GitError']
__all__ = ['GitWrapper', 'GitError', 'UnresolvedConflictError']

###############################################################################
# IMPORTS
Expand Down Expand Up @@ -144,9 +144,11 @@ def stash():

stashed[0] = True

stash.suppress_pop = False

yield stash

if stashed[0]:
if stashed[0] and not stash.suppress_pop:
print(colored('unstashing', 'magenta'))
try:
self._run('stash', 'pop')
Expand Down Expand Up @@ -341,3 +343,19 @@ def __init__(self, current_branch, target_branch, **kwargs):
current_branch, target_branch
)
GitError.__init__(self, message, **kwargs)


class UnresolvedConflictError(GitError):
"""
Rebase conflict could not be resolved. Repo left in conflicted state.
"""

def __init__(self, branch_name, target_branch, repo_path, **kwargs):
kwargs.pop('message', None)
message = (
f"Failed to resolve rebase conflicts for {branch_name} "
f"onto {target_branch}.\n"
f"The repo at {repo_path} is left in a conflicted state.\n"
f"Resolve manually, then run: git rebase --continue"
)
GitError.__init__(self, message, **kwargs)
94 changes: 92 additions & 2 deletions PyGitUp/gitup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import os
import re
import json
import shlex
import subprocess
from io import StringIO
from tempfile import NamedTemporaryFile
Expand All @@ -38,7 +39,8 @@

# PyGitUp libs
from PyGitUp.utils import execute, uniq, find
from PyGitUp.git_wrapper import GitWrapper, GitError
from PyGitUp.git_wrapper import GitWrapper, GitError, RebaseError, \
UnresolvedConflictError

ON_WINDOWS = sys.platform == 'win32'

Expand Down Expand Up @@ -98,6 +100,7 @@ class GitUp:
'rebase.arguments': None,
'rebase.auto': True,
'rebase.log-hook': None,
'rebase.conflict-resolver': None,
'updates.check': True,
'push.auto': False,
'push.tags': False,
Expand Down Expand Up @@ -296,7 +299,16 @@ def rebase_all_branches(self):
else:
stasher()
self.git.checkout(branch.name)
self.git.rebase(target)
try:
self.git.rebase(target)
except RebaseError:
if self._try_resolve_conflicts(
branch.name, target.name,
self.repo.working_dir
):
continue
stasher.suppress_pop = True
raise

if (self.repo.head.is_detached # Only on Travis CI,
# we get a detached head after doing our rebase *confused*.
Expand All @@ -306,6 +318,84 @@ def rebase_all_branches(self):
'magenta'))
original_branch.checkout()

def _build_resolver_prompt(self, branch_name, target_name, repo_path):
"""Build the default prompt with conflict context."""
try:
result = subprocess.run(
['git', 'diff', '--name-only', '--diff-filter=U'],
cwd=repo_path, capture_output=True, text=True
)
conflicted = result.stdout.strip()
except Exception:
conflicted = '(unable to determine)'

return (
f"Resolve the git rebase conflicts in this repository.\n\n"
f"Branch '{branch_name}' is being rebased onto "
f"'{target_name}'.\n\n"
f"Conflicted files:\n{conflicted}\n\n"
f"Steps:\n"
f"1. Read each conflicted file and resolve the conflict "
f"markers\n"
f"2. Stage resolved files with `git add`\n"
f"3. Run `git rebase --continue`\n"
f"4. If further conflicts arise, repeat steps 1-3\n"
f"5. Exit when the rebase is fully complete"
)

def _try_resolve_conflicts(self, branch_name, target_name, repo_path):
"""
Invoke the configured conflict resolver command.

Returns True if the resolver succeeded and rebase completed.
Returns False if no resolver is configured.
Raises UnresolvedConflictError if the resolver failed.
"""
resolver_template = self.settings['rebase.conflict-resolver']
if not resolver_template:
return False

print(colored('invoking conflict resolver...', 'yellow'))

prompt = self._build_resolver_prompt(
branch_name, target_name, repo_path
)
command = resolver_template.replace(
'{prompt}', shlex.quote(prompt)
)

env = os.environ.copy()
env['GITUP_BRANCH'] = branch_name
env['GITUP_TARGET'] = target_name
env['GITUP_REPO_PATH'] = repo_path

result = subprocess.run(
command, shell=True, cwd=repo_path, env=env
)

if result.returncode != 0:
raise UnresolvedConflictError(
branch_name, target_name, repo_path
)

# Verify rebase completed
git_dir = subprocess.run(
['git', 'rev-parse', '--git-dir'],
cwd=repo_path, capture_output=True, text=True
).stdout.strip()

if not os.path.isabs(git_dir):
git_dir = os.path.join(repo_path, git_dir)

if (os.path.isdir(os.path.join(git_dir, 'rebase-merge')) or
os.path.isdir(os.path.join(git_dir, 'rebase-apply'))):
raise UnresolvedConflictError(
branch_name, target_name, repo_path
)

print(colored('conflict resolved', 'green'))
return True

def fetch(self):
"""
Fetch the recent refs from the remotes.
Expand Down
129 changes: 129 additions & 0 deletions PyGitUp/tests/test_conflict_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# System imports
import os
import stat
from os.path import join

import pytest
from git import *
from PyGitUp.git_wrapper import RebaseError, UnresolvedConflictError
from PyGitUp.tests import basepath, write_file, init_master, update_file, \
testfile_name

test_name_success = 'conflict_resolve_success'
test_name_fail = 'conflict_resolve_fail'
test_name_noresolver = 'conflict_no_resolver'

repo_path_success = join(basepath, test_name_success + os.sep)
repo_path_fail = join(basepath, test_name_fail + os.sep)
repo_path_noresolver = join(basepath, test_name_noresolver + os.sep)


def setup_conflict_repo(test_name):
"""Set up a repo with a rebase conflict."""
master_path, master = init_master(test_name)

# Prepare master repo
master.git.checkout(b=test_name)

# Clone to test repo
path = join(basepath, test_name)
master.clone(path, b=test_name)
repo = Repo(path, odbt=GitCmdObjectDB)
assert repo.working_dir == path

# Modify file in master
update_file(master, test_name)

# Modify same file in our repo (conflicting change)
contents = 'completely changed!'
repo_file = join(path, testfile_name)
write_file(repo_file, contents)
repo.index.add([repo_file])
repo.index.commit(test_name)

# Modify file in master again
update_file(master, test_name)

return master, repo


def make_resolver_script(basedir, script_content):
"""Write a resolver shell script and return its path."""
script_path = join(basedir, 'resolver.sh')
write_file(script_path, script_content)
os.chmod(script_path, stat.S_IRWXU)
return script_path


def setup_module():
global master_success, repo_success
global master_fail, repo_fail
global master_noresolver, repo_noresolver

master_success, repo_success = setup_conflict_repo(test_name_success)
master_fail, repo_fail = setup_conflict_repo(test_name_fail)
master_noresolver, repo_noresolver = setup_conflict_repo(
test_name_noresolver
)


def test_resolver_succeeds():
"""Resolver fixes conflicts and completes rebase."""
os.chdir(repo_path_success)

script = make_resolver_script(repo_path_success, (
'#!/bin/bash\n'
'git checkout --theirs .\n'
'git add -A\n'
'GIT_EDITOR=true git rebase --continue\n'
))

from PyGitUp.gitup import GitUp
gitup = GitUp(testing=True)
gitup.settings['rebase.conflict-resolver'] = script + ' {prompt}'
gitup.run()

assert 'rebasing' in gitup.states


def test_resolver_fails():
"""Resolver exits non-zero; UnresolvedConflictError is raised."""
os.chdir(repo_path_fail)

script = make_resolver_script(repo_path_fail, (
'#!/bin/bash\n'
'exit 1\n'
))

from PyGitUp.gitup import GitUp
gitup = GitUp(testing=True)
gitup.settings['rebase.conflict-resolver'] = script + ' {prompt}'

with pytest.raises(UnresolvedConflictError):
gitup.run()


def test_no_resolver():
"""Without a resolver, RebaseError is raised as before."""
os.chdir(repo_path_noresolver)

from PyGitUp.gitup import GitUp
gitup = GitUp(testing=True)

with pytest.raises(RebaseError):
gitup.run()


def test_prompt_content():
"""Prompt includes branch name, target, and instructions."""
from PyGitUp.gitup import GitUp
os.chdir(repo_path_success)

gitup = GitUp(testing=True)
prompt = gitup._build_resolver_prompt(
'my-branch', 'origin/main', repo_path_success
)

assert 'my-branch' in prompt
assert 'origin/main' in prompt
assert 'Resolve the git rebase conflicts' in prompt
24 changes: 24 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,30 @@ options:
``PyGitUp`` will show the hashes of the current commit (or the point
where the rebase starts) and the target commit like ``git pull`` does.

- ``git-up.rebase.conflict-resolver [cmd]``: If set, ``PyGitUp`` will
invoke this command when a rebase conflict occurs. The command should
contain a ``{prompt}`` placeholder, which will be replaced with a
shell-escaped prompt containing conflict context (branch names,
conflicted files, and resolution steps). The command is expected to
resolve all conflicts, stage the files, and run
``git rebase --continue``. If it exits with code 0 and the rebase is
complete, ``git up`` continues to the next branch. If it fails, the
repo is left in the conflicted state for manual resolution.

Environment variables ``GITUP_BRANCH``, ``GITUP_TARGET``, and
``GITUP_REPO_PATH`` are also set for the resolver process.

Examples::

git config git-up.rebase.conflict-resolver "claude -p {prompt}"
git config git-up.rebase.conflict-resolver "claude -p {prompt} --dangerously-skip-permissions"
git config git-up.rebase.conflict-resolver "aider --message {prompt}"

Note: AI agents like ``claude`` may prompt for tool approvals by
default. Use ``--dangerously-skip-permissions`` to run fully
autonomously, or ``--allowedTools 'Edit,Read,Bash,Write,Glob,Grep'``
to scope the permissions.

New in v1.0.0:
~~~~~~~~~~~~~~

Expand Down
Loading