diff --git a/PyGitUp/git_wrapper.py b/PyGitUp/git_wrapper.py index 40d4181..de7b6da 100644 --- a/PyGitUp/git_wrapper.py +++ b/PyGitUp/git_wrapper.py @@ -8,7 +8,7 @@ """ -__all__ = ['GitWrapper', 'GitError'] +__all__ = ['GitWrapper', 'GitError', 'UnresolvedConflictError'] ############################################################################### # IMPORTS @@ -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') @@ -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) diff --git a/PyGitUp/gitup.py b/PyGitUp/gitup.py index fd97e64..dbf526d 100644 --- a/PyGitUp/gitup.py +++ b/PyGitUp/gitup.py @@ -15,6 +15,7 @@ import os import re import json +import shlex import subprocess from io import StringIO from tempfile import NamedTemporaryFile @@ -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' @@ -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, @@ -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*. @@ -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. diff --git a/PyGitUp/tests/test_conflict_resolver.py b/PyGitUp/tests/test_conflict_resolver.py new file mode 100644 index 0000000..cad67ff --- /dev/null +++ b/PyGitUp/tests/test_conflict_resolver.py @@ -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 diff --git a/README.rst b/README.rst index 859fd75..c419f72 100644 --- a/README.rst +++ b/README.rst @@ -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: ~~~~~~~~~~~~~~