From bea6089dbd513f1f6f07b453b563deecd0a161a3 Mon Sep 17 00:00:00 2001 From: George Campbell Date: Mon, 13 Apr 2026 12:14:39 -0700 Subject: [PATCH 1/4] 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) --- PyGitUp/git_wrapper.py | 18 ++- PyGitUp/gitup.py | 94 ++++++++++++- PyGitUp/tests/test_conflict_resolver.py | 129 ++++++++++++++++++ .../tests/test_overwrite_untracked_error.py | 1 + PyGitUp/tests/test_rebase_arguments.py | 1 + PyGitUp/tests/test_rebase_error.py | 1 + 6 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 PyGitUp/tests/test_conflict_resolver.py diff --git a/PyGitUp/git_wrapper.py b/PyGitUp/git_wrapper.py index 1f40154..2dde664 100644 --- a/PyGitUp/git_wrapper.py +++ b/PyGitUp/git_wrapper.py @@ -8,7 +8,7 @@ """ -__all__ = ['GitWrapper', 'GitError'] +__all__ = ['GitWrapper', 'GitError', 'UnresolvedConflictError'] ############################################################################### # IMPORTS @@ -346,3 +346,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 79e2d91..d1f6943 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, RebaseError +from PyGitUp.git_wrapper import GitWrapper, GitError, RebaseError, \ + UnresolvedConflictError ON_WINDOWS = sys.platform == 'win32' @@ -124,6 +126,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, @@ -336,7 +339,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*. @@ -467,6 +479,84 @@ def _rebase_in_worktree(self, branch, target, worktree_path, stash.suppress_pop = True raise + 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/PyGitUp/tests/test_overwrite_untracked_error.py b/PyGitUp/tests/test_overwrite_untracked_error.py index deaf97f..71fdafd 100644 --- a/PyGitUp/tests/test_overwrite_untracked_error.py +++ b/PyGitUp/tests/test_overwrite_untracked_error.py @@ -38,6 +38,7 @@ def test_fast_forwarded(): from PyGitUp.gitup import GitUp gitup = GitUp(testing=True) + gitup.settings['rebase.conflict-resolver'] = None with pytest.raises(RebaseError): gitup.run() diff --git a/PyGitUp/tests/test_rebase_arguments.py b/PyGitUp/tests/test_rebase_arguments.py index 5748842..8f8e233 100644 --- a/PyGitUp/tests/test_rebase_arguments.py +++ b/PyGitUp/tests/test_rebase_arguments.py @@ -53,6 +53,7 @@ def test_rebase_arguments(): from PyGitUp.gitup import GitUp gitup = GitUp(testing=True) + gitup.settings['rebase.conflict-resolver'] = None with pytest.raises(RebaseError): gitup.run() diff --git a/PyGitUp/tests/test_rebase_error.py b/PyGitUp/tests/test_rebase_error.py index ea94e3c..37f5511 100644 --- a/PyGitUp/tests/test_rebase_error.py +++ b/PyGitUp/tests/test_rebase_error.py @@ -46,6 +46,7 @@ def test_rebase_error(): from PyGitUp.gitup import GitUp gitup = GitUp(testing=True) + gitup.settings['rebase.conflict-resolver'] = None with pytest.raises(RebaseError): gitup.run() From cc775f9650a4799ecf164a8def0b005f5a2d418b Mon Sep 17 00:00:00 2001 From: George Campbell Date: Mon, 13 Apr 2026 12:18:52 -0700 Subject: [PATCH 2/4] docs: add rebase.conflict-resolver to README Co-Authored-By: Claude Opus 4.6 (1M context) --- PyGitUp/tests/test_conflict_resolver.py | 1 + README.rst | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/PyGitUp/tests/test_conflict_resolver.py b/PyGitUp/tests/test_conflict_resolver.py index cad67ff..3c4f575 100644 --- a/PyGitUp/tests/test_conflict_resolver.py +++ b/PyGitUp/tests/test_conflict_resolver.py @@ -109,6 +109,7 @@ def test_no_resolver(): from PyGitUp.gitup import GitUp gitup = GitUp(testing=True) + gitup.settings['rebase.conflict-resolver'] = None with pytest.raises(RebaseError): gitup.run() 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: ~~~~~~~~~~~~~~ From 1bfded2d37b498b5cde8737994a86e681954da0e Mon Sep 17 00:00:00 2001 From: George Campbell Date: Thu, 6 Aug 2026 10:47:14 -0700 Subject: [PATCH 3/4] fix: drop dead suppress_pop mechanism from stasher stasher() has no try/finally around its yield, so when an exception escapes the with block, contextlib re-raises it at the yield point without ever running the unstash code that follows. The stash is already left in place on a failed rebase regardless of the flag, and on the resolver-failure path _try_resolve_conflicts raises before suppress_pop is even set. The flag had no effect either way. --- PyGitUp/git_wrapper.py | 4 +--- PyGitUp/gitup.py | 7 +------ 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/PyGitUp/git_wrapper.py b/PyGitUp/git_wrapper.py index 2dde664..35ff51f 100644 --- a/PyGitUp/git_wrapper.py +++ b/PyGitUp/git_wrapper.py @@ -144,11 +144,9 @@ def stash(): stashed[0] = True - stash.suppress_pop = False - yield stash - if stashed[0] and not stash.suppress_pop: + if stashed[0]: print(colored('unstashing', 'magenta')) try: self._run('stash', 'pop') diff --git a/PyGitUp/gitup.py b/PyGitUp/gitup.py index d1f6943..ddd20bc 100644 --- a/PyGitUp/gitup.py +++ b/PyGitUp/gitup.py @@ -347,7 +347,6 @@ def rebase_all_branches(self): self.repo.working_dir ): continue - stasher.suppress_pop = True raise if (self.repo.head.is_detached # Only on Travis CI, @@ -473,11 +472,7 @@ def _rebase_in_worktree(self, branch, target, worktree_path, else: with worktree_git.stasher() as stash: stash() - try: - worktree_git.rebase(target) - except RebaseError: - stash.suppress_pop = True - raise + worktree_git.rebase(target) def _build_resolver_prompt(self, branch_name, target_name, repo_path): """Build the default prompt with conflict context.""" From 9ce6f1b45950dabd0c75c165e355130c83d192bb Mon Sep 17 00:00:00 2001 From: George Campbell Date: Sun, 9 Aug 2026 13:18:24 -0700 Subject: [PATCH 4/4] refactor: drop {prompt} templating, wire resolver into worktree path The resolver already runs with cwd set to the repo mid-rebase, so it can inspect the conflict itself (git status, git diff) rather than have PyGitUp hand it a pre-built, pre-escaped prompt. Dropping the placeholder also removes the only place a multi-line string was ever spliced into a shell command line, which was going to be a real cross-platform (cmd.exe vs POSIX shell) headache to get right. Users now write their own instruction text as part of the configured command, e.g. `claude -p 'Resolve the current git rebase conflicts...'`. Also wires the resolver into _rebase_in_worktree, which previously had no conflict-resolution fallback at all for branches rebased via the worktree path added in #145. Added tests that actually exercise cwd/env-var wiring instead of just happening to pass because the test process's cwd already matched the repo: the resolver is now invoked from outside the repo, and a marker file captures $PWD plus GITUP_BRANCH/GITUP_TARGET/GITUP_REPO_PATH for assertions, for both the plain and worktree rebase paths. --- PyGitUp/gitup.py | 48 ++------ PyGitUp/tests/test_conflict_resolver.py | 143 ++++++++++++++++++++++-- README.rst | 22 ++-- 3 files changed, 154 insertions(+), 59 deletions(-) diff --git a/PyGitUp/gitup.py b/PyGitUp/gitup.py index ddd20bc..524895b 100644 --- a/PyGitUp/gitup.py +++ b/PyGitUp/gitup.py @@ -15,7 +15,6 @@ import os import re import json -import shlex import subprocess from io import StringIO from tempfile import NamedTemporaryFile @@ -472,32 +471,14 @@ def _rebase_in_worktree(self, branch, target, worktree_path, else: with worktree_git.stasher() as stash: stash() - worktree_git.rebase(target) - - 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" - ) + try: + worktree_git.rebase(target) + except RebaseError: + if self._try_resolve_conflicts( + branch.name, target.name, worktree_path + ): + return + raise def _try_resolve_conflicts(self, branch_name, target_name, repo_path): """ @@ -507,26 +488,19 @@ def _try_resolve_conflicts(self, branch_name, target_name, repo_path): Returns False if no resolver is configured. Raises UnresolvedConflictError if the resolver failed. """ - resolver_template = self.settings['rebase.conflict-resolver'] - if not resolver_template: + resolver_command = self.settings['rebase.conflict-resolver'] + if not resolver_command: 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 + resolver_command, shell=True, cwd=repo_path, env=env ) if result.returncode != 0: diff --git a/PyGitUp/tests/test_conflict_resolver.py b/PyGitUp/tests/test_conflict_resolver.py index 3c4f575..a842f04 100644 --- a/PyGitUp/tests/test_conflict_resolver.py +++ b/PyGitUp/tests/test_conflict_resolver.py @@ -1,5 +1,6 @@ # System imports import os +import platform import stat from os.path import join @@ -9,13 +10,25 @@ from PyGitUp.tests import basepath, write_file, init_master, update_file, \ testfile_name +pytestmark = pytest.mark.skipif( + platform.system() == 'Windows', + reason="resolver scripts here are bash scripts; on Windows the " + "resolver command runs via cmd.exe (not bash), so a bare " + ".sh path won't execute there", +) + test_name_success = 'conflict_resolve_success' test_name_fail = 'conflict_resolve_fail' test_name_noresolver = 'conflict_no_resolver' +test_name_context = 'conflict_resolve_context' +test_name_worktree = 'conflict_resolve_worktree' 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) +repo_path_context = join(basepath, test_name_context + os.sep) +repo_path_worktree = join(basepath, test_name_worktree + os.sep) +worktree_path_worktree = join(basepath, test_name_worktree + '-wt' + os.sep) def setup_conflict_repo(test_name): @@ -55,16 +68,79 @@ def make_resolver_script(basedir, script_content): return script_path +def make_context_capturing_script(basedir, marker_path): + """ + A resolver script that records its cwd and the GITUP_* env vars it + was invoked with, then resolves the conflict as usual. + """ + return make_resolver_script(basedir, ( + '#!/bin/bash\n' + 'echo "$PWD" > "{marker}"\n' + 'echo "$GITUP_BRANCH" >> "{marker}"\n' + 'echo "$GITUP_TARGET" >> "{marker}"\n' + 'echo "$GITUP_REPO_PATH" >> "{marker}"\n' + 'git checkout --theirs .\n' + 'git add -A\n' + 'GIT_EDITOR=true git rebase --continue\n' + ).format(marker=marker_path)) + + +def read_marker(marker_path): + with open(marker_path) as f: + lines = [line.strip() for line in f.readlines()] + return lines + + +def setup_worktree_conflict(test_name, worktree_path): + """ + Set up a repo with a branch checked out in a worktree that has a + conflicting change relative to its upstream. + """ + master_path, master = init_master(test_name) + master.git.checkout(b=test_name) + + path = join(basepath, test_name) + master.clone(path, b=test_name) + repo = Repo(path, odbt=GitCmdObjectDB) + assert repo.working_dir == path + + wt_branch = test_name + '-wt' + repo.git.branch(wt_branch, 'origin/' + test_name) + repo.git.worktree('add', worktree_path, wt_branch) + repo.git.branch('--set-upstream-to', 'origin/' + test_name, wt_branch) + + # Conflicting change in the worktree branch. GitPython can't reliably + # access a worktree's git dir directly (see test_worktree.py), so use + # the git CLI via a plain Git() instance pointed at the worktree. + wt_git = Git(worktree_path) + contents = 'completely changed in worktree!' + wt_file = join(worktree_path, testfile_name) + write_file(wt_file, contents) + wt_git.add(testfile_name) + wt_git.commit(m=test_name) + + # Diverge master with a conflicting change to the same file + update_file(master, test_name) + + return master, repo, wt_branch + + def setup_module(): global master_success, repo_success global master_fail, repo_fail global master_noresolver, repo_noresolver + global master_context, repo_context + global master_worktree, repo_worktree, worktree_branch 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 ) + master_context, repo_context = setup_conflict_repo(test_name_context) + master_worktree, repo_worktree, worktree_branch = ( + setup_worktree_conflict(test_name_worktree, worktree_path_worktree) + ) def test_resolver_succeeds(): @@ -80,7 +156,7 @@ def test_resolver_succeeds(): from PyGitUp.gitup import GitUp gitup = GitUp(testing=True) - gitup.settings['rebase.conflict-resolver'] = script + ' {prompt}' + gitup.settings['rebase.conflict-resolver'] = script gitup.run() assert 'rebasing' in gitup.states @@ -97,7 +173,7 @@ def test_resolver_fails(): from PyGitUp.gitup import GitUp gitup = GitUp(testing=True) - gitup.settings['rebase.conflict-resolver'] = script + ' {prompt}' + gitup.settings['rebase.conflict-resolver'] = script with pytest.raises(UnresolvedConflictError): gitup.run() @@ -115,16 +191,61 @@ def test_no_resolver(): gitup.run() -def test_prompt_content(): - """Prompt includes branch name, target, and instructions.""" - from PyGitUp.gitup import GitUp - os.chdir(repo_path_success) +def test_resolver_runs_in_repo_with_context(): + """ + The resolver runs with cwd set to the repo (not inherited from the + calling process) and with GITUP_BRANCH/GITUP_TARGET/GITUP_REPO_PATH + set correctly. + """ + os.chdir(repo_path_context) + + marker = join(basepath, 'resolver-context.txt') + script = make_context_capturing_script(repo_path_context, marker) + from PyGitUp.gitup import GitUp gitup = GitUp(testing=True) - prompt = gitup._build_resolver_prompt( - 'my-branch', 'origin/main', repo_path_success + gitup.settings['rebase.conflict-resolver'] = script + + # Move outside the repo before running: if the resolver's cwd were + # ever accidentally inherited from the calling process instead of + # passed explicitly, this would make that bug visible. + os.chdir(basepath) + + gitup.run() + + assert 'rebasing' in gitup.states + + pwd, branch, target, repo_path = read_marker(marker) + assert os.path.realpath(pwd) == os.path.realpath( + repo_path_context.rstrip(os.sep) + ) + assert branch == test_name_context + assert target == 'origin/' + test_name_context + assert os.path.realpath(repo_path) == os.path.realpath( + repo_path_context.rstrip(os.sep) ) - assert 'my-branch' in prompt - assert 'origin/main' in prompt - assert 'Resolve the git rebase conflicts' in prompt + +def test_resolver_in_worktree(): + """The resolver is also invoked for branches rebased via a worktree.""" + os.chdir(repo_path_worktree) + + marker = join(basepath, 'resolver-worktree-context.txt') + script = make_context_capturing_script(worktree_path_worktree, marker) + + from PyGitUp.gitup import GitUp + gitup = GitUp(testing=True) + gitup.settings['rebase.conflict-resolver'] = script + gitup.run() + + assert 'rebasing' in gitup.states + + pwd, branch, target, repo_path = read_marker(marker) + assert os.path.realpath(pwd) == os.path.realpath( + worktree_path_worktree.rstrip(os.sep) + ) + assert branch == worktree_branch + assert target == 'origin/' + test_name_worktree + assert os.path.realpath(repo_path) == os.path.realpath( + worktree_path_worktree.rstrip(os.sep) + ) diff --git a/README.rst b/README.rst index c419f72..bb7886e 100644 --- a/README.rst +++ b/README.rst @@ -144,23 +144,23 @@ options: 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. + run this command when a rebase conflict occurs. It runs in the + repository's working directory while the rebase is still in progress, + so the command can inspect the conflict itself (e.g. via + ``git status``, ``git diff``) and 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}" + git config git-up.rebase.conflict-resolver "claude -p 'Resolve the current git rebase conflicts, then run git rebase --continue'" + git config git-up.rebase.conflict-resolver "claude -p 'Resolve the current git rebase conflicts, then run git rebase --continue' --dangerously-skip-permissions" + git config git-up.rebase.conflict-resolver "aider --message 'Resolve the current git rebase conflicts, then run git rebase --continue'" Note: AI agents like ``claude`` may prompt for tool approvals by default. Use ``--dangerously-skip-permissions`` to run fully