diff options
author | Dmitrii Naumenko <dmitrii.naumenko@jetbrains.com> | 2023-11-22 19:11:17 +0100 |
---|---|---|
committer | Matthias Sohn <matthias.sohn@sap.com> | 2024-01-28 16:13:32 +0100 |
commit | c646649257070cd1707b7e86887e50c5acafa86c (patch) | |
tree | c7211b1548a6e02bf36bb39bac542b20e2597793 | |
parent | 74471b8d755bdfe15a0585e49e1449788b4fc843 (diff) | |
download | jgit-c646649257070cd1707b7e86887e50c5acafa86c.tar.gz jgit-c646649257070cd1707b7e86887e50c5acafa86c.zip |
CherryPick: add ability to customise cherry-picked commit message
Originally I wanted to support a feature similar to `-x` options from
https://git-scm.com/docs/git-cherry-pick#_options.
The idea was to append original commit hash in this format:
```
my original commit message
(cherry picked from commit 75355897dc28e9975afed028c1a6d8c6b97b2a3c)
```
This can be useful information in some integrations.
I decided to make it in a more generic way
and pass custom `CherryPickCommitMessageProvider` implementation.
One of the two default implementations can append original commit hash
Change-Id: Id664e8438b0b76c5cb9b58113887eec04aa6f611
5 files changed, 309 insertions, 1 deletions
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/CherryPickCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/CherryPickCommandTest.java index 301d6be662..be3b33a9c5 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/CherryPickCommandTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/CherryPickCommandTest.java @@ -9,6 +9,8 @@ */ package org.eclipse.jgit.api; +import static org.eclipse.jgit.api.CherryPickCommitMessageProvider.ORIGINAL; +import static org.eclipse.jgit.api.CherryPickCommitMessageProvider.ORIGINAL_WITH_REFERENCE; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -590,4 +592,187 @@ public class CherryPickCommandTest extends RepositoryTestCase { checkFile(new File(db.getWorkTree(), "file"), "a\n2\n3\n"); } } + + private void doCherryPickWithCustomProviderBaseTest(Git git, + CherryPickCommitMessageProvider commitMessageProvider) + throws Exception { + writeTrashFile("fileA", "line 1\nline 2\nline 3\n"); + git.add().addFilepattern("fileA").call(); + RevCommit commitFirst = git.commit().setMessage("create fileA").call(); + + writeTrashFile("fileB", "content from file B\n"); + git.add().addFilepattern("fileB").call(); + RevCommit commitCreateFileB = git.commit() + .setMessage("create fileB\n\nsome commit details").call(); + + writeTrashFile("fileA", "line 1\nline 2\nline 3\nline 4\n"); + git.add().addFilepattern("fileA").call(); + RevCommit commitEditFileA1 = git.commit().setMessage("patch fileA 1") + .call(); + + writeTrashFile("fileA", "line 1\nline 2\nline 3\nline 4\nline 5\n"); + git.add().addFilepattern("fileA").call(); + RevCommit commitEditFileA2 = git.commit().setMessage("patch fileA 2") + .call(); + + git.branchCreate().setName("side").setStartPoint(commitFirst).call(); + checkoutBranch("refs/heads/side"); + + CherryPickResult pickResult = git.cherryPick() + .setCherryPickCommitMessageProvider(commitMessageProvider) + .include(commitCreateFileB).include(commitEditFileA1) + .include(commitEditFileA2).call(); + + assertEquals(CherryPickStatus.OK, pickResult.getStatus()); + + assertTrue(new File(db.getWorkTree(), "fileA").exists()); + assertTrue(new File(db.getWorkTree(), "fileB").exists()); + + checkFile(new File(db.getWorkTree(), "fileA"), + "line 1\nline 2\nline 3\nline 4\nline 5\n"); + checkFile(new File(db.getWorkTree(), "fileB"), "content from file B\n"); + } + + @Test + public void testCherryPickWithCustomCommitMessageProvider() + throws Exception { + try (Git git = new Git(db)) { + @SuppressWarnings("boxing") + CherryPickCommitMessageProvider messageProvider = srcCommit -> { + String message = srcCommit.getFullMessage(); + return String.format("%s (message length: %d)", message, + message.length()); + }; + doCherryPickWithCustomProviderBaseTest(git, messageProvider); + + Iterator<RevCommit> history = git.log().call().iterator(); + assertEquals("patch fileA 2 (message length: 13)", + history.next().getFullMessage()); + assertEquals("patch fileA 1 (message length: 13)", + history.next().getFullMessage()); + assertEquals( + "create fileB\n\nsome commit details (message length: 33)", + history.next().getFullMessage()); + assertEquals("create fileA", history.next().getFullMessage()); + assertFalse(history.hasNext()); + } + } + + @Test + public void testCherryPickWithCustomCommitMessageProvider_ORIGINAL() + throws Exception { + try (Git git = new Git(db)) { + doCherryPickWithCustomProviderBaseTest(git, ORIGINAL); + + Iterator<RevCommit> history = git.log().call().iterator(); + assertEquals("patch fileA 2", history.next().getFullMessage()); + assertEquals("patch fileA 1", history.next().getFullMessage()); + assertEquals("create fileB\n\nsome commit details", + history.next().getFullMessage()); + assertEquals("create fileA", history.next().getFullMessage()); + assertFalse(history.hasNext()); + } + } + + @Test + public void testCherryPickWithCustomCommitMessageProvider_ORIGINAL_WITH_REFERENCE() + throws Exception { + try (Git git = new Git(db)) { + doCherryPickWithCustomProviderBaseTest(git, + ORIGINAL_WITH_REFERENCE); + + Iterator<RevCommit> history = git.log().call().iterator(); + assertEquals("patch fileA 2\n\n(cherry picked from commit 1ac121e90b0fb6fb18bbb4307e3e9731ceeba9e1)", history.next().getFullMessage()); + assertEquals("patch fileA 1\n\n(cherry picked from commit 71475239df59076e18564fa360e3a74280926c2a)", history.next().getFullMessage()); + assertEquals("create fileB\n\nsome commit details\n\n(cherry picked from commit 29b4501297ccf8de9de9f451e7beb384b51f5378)", + history.next().getFullMessage()); + assertEquals("create fileA", history.next().getFullMessage()); + assertFalse(history.hasNext()); + } + } + + @Test + public void testCherryPickWithCustomCommitMessageProvider_ORIGINAL_WITH_REFERENCE_DonNotAddNewLineAfterFooter() + throws Exception { + try (Git git = new Git(db)) { + CherryPickCommitMessageProvider commitMessageProvider = CherryPickCommitMessageProvider.ORIGINAL_WITH_REFERENCE; + + RevCommit commit1 = addFileAndCommit(git, "file1", "content 1", + "commit1: no footer line"); + RevCommit commit2 = addFileAndCommit(git, "file2", "content 2", + "commit2: simple single footer line" + + "\n\nSigned-off-by: Alice <alice@example.com>"); + RevCommit commit3 = addFileAndCommit(git, "file3", "content 3", + "commit3: multiple footer lines\n\n" + + "Signed-off-by: Alice <alice@example.com>\n" + + "Signed-off-by: Bob <bob@example.com>"); + RevCommit commit4 = addFileAndCommit(git, "file4", "content 4", + "commit4: extra commit text before footer line\n\n" + + "Commit message details\n\n" + + "Signed-off-by: Alice <alice@example.com>\n" + + "Signed-off-by: Bob <bob@example.com>"); + RevCommit commit5 = addFileAndCommit(git, "file5", "content 5", + "commit5: extra commit text after footer line\n\n" + + "Signed-off-by: Alice <alice@example.com>\n" + + "Signed-off-by: Bob <bob@example.com>\n\n" + + "some extra description after footer"); + + git.branchCreate().setName("side").setStartPoint(commit1).call(); + checkoutBranch("refs/heads/side"); + + CherryPickResult pickResult = git.cherryPick() + .setCherryPickCommitMessageProvider(commitMessageProvider) + .include(commit2).include(commit3).include(commit4) + .include(commit5).call(); + + assertEquals(CherryPickStatus.OK, pickResult.getStatus()); + + assertTrue(new File(db.getWorkTree(), "file1").exists()); + assertTrue(new File(db.getWorkTree(), "file2").exists()); + assertTrue(new File(db.getWorkTree(), "file3").exists()); + assertTrue(new File(db.getWorkTree(), "file4").exists()); + assertTrue(new File(db.getWorkTree(), "file5").exists()); + + Iterator<RevCommit> history = git.log().call().iterator(); + RevCommit cpCommit1 = history.next(); + RevCommit cpCommit2 = history.next(); + RevCommit cpCommit3 = history.next(); + RevCommit cpCommit4 = history.next(); + RevCommit cpCommitInit = history.next(); + assertFalse(history.hasNext()); + + assertEquals("commit5: extra commit text after footer line\n\n" + + "Signed-off-by: Alice <alice@example.com>\n" + + "Signed-off-by: Bob <bob@example.com>\n\n" + + "some extra description after footer\n\n" + + "(cherry picked from commit c3c9959207dc7ae7c83da5d36dc14ef2ca42d572)", + cpCommit1.getFullMessage()); + assertEquals("commit4: extra commit text before footer line\n\n" + + "Commit message details\n\n" + + "Signed-off-by: Alice <alice@example.com>\n" + + "Signed-off-by: Bob <bob@example.com>\n" + + "(cherry picked from commit af3e8106c12cb946a37b403ddb2dd6c11a883698)", + cpCommit2.getFullMessage()); + assertEquals("commit3: multiple footer lines\n\n" + + "Signed-off-by: Alice <alice@example.com>\n" + + "Signed-off-by: Bob <bob@example.com>\n" + + "(cherry picked from commit 6d60f1a70a11a32dff4402c157c4ac328c32ce6c)", + cpCommit3.getFullMessage()); + assertEquals("commit2: simple single footer line\n\n" + + "Signed-off-by: Alice <alice@example.com>\n" + + "(cherry picked from commit 92bf0ec458814ecc73da8e050e60547d2ea6cce5)", + cpCommit4.getFullMessage()); + + assertEquals("commit1: no footer line", + cpCommitInit.getFullMessage()); + } + } + + private RevCommit addFileAndCommit(Git git, String fileName, + String fileText, String commitMessage) + throws IOException, GitAPIException { + writeTrashFile(fileName, fileText); + git.add().addFilepattern(fileName).call(); + return git.commit().setMessage(commitMessage).call(); + } } diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/revwalk/FooterLineTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/revwalk/FooterLineTest.java index cac0743d68..657c3d242f 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/revwalk/FooterLineTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/revwalk/FooterLineTest.java @@ -291,18 +291,26 @@ public class FooterLineTest extends RepositoryTestCase { f = footers.get(0); assertEquals("Signed-off-by", f.getKey()); assertEquals("A. U. Thor <a@example.com>", f.getValue()); + assertEquals(217, f.getStartOffset()); + assertEquals(258, f.getEndOffset()); f = footers.get(1); assertEquals("CC", f.getKey()); assertEquals("<some.mailing.list@example.com>", f.getValue()); + assertEquals(259, f.getStartOffset()); + assertEquals(305, f.getEndOffset()); f = footers.get(2); assertEquals("Acked-by", f.getKey()); assertEquals("Some Reviewer <sr@example.com>", f.getValue()); + assertEquals(356, f.getStartOffset()); + assertEquals(396, f.getEndOffset()); f = footers.get(3); assertEquals("Signed-off-by", f.getKey()); assertEquals("Main Tain Er <mte@example.com>", f.getValue()); + assertEquals(397, f.getStartOffset()); + assertEquals(442, f.getEndOffset()); } @Test diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java index 38e795b0eb..a1c64788bd 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java @@ -9,6 +9,7 @@ */ package org.eclipse.jgit.api; +import static org.eclipse.jgit.api.CherryPickCommitMessageProvider.ORIGINAL; import static org.eclipse.jgit.lib.Constants.OBJECT_ID_ABBREV_STRING_LENGTH; import java.io.IOException; @@ -66,6 +67,8 @@ public class CherryPickCommand extends GitCommand<CherryPickResult> { private String ourCommitName = null; + private CherryPickCommitMessageProvider messageProvider = ORIGINAL; + private MergeStrategy strategy = MergeStrategy.RECURSIVE; private ContentMergeStrategy contentStrategy; @@ -168,8 +171,10 @@ public class CherryPickCommand extends GitCommand<CherryPickResult> { dco.checkout(); if (!noCommit) { try (Git git = new Git(getRepository())) { + String commitMessage = messageProvider + .getCherryPickedCommitMessage(srcCommit); newHead = git.commit() - .setMessage(srcCommit.getFullMessage()) + .setMessage(commitMessage) .setReflogComment(reflogPrefix + " " //$NON-NLS-1$ + srcCommit.getShortMessage()) .setAuthor(srcCommit.getAuthorIdent()) @@ -297,6 +302,22 @@ public class CherryPickCommand extends GitCommand<CherryPickResult> { } /** + * Set a message provider for a target cherry-picked commit<br> + * By default original commit message is used (see + * {@link CherryPickCommitMessageProvider#ORIGINAL}) + * + * @param messageProvider + * the commit message provider + * @return {@code this} + * @since 6.9 + */ + public CherryPickCommand setCherryPickCommitMessageProvider( + CherryPickCommitMessageProvider messageProvider) { + this.messageProvider = messageProvider; + return this; + } + + /** * Set the prefix to use in the reflog. * <p> * This is primarily needed for implementing rebase in terms of diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommitMessageProvider.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommitMessageProvider.java new file mode 100644 index 0000000000..50d65b6fa8 --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommitMessageProvider.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 Dmitrii Naumenko <dmitrii.naumenko@jetbrains.com> + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is aailable at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.api; + +import java.util.List; + +import org.eclipse.jgit.revwalk.FooterLine; +import org.eclipse.jgit.revwalk.RevCommit; + +/** + * The interface is used to construct a cherry-picked commit message based on + * the original commit + * + * @see #ORIGINAL + * @see #ORIGINAL_WITH_REFERENCE + * @since 6.9 + */ +public interface CherryPickCommitMessageProvider { + + /** + * This provider returns the original commit message + */ + static final CherryPickCommitMessageProvider ORIGINAL = RevCommit::getFullMessage; + + /** + * This provider returns the original commit message with original commit + * hash in SHA-1 form.<br> + * Example: + * + * <pre> + * <code>my original commit message + * + * (cherry picked from commit 75355897dc28e9975afed028c1a6d8c6b97b2a3c)</code> + * </pre> + * + * This is similar to <code>-x</code> flag in git-scm (see <a href= + * "https://git-scm.com/docs/git-cherry-pick#_options">https://git-scm.com/docs/git-cherry-pick#_options</a>) + */ + static final CherryPickCommitMessageProvider ORIGINAL_WITH_REFERENCE = srcCommit -> { + String fullMessage = srcCommit.getFullMessage(); + + // Don't add extra new line after footer (aka trailer) + // https://stackoverflow.com/questions/70007405/git-log-exclude-cherry-pick-messages-for-trailers + // https://lore.kernel.org/git/7vmx136cdc.fsf@alter.siamese.dyndns.org + String separator = messageEndsWithFooter(srcCommit) ? "\n" : "\n\n"; //$NON-NLS-1$//$NON-NLS-2$ + String revisionString = srcCommit.getName(); + return String.format("%s%s(cherry picked from commit %s)", //$NON-NLS-1$ + fullMessage, separator, revisionString); + }; + + /** + * @param srcCommit + * original cherry-picked commit + * @return target cherry-picked commit message + */ + String getCherryPickedCommitMessage(RevCommit srcCommit); + + private static boolean messageEndsWithFooter(RevCommit srcCommit) { + byte[] rawBuffer = srcCommit.getRawBuffer(); + List<FooterLine> footers = srcCommit.getFooterLines(); + int maxFooterEnd = footers.stream().mapToInt(FooterLine::getEndOffset) + .max().orElse(-1); + return rawBuffer.length == maxFooterEnd; + } +} diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/FooterLine.java b/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/FooterLine.java index 227ea0fd65..fa7fb316c9 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/FooterLine.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/revwalk/FooterLine.java @@ -251,6 +251,28 @@ public final class FooterLine { return RawParseUtils.decode(enc, buffer, lt, gt - 1); } + /** + * @return start offset of the footer relative to the original raw message + * byte buffer + * + * @see #fromMessage(byte[]) + * @since 6.9 + */ + public int getStartOffset() { + return keyStart; + } + + /** + * @return end offset of the footer relative to the original raw message + * byte buffer + * + * @see #fromMessage(byte[]) + * @since 6.9 + */ + public int getEndOffset() { + return valEnd; + } + @Override public String toString() { return getKey() + ": " + getValue(); //$NON-NLS-1$ |