diff --git a/CHANGELOG.md b/CHANGELOG.md index 50677c382..1ff9b4b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ - 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` + [#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: - 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..1cc27dc8b --- /dev/null +++ b/docs/rebase.rst @@ -0,0 +1,83 @@ +********************************************************************** +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. + +``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 +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..063c386a8 --- /dev/null +++ b/pygit2/rebase.py @@ -0,0 +1,249 @@ +# 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 | None': + """ + 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. + + 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, + ) + if err == C.GIT_EAPPLIED: + return None + 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..80342924e --- /dev/null +++ b/test/test_rebase.py @@ -0,0 +1,795 @@ +# 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 + new_id = rebase.commit(committer=_signature()) + assert new_id is not None + replayed.append(new_id) + 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()) + assert new_oid is not None + 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; 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')], + local=[('file3.txt', 'identical\n', 'Add file3.txt locally')], + ) + rebase = rebaserepo.rebase_init(upstream=rebaserepo.branches['upstream']) + next(rebase) + assert rebase.commit(committer=_signature()) is None + 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() == {}