diff --git a/maven-scm-api/src/main/java/org/apache/maven/scm/CommandParameter.java b/maven-scm-api/src/main/java/org/apache/maven/scm/CommandParameter.java index 121bb7097..2c7d87354 100644 --- a/maven-scm-api/src/main/java/org/apache/maven/scm/CommandParameter.java +++ b/maven-scm-api/src/main/java/org/apache/maven/scm/CommandParameter.java @@ -79,6 +79,17 @@ public class CommandParameter implements Serializable { */ public static final CommandParameter SCM_SHORT_REVISION_LENGTH = new CommandParameter("shortRevisionLength"); + /** + * Parameter used only for the Git SCM {@code info} command to control whether merge commits are skipped + * (adds --no-merges to the underlying git log invocation). + * Defaults to {@code true} (skip merge commits) to keep backward compatibility. Set to {@code false} to + * include merge commits, e.g. so that the reported revision matches the actual {@code HEAD} in merge-based + * release workflows. + * + * @since 2.2.2 + */ + public static final CommandParameter SCM_SKIP_MERGE_COMMITS = new CommandParameter("skipMergeCommits"); + /** * Parameter to force add. * diff --git a/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gitexe/src/main/java/org/apache/maven/scm/provider/git/gitexe/command/info/GitInfoCommand.java b/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gitexe/src/main/java/org/apache/maven/scm/provider/git/gitexe/command/info/GitInfoCommand.java index 08262db6b..dc2046287 100644 --- a/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gitexe/src/main/java/org/apache/maven/scm/provider/git/gitexe/command/info/GitInfoCommand.java +++ b/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gitexe/src/main/java/org/apache/maven/scm/provider/git/gitexe/command/info/GitInfoCommand.java @@ -47,13 +47,20 @@ public class GitInfoCommand extends AbstractCommand implements GitCommand { public static final int NO_REVISION_LENGTH = -1; + /** Default value applied when the {@link CommandParameter#SCM_SKIP_MERGE_COMMITS} parameter is absent. */ + public static final boolean DEFAULT_SKIP_MERGE_COMMITS = true; + @Override protected ScmResult executeCommand( ScmProviderRepository repository, ScmFileSet fileSet, CommandParameters parameters) throws ScmException { + boolean skipMergeCommits = isSkipMergeCommits(parameters); + Commandline baseCli = GitCommandLineUtils.getBaseGitCommandLine(fileSet.getBasedir(), "log"); baseCli.createArg().setValue("-1"); // only most recent commit matters - baseCli.createArg().setValue("--no-merges"); // skip merge commits + if (skipMergeCommits) { + baseCli.createArg().setValue("--no-merges"); // skip merge commits + } baseCli.addArg(GitInfoConsumer.getFormatArgument()); List infoItems = new LinkedList<>(); @@ -64,7 +71,9 @@ protected ScmResult executeCommand( for (File scmFile : fileSet.getFileList()) { baseCli = GitCommandLineUtils.getBaseGitCommandLine(fileSet.getBasedir(), "log"); baseCli.createArg().setValue("-1"); // only most recent commit matters - baseCli.createArg().setValue("--no-merges"); // skip merge commits + if (skipMergeCommits) { + baseCli.createArg().setValue("--no-merges"); // skip merge commits + } baseCli.addArg(GitInfoConsumer.getFormatArgument()); // Insert a separator to make sure that files aren't interpreted as part of the version spec baseCli.createArg().setValue("--"); @@ -102,4 +111,21 @@ private static int getRevisionLength(final CommandParameters parameters) throws return parameters.getInt(CommandParameter.SCM_SHORT_REVISION_LENGTH, NO_REVISION_LENGTH); } } + + /** + * Whether merge commits should be skipped (i.e. whether {@code --no-merges} should be added). + * + * @param parameters the command parameters + * @return {@link #DEFAULT_SKIP_MERGE_COMMITS} if parameter {@link CommandParameter#SCM_SKIP_MERGE_COMMITS} + * (or the whole {@code parameters}) is absent, and otherwise the requested value + * @throws ScmException if the parameter has the wrong type + * @since 2.2.2 + */ + private static boolean isSkipMergeCommits(final CommandParameters parameters) throws ScmException { + if (parameters == null) { + return DEFAULT_SKIP_MERGE_COMMITS; + } else { + return parameters.getBoolean(CommandParameter.SCM_SKIP_MERGE_COMMITS, DEFAULT_SKIP_MERGE_COMMITS); + } + } } diff --git a/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gittest/pom.xml b/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gittest/pom.xml index ee2938a3d..a5343a445 100644 --- a/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gittest/pom.xml +++ b/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gittest/pom.xml @@ -44,6 +44,12 @@ org.codehaus.plexus plexus-utils + + + org.eclipse.jgit + org.eclipse.jgit + ${jgitVersion} + org.apache.sshd diff --git a/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gittest/src/main/java/org/apache/maven/scm/provider/git/command/info/GitInfoCommandTckTest.java b/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gittest/src/main/java/org/apache/maven/scm/provider/git/command/info/GitInfoCommandTckTest.java index fa0a91570..cd6bb1b19 100644 --- a/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gittest/src/main/java/org/apache/maven/scm/provider/git/command/info/GitInfoCommandTckTest.java +++ b/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-gittest/src/main/java/org/apache/maven/scm/provider/git/command/info/GitInfoCommandTckTest.java @@ -18,8 +18,29 @@ */ package org.apache.maven.scm.provider.git.command.info; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Date; + +import org.apache.maven.scm.CommandParameter; +import org.apache.maven.scm.CommandParameters; +import org.apache.maven.scm.command.info.InfoScmResult; +import org.apache.maven.scm.provider.ScmProvider; import org.apache.maven.scm.provider.git.GitScmTestUtils; import org.apache.maven.scm.tck.command.info.InfoCommandTckTest; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.MergeCommand; +import org.eclipse.jgit.api.MergeResult; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.StoredConfig; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * @author Mark Struberg @@ -31,4 +52,93 @@ public abstract class GitInfoCommandTckTest extends InfoCommandTckTest { public void initRepo() throws Exception { GitScmTestUtils.initRepo("src/test/resources/repository/", getRepositoryRoot(), getWorkingDirectory()); } + + @Test + void testInfoCommandSkipsMergeCommitsByDefault() throws Exception { + MergeScenario scenario = createMergeCommitInWorkingCopy(); + ScmProvider scmProvider = getScmManager().getProviderByUrl(getScmUrl()); + InfoScmResult result = scmProvider.info(getScmRepository().getProviderRepository(), getScmFileSet(), null); + assertResultIsSuccess(result); + assertEquals(1, result.getInfoItems().size()); + assertEquals( + scenario.lastNonMergeCommit.getName(), + result.getInfoItems().get(0).getRevision(), + "the most recent non-merge commit must be reported by default"); + } + + @Test + void testInfoCommandIncludesMergeCommitsOnDemand() throws Exception { + MergeScenario scenario = createMergeCommitInWorkingCopy(); + ScmProvider scmProvider = getScmManager().getProviderByUrl(getScmUrl()); + CommandParameters parameters = new CommandParameters(); + parameters.setString(CommandParameter.SCM_SKIP_MERGE_COMMITS, Boolean.FALSE.toString()); + InfoScmResult result = + scmProvider.info(getScmRepository().getProviderRepository(), getScmFileSet(), parameters); + assertResultIsSuccess(result); + assertEquals(1, result.getInfoItems().size()); + assertEquals( + scenario.mergeCommit.getName(), + result.getInfoItems().get(0).getRevision(), + "the merge commit at HEAD must be reported when skipMergeCommits=false"); + } + + /** + * Turns the working copy's {@code HEAD} into a no-fast-forward merge commit (two parents): + * commits once on a side branch and once on the default branch, then merges the side branch. + * Distinct commit times make "the most recent non-merge commit" unambiguous. + */ + private MergeScenario createMergeCommitInWorkingCopy() throws Exception { + try (Git git = Git.open(getWorkingCopy())) { + StoredConfig config = git.getRepository().getConfig(); + config.setString("user", null, "name", "Test User"); + config.setString("user", null, "email", "test@example.com"); + config.setBoolean("commit", null, "gpgsign", false); + config.save(); + + Date sideBranchTime = new Date(System.currentTimeMillis() - 120_000L); + Date defaultBranchTime = new Date(sideBranchTime.getTime() + 60_000L); + + String defaultBranch = git.getRepository().getBranch(); + git.checkout().setCreateBranch(true).setName("side-branch").call(); + commitNewFile(git, "side.txt", sideBranchTime); + + git.checkout().setName(defaultBranch).call(); + RevCommit lastNonMergeCommit = commitNewFile(git, "default.txt", defaultBranchTime); + + MergeResult mergeResult = git.merge() + .include(git.getRepository().resolve("side-branch")) + .setFastForward(MergeCommand.FastForwardMode.NO_FF) + .setMessage("merge side-branch") + .call(); + assertTrue( + mergeResult.getMergeStatus().isSuccessful(), + "merge must succeed but was: " + mergeResult.getMergeStatus()); + ObjectId mergeCommit = mergeResult.getNewHead(); + assertNotNull(mergeCommit, "merge must produce a new HEAD commit"); + return new MergeScenario(lastNonMergeCommit, mergeCommit); + } + } + + private RevCommit commitNewFile(Git git, String fileName, Date commitTime) throws Exception { + File file = new File(git.getRepository().getWorkTree(), fileName); + Files.write(file.toPath(), ("content of " + fileName).getBytes(StandardCharsets.UTF_8)); + git.add().addFilepattern(fileName).call(); + PersonIdent ident = new PersonIdent("Test User", "test@example.com", commitTime, GMT_TIME_ZONE); + return git.commit() + .setMessage("add " + fileName) + .setAuthor(ident) + .setCommitter(ident) + .setSign(false) + .call(); + } + + private static final class MergeScenario { + private final RevCommit lastNonMergeCommit; + private final ObjectId mergeCommit; + + private MergeScenario(RevCommit lastNonMergeCommit, ObjectId mergeCommit) { + this.lastNonMergeCommit = lastNonMergeCommit; + this.mergeCommit = mergeCommit; + } + } } diff --git a/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-jgit/pom.xml b/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-jgit/pom.xml index d1c8c8b6d..209dc369a 100644 --- a/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-jgit/pom.xml +++ b/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-jgit/pom.xml @@ -31,11 +31,6 @@ JGIT implementation for SCM Git Provider. see http://eclipse.org/jgit/ - - - 5.13.5.202508271544-r - - diff --git a/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-jgit/src/main/java/org/apache/maven/scm/provider/git/jgit/command/info/JGitInfoCommand.java b/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-jgit/src/main/java/org/apache/maven/scm/provider/git/jgit/command/info/JGitInfoCommand.java index 75582bb4d..cb892a660 100644 --- a/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-jgit/src/main/java/org/apache/maven/scm/provider/git/jgit/command/info/JGitInfoCommand.java +++ b/maven-scm-providers/maven-scm-providers-git/maven-scm-provider-jgit/src/main/java/org/apache/maven/scm/provider/git/jgit/command/info/JGitInfoCommand.java @@ -24,6 +24,7 @@ import java.util.List; import org.apache.commons.lang3.StringUtils; +import org.apache.maven.scm.CommandParameter; import org.apache.maven.scm.CommandParameters; import org.apache.maven.scm.ScmException; import org.apache.maven.scm.ScmFileSet; @@ -42,6 +43,7 @@ import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevSort; import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.revwalk.filter.RevFilter; import org.eclipse.jgit.treewalk.filter.AndTreeFilter; import org.eclipse.jgit.treewalk.filter.PathFilter; import org.eclipse.jgit.treewalk.filter.TreeFilter; @@ -54,6 +56,7 @@ public class JGitInfoCommand extends AbstractCommand implements GitCommand { protected ScmResult executeCommand( ScmProviderRepository repository, ScmFileSet fileSet, CommandParameters parameters) throws ScmException { File basedir = fileSet.getBasedir(); + boolean skipMergeCommits = isSkipMergeCommits(parameters); Git git = null; try { git = JGitUtils.openRepo(basedir); @@ -64,13 +67,13 @@ protected ScmResult executeCommand( List infoItems = new LinkedList<>(); if (fileSet.getFileList().isEmpty()) { - RevCommit headCommit = git.getRepository().parseCommit(objectId); - infoItems.add(getInfoItem(headCommit, fileSet.getBasedir())); + RevCommit commit = getMostRecentCommit(git.getRepository(), objectId, skipMergeCommits); + infoItems.add(getInfoItem(commit, fileSet.getBasedir())); } else { // iterate over all files for (File file : JGitUtils.getWorkingCopyRelativePaths( git.getRepository().getWorkTree(), fileSet)) { - infoItems.add(getInfoItem(git.getRepository(), objectId, file)); + infoItems.add(getInfoItem(git.getRepository(), objectId, file, skipMergeCommits)); } } return new InfoScmResult(infoItems, new ScmResult("JGit.resolve(HEAD)", "", objectId.toString(), true)); @@ -81,16 +84,42 @@ protected ScmResult executeCommand( } } - protected InfoItem getInfoItem(Repository repository, ObjectId headObjectId, File file) throws IOException { - RevCommit commit = getMostRecentCommitForPath(repository, headObjectId, JGitUtils.toNormalizedFilePath(file)); + protected InfoItem getInfoItem(Repository repository, ObjectId headObjectId, File file, boolean skipMergeCommits) + throws IOException { + RevCommit commit = getMostRecentCommitForPath( + repository, headObjectId, JGitUtils.toNormalizedFilePath(file), skipMergeCommits); return getInfoItem(commit, file); } + /** + * Returns the most recent commit reachable from {@code headObjectId}, optionally ignoring merge commits + * (mimics {@code git log -1 --no-merges} when {@code skipMergeCommits} is {@code true}). + * May return {@code null} when no non-merge commit is reachable (e.g. shallow history of merge commits only). + */ + private RevCommit getMostRecentCommit(Repository repository, ObjectId headObjectId, boolean skipMergeCommits) + throws IOException { + try (RevWalk revWalk = new RevWalk(repository)) { + RevCommit headCommit = revWalk.parseCommit(headObjectId); + if (!skipMergeCommits) { + return headCommit; + } + revWalk.markStart(headCommit); + revWalk.sort(RevSort.COMMIT_TIME_DESC); + revWalk.setRevFilter(RevFilter.NO_MERGES); + return revWalk.next(); + } + } + protected InfoItem getInfoItem(RevCommit fileCommit, File file) { InfoItem infoItem = new InfoItem(); infoItem.setPath(file.getPath()); - infoItem.setRevision(StringUtils.trim(fileCommit.name())); infoItem.setURL(file.toPath().toUri().toASCIIString()); + if (fileCommit == null) { + // no matching commit (e.g. path without history, or only merge commits while skipping them): + // like gitexe on empty "git log" output, report the item without revision metadata + return infoItem; + } + infoItem.setRevision(StringUtils.trim(fileCommit.name())); PersonIdent authorIdent = fileCommit.getAuthorIdent(); infoItem.setLastChangedDateTime(authorIdent .getWhen() @@ -100,16 +129,34 @@ protected InfoItem getInfoItem(RevCommit fileCommit, File file) { return infoItem; } - private RevCommit getMostRecentCommitForPath(Repository repository, ObjectId headObjectId, String path) - throws IOException { + private RevCommit getMostRecentCommitForPath( + Repository repository, ObjectId headObjectId, String path, boolean skipMergeCommits) throws IOException { RevCommit latestCommit = null; try (RevWalk revWalk = new RevWalk(repository)) { RevCommit headCommit = revWalk.parseCommit(headObjectId); revWalk.markStart(headCommit); revWalk.sort(RevSort.COMMIT_TIME_DESC); + if (skipMergeCommits) { + revWalk.setRevFilter(RevFilter.NO_MERGES); + } revWalk.setTreeFilter(AndTreeFilter.create(PathFilter.create(path), TreeFilter.ANY_DIFF)); latestCommit = revWalk.next(); } return latestCommit; } + + /** + * Whether merge commits should be skipped for the {@code info} command. + * + * @param parameters the command parameters (may be {@code null}) + * @return {@code true} if parameter {@link CommandParameter#SCM_SKIP_MERGE_COMMITS} (or the whole + * {@code parameters}) is absent, and otherwise the requested value + * @throws ScmException if the parameter has the wrong type + */ + private static boolean isSkipMergeCommits(CommandParameters parameters) throws ScmException { + if (parameters == null) { + return true; + } + return parameters.getBoolean(CommandParameter.SCM_SKIP_MERGE_COMMITS, true); + } } diff --git a/maven-scm-providers/maven-scm-providers-git/pom.xml b/maven-scm-providers/maven-scm-providers-git/pom.xml index d5e7733e4..81e8653b4 100644 --- a/maven-scm-providers/maven-scm-providers-git/pom.xml +++ b/maven-scm-providers/maven-scm-providers-git/pom.xml @@ -41,6 +41,8 @@ 2.19.0 + + 5.13.5.202508271544-r