Skip to content

Commit a9b7a51

Browse files
abersnazeclaude
andcommitted
feat: add configurable conflict resolver for rebase conflicts
When `git up` encounters a rebase conflict, it can now invoke a configurable command to resolve it automatically. Configure via: git config git-up.rebase.conflict-resolver "claude -p {prompt}" git-up builds a rich default prompt with branch names, conflicted files, and resolution steps, then substitutes it into the {prompt} placeholder. Environment variables GITUP_BRANCH, GITUP_TARGET, and GITUP_REPO_PATH are also set. If the resolver succeeds (exit 0 and rebase completed), git-up continues to the next branch. If it fails, the repo is left in the conflicted state for manual resolution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 531cbe3 commit a9b7a51

3 files changed

Lines changed: 241 additions & 4 deletions

File tree

PyGitUp/git_wrapper.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"""
99

1010

11-
__all__ = ['GitWrapper', 'GitError']
11+
__all__ = ['GitWrapper', 'GitError', 'UnresolvedConflictError']
1212

1313
###############################################################################
1414
# IMPORTS
@@ -144,9 +144,11 @@ def stash():
144144

145145
stashed[0] = True
146146

147+
stash.suppress_pop = False
148+
147149
yield stash
148150

149-
if stashed[0]:
151+
if stashed[0] and not stash.suppress_pop:
150152
print(colored('unstashing', 'magenta'))
151153
try:
152154
self._run('stash', 'pop')
@@ -341,3 +343,19 @@ def __init__(self, current_branch, target_branch, **kwargs):
341343
current_branch, target_branch
342344
)
343345
GitError.__init__(self, message, **kwargs)
346+
347+
348+
class UnresolvedConflictError(GitError):
349+
"""
350+
Rebase conflict could not be resolved. Repo left in conflicted state.
351+
"""
352+
353+
def __init__(self, branch_name, target_branch, repo_path, **kwargs):
354+
kwargs.pop('message', None)
355+
message = (
356+
f"Failed to resolve rebase conflicts for {branch_name} "
357+
f"onto {target_branch}.\n"
358+
f"The repo at {repo_path} is left in a conflicted state.\n"
359+
f"Resolve manually, then run: git rebase --continue"
360+
)
361+
GitError.__init__(self, message, **kwargs)

PyGitUp/gitup.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import os
1616
import re
1717
import json
18+
import shlex
1819
import subprocess
1920
from io import StringIO
2021
from tempfile import NamedTemporaryFile
@@ -38,7 +39,8 @@
3839

3940
# PyGitUp libs
4041
from PyGitUp.utils import execute, uniq, find
41-
from PyGitUp.git_wrapper import GitWrapper, GitError
42+
from PyGitUp.git_wrapper import GitWrapper, GitError, RebaseError, \
43+
UnresolvedConflictError
4244

4345
ON_WINDOWS = sys.platform == 'win32'
4446

@@ -98,6 +100,7 @@ class GitUp:
98100
'rebase.arguments': None,
99101
'rebase.auto': True,
100102
'rebase.log-hook': None,
103+
'rebase.conflict-resolver': None,
101104
'updates.check': True,
102105
'push.auto': False,
103106
'push.tags': False,
@@ -296,7 +299,16 @@ def rebase_all_branches(self):
296299
else:
297300
stasher()
298301
self.git.checkout(branch.name)
299-
self.git.rebase(target)
302+
try:
303+
self.git.rebase(target)
304+
except RebaseError:
305+
if self._try_resolve_conflicts(
306+
branch.name, target.name,
307+
self.repo.working_dir
308+
):
309+
continue
310+
stasher.suppress_pop = True
311+
raise
300312

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

321+
def _build_resolver_prompt(self, branch_name, target_name, repo_path):
322+
"""Build the default prompt with conflict context."""
323+
try:
324+
result = subprocess.run(
325+
['git', 'diff', '--name-only', '--diff-filter=U'],
326+
cwd=repo_path, capture_output=True, text=True
327+
)
328+
conflicted = result.stdout.strip()
329+
except Exception:
330+
conflicted = '(unable to determine)'
331+
332+
return (
333+
f"Resolve the git rebase conflicts in this repository.\n\n"
334+
f"Branch '{branch_name}' is being rebased onto "
335+
f"'{target_name}'.\n\n"
336+
f"Conflicted files:\n{conflicted}\n\n"
337+
f"Steps:\n"
338+
f"1. Read each conflicted file and resolve the conflict "
339+
f"markers\n"
340+
f"2. Stage resolved files with `git add`\n"
341+
f"3. Run `git rebase --continue`\n"
342+
f"4. If further conflicts arise, repeat steps 1-3\n"
343+
f"5. Exit when the rebase is fully complete"
344+
)
345+
346+
def _try_resolve_conflicts(self, branch_name, target_name, repo_path):
347+
"""
348+
Invoke the configured conflict resolver command.
349+
350+
Returns True if the resolver succeeded and rebase completed.
351+
Returns False if no resolver is configured.
352+
Raises UnresolvedConflictError if the resolver failed.
353+
"""
354+
resolver_template = self.settings['rebase.conflict-resolver']
355+
if not resolver_template:
356+
return False
357+
358+
print(colored('invoking conflict resolver...', 'yellow'))
359+
360+
prompt = self._build_resolver_prompt(
361+
branch_name, target_name, repo_path
362+
)
363+
command = resolver_template.replace(
364+
'{prompt}', shlex.quote(prompt)
365+
)
366+
367+
env = os.environ.copy()
368+
env['GITUP_BRANCH'] = branch_name
369+
env['GITUP_TARGET'] = target_name
370+
env['GITUP_REPO_PATH'] = repo_path
371+
372+
result = subprocess.run(
373+
command, shell=True, cwd=repo_path, env=env
374+
)
375+
376+
if result.returncode != 0:
377+
raise UnresolvedConflictError(
378+
branch_name, target_name, repo_path
379+
)
380+
381+
# Verify rebase completed
382+
git_dir = subprocess.run(
383+
['git', 'rev-parse', '--git-dir'],
384+
cwd=repo_path, capture_output=True, text=True
385+
).stdout.strip()
386+
387+
if not os.path.isabs(git_dir):
388+
git_dir = os.path.join(repo_path, git_dir)
389+
390+
if (os.path.isdir(os.path.join(git_dir, 'rebase-merge')) or
391+
os.path.isdir(os.path.join(git_dir, 'rebase-apply'))):
392+
raise UnresolvedConflictError(
393+
branch_name, target_name, repo_path
394+
)
395+
396+
print(colored('conflict resolved', 'green'))
397+
return True
398+
309399
def fetch(self):
310400
"""
311401
Fetch the recent refs from the remotes.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# System imports
2+
import os
3+
import stat
4+
from os.path import join
5+
6+
import pytest
7+
from git import *
8+
from PyGitUp.git_wrapper import RebaseError, UnresolvedConflictError
9+
from PyGitUp.tests import basepath, write_file, init_master, update_file, \
10+
testfile_name
11+
12+
test_name_success = 'conflict_resolve_success'
13+
test_name_fail = 'conflict_resolve_fail'
14+
test_name_noresolver = 'conflict_no_resolver'
15+
16+
repo_path_success = join(basepath, test_name_success + os.sep)
17+
repo_path_fail = join(basepath, test_name_fail + os.sep)
18+
repo_path_noresolver = join(basepath, test_name_noresolver + os.sep)
19+
20+
21+
def setup_conflict_repo(test_name):
22+
"""Set up a repo with a rebase conflict."""
23+
master_path, master = init_master(test_name)
24+
25+
# Prepare master repo
26+
master.git.checkout(b=test_name)
27+
28+
# Clone to test repo
29+
path = join(basepath, test_name)
30+
master.clone(path, b=test_name)
31+
repo = Repo(path, odbt=GitCmdObjectDB)
32+
assert repo.working_dir == path
33+
34+
# Modify file in master
35+
update_file(master, test_name)
36+
37+
# Modify same file in our repo (conflicting change)
38+
contents = 'completely changed!'
39+
repo_file = join(path, testfile_name)
40+
write_file(repo_file, contents)
41+
repo.index.add([repo_file])
42+
repo.index.commit(test_name)
43+
44+
# Modify file in master again
45+
update_file(master, test_name)
46+
47+
return master, repo
48+
49+
50+
def make_resolver_script(basedir, script_content):
51+
"""Write a resolver shell script and return its path."""
52+
script_path = join(basedir, 'resolver.sh')
53+
write_file(script_path, script_content)
54+
os.chmod(script_path, stat.S_IRWXU)
55+
return script_path
56+
57+
58+
def setup_module():
59+
global master_success, repo_success
60+
global master_fail, repo_fail
61+
global master_noresolver, repo_noresolver
62+
63+
master_success, repo_success = setup_conflict_repo(test_name_success)
64+
master_fail, repo_fail = setup_conflict_repo(test_name_fail)
65+
master_noresolver, repo_noresolver = setup_conflict_repo(
66+
test_name_noresolver
67+
)
68+
69+
70+
def test_resolver_succeeds():
71+
"""Resolver fixes conflicts and completes rebase."""
72+
os.chdir(repo_path_success)
73+
74+
script = make_resolver_script(repo_path_success, (
75+
'#!/bin/bash\n'
76+
'git checkout --theirs .\n'
77+
'git add -A\n'
78+
'GIT_EDITOR=true git rebase --continue\n'
79+
))
80+
81+
from PyGitUp.gitup import GitUp
82+
gitup = GitUp(testing=True)
83+
gitup.settings['rebase.conflict-resolver'] = script + ' {prompt}'
84+
gitup.run()
85+
86+
assert 'rebasing' in gitup.states
87+
88+
89+
def test_resolver_fails():
90+
"""Resolver exits non-zero; UnresolvedConflictError is raised."""
91+
os.chdir(repo_path_fail)
92+
93+
script = make_resolver_script(repo_path_fail, (
94+
'#!/bin/bash\n'
95+
'exit 1\n'
96+
))
97+
98+
from PyGitUp.gitup import GitUp
99+
gitup = GitUp(testing=True)
100+
gitup.settings['rebase.conflict-resolver'] = script + ' {prompt}'
101+
102+
with pytest.raises(UnresolvedConflictError):
103+
gitup.run()
104+
105+
106+
def test_no_resolver():
107+
"""Without a resolver, RebaseError is raised as before."""
108+
os.chdir(repo_path_noresolver)
109+
110+
from PyGitUp.gitup import GitUp
111+
gitup = GitUp(testing=True)
112+
113+
with pytest.raises(RebaseError):
114+
gitup.run()
115+
116+
117+
def test_prompt_content():
118+
"""Prompt includes branch name, target, and instructions."""
119+
from PyGitUp.gitup import GitUp
120+
os.chdir(repo_path_success)
121+
122+
gitup = GitUp(testing=True)
123+
prompt = gitup._build_resolver_prompt(
124+
'my-branch', 'origin/main', repo_path_success
125+
)
126+
127+
assert 'my-branch' in prompt
128+
assert 'origin/main' in prompt
129+
assert 'Resolve the git rebase conflicts' in prompt

0 commit comments

Comments
 (0)