From 91637ff61ac7fa45b1de2b1a0ba1a21ac7ff1be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 5 Aug 2026 08:41:04 +0200 Subject: [PATCH 1/2] Closes #693: Wrap libgit2's rebase API Add Repository.rebase_init() and Repository.rebase_open(), returning a Rebase object that drives git_rebase_next/commit/finish/abort through the Python iterator protocol, plus RebaseOperation and enums.RebaseOperationType. The options expose inmemory, quiet, rewrite_notes_ref, the merge favor/flags/file_flags knobs, checkout strategy, and conflict marker labels. Also fix enums.CheckoutStrategy.CONFLICT_STYLE_ZDIFF3, which was mistakenly bound to the DIFF3 constant. The tests cover the native API (clean replay, hunk-level conflict markers, custom labels, diff3 style, unresolved-conflict and already-applied errors, abort, in-memory rebase, fast-forward via finish, resuming with rebase_open) and keep a "manual rebase" implementation built from pygit2 primitives as a behavioral baseline to compare edge cases against. Assisted-by: Claude Fable 5 --- CHANGELOG.md | 7 + docs/index.rst | 1 + docs/rebase.rst | 79 ++++ pygit2/__init__.py | 1 + pygit2/_libgit2/ffi.pyi | 26 ++ pygit2/_run.py | 1 + pygit2/decl/rebase.h | 80 ++++ pygit2/enums.py | 30 +- pygit2/rebase.py | 246 +++++++++++++ pygit2/repository.py | 249 ++++++++++++- test/test_rebase.py | 793 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 1492 insertions(+), 21 deletions(-) create mode 100644 docs/rebase.rst create mode 100644 pygit2/decl/rebase.h create mode 100644 pygit2/rebase.py create mode 100644 test/test_rebase.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50677c382..05d3a3847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ - Update wheels to libgit2 1.9.6 +- Wrap libgit2's rebase API: add `Repository.rebase_init(...)` and + `Repository.rebase_open(...)` returning a `Rebase` object, plus + `RebaseOperation` and `enums.RebaseOperationType` + +- Fix `enums.CheckoutStrategy.CONFLICT_STYLE_ZDIFF3`, which was mistakenly + bound to the `DIFF3` constant + Breaking changes: - Remove deprecated `pygit2.legacyenums` module and `GIT_*` constants, diff --git a/docs/index.rst b/docs/index.rst index 653a8556b..af3380e59 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -75,6 +75,7 @@ Table of Contents objects oid packing + rebase references transactions remotes diff --git a/docs/rebase.rst b/docs/rebase.rst new file mode 100644 index 000000000..be2dc2c96 --- /dev/null +++ b/docs/rebase.rst @@ -0,0 +1,79 @@ +********************************************************************** +Rebase +********************************************************************** + +.. contents:: + +.. automethod:: pygit2.Repository.rebase_init +.. automethod:: pygit2.Repository.rebase_open + +The Rebase type +==================== + +.. autoclass:: pygit2.Rebase + :members: + :special-members: __len__, __getitem__, __next__ + +.. autoclass:: pygit2.RebaseOperation + :members: + +Example +======= + +Rebase the current branch onto its upstream:: + + >>> committer = repo.default_signature + >>> rebase = repo.rebase_init(upstream=repo.branches['origin/master']) + >>> for operation in rebase: + ... # If repo.index.conflicts is not None at this point, the + ... # operation left conflicts in the index and conflict markers + ... # in the working directory. Resolve them, stage each + ... # resolution with repo.index.add(path), and only then commit. + ... rebase.commit(committer=committer) + >>> rebase.finish(committer) + +Use ``abort()`` instead of ``finish()`` to reset the repository and the +working directory to their state before the rebase began. + +With ``inmemory=True`` the rebase does not touch HEAD, the repository +state, or the working directory; each step's result is available as +``rebase.inmemory_index`` and updating the branch reference afterwards is +the caller's responsibility. + +Working with rebase operations +============================== + +Iterating over a ``Rebase`` yields a :py:class:`~pygit2.RebaseOperation` +describing each step. ``len()`` and indexing expose the same operations +up front, without advancing the rebase, so the plan can be inspected +before applying it:: + + >>> rebase = repo.rebase_init(upstream=repo.branches['origin/master']) + >>> for i in range(len(rebase)): + ... print(rebase[i]) + + + +A rebase started with ``rebase_init()`` replays the non-merge commits +in ``upstream..branch``; merge commits are skipped, linearizing the +history, just like plain ``git rebase`` (libgit2 has no equivalent of +``--rebase-merges``). Every operation's ``type`` is therefore +``RebaseOperationType.PICK``, ``id`` names the original commit being +replayed, and ``exec`` is ``None``. The remaining +``RebaseOperationType`` values mirror the verbs of git's interactive +rebase, which libgit2 does not implement (as of 1.9): they are declared +for completeness but never produced. Looking the original commit up is +useful for progress reporting or for reusing its metadata:: + + >>> from pygit2.enums import RebaseOperationType + >>> committer = repo.default_signature + >>> for operation in rebase: + ... assert operation.type == RebaseOperationType.PICK + ... original = repo[operation.id] + ... step, total = rebase.current_index + 1, len(rebase) + ... print(f'[{step}/{total}] picking {original.short_id}:', + ... original.message.strip()) + ... rebase.commit(committer=committer) + [1/2] picking 4a3fe06: Add feature + [2/2] picking 8ae4a25: Fix tests + >>> rebase.finish(committer) diff --git a/pygit2/__init__.py b/pygit2/__init__.py index 8d4346ff1..4cd1a2415 100644 --- a/pygit2/__init__.py +++ b/pygit2/__init__.py @@ -360,6 +360,7 @@ option, ) from .packbuilder import PackBuilder +from .rebase import Rebase, RebaseOperation from .remotes import Remote from .repository import Repository from .settings import Settings diff --git a/pygit2/_libgit2/ffi.pyi b/pygit2/_libgit2/ffi.pyi index 690abf2b5..a784feb69 100644 --- a/pygit2/_libgit2/ffi.pyi +++ b/pygit2/_libgit2/ffi.pyi @@ -172,6 +172,9 @@ class GitBufC: class GitCheckoutOptionsC: # incomplete checkout_strategy: int + ancestor_label: ArrayC[char] + our_label: ArrayC[char] + their_label: ArrayC[char] class GitCommitC: pass @@ -271,6 +274,22 @@ class GitProxyOptionsC: # certificate_check # payload +class GitRebaseC: + pass + +class GitRebaseOperationC: + type: int + id: GitOidC + exec: char_pointer + +class GitRebaseOptionsC: + version: int + quiet: int + inmemory: int + rewrite_notes_ref: ArrayC[char] + merge_options: GitMergeOptionsC + checkout_options: GitCheckoutOptionsC + class GitRemoteC: pass @@ -382,6 +401,12 @@ def new(a: Literal['git_stash_save_options *']) -> GitStashSaveOptionsC: ... @overload def new(a: Literal['git_strarray *']) -> GitStrrayC: ... @overload +def new(a: Literal['git_rebase **']) -> _Pointer[GitRebaseC]: ... +@overload +def new(a: Literal['git_rebase_options *']) -> GitRebaseOptionsC: ... +@overload +def new(a: Literal['git_rebase_operation **']) -> _Pointer[GitRebaseOperationC]: ... +@overload def new(a: Literal['git_tree **']) -> _Pointer[GitTreeC]: ... @overload def new(a: Literal['git_buf *'], b: tuple[NULL_TYPE, Literal[0]]) -> GitBufC: ... @@ -401,6 +426,7 @@ def new( ) -> ArrayC[char_pointer]: ... # For string arrays def addressof(a: object, attribute: str) -> _Pointer[object]: ... def new_handle(a: T) -> _Pointer[T]: ... +def gc(cdata: T, destructor: Any, size: int = ...) -> T: ... class buffer(bytes): def __init__(self, a: object) -> None: ... diff --git a/pygit2/_run.py b/pygit2/_run.py index 85d31f69d..9aace11cf 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -74,6 +74,7 @@ 'graph.h', 'index.h', 'merge.h', + 'rebase.h', 'net.h', 'refspec.h', 'repository.h', diff --git a/pygit2/decl/rebase.h b/pygit2/decl/rebase.h new file mode 100644 index 000000000..542143bfb --- /dev/null +++ b/pygit2/decl/rebase.h @@ -0,0 +1,80 @@ +typedef struct git_rebase git_rebase; + +#define GIT_REBASE_OPTIONS_VERSION ... +#define GIT_REBASE_NO_OPERATION ... + +typedef enum { + GIT_REBASE_OPERATION_PICK = 0, + GIT_REBASE_OPERATION_REWORD, + GIT_REBASE_OPERATION_EDIT, + GIT_REBASE_OPERATION_SQUASH, + GIT_REBASE_OPERATION_FIXUP, + GIT_REBASE_OPERATION_EXEC +} git_rebase_operation_t; + +typedef struct { + unsigned int version; + int quiet; + int inmemory; + const char *rewrite_notes_ref; + git_merge_options merge_options; + git_checkout_options checkout_options; + ...; +} git_rebase_options; + +typedef struct { + git_rebase_operation_t type; + const git_oid id; + const char *exec; + ...; +} git_rebase_operation; + +int git_rebase_options_init(git_rebase_options *opts, unsigned int version); + +int git_rebase_init( + git_rebase **out, + git_repository *repo, + const git_annotated_commit *branch, + const git_annotated_commit *upstream, + const git_annotated_commit *onto, + const git_rebase_options *opts); + +int git_rebase_open( + git_rebase **out, + git_repository *repo, + const git_rebase_options *opts); + +const char *git_rebase_orig_head_name(git_rebase *rebase); +const git_oid *git_rebase_orig_head_id(git_rebase *rebase); +const char *git_rebase_onto_name(git_rebase *rebase); +const git_oid *git_rebase_onto_id(git_rebase *rebase); + +size_t git_rebase_operation_entrycount(git_rebase *rebase); +size_t git_rebase_operation_current(git_rebase *rebase); +git_rebase_operation *git_rebase_operation_byindex( + git_rebase *rebase, + size_t idx); + +int git_rebase_next( + git_rebase_operation **operation, + git_rebase *rebase); + +int git_rebase_inmemory_index( + git_index **index, + git_rebase *rebase); + +int git_rebase_commit( + git_oid *id, + git_rebase *rebase, + const git_signature *author, + const git_signature *committer, + const char *message_encoding, + const char *message); + +int git_rebase_abort(git_rebase *rebase); + +int git_rebase_finish( + git_rebase *rebase, + const git_signature *signature); + +void git_rebase_free(git_rebase *rebase); diff --git a/pygit2/enums.py b/pygit2/enums.py index 5534b85b5..bd74ea6ae 100644 --- a/pygit2/enums.py +++ b/pygit2/enums.py @@ -225,7 +225,7 @@ class CheckoutStrategy(IntFlag): notifications; don't update the working directory or index. """ - CONFLICT_STYLE_ZDIFF3 = _pygit2.GIT_CHECKOUT_CONFLICT_STYLE_DIFF3 + CONFLICT_STYLE_ZDIFF3 = _pygit2.GIT_CHECKOUT_CONFLICT_STYLE_ZDIFF3 """ Include common ancestor data in zdiff3 format for conflicts """ @@ -998,6 +998,34 @@ class Option(IntEnum): ADD_SSL_X509_CERT = options.GIT_OPT_ADD_SSL_X509_CERT +class RebaseOperationType(IntEnum): + """Type of rebase operation in-progress after calling Rebase.next().""" + + PICK = C.GIT_REBASE_OPERATION_PICK + """The given commit is to be cherry-picked. The client should commit + the changes and continue if there are no conflicts.""" + + REWORD = C.GIT_REBASE_OPERATION_REWORD + """The given commit is to be cherry-picked, but the client should prompt + the user to provide an updated commit message.""" + + EDIT = C.GIT_REBASE_OPERATION_EDIT + """The given commit is to be cherry-picked, but the client should stop + to allow the user to edit the changes before committing them.""" + + SQUASH = C.GIT_REBASE_OPERATION_SQUASH + """The given commit is to be squashed into the previous commit. The + commit message will be merged with the previous message.""" + + FIXUP = C.GIT_REBASE_OPERATION_FIXUP + """The given commit is to be squashed into the previous commit. The + commit message from this commit will be discarded.""" + + EXEC = C.GIT_REBASE_OPERATION_EXEC + """No commit will be cherry-picked. The client should run the given + command and (if successful) continue.""" + + class ReferenceFilter(IntEnum): """Filters for References.iterator().""" diff --git a/pygit2/rebase.py b/pygit2/rebase.py new file mode 100644 index 000000000..e9aa1014d --- /dev/null +++ b/pygit2/rebase.py @@ -0,0 +1,246 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +from typing import TYPE_CHECKING + +# Import from pygit2 +from ._pygit2 import Oid, Signature +from .enums import RebaseOperationType +from .errors import check_error +from .ffi import C, ffi +from .index import Index +from .utils import maybe_string + +if TYPE_CHECKING: + from ._libgit2.ffi import GitRebaseC, GitRebaseOperationC + from .repository import BaseRepository + + +def _signature_ptr(signature: 'Signature | None'): + """Return a git_signature* cdata for the given signature, or ffi.NULL. + + The returned pointer borrows the memory owned by the Signature object, + which the caller must keep alive for the duration of the C call. + """ + if signature is None: + return ffi.NULL + ptr = ffi.new('git_signature **') + ffi.buffer(ptr)[:] = signature._pointer[:] + return ptr[0] + + +class RebaseOperation: + """A single instruction to be performed during a rebase.""" + + def __init__(self, type: RebaseOperationType, id: Oid, exec: 'str | None') -> None: + self.type = type + 'The type of rebase operation.' + + self.id = id + """The commit ID being cherry-picked. This will be populated for + all operations except those of type RebaseOperationType.EXEC.""" + + self.exec = exec + """The executable the user has requested be run. This will only + be populated for operations of type RebaseOperationType.EXEC.""" + + @classmethod + def _from_c(cls, coperation: 'GitRebaseOperationC') -> 'RebaseOperation': + type = RebaseOperationType(coperation.type) + id = Oid(raw=bytes(ffi.buffer(ffi.addressof(coperation, 'id'))[:])) + exec = maybe_string(coperation.exec) + return cls(type, id, exec) + + def __repr__(self) -> str: + return f'' + + +class Rebase: + """An in-progress rebase. + + Returned by Repository.rebase_init() and Repository.rebase_open(). + Iterating over this object performs the rebase operations one by one; + each must be committed with commit(), after resolving any conflicts + that were left in the repository's index. Finalize with finish(), or + roll everything back with abort(). + """ + + def __init__( + self, repo: 'BaseRepository', crebase: 'GitRebaseC', refs: list + ) -> None: + """The constructor is for internal use only.""" + self._repo = repo + self._rebase = ffi.gc(crebase, C.git_rebase_free) + # Keep alive the git_rebase_options and every cdata it points into: + # libgit2 reads the options during __next__() and abort(), long + # after rebase_init() returned. + self._refs = refs + + def __len__(self) -> int: + """The total number of rebase operations.""" + return C.git_rebase_operation_entrycount(self._rebase) + + def __getitem__(self, index: int) -> RebaseOperation: + """The rebase operation at the given index.""" + if index < 0: + index += len(self) + if index < 0: + raise IndexError('rebase operation index out of range') + coperation = C.git_rebase_operation_byindex(self._rebase, index) + if coperation == ffi.NULL: + raise IndexError('rebase operation index out of range') + return RebaseOperation._from_c(coperation) + + def __iter__(self) -> 'Rebase': + return self + + def __next__(self) -> RebaseOperation: + """ + Perform the next rebase operation and return it. + + If the operation is one that applies a patch (which is any + operation except RebaseOperationType.EXEC) then the patch will be + applied and the index and working directory will be updated with + the changes. If there are conflicts, you will need to address + those before calling commit(). + + Raises StopIteration when there are no more operations to perform. + """ + coperation = ffi.new('git_rebase_operation **') + err = C.git_rebase_next(coperation, self._rebase) + check_error(err) # raises StopIteration on GIT_ITEROVER + return RebaseOperation._from_c(coperation[0]) + + @property + def current_index(self) -> 'int | None': + """The index of the rebase operation that is currently being + applied, or None if the first operation has not yet been applied + (because __next__() has not been called yet).""" + index = C.git_rebase_operation_current(self._rebase) + if index == C.GIT_REBASE_NO_OPERATION: + return None + return index + + @property + def inmemory_index(self) -> Index: + """ + The index produced by the last operation, which is the result of + __next__() and which will be committed by the next invocation of + commit(). This is useful for resolving conflicts in an in-memory + rebase before committing them. + + This is only applicable for in-memory rebases; for rebases within + a working directory, the changes were applied to the repository's + index. + """ + cindex = ffi.new('git_index **') + err = C.git_rebase_inmemory_index(cindex, self._rebase) + check_error(err) + return Index.from_c(self._repo, cindex) + + def commit( + self, + committer: Signature, + author: 'Signature | None' = None, + message: 'str | None' = None, + ) -> Oid: + """ + Commit the current patch and return the id of the new commit. You + must have resolved any conflicts that were introduced during the + patch application from the last __next__() invocation. + + Raises GitError if there are unmerged changes in the index, or if + the current commit has already been applied to the upstream and + there is nothing to commit. + + Parameters: + + committer : Signature + The committer of the rebase. + + author : Signature + The author of the updated commit, or None to keep the author + from the original commit. + + message : str + The message for this commit, or None to use the message from + the original commit. + """ + cmessage = ( + ffi.new('char[]', message.encode('utf-8')) + if message is not None + else ffi.NULL + ) + coid = ffi.new('git_oid *') + err = C.git_rebase_commit( + coid, + self._rebase, + _signature_ptr(author), + _signature_ptr(committer), + ffi.NULL, + cmessage, + ) + check_error(err) + return Oid(raw=bytes(ffi.buffer(coid)[:])) + + def finish(self, signature: 'Signature | None' = None) -> None: + """ + Finish the rebase once all patches have been applied. + + Parameters: + + signature : Signature + The identity that is finishing the rebase (optional). + """ + err = C.git_rebase_finish(self._rebase, _signature_ptr(signature)) + check_error(err) + + def abort(self) -> None: + """Abort the rebase, resetting the repository and working + directory to their state before the rebase began.""" + err = C.git_rebase_abort(self._rebase) + check_error(err) + + @property + def orig_head_name(self) -> 'str | None': + """The original HEAD ref name.""" + return maybe_string(C.git_rebase_orig_head_name(self._rebase)) + + @property + def orig_head_id(self) -> Oid: + """The original HEAD id.""" + coid = C.git_rebase_orig_head_id(self._rebase) + return Oid(raw=bytes(ffi.buffer(coid)[:])) + + @property + def onto_name(self) -> 'str | None': + """The onto ref name.""" + return maybe_string(C.git_rebase_onto_name(self._rebase)) + + @property + def onto_id(self) -> Oid: + """The onto id.""" + coid = C.git_rebase_onto_id(self._rebase) + return Oid(raw=bytes(ffi.buffer(coid)[:])) diff --git a/pygit2/repository.py b/pygit2/repository.py index f3c34e81a..64c177cfc 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -77,6 +77,7 @@ from .filter import FilterList from .index import Index, IndexEntry, MergeFileResult from .packbuilder import PackBuilder +from .rebase import Rebase from .references import References from .remotes import RemoteCollection from .submodules import SubmoduleCollection @@ -86,7 +87,9 @@ if TYPE_CHECKING: from pygit2._libgit2.ffi import ( ArrayC, + GitAnnotatedCommitC, GitMergeOptionsC, + GitRebaseOptionsC, GitRepositoryC, _Pointer, char, @@ -1001,6 +1004,32 @@ def merge_trees( return Index.from_c(self, cindex) + def _annotated_commit( + self, source: 'Reference | Commit | Oid | None' + ) -> '_Pointer[GitAnnotatedCommitC] | None': + """Return a git_annotated_commit** cdata for the given source, or + None if source is None. The caller must free the result with + git_annotated_commit_free.""" + if source is None: + return None + commit_ptr = ffi.new('git_annotated_commit **') + if isinstance(source, Reference): + cptr = ffi.new('struct git_reference **') + ffi.buffer(cptr)[:] = source._pointer[:] # type: ignore[attr-defined] + err = C.git_annotated_commit_from_ref(commit_ptr, self._repo, cptr[0]) + else: + if isinstance(source, Commit): + oid = source.id + elif isinstance(source, Oid): + oid = source + else: + raise TypeError('expected Reference, Commit, or Oid') + c_id = ffi.new('git_oid *') + ffi.buffer(c_id)[:] = oid.raw[:] + err = C.git_annotated_commit_lookup(commit_ptr, self._repo, c_id) + check_error(err) + return commit_ptr + def merge( self, source: Reference | Commit | Oid, @@ -1035,26 +1064,9 @@ def merge( A combination of enums.MergeFileFlag constants. """ - if isinstance(source, Reference): - # Annotated commit from ref - cptr = ffi.new('struct git_reference **') - ffi.buffer(cptr)[:] = source._pointer[:] # type: ignore[attr-defined] - commit_ptr = ffi.new('git_annotated_commit **') - err = C.git_annotated_commit_from_ref(commit_ptr, self._repo, cptr[0]) - check_error(err) - else: - # Annotated commit from commit id - if isinstance(source, Commit): - oid = source.id - elif isinstance(source, Oid): - oid = source - else: - raise TypeError('expected Reference, Commit, or Oid') - c_id = ffi.new('git_oid *') - ffi.buffer(c_id)[:] = oid.raw[:] - commit_ptr = ffi.new('git_annotated_commit **') - err = C.git_annotated_commit_lookup(commit_ptr, self._repo, c_id) - check_error(err) + commit_ptr = self._annotated_commit(source) + if commit_ptr is None: + raise TypeError('expected Reference, Commit, or Oid') merge_opts = self._merge_options(favor, flags, file_flags) @@ -1068,6 +1080,203 @@ def merge( C.git_annotated_commit_free(commit_ptr[0]) check_error(err) + # + # Rebasing + # + def _rebase_options( + self, + inmemory: bool, + quiet: bool, + rewrite_notes_ref: 'str | None', + favor: MergeFavor, + flags: MergeFlag, + file_flags: MergeFileFlag, + checkout_strategy: 'CheckoutStrategy | None', + ancestor_label: 'str | None', + our_label: 'str | None', + their_label: 'str | None', + ) -> 'tuple[GitRebaseOptionsC, list]': + """Return a git_rebase_options pointer plus the list of cdata + objects that must be kept alive for as long as libgit2 may read + the options.""" + opts = ffi.new('git_rebase_options *') + err = C.git_rebase_options_init(opts, C.GIT_REBASE_OPTIONS_VERSION) + check_error(err) + refs: list = [opts] + + opts.inmemory = int(inmemory) + opts.quiet = int(quiet) + if rewrite_notes_ref is not None: + notes_ref = ffi.new('char[]', to_bytes(rewrite_notes_ref)) + refs.append(notes_ref) + opts.rewrite_notes_ref = notes_ref + + merge_opts = self._merge_options(favor, flags, file_flags) + ffi.buffer(ffi.addressof(opts, 'merge_options'))[:] = ffi.buffer(merge_opts)[:] + + if checkout_strategy is not None: + opts.checkout_options.checkout_strategy = int(checkout_strategy) + labels = ( + ('ancestor_label', ancestor_label), + ('our_label', our_label), + ('their_label', their_label), + ) + for field, label in labels: + if label is not None: + clabel = ffi.new('char[]', to_bytes(label)) + refs.append(clabel) + setattr(opts.checkout_options, field, clabel) + + return opts, refs + + def rebase_init( + self, + branch: 'Reference | Commit | Oid | None' = None, + upstream: 'Reference | Commit | Oid | None' = None, + onto: 'Reference | Commit | Oid | None' = None, + *, + inmemory: bool = False, + quiet: bool = False, + rewrite_notes_ref: 'str | None' = None, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + checkout_strategy: 'CheckoutStrategy | None' = None, + ancestor_label: 'str | None' = None, + our_label: 'str | None' = None, + their_label: 'str | None' = None, + ) -> Rebase: + """ + Initialize a rebase operation to rebase the changes in `branch` + relative to `upstream` onto another branch, and return a Rebase + object. To begin the rebase process, iterate over it; commit each + successful operation with Rebase.commit(), then call + Rebase.finish() or Rebase.abort(). + + Parameters: + + branch + The terminal commit to rebase: a Reference, Commit, or commit + Oid. None means rebase the current branch. + + upstream + The commit to begin rebasing from. None means rebase all + reachable commits. + + onto + The branch to rebase onto. None means rebase onto the given + upstream. + + inmemory + Begin an in-memory rebase, which will allow callers to step + through the rebase operations and commit the rebased changes, + but will not rewind HEAD or update the repository to be in a + rebasing state. This will not interfere with the working + directory. + + quiet + Instruct other clients working on this rebase that you want a + quiet rebase experience. This has no effect upon libgit2 + directly, but is provided for interoperability between Git + tools. + + rewrite_notes_ref + Name of the notes reference used to rewrite notes for rebased + commits when finishing the rebase. If None, the + `notes.rewriteRef` configuration option is examined. + + favor + An enums.MergeFavor constant specifying how to deal with + file-level conflicts. For all but NORMAL, the index will not + record a conflict. + + flags + A combination of enums.MergeFlag constants. + + file_flags + A combination of enums.MergeFileFlag constants. For example, + MergeFileFlag.STYLE_DIFF3 asks for conflict markers that + include the common ancestor content. + + checkout_strategy + A CheckoutStrategy value controlling how files are written + during Rebase.__next__() and Rebase.abort(), or None for + libgit2's default. + + ancestor_label, our_label, their_label + Override the labels used in conflict markers. By default + libgit2 labels the "ours" side with the name of the branch + being rebased onto, and the "theirs" side with the summary of + the commit being replayed. + """ + opts, refs = self._rebase_options( + inmemory, + quiet, + rewrite_notes_ref, + favor, + flags, + file_flags, + checkout_strategy, + ancestor_label, + our_label, + their_label, + ) + branch_c = self._annotated_commit(branch) + upstream_c = self._annotated_commit(upstream) + onto_c = self._annotated_commit(onto) + + crebase = ffi.new('git_rebase **') + err = C.git_rebase_init( + crebase, + self._repo, + branch_c[0] if branch_c is not None else ffi.NULL, + upstream_c[0] if upstream_c is not None else ffi.NULL, + onto_c[0] if onto_c is not None else ffi.NULL, + opts, + ) + for commit_c in (branch_c, upstream_c, onto_c): + if commit_c is not None: + C.git_annotated_commit_free(commit_c[0]) + check_error(err) + return Rebase(self, crebase[0], refs) + + def rebase_open( + self, + *, + inmemory: bool = False, + quiet: bool = False, + rewrite_notes_ref: 'str | None' = None, + favor: MergeFavor = MergeFavor.NORMAL, + flags: MergeFlag = MergeFlag.FIND_RENAMES, + file_flags: MergeFileFlag = MergeFileFlag.DEFAULT, + checkout_strategy: 'CheckoutStrategy | None' = None, + ancestor_label: 'str | None' = None, + our_label: 'str | None' = None, + their_label: 'str | None' = None, + ) -> Rebase: + """ + Open an existing rebase that was previously started by either an + invocation of rebase_init() or by another client. + + The keyword arguments have the same meaning as in rebase_init(). + """ + opts, refs = self._rebase_options( + inmemory, + quiet, + rewrite_notes_ref, + favor, + flags, + file_flags, + checkout_strategy, + ancestor_label, + our_label, + their_label, + ) + crebase = ffi.new('git_rebase **') + err = C.git_rebase_open(crebase, self._repo, opts) + check_error(err) + return Rebase(self, crebase[0], refs) + # # Prepared message (MERGE_MSG) # diff --git a/test/test_rebase.py b/test/test_rebase.py new file mode 100644 index 000000000..efebfdd0a --- /dev/null +++ b/test/test_rebase.py @@ -0,0 +1,793 @@ +# Copyright 2010-2025 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for rebasing. + +Two flavors are covered here: + +1. "Manual rebase": rebasing implemented from first principles out of + merge_base(), walk(), merge_trees(), create_commit() and checkout, the + way applications had to before pygit2 wrapped libgit2's rebase API. + These tests are kept as a behavioral baseline to compare edge cases + against. + +2. The native rebase API: Repository.rebase_init() / rebase_open() and the + Rebase object, wrapping libgit2's git_rebase_* functions. +""" + +from itertools import count +from pathlib import Path + +import pytest + +import pygit2 +from pygit2 import ( + Blob, + Commit, + Index, + IndexEntry, + Oid, + Reference, + Repository, + Signature, +) +from pygit2.enums import ( + CheckoutStrategy, + FileMode, + RebaseOperationType, + RepositoryState, + SortMode, +) + +# Fixed base timestamp (like libgit2's examples/rebase.c) so that commit ids +# do not depend on the clock. +_timestamps = count(1_700_000_000) + +FileSpec = tuple[str, str, str] # path, content, commit message + + +def _signature() -> Signature: + return Signature('Test User', 'test@example.com', time=next(_timestamps), offset=0) + + +def _tip(ref: Reference) -> Oid: + target = ref.target + assert isinstance(target, Oid), 'symbolic reference where a commit was expected' + return target + + +def _commit_index(repo: Repository, message: str) -> Oid: + index = repo.index + index.write() + tree = index.write_tree() + signature = _signature() + parents = [] if repo.head_is_unborn else [repo.head.target] + return repo.create_commit('HEAD', signature, signature, message, tree, parents) + + +def _commit_file(repo: Repository, name: str, content: str, message: str) -> Oid: + (Path(repo.workdir) / name).write_text(content) + repo.index.add(name) + return _commit_index(repo, message) + + +def _commit_removal(repo: Repository, name: str, message: str) -> Oid: + (Path(repo.workdir) / name).unlink() + repo.index.remove(name) + return _commit_index(repo, message) + + +def _diverge( + repo: Repository, upstream: list[FileSpec], local: list[FileSpec] +) -> tuple[Oid, list[Oid]]: + """Grow an 'upstream' branch and the current branch from the current HEAD. + + Returns the tip of the upstream branch and the local commit ids, and + leaves the repository back on the original branch. + """ + main = repo.head.shorthand + repo.branches.local.create('upstream', repo.head.peel(Commit)) + repo.checkout(repo.branches['upstream']) + for name, content, message in upstream: + _commit_file(repo, name, content, message) + upstream_target = _tip(repo.branches['upstream']) + repo.checkout(repo.branches[main]) + local_oids = [ + _commit_file(repo, name, content, message) for name, content, message in local + ] + return upstream_target, local_oids + + +def _entry_text(repo: Repository, entry: IndexEntry | None) -> str: + """One side of a conflict: '' for a deleted side, newline-terminated text + otherwise.""" + if entry is None: + return '' + blob = repo[entry.id] + assert isinstance(blob, Blob) + text = blob.data.decode('utf-8', errors='replace') + if text and not text.endswith('\n'): + text += '\n' + return text + + +def _resolve_with_markers(repo: Repository, merge_index: Index, commit: Commit) -> None: + """Resolve every conflict by embedding both sides of the file in git-style + conflict markers, then stage the marked-up file, the way `git rebase` + leaves conflicting files in the working tree for the user to edit.""" + conflicts = merge_index.conflicts + assert conflicts is not None + resolutions = [] + for ancestor_entry, our_entry, their_entry in conflicts: + some_entry = their_entry or our_entry or ancestor_entry + assert some_entry is not None + content = ( + '<<<<<<< HEAD (rebased)\n' + + _entry_text(repo, our_entry) + + '=======\n' + + _entry_text(repo, their_entry) + + f'>>>>>>> {commit.short_id} ({commit.message.strip()})\n' + ) + blob_oid = repo.create_blob(content.encode('utf-8')) + resolutions.append(IndexEntry(some_entry.path, blob_oid, FileMode.BLOB)) + for entry in resolutions: + del merge_index.conflicts[entry.path] + merge_index.add(entry) + + +def _replay_commit(repo: Repository, commit: Commit, onto: Oid) -> Oid: + """Replay one commit on top of `onto` with a three-way merge of trees.""" + merge_index = repo.merge_trees( + commit.parents[0].tree, # ancestor: state the commit was made against + repo[onto].peel(Commit).tree, # ours: state rebuilt so far + commit.tree, # theirs: the commit being replayed + ) + message = commit.message + if merge_index.conflicts is not None: + _resolve_with_markers(repo, merge_index, commit) + message = ( + f'{message.rstrip()}\n\n[Rebased with conflicts - manual resolution needed]' + ) + tree_oid = merge_index.write_tree(repo) + signature = _signature() + return repo.create_commit(None, signature, signature, message, tree_oid, [onto]) + + +def _fast_forward(repo: Repository, upstream_target: Oid) -> None: + repo.checkout_tree(repo[upstream_target]) # type: ignore[no-untyped-call] + repo.references[repo.head.name].set_target(upstream_target) + + +def _rebase_onto(repo: Repository, upstream_target: Oid) -> None: + """Rebase the current branch onto `upstream_target` from first principles. + + This is the second half of a hand-rolled `git pull --rebase` (the first + half being a fetch): fast-forward when possible, otherwise replay the + diverged local commits one by one on top of the upstream tip. + """ + merge_base = repo.merge_base(repo.head.target, upstream_target) + if merge_base == upstream_target: + # Upstream did not move, there is nothing to rebase onto. + return + if merge_base == repo.head.target: + _fast_forward(repo, upstream_target) + return + + walker = repo.walk(repo.head.target, SortMode.TOPOLOGICAL) + walker.hide(merge_base) + commits_to_replay = list(walker) + commits_to_replay.reverse() # replay oldest first + + repo.checkout_tree(repo[upstream_target]) # type: ignore[no-untyped-call] + current_parent = upstream_target + for commit in commits_to_replay: + current_parent = _replay_commit(repo, commit, current_parent) + + repo.references[repo.head.name].set_target(current_parent) + repo.checkout('HEAD', strategy=CheckoutStrategy.FORCE) + + +def _linear_history(repo: Repository) -> list[str]: + """Commit messages from HEAD down to the root, asserting that the history + contains no merge commits.""" + messages = [] + for commit in repo.walk(repo.head.target, SortMode.TOPOLOGICAL): + assert len(commit.parents) <= 1 + messages.append(commit.message) + return messages + + +@pytest.fixture +def rebaserepo(tmp_path: Path) -> Repository: + repo = pygit2.init_repository(tmp_path / 'rebaserepo') + _commit_file(repo, 'README.md', '# Test Repository\n', 'Initial commit') + _commit_file( + repo, 'file1.txt', 'Content of file 1\nLine 2\nLine 3\n', 'Add file1.txt' + ) + _commit_file( + repo, 'file2.txt', 'Content of file 2\nOriginal content\n', 'Add file2.txt' + ) + return repo + + +def test_rebase_noop_when_up_to_date(rebaserepo: Repository) -> None: + head_before = _tip(rebaserepo.head) + _rebase_onto(rebaserepo, head_before) + assert rebaserepo.head.target == head_before + assert rebaserepo.status() == {} + + +def test_rebase_noop_when_upstream_is_behind(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[], + local=[('file4.txt', 'New file 4\n', 'Add file4.txt')], + ) + _rebase_onto(rebaserepo, upstream_target) + assert rebaserepo.head.target == local_oids[0] + assert rebaserepo.status() == {} + + +def test_rebase_fast_forwards_when_local_did_not_diverge( + rebaserepo: Repository, +) -> None: + upstream_target, _ = _diverge( + rebaserepo, + upstream=[ + ('file3.txt', 'New file 3\n', 'Add file3.txt'), + ('file1.txt', 'Content of file 1\nLine 2 changed\n', 'Change file1.txt'), + ], + local=[], + ) + _rebase_onto(rebaserepo, upstream_target) + assert rebaserepo.head.target == upstream_target + workdir = Path(rebaserepo.workdir) + assert (workdir / 'file3.txt').read_text() == 'New file 3\n' + assert (workdir / 'file1.txt').read_text() == 'Content of file 1\nLine 2 changed\n' + assert rebaserepo.status() == {} + + +def test_rebase_replays_diverged_commit_cleanly(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'New file 3 from upstream\n', 'Add file3.txt')], + local=[('file4.txt', 'New file 4 from local\n', 'Add file4.txt')], + ) + _rebase_onto(rebaserepo, upstream_target) + + head = rebaserepo.head.peel(Commit) + assert head.message == 'Add file4.txt' + # The replayed commit is a new object with the upstream tip as its parent. + assert head.id != local_oids[0] + assert [parent.id for parent in head.parents] == [upstream_target] + workdir = Path(rebaserepo.workdir) + assert (workdir / 'file3.txt').read_text() == 'New file 3 from upstream\n' + assert (workdir / 'file4.txt').read_text() == 'New file 4 from local\n' + assert rebaserepo.status() == {} + # The pre-rebase commit is still in the object database. + assert rebaserepo.get(local_oids[0]) is not None + + +def test_rebase_replays_multiple_commits_oldest_first(rebaserepo: Repository) -> None: + upstream_target, _ = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ('c.txt', 'c\n', 'Add c.txt'), + ], + ) + _rebase_onto(rebaserepo, upstream_target) + assert _linear_history(rebaserepo) == [ + 'Add c.txt', + 'Add b.txt', + 'Add a.txt', + 'Upstream commit', + 'Add file2.txt', + 'Add file1.txt', + 'Initial commit', + ] + workdir = Path(rebaserepo.workdir) + for name in ('a.txt', 'b.txt', 'c.txt', 'file3.txt'): + assert (workdir / name).exists() + assert rebaserepo.status() == {} + + +def test_rebase_conflicting_commit_gets_markers(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed upstream\nLine 3\n', + 'Change line 2 upstream', + ) + ], + local=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed locally\nLine 3\n', + 'Change line 2 locally', + ) + ], + ) + original = rebaserepo[local_oids[0]].peel(Commit) + _rebase_onto(rebaserepo, upstream_target) + + head = rebaserepo.head.peel(Commit) + assert head.message == ( + 'Change line 2 locally\n\n[Rebased with conflicts - manual resolution needed]' + ) + assert [parent.id for parent in head.parents] == [upstream_target] + expected = ( + '<<<<<<< HEAD (rebased)\n' + 'Content of file 1\n' + 'Line 2 changed upstream\n' + 'Line 3\n' + '=======\n' + 'Content of file 1\n' + 'Line 2 changed locally\n' + 'Line 3\n' + f'>>>>>>> {original.short_id} (Change line 2 locally)\n' + ) + assert (Path(rebaserepo.workdir) / 'file1.txt').read_text() == expected + assert rebaserepo.index.conflicts is None + assert rebaserepo.status() == {} + + +def test_rebase_conflict_when_local_deleted_a_modified_file( + rebaserepo: Repository, +) -> None: + main = rebaserepo.head.shorthand + rebaserepo.branches.local.create('upstream', rebaserepo.head.peel(Commit)) + rebaserepo.checkout(rebaserepo.branches['upstream']) + _commit_file( + rebaserepo, + 'file2.txt', + 'Content of file 2\nModified upstream\n', + 'Modify file2.txt upstream', + ) + upstream_target = _tip(rebaserepo.branches['upstream']) + rebaserepo.checkout(rebaserepo.branches[main]) + removal_oid = _commit_removal(rebaserepo, 'file2.txt', 'Delete file2.txt') + original = rebaserepo[removal_oid].peel(Commit) + + _rebase_onto(rebaserepo, upstream_target) + + head = rebaserepo.head.peel(Commit) + assert head.message == ( + 'Delete file2.txt\n\n[Rebased with conflicts - manual resolution needed]' + ) + # The deleted side is left empty between the conflict markers. + expected = ( + '<<<<<<< HEAD (rebased)\n' + 'Content of file 2\n' + 'Modified upstream\n' + '=======\n' + f'>>>>>>> {original.short_id} (Delete file2.txt)\n' + ) + assert (Path(rebaserepo.workdir) / 'file2.txt').read_text() == expected + assert rebaserepo.status() == {} + + +def test_rebase_mixed_clean_and_conflicting_commits(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed upstream\nLine 3\n', + 'Change line 2 upstream', + ) + ], + local=[ + ('file4.txt', 'New file 4\n', 'Add file4.txt'), + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed locally\nLine 3\n', + 'Change line 2 locally', + ), + ], + ) + original = rebaserepo[local_oids[1]].peel(Commit) + _rebase_onto(rebaserepo, upstream_target) + + # The clean commit is replayed verbatim, only the conflicting one is + # annotated. + assert _linear_history(rebaserepo) == [ + 'Change line 2 locally\n\n[Rebased with conflicts - manual resolution needed]', + 'Add file4.txt', + 'Change line 2 upstream', + 'Add file2.txt', + 'Add file1.txt', + 'Initial commit', + ] + workdir = Path(rebaserepo.workdir) + assert (workdir / 'file4.txt').read_text() == 'New file 4\n' + expected = ( + '<<<<<<< HEAD (rebased)\n' + 'Content of file 1\n' + 'Line 2 changed upstream\n' + 'Line 3\n' + '=======\n' + 'Content of file 1\n' + 'Line 2 changed locally\n' + 'Line 3\n' + f'>>>>>>> {original.short_id} (Change line 2 locally)\n' + ) + assert (workdir / 'file1.txt').read_text() == expected + assert rebaserepo.status() == {} + + +def test_pull_rebase_after_fetch_from_remote(tmp_path: Path) -> None: + """The full `git pull --rebase` flow against a local "remote".""" + origin_path = tmp_path / 'origin' + origin = pygit2.init_repository(origin_path) + _commit_file(origin, 'README.md', '# Test Repository\n', 'Initial commit') + _commit_file(origin, 'file1.txt', 'Content of file 1\n', 'Add file1.txt') + + local = pygit2.clone_repository(str(origin_path), str(tmp_path / 'local')) + + _commit_file(origin, 'file3.txt', 'From origin\n', 'Add file3.txt in origin') + local_oid = _commit_file(local, 'file4.txt', 'From local\n', 'Add file4.txt local') + + for remote in local.remotes: + remote.fetch() + + branch = local.branches[local.head.shorthand] + upstream = branch.upstream + assert upstream is not None + _rebase_onto(local, _tip(upstream)) + + assert _linear_history(local) == [ + 'Add file4.txt local', + 'Add file3.txt in origin', + 'Add file1.txt', + 'Initial commit', + ] + assert local.head.target != local_oid + workdir = Path(local.workdir) + assert (workdir / 'file3.txt').read_text() == 'From origin\n' + assert (workdir / 'file4.txt').read_text() == 'From local\n' + assert local.status() == {} + + +# --------------------------------------------------------------------------- +# The native rebase API: Repository.rebase_init() / rebase_open() and Rebase +# --------------------------------------------------------------------------- + +CONFLICT_SCENARIO = dict( + upstream=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed upstream\nLine 3\n', + 'Change line 2 upstream', + ) + ], + local=[ + ( + 'file1.txt', + 'Content of file 1\nLine 2 changed locally\nLine 3\n', + 'Change line 2 locally', + ) + ], +) + + +def test_rebase_api_clean(rebaserepo: Repository) -> None: + main = rebaserepo.head.shorthand + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'New file 3 from upstream\n', 'Add file3.txt')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + + assert len(rebase) == 2 + assert rebase.current_index is None + assert rebase.onto_id == upstream_target + assert rebase.onto_name == 'upstream' + assert rebase.orig_head_name == f'refs/heads/{main}' + assert rebase.orig_head_id == local_oids[-1] + # Operations replay the diverged commits oldest first. + assert [rebase[i].id for i in range(len(rebase))] == local_oids + assert rebase[-1].id == local_oids[-1] + with pytest.raises(IndexError): + rebase[2] + + replayed: list[Oid] = [] + for i, operation in enumerate(rebase): + assert operation.type == RebaseOperationType.PICK + assert operation.id == local_oids[i] + assert operation.exec is None + assert rebase.current_index == i + assert rebaserepo.state() == RepositoryState.REBASE_MERGE + replayed.append(rebase.commit(committer=_signature())) + rebase.finish(_signature()) + + assert rebaserepo.state() == RepositoryState.NONE + assert replayed[0] != local_oids[0] + head = rebaserepo.head.peel(Commit) + assert head.id == replayed[-1] + assert rebaserepo.head.name == f'refs/heads/{main}' + assert _linear_history(rebaserepo)[:4] == [ + 'Add b.txt', + 'Add a.txt', + 'Add file3.txt', + 'Add file2.txt', + ] + workdir = Path(rebaserepo.workdir) + for name in ('a.txt', 'b.txt', 'file3.txt'): + assert (workdir / name).exists() + assert rebaserepo.status() == {} + + +def test_rebase_api_conflict_has_hunk_level_markers(rebaserepo: Repository) -> None: + _, local_oids = _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + + next(rebase) + conflicts = rebaserepo.index.conflicts + assert conflicts is not None + ancestor, ours, theirs = conflicts['file1.txt'] + assert ancestor is not None and ours is not None and theirs is not None + + # Unlike the manual whole-file markers, libgit2 wrote hunk-level + # markers into the working directory: common lines stay outside, and + # the sides are labeled with the onto name and the commit summary. + expected = ( + 'Content of file 1\n' + '<<<<<<< upstream\n' + 'Line 2 changed upstream\n' + '=======\n' + 'Line 2 changed locally\n' + '>>>>>>> Change line 2 locally\n' + 'Line 3\n' + ) + assert (Path(rebaserepo.workdir) / 'file1.txt').read_text() == expected + + # Keep the markers as the resolution, the autocommit way: staging the + # file marks the conflict as resolved. + rebaserepo.index.add('file1.txt') + rebaserepo.index.write() + assert rebaserepo.index.conflicts is None + rebase.commit( + committer=_signature(), + message='Change line 2 locally\n\n[Rebased with conflicts]', + ) + with pytest.raises(StopIteration): + next(rebase) + rebase.finish(_signature()) + + head = rebaserepo.head.peel(Commit) + assert head.message == 'Change line 2 locally\n\n[Rebased with conflicts]' + assert (Path(rebaserepo.workdir) / 'file1.txt').read_text() == expected + assert rebaserepo.status() == {} + + +def test_rebase_api_unresolved_conflict_blocks_commit(rebaserepo: Repository) -> None: + _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + next(rebase) + with pytest.raises(pygit2.GitError): + rebase.commit(committer=_signature()) + rebase.abort() + + +def test_rebase_api_custom_labels(rebaserepo: Repository) -> None: + _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init( + upstream=rebaserepo.branches['upstream'], + our_label='HEAD (rebased)', + their_label='incoming', + ) + next(rebase) + content = (Path(rebaserepo.workdir) / 'file1.txt').read_text() + assert '<<<<<<< HEAD (rebased)\n' in content + assert '>>>>>>> incoming\n' in content + rebase.abort() + + +def test_rebase_api_diff3_conflict_style(rebaserepo: Repository) -> None: + _diverge(rebaserepo, **CONFLICT_SCENARIO) + rebase = rebaserepo.rebase_init( + upstream=rebaserepo.branches['upstream'], + checkout_strategy=CheckoutStrategy.SAFE + | CheckoutStrategy.RECREATE_MISSING + | CheckoutStrategy.CONFLICT_STYLE_DIFF3, + ) + next(rebase) + content = (Path(rebaserepo.workdir) / 'file1.txt').read_text() + # diff3 style includes the common ancestor version of the hunk. + assert '||||||| ancestor\nLine 2\n=======\n' in content + rebase.abort() + + +def test_rebase_api_abort(rebaserepo: Repository) -> None: + main = rebaserepo.head.shorthand + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ], + ) + head_before = _tip(rebaserepo.head) + + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + operation = next(rebase) + rebase.commit(committer=_signature()) + operation = next(rebase) + assert operation.id == local_oids[1] + assert rebaserepo.state() == RepositoryState.REBASE_MERGE + + rebase.abort() + + assert rebaserepo.state() == RepositoryState.NONE + assert rebaserepo.head.name == f'refs/heads/{main}' + assert rebaserepo.head.target == head_before + workdir = Path(rebaserepo.workdir) + assert (workdir / 'a.txt').exists() + assert (workdir / 'b.txt').exists() + assert not (workdir / 'file3.txt').exists() + assert rebaserepo.status() == {} + + +def test_rebase_api_inmemory(rebaserepo: Repository) -> None: + main = rebaserepo.head.shorthand + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[('file4.txt', 'local\n', 'Add file4.txt')], + ) + head_before = _tip(rebaserepo.head) + + rebase = rebaserepo.rebase_init( + upstream=rebaserepo.branches['upstream'], inmemory=True + ) + operation = next(rebase) + assert operation.id == local_oids[0] + # The repository is not put into a rebasing state, and the working + # directory is not touched. + assert rebaserepo.state() == RepositoryState.NONE + assert not (Path(rebaserepo.workdir) / 'file3.txt').exists() + + merged = rebase.inmemory_index + assert merged.conflicts is None + assert 'file4.txt' in merged + + new_oid = rebase.commit(committer=_signature()) + rebase.finish(_signature()) + + # HEAD and the branch were left alone; putting the result in place is + # the caller's job, like the manual _fast_forward() epilogue. + assert rebaserepo.head.target == head_before + new_commit = rebaserepo[new_oid].peel(Commit) + assert [parent.id for parent in new_commit.parents] == [upstream_target] + assert new_commit.message == 'Add file4.txt' + + rebaserepo.references[f'refs/heads/{main}'].set_target(new_oid) + rebaserepo.checkout('HEAD', strategy=CheckoutStrategy.FORCE) + assert _linear_history(rebaserepo)[:2] == ['Add file4.txt', 'Upstream commit'] + assert rebaserepo.status() == {} + + +def test_rebase_api_finish_moves_branch_when_local_did_not_diverge( + rebaserepo: Repository, +) -> None: + upstream_target, _ = _diverge( + rebaserepo, + upstream=[('file3.txt', 'New file 3\n', 'Add file3.txt')], + local=[], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + # Local did not diverge: there is nothing to replay, and finishing + # fast-forwards the branch to the upstream tip. + assert len(rebase) == 0 + with pytest.raises(StopIteration): + next(rebase) + rebase.finish(_signature()) + assert rebaserepo.head.target == upstream_target + assert (Path(rebaserepo.workdir) / 'file3.txt').exists() + assert rebaserepo.status() == {} + + +def test_rebase_api_replays_even_when_upstream_is_behind( + rebaserepo: Repository, +) -> None: + """Unlike `git pull --rebase` porcelain (and the manual no-op check), + the plumbing does not detect that the upstream is simply behind: it + replays the local commits, rewriting their ids.""" + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[], + local=[('file4.txt', 'New file 4\n', 'Add file4.txt')], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + assert len(rebase) == 1 + for _operation in rebase: + rebase.commit(committer=_signature()) + rebase.finish(_signature()) + assert _linear_history(rebaserepo)[:2] == ['Add file4.txt', 'Add file2.txt'] + assert rebaserepo.head.target != local_oids[0] + assert rebaserepo.status() == {} + + +def test_rebase_api_already_applied_commit(rebaserepo: Repository) -> None: + """A local commit whose changes are already present upstream has + nothing left to commit; libgit2 reports that as an error and the + caller simply moves on to the next operation.""" + _diverge( + rebaserepo, + upstream=[('file3.txt', 'identical\n', 'Add file3.txt upstream')], + local=[('file3.txt', 'identical\n', 'Add file3.txt locally')], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + next(rebase) + with pytest.raises(pygit2.GitError): + rebase.commit(committer=_signature()) + with pytest.raises(StopIteration): + next(rebase) + rebase.finish(_signature()) + assert rebaserepo.head.target == _tip(rebaserepo.branches['upstream']) + assert rebaserepo.status() == {} + + +def test_rebase_open_resumes_rebase(rebaserepo: Repository) -> None: + upstream_target, local_oids = _diverge( + rebaserepo, + upstream=[('file3.txt', 'upstream\n', 'Upstream commit')], + local=[ + ('a.txt', 'a\n', 'Add a.txt'), + ('b.txt', 'b\n', 'Add b.txt'), + ], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + next(rebase) + rebase.commit(committer=_signature()) + del rebase + + # Another client (or a later process) picks the rebase up from disk. + resumed = rebaserepo.rebase_open() + assert len(resumed) == 2 + assert resumed.current_index == 0 + assert resumed.onto_id == upstream_target + operation = next(resumed) + assert operation.id == local_oids[1] + resumed.commit(committer=_signature()) + resumed.finish(_signature()) + + assert rebaserepo.state() == RepositoryState.NONE + assert _linear_history(rebaserepo)[:3] == [ + 'Add b.txt', + 'Add a.txt', + 'Upstream commit', + ] + assert rebaserepo.status() == {} From e5f6b422e1009e214090de8efd73a2701a043016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 5 Aug 2026 09:39:10 +0200 Subject: [PATCH 2/2] Rebase.commit() returns None for already-applied patches Callers previously could not tell GIT_EAPPLIED apart from any other error: pygit2 exceptions don't carry the libgit2 error code, so driving a rebase loop meant guessing which GitError is safe to skip. Mirror `git rebase`, which skips patches that are already present upstream, by returning None from commit() in that case. Unresolved conflicts still raise GitError. Also link the 1.20.0 changelog entries to the PR. Assisted-by: Claude Fable 5 --- CHANGELOG.md | 2 ++ docs/rebase.rst | 4 ++++ pygit2/rebase.py | 17 ++++++++++------- test/test_rebase.py | 12 +++++++----- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d3a3847..1ff9b4b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,11 @@ - Wrap libgit2's rebase API: add `Repository.rebase_init(...)` and `Repository.rebase_open(...)` returning a `Rebase` object, plus `RebaseOperation` and `enums.RebaseOperationType` + [#1483](https://github.com/libgit2/pygit2/pull/1483) - Fix `enums.CheckoutStrategy.CONFLICT_STYLE_ZDIFF3`, which was mistakenly bound to the `DIFF3` constant + [#1483](https://github.com/libgit2/pygit2/pull/1483) Breaking changes: diff --git a/docs/rebase.rst b/docs/rebase.rst index be2dc2c96..1cc27dc8b 100644 --- a/docs/rebase.rst +++ b/docs/rebase.rst @@ -35,6 +35,10 @@ Rebase the current branch onto its upstream:: Use ``abort()`` instead of ``finish()`` to reset the repository and the working directory to their state before the rebase began. +``commit()`` returns ``None`` for a patch that turns out to be already +present upstream; like ``git rebase``, simply move on to the next +operation. + With ``inmemory=True`` the rebase does not touch HEAD, the repository state, or the working directory; each step's result is available as ``rebase.inmemory_index`` and updating the branch reference afterwards is diff --git a/pygit2/rebase.py b/pygit2/rebase.py index e9aa1014d..063c386a8 100644 --- a/pygit2/rebase.py +++ b/pygit2/rebase.py @@ -165,15 +165,16 @@ def commit( committer: Signature, author: 'Signature | None' = None, message: 'str | None' = None, - ) -> Oid: + ) -> 'Oid | None': """ - Commit the current patch and return the id of the new commit. You - must have resolved any conflicts that were introduced during the - patch application from the last __next__() invocation. + Commit the current patch and return the id of the new commit, or + None if the current commit has already been applied to the upstream + and there is nothing to commit — mirroring how `git rebase` skips + already-applied patches. You must have resolved any conflicts that + were introduced during the patch application from the last + __next__() invocation. - Raises GitError if there are unmerged changes in the index, or if - the current commit has already been applied to the upstream and - there is nothing to commit. + Raises GitError if there are unmerged changes in the index. Parameters: @@ -202,6 +203,8 @@ def commit( ffi.NULL, cmessage, ) + if err == C.GIT_EAPPLIED: + return None check_error(err) return Oid(raw=bytes(ffi.buffer(coid)[:])) diff --git a/test/test_rebase.py b/test/test_rebase.py index efebfdd0a..80342924e 100644 --- a/test/test_rebase.py +++ b/test/test_rebase.py @@ -528,7 +528,9 @@ def test_rebase_api_clean(rebaserepo: Repository) -> None: assert operation.exec is None assert rebase.current_index == i assert rebaserepo.state() == RepositoryState.REBASE_MERGE - replayed.append(rebase.commit(committer=_signature())) + new_id = rebase.commit(committer=_signature()) + assert new_id is not None + replayed.append(new_id) rebase.finish(_signature()) assert rebaserepo.state() == RepositoryState.NONE @@ -684,6 +686,7 @@ def test_rebase_api_inmemory(rebaserepo: Repository) -> None: assert 'file4.txt' in merged new_oid = rebase.commit(committer=_signature()) + assert new_oid is not None rebase.finish(_signature()) # HEAD and the branch were left alone; putting the result in place is @@ -742,8 +745,8 @@ def test_rebase_api_replays_even_when_upstream_is_behind( def test_rebase_api_already_applied_commit(rebaserepo: Repository) -> None: """A local commit whose changes are already present upstream has - nothing left to commit; libgit2 reports that as an error and the - caller simply moves on to the next operation.""" + nothing left to commit; commit() reports that by returning None and + the caller simply moves on to the next operation.""" _diverge( rebaserepo, upstream=[('file3.txt', 'identical\n', 'Add file3.txt upstream')], @@ -751,8 +754,7 @@ def test_rebase_api_already_applied_commit(rebaserepo: Repository) -> None: ) rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) next(rebase) - with pytest.raises(pygit2.GitError): - rebase.commit(committer=_signature()) + assert rebase.commit(committer=_signature()) is None with pytest.raises(StopIteration): next(rebase) rebase.finish(_signature())