From 67aad70e3bd7d52dc5e7f6fcfd60f91370490eeb Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Fri, 23 Oct 2026 18:56:13 +0200 Subject: [PATCH] fix: a conflicted `pull --pin` records the pin on `commit` The pin of a pull that stops on a conflict was lost: `git nested commit` had no `--pin`, so `.gitnested` kept the old pin, and the next pull followed the newest commit of the branch. `commit` takes `--pin` now, and the steps the pull prints pass it on. Fixes #173 Co-Authored-By: Claude Opus 5.5 (1M context) --- doc/cli/commit.md | 1 + doc/guides/conflicts.md | 3 +++ src/git_nested/cli/spec.py | 2 +- src/git_nested/commands/pull.py | 2 +- src/git_nested/messages.py | 14 ++++++++------ tests/e2e/test_pin.py | 26 ++++++++++++++++++++++++++ 6 files changed, 40 insertions(+), 8 deletions(-) diff --git a/doc/cli/commit.md b/doc/cli/commit.md index 8e155ef..f98e1f9 100644 --- a/doc/cli/commit.md +++ b/doc/cli/commit.md @@ -31,6 +31,7 @@ It needs a clean working tree, and a worktree of the nested branch. | `-f`, `--force` | commit even if the branch does not contain the fetched upstream commit | | `-m`, `--message ` | the commit message | | `--file ` | read the commit message from a file | +| `--pin ` | record this pin in `.gitnested`. The help of a conflicted `pull --pin` passes it | | `-S`, `--gpg-sign` | GPG-sign the commit | | `--signoff` | add a `Signed-off-by:` trailer to the commit | diff --git a/doc/guides/conflicts.md b/doc/guides/conflicts.md index f7d0893..a56e39c 100644 --- a/doc/guides/conflicts.md +++ b/doc/guides/conflicts.md @@ -41,6 +41,9 @@ git nested commit ext/lib `git nested commit` puts the result into `ext/lib`, updates `.gitnested` and makes one commit. It also removes the worktree. +After `pull --pin `, step 7 is `git nested commit --pin= ext/lib`. +The pin then goes into `.gitnested` as well. + ### With the rebase method Step 4 is `git rebase --continue`. Repeat steps 2 to 4 until the rebase is diff --git a/src/git_nested/cli/spec.py b/src/git_nested/cli/spec.py index 8e31ef3..75df924 100644 --- a/src/git_nested/cli/spec.py +++ b/src/git_nested/cli/spec.py @@ -31,7 +31,7 @@ 'recurse_submodules', 'signoff', ], - 'commit': ['fetch', 'force', 'gpg_sign', 'message', 'msg_file', 'signoff'], + 'commit': ['fetch', 'force', 'gpg_sign', 'message', 'msg_file', 'pin', 'signoff'], 'completion': [], 'config': [], 'diff': ['all', 'branch', 'remote'], diff --git a/src/git_nested/commands/pull.py b/src/git_nested/commands/pull.py index eba1c55..08acf49 100644 --- a/src/git_nested/commands/pull.py +++ b/src/git_nested/commands/pull.py @@ -55,7 +55,7 @@ def do_pull( try: git.run(cmd, cwd=subdir_worktree) except GitNestedError as e: - help_text = messages.pull_conflict_help(subdir, subdir_worktree, method, flags.message_file, subref) + help_text = messages.pull_conflict_help(subdir, subdir_worktree, method, flags.message_file, flags.pin, subref) # e.message ends with git's own stderr, and so with however many # newlines git felt like; rstrip normalises that, and the one added # here plus the one help_text opens with leave a blank line between diff --git a/src/git_nested/messages.py b/src/git_nested/messages.py index 1084de2..4803209 100644 --- a/src/git_nested/messages.py +++ b/src/git_nested/messages.py @@ -70,7 +70,9 @@ def branch_exists(subdir: Path, branch: str, facts: list[tuple[str, str | None]] return "\n".join(lines) -def pull_conflict_help(subdir: Path, worktree: Path, method: str, message_file: str | None, subref: str) -> str: +def pull_conflict_help( + subdir: Path, worktree: Path, method: str, message_file: str | None, pin: str | None, subref: str +) -> str: """How to finish a pull by hand after its merge or rebase hit conflicts. git-nested has already left the half-finished operation in a worktree of @@ -78,11 +80,11 @@ def pull_conflict_help(subdir: Path, worktree: Path, method: str, message_file: handing the result back -- which is what the numbered steps spell out. """ resume = "git rebase --continue" if method == 'rebase' else "git commit" - commit = ( - f"git nested commit --file={message_file} {subdir.as_posix()}" - if message_file - else f"git nested commit {subdir.as_posix()}" - ) + options = [f'--file={message_file}'] if message_file else [] + if pin: + # Only the pull got it: without it, commit would not record the pin in .gitnested + options.append(f'--pin={pin}') + commit = ' '.join(['git nested commit', *options, subdir.as_posix()]) text = textwrap.dedent(f"""\ The conflicts are in a worktree of their own at {worktree.as_posix()}. diff --git a/tests/e2e/test_pin.py b/tests/e2e/test_pin.py index f312d1d..8de6c10 100644 --- a/tests/e2e/test_pin.py +++ b/tests/e2e/test_pin.py @@ -49,3 +49,29 @@ def test_clone_pin_must_be_on_the_branch(foo_bar_cloned): assert result.returncode == 1 assert result.output.strip() == f"git-nested: {side} is not on master of {(env.upstream / 'bar').as_posix()}" + + +def test_pull_conflict_keeps_the_pin(foo_bar_cloned): + """A pull with --pin that conflicts records the pin once it is finished as printed.""" + env = foo_bar_cloned + foo, bar = env.workspace / 'foo', env.workspace / 'bar' + cmd_git_nested(['clone', '-b', 'master', '--pin', 'A', (env.upstream / 'bar').as_posix()], foo) + env.modify_files('bar/Bar', text='foo', cwd=foo) + env.modify_files('Bar', text='bar', cwd=bar) + pin = git_rev_parse(['HEAD'], cwd=bar) + env.add_new_files('Newer', cwd=bar) + env.run(['git', 'push', '-q'], cwd=bar) + + result = cmd_git_nested(['pull', '--pin', pin, 'bar'], foo, check=False) + assert result.returncode == 1 + worktree = foo / '.git' / 'tmp' / 'nested' / 'bar' + (worktree / 'Bar').write_text('merged\n') + env.run(['git', 'add', 'Bar'], cwd=worktree) + env.run(['git', 'commit', '-q', '--no-edit'], cwd=worktree) + # the last step the pull printed + commit_step = result.output.split('7. ')[1].splitlines()[0] + cmd_git_nested(commit_step.removeprefix('git nested '), foo) + + assert _nested(foo, 'pin') == pin + assert _nested(foo, 'commit') == pin + assert "already up to date" in cmd_git_nested('pull bar', cwd=foo).output