From 98e3d1fb5e47737913ecddcf939198385eae53a3 Mon Sep 17 00:00:00 2001 From: Iliyas Jorio Date: Sun, 17 Oct 2021 18:08:45 +0200 Subject: [PATCH] Add Repository.amend_commit --- pygit2/_run.py | 1 + pygit2/decl/commit.h | 9 ++++ pygit2/repository.py | 115 +++++++++++++++++++++++++++++++++++++++++- test/test_commit.py | 116 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 pygit2/decl/commit.h diff --git a/pygit2/_run.py b/pygit2/_run.py index e9d8e9702..4d68f3d20 100644 --- a/pygit2/_run.py +++ b/pygit2/_run.py @@ -77,6 +77,7 @@ 'net.h', 'refspec.h', 'repository.h', + 'commit.h', 'revert.h', 'stash.h', 'submodule.h', diff --git a/pygit2/decl/commit.h b/pygit2/decl/commit.h new file mode 100644 index 000000000..3e3b04a9d --- /dev/null +++ b/pygit2/decl/commit.h @@ -0,0 +1,9 @@ +int git_commit_amend( + git_oid *id, + const git_commit *commit_to_amend, + const char *update_ref, + const git_signature *author, + const git_signature *committer, + const char *message_encoding, + const char *message, + const git_tree *tree); diff --git a/pygit2/repository.py b/pygit2/repository.py index a0b585236..907967224 100644 --- a/pygit2/repository.py +++ b/pygit2/repository.py @@ -37,7 +37,7 @@ from ._pygit2 import GIT_FILEMODE_LINK from ._pygit2 import GIT_BRANCH_LOCAL, GIT_BRANCH_REMOTE, GIT_BRANCH_ALL from ._pygit2 import GIT_REF_SYMBOLIC -from ._pygit2 import Reference, Tree, Commit, Blob +from ._pygit2 import Reference, Tree, Commit, Blob, Signature from ._pygit2 import InvalidSpecError from .callbacks import git_fetch_options @@ -1364,6 +1364,119 @@ def revert_commit(self, revert_commit, our_commit, mainline=0): return Index.from_c(self, cindex) + # + # Amend commit + # + def amend_commit(self, commit, refname, author=None, + committer=None, message=None, tree=None, + encoding='UTF-8'): + """ + Amend an existing commit by replacing only explicitly passed values, + return the rewritten commit's oid. + + This creates a new commit that is exactly the same as the old commit, + except that any explicitly passed values will be updated. The new + commit has the same parents as the old commit. + + You may omit the `author`, `committer`, `message`, `tree`, and + `encoding` parameters, in which case this will use the values + from the original `commit`. + + Parameters: + + commit : Commit, Oid, or str + The commit to amend. + + refname : Reference or str + If not `None`, name of the reference that will be updated to point + to the newly rewritten commit. Use "HEAD" to update the HEAD of the + current branch and make it point to the rewritten commit. + If you want to amend a commit that is not currently the tip of the + branch and then rewrite the following commits to reach a ref, pass + this as `None` and update the rest of the commit chain and ref + separately. + + author : Signature + If not None, replace the old commit's author signature with this + one. + + committer : Signature + If not None, replace the old commit's committer signature with this + one. + + message : str + If not None, replace the old commit's message with this one. + + tree : Tree, Oid, or str + If not None, replace the old commit's tree with this one. + + encoding : str + Optional encoding for `message`. + """ + + # Initialize parameters to pass on to C function git_commit_amend. + # Note: the pointers are all initialized to NULL by default. + coid = ffi.new('git_oid *') + commit_cptr = ffi.new('git_commit **') + refname_cstr = ffi.NULL + author_cptr = ffi.new('git_signature **') + committer_cptr = ffi.new('git_signature **') + message_cstr = ffi.NULL + encoding_cstr = ffi.NULL + tree_cptr = ffi.new('git_tree **') + + # Get commit as pointer to git_commit. + if isinstance(commit, (str, Oid)): + commit = self[commit] + elif isinstance(commit, Commit): + pass + elif commit is None: + raise ValueError("the commit to amend cannot be None") + else: + raise TypeError("the commit to amend must be a Commit, str, or Oid") + commit = commit.peel(Commit) + ffi.buffer(commit_cptr)[:] = commit._pointer[:] + + # Get refname as C string. + if isinstance(refname, Reference): + refname_cstr = ffi.new('char[]', to_bytes(refname.name)) + elif type(refname) is str: + refname_cstr = ffi.new('char[]', to_bytes(refname)) + elif refname is not None: + raise TypeError("refname must be a str or Reference") + + # Get author as pointer to git_signature. + if isinstance(author, Signature): + ffi.buffer(author_cptr)[:] = author._pointer[:] + elif author is not None: + raise TypeError("author must be a Signature") + + # Get committer as pointer to git_signature. + if isinstance(committer, Signature): + ffi.buffer(committer_cptr)[:] = committer._pointer[:] + elif committer is not None: + raise TypeError("committer must be a Signature") + + # Get message and encoding as C strings. + if message is not None: + message_cstr = ffi.new('char[]', to_bytes(message, encoding)) + encoding_cstr = ffi.new('char[]', to_bytes(encoding)) + + # Get tree as pointer to git_tree. + if tree is not None: + if isinstance(tree, (str, Oid)): + tree = self[tree] + tree = tree.peel(Tree) + ffi.buffer(tree_cptr)[:] = tree._pointer[:] + + # Amend the commit. + err = C.git_commit_amend(coid, commit_cptr[0], refname_cstr, + author_cptr[0], committer_cptr[0], + encoding_cstr, message_cstr, tree_cptr[0]) + check_error(err) + + return Oid(raw=bytes(ffi.buffer(coid)[:])) + class Branches: diff --git a/test/test_commit.py b/test/test_commit.py index 9029ddbda..dccc26d20 100644 --- a/test/test_commit.py +++ b/test/test_commit.py @@ -29,11 +29,12 @@ import pytest -from pygit2 import GIT_OBJ_COMMIT, Signature, Oid +from pygit2 import GIT_OBJ_COMMIT, Signature, Oid, GitError from . import utils COMMIT_SHA = '5fe808e8953c12735680c257f56600cb0de44b10' +COMMIT_SHA_TO_AMEND = '784855caf26449a1914d2cf62d12b9374d76ae78' # tip of the master branch @utils.refcount @@ -133,3 +134,116 @@ def test_modify_commit(barerepo): with pytest.raises(AttributeError): setattr(commit, 'author', author) with pytest.raises(AttributeError): setattr(commit, 'tree', None) with pytest.raises(AttributeError): setattr(commit, 'parents', None) + +def test_amend_commit_metadata(barerepo): + repo = barerepo + commit = repo[COMMIT_SHA_TO_AMEND] + assert commit.oid == repo.head.target + + encoding = 'iso-8859-1' + amended_message = "Amended commit message.\n\nMessage with non-ascii chars: ééé.\n" + amended_author = Signature('Jane Author', 'jane@example.com', 12345, 0) + amended_committer = Signature('John Committer', 'john@example.com', 12346, 0) + + amended_oid = repo.amend_commit( + commit, 'HEAD', message=amended_message, author=amended_author, + committer=amended_committer, encoding=encoding) + amended_commit = repo[amended_oid] + + assert repo.head.target == amended_oid + assert GIT_OBJ_COMMIT == amended_commit.type + assert amended_committer == amended_commit.committer + assert amended_author == amended_commit.author + assert amended_message.encode(encoding) == amended_commit.raw_message + assert commit.author != amended_commit.author + assert commit.committer != amended_commit.committer + assert commit.tree == amended_commit.tree # we didn't touch the tree + +def test_amend_commit_tree(barerepo): + repo = barerepo + commit = repo[COMMIT_SHA_TO_AMEND] + assert commit.oid == repo.head.target + + tree = '967fce8df97cc71722d3c2a5930ef3e6f1d27b12' + tree_prefix = tree[:5] + + amended_oid = repo.amend_commit(commit, 'HEAD', tree=tree_prefix) + amended_commit = repo[amended_oid] + + assert repo.head.target == amended_oid + assert GIT_OBJ_COMMIT == amended_commit.type + assert commit.message == amended_commit.message + assert commit.author == amended_commit.author + assert commit.committer == amended_commit.committer + assert commit.tree_id != amended_commit.tree_id + assert Oid(hex=tree) == amended_commit.tree_id + +def test_amend_commit_not_tip_of_branch(barerepo): + repo = barerepo + + # This commit isn't at the tip of the branch. + commit = repo['5fe808e8953c12735680c257f56600cb0de44b10'] + assert commit.oid != repo.head.target + + # Can't update HEAD to the rewritten commit because it's not the tip of the branch. + with pytest.raises(GitError): + repo.amend_commit(commit, 'HEAD', message="this won't work!") + + # We can still amend the commit if we don't try to update a ref. + repo.amend_commit(commit, None, message="this will work") + +def test_amend_commit_no_op(barerepo): + repo = barerepo + commit = repo[COMMIT_SHA_TO_AMEND] + assert commit.oid == repo.head.target + + amended_oid = repo.amend_commit(commit, None) + assert amended_oid == commit.oid + +def test_amend_commit_argument_types(barerepo): + repo = barerepo + + some_tree = repo['967fce8df97cc71722d3c2a5930ef3e6f1d27b12'] + commit = repo[COMMIT_SHA_TO_AMEND] + alt_commit1 = Oid(hex=COMMIT_SHA_TO_AMEND) + alt_commit2 = COMMIT_SHA_TO_AMEND + alt_tree = some_tree + alt_refname = repo.head # try this one last, because it'll change the commit at the tip + + # Pass bad values/types for the commit + with pytest.raises(ValueError): repo.amend_commit(None, None) + with pytest.raises(TypeError): repo.amend_commit(some_tree, None) + + # Pass bad types for signatures + with pytest.raises(TypeError): repo.amend_commit(commit, None, author="Toto") + with pytest.raises(TypeError): repo.amend_commit(commit, None, committer="Toto") + + # Pass bad refnames + with pytest.raises(ValueError): repo.amend_commit(commit, "this-ref-doesnt-exist") + with pytest.raises(TypeError): repo.amend_commit(commit, repo) + + # Pass bad trees + with pytest.raises(ValueError): repo.amend_commit(commit, None, tree="can't parse this") + with pytest.raises(KeyError): repo.amend_commit(commit, None, tree="baaaaad") + + # Pass an Oid for the commit + amended_oid = repo.amend_commit(alt_commit1, None, message="Hello") + amended_commit = repo[amended_oid] + assert GIT_OBJ_COMMIT == amended_commit.type + assert str(amended_oid) != COMMIT_SHA_TO_AMEND + + # Pass a str for the commit + amended_oid = repo.amend_commit(alt_commit2, None, message="Hello", tree=alt_tree) + amended_commit = repo[amended_oid] + assert GIT_OBJ_COMMIT == amended_commit.type + assert str(amended_oid) != COMMIT_SHA_TO_AMEND + assert repo[COMMIT_SHA_TO_AMEND].tree != amended_commit.tree + assert alt_tree.oid == amended_commit.tree_id + + # Pass an actual reference object for refname + # (Warning: the tip of the branch will be altered after this test!) + amended_oid = repo.amend_commit(alt_commit2, alt_refname, message="Hello") + amended_commit = repo[amended_oid] + assert GIT_OBJ_COMMIT == amended_commit.type + assert str(amended_oid) != COMMIT_SHA_TO_AMEND + assert repo.head.target == amended_oid