diff --git a/build.gradle b/build.gradle index 4205868..9de737d 100644 --- a/build.gradle +++ b/build.gradle @@ -18,7 +18,7 @@ sourceSets { } group 'net.neoforged.gradleutils' -version '4.0.0' +version '4.0.1' repositories { mavenCentral() diff --git a/src/main/groovy/net/neoforged/gradleutils/ChangelogGenerationExtension.groovy b/src/main/groovy/net/neoforged/gradleutils/ChangelogGenerationExtension.groovy index f256386..5834424 100644 --- a/src/main/groovy/net/neoforged/gradleutils/ChangelogGenerationExtension.groovy +++ b/src/main/groovy/net/neoforged/gradleutils/ChangelogGenerationExtension.groovy @@ -21,6 +21,13 @@ class ChangelogGenerationExtension { this.project = project } + void enable() { + ChangelogUtils.setupChangelogGeneration(project, null) + project.afterEvaluate { + afterEvaluate(project) + } + } + void from(final String revision) { ChangelogUtils.setupChangelogGeneration(project, revision) project.afterEvaluate { diff --git a/src/main/groovy/net/neoforged/gradleutils/ChangelogGenerator.groovy b/src/main/groovy/net/neoforged/gradleutils/ChangelogGenerator.groovy index 628579e..b1439ec 100644 --- a/src/main/groovy/net/neoforged/gradleutils/ChangelogGenerator.groovy +++ b/src/main/groovy/net/neoforged/gradleutils/ChangelogGenerator.groovy @@ -7,59 +7,106 @@ package net.neoforged.gradleutils import groovy.transform.CompileStatic import groovy.transform.PackageScope -import org.eclipse.jgit.api.Git +import net.neoforged.gradleutils.git.GitProvider +import net.neoforged.gradleutils.specs.VersionSpec import org.eclipse.jgit.lib.Constants -import org.eclipse.jgit.revwalk.RevCommit -import org.eclipse.jgit.revwalk.RevWalk +import org.jetbrains.annotations.Nullable @CompileStatic @PackageScope class ChangelogGenerator { private final VersionCalculator calculator + private final VersionSpec versionSpec - ChangelogGenerator(VersionCalculator calculator) { + ChangelogGenerator(VersionCalculator calculator, VersionSpec versionSpec) { this.calculator = calculator + this.versionSpec = versionSpec } - String generate(Git git, String earliest, String latest = Constants.HEAD) { - // Resolve both commits - final RevCommit earliestCommit, latestCommit - try (RevWalk walk = new RevWalk(git.repository)) { - earliestCommit = walk.parseCommit(git.repository.resolve(earliest)) - latestCommit = walk.parseCommit(git.repository.resolve(latest)) - } + String generate(GitProvider git, @Nullable String earliest, String latest = Constants.HEAD) { - // List all commits between latest and earliest commits -- including the two ends - def logCommand = git.log().add(latestCommit) - // Exclude all parents of earliest commit - for (RevCommit parent : earliestCommit.getParents()) { - logCommand.not(parent) - } + // Map of Commit -> Tags + Map> tags = git.getTags(versionSpec.tags.includeLightweightTags.get()) + .findAll { calculator.isIncludedTag(it.name()) } + .groupBy { it.hash() } + .collectEntries { k, v -> + [(k): v.collect { it.name() }] + } - // List has order of latest (0) to earliest (list.size()) - final List commits = logCommand.call().collect() + def commits = git.getCommits(latest, earliest) + + def versions = buildCommitToVersionMap(commits, tags, git) // TODO: headers for tags -- need more hooks into version calculator // TODO: caching for version calculation -- perhaps split version calculator to two passes? final StringBuilder builder = new StringBuilder() - for (RevCommit commit : commits) { + String currentMajor = "" - final version = calculateVersion(git, commit.name()) + for (GitProvider.CommitData commit : commits) { + final version = versions.get(commit.hash()) // " - `` " // " " if multi-line - builder.append(" - `$version` ") - buildCommitMessage(builder, commit, " ") + if (version) { + String majorVersion = version.split("\\.")[0..1].join(".") + if (majorVersion != currentMajor) { + builder.append("\n") + builder.append("# $majorVersion") + builder.append("\n\n") + currentMajor = majorVersion + } + + builder.append(" - `$version` ") + } else { + // This might be a commit before first tag + builder.append(" - `${commit.shortHash()}` ") + } + buildCommitMessage(builder, commit.message(), " ") } return builder.toString() } - private static void buildCommitMessage(StringBuilder builder, RevCommit commit, String continueHeader) { + private Map buildCommitToVersionMap(List commits, Map> tags, GitProvider git) { + var result = new HashMap() + // Work on each entry in reverse + int prevTagAt = -1 + String prevTag = null + String label = null + for (int i = commits.size() - 1; i >= 0; i--) { + final commit = commits[i] + final commitTags = tags.getOrDefault(commit.hash(), []) + for (var tag in commitTags) { + if (calculator.isLabelResetTag(tag)) { + label = null + continue + } + + var tagLabel = calculator.getTagLabel(tag) + if (tagLabel != null) { + // found a label for the current anchor + label = tagLabel + } else { + // new version anchor found + prevTagAt = i + prevTag = tag + label = calculator.defaultLabel + } + break + } + + if (prevTag != null) { + final offset = prevTagAt - i + result[commit.hash()] = calculator.calculateForTag(git, prevTag, label, offset, true, true) + } + } + return result + } + + private static void buildCommitMessage(StringBuilder builder, String message, String continueHeader) { // Assume the current line in the builder already contains the initial part of the line (with the version) - final message = commit.fullMessage // Assume that the message contains at least one LF // If the first and last LF in the message are at the same position, then there is only one singular LF if (message.indexOf('\n') == message.lastIndexOf('\n')) { @@ -77,9 +124,4 @@ class ChangelogGenerator { } } } - - private String calculateVersion(Git git, String rev) { - // Skip branch suffix - return calculator.calculate(git, rev, true, true) - } } diff --git a/src/main/groovy/net/neoforged/gradleutils/ChangelogGeneratorValueSource.groovy b/src/main/groovy/net/neoforged/gradleutils/ChangelogGeneratorValueSource.groovy index 9358307..f9a4ec4 100644 --- a/src/main/groovy/net/neoforged/gradleutils/ChangelogGeneratorValueSource.groovy +++ b/src/main/groovy/net/neoforged/gradleutils/ChangelogGeneratorValueSource.groovy @@ -7,10 +7,8 @@ package net.neoforged.gradleutils import groovy.transform.CompileStatic import groovy.transform.PackageScope +import net.neoforged.gradleutils.git.GitProvider import net.neoforged.gradleutils.specs.VersionSpec -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib.Repository -import org.eclipse.jgit.storage.file.FileRepositoryBuilder import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property import org.gradle.api.provider.ValueSource @@ -30,12 +28,10 @@ abstract class ChangelogGeneratorValueSource implements ValueSource task = project.getTasks().register(CHANGELOG_GENERATION_TASK_NAME, GenerateChangelogTask.class) { - it.startingRevision.set(rev) + if (rev != null) { + it.startingRevision.set(rev) + } } project.plugins.withType(LifecycleBasePlugin).configureEach { diff --git a/src/main/groovy/net/neoforged/gradleutils/GitInfoValueSource.groovy b/src/main/groovy/net/neoforged/gradleutils/GitInfoValueSource.groovy index 54e5786..f04abe2 100644 --- a/src/main/groovy/net/neoforged/gradleutils/GitInfoValueSource.groovy +++ b/src/main/groovy/net/neoforged/gradleutils/GitInfoValueSource.groovy @@ -9,21 +9,22 @@ package net.neoforged.gradleutils import groovy.transform.CompileStatic import groovy.transform.Immutable import groovy.transform.PackageScope -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.errors.RepositoryNotFoundException -import org.eclipse.jgit.lib.ObjectId +import net.neoforged.gradleutils.git.GitProvider import org.eclipse.jgit.lib.Repository -import org.eclipse.jgit.storage.file.FileRepositoryBuilder import org.gradle.api.file.DirectoryProperty +import org.gradle.api.logging.Logging import org.gradle.api.provider.SetProperty import org.gradle.api.provider.ValueSource import org.gradle.api.provider.ValueSourceParameters +import org.slf4j.Logger import javax.annotation.Nullable @PackageScope @CompileStatic abstract class GitInfoValueSource implements ValueSource { + private static final Logger LOGGER = Logging.getLogger(GitInfoValueSource.class) + static interface Parameters extends ValueSourceParameters { DirectoryProperty getWorkingDirectory() @@ -32,35 +33,34 @@ abstract class GitInfoValueSource implements ValueSource { @Override GitInfo obtain() { - try (Repository repo = new FileRepositoryBuilder().findGitDir(parameters.workingDirectory.get().asFile).build()) { - final git = Git.wrap(repo) - + try (GitProvider provider = GradleUtils.openGitProvider(parameters.workingDirectory.get().asFile)) { final filters = parameters.tagFilters.get(). toArray(new String[0]) - final tag = git.describe().setLong(true).setTags(true).setMatch(filters).call() + final tag = provider.describe().longFormat(true).includeLightweightTags(true).matching(filters).run() final desc = GradleUtils.rsplit(tag, '-', 2) ?: ['0.0', '0', '00000000'] - final head = git.repository.exactRef('HEAD') - final String longBranch = head.symbolic ? head?.target?.name : null + final head = provider.head + final String longBranch = provider.fullBranch // matches Repository.getFullBranch() but returning null when on a detached HEAD Map gitInfoMap = [:] - gitInfoMap.dir = repo.getDirectory().parentFile.absolutePath + gitInfoMap.dir = provider.dotGitDirectory.parentFile.absolutePath gitInfoMap.tag = desc[0] if (gitInfoMap.tag.startsWith("v") && gitInfoMap.tag.length() > 1 && gitInfoMap.tag.charAt(1).digit) gitInfoMap.tag = gitInfoMap.tag.substring(1) gitInfoMap.offset = desc[1] gitInfoMap.hash = desc[2] gitInfoMap.branch = longBranch != null ? Repository.shortenRefName(longBranch) : null - gitInfoMap.commit = ObjectId.toString(head.objectId) - gitInfoMap.abbreviatedId = head.objectId.abbreviate(8).name() + gitInfoMap.commit = head // TODO: double-check this is a commit + gitInfoMap.abbreviatedId = provider.abbreviateRef(head, 8) // Remove any lingering null values gitInfoMap.removeAll { it.value == null } - final originUrl = getRemotePushUrl(git, "origin") + final originUrl = transformPushUrl(provider.getRemotePushUrl("origin")) return new GitInfo(gitInfoMap, originUrl) - } catch (RepositoryNotFoundException ignored) { + } catch (Exception ex) { + LOGGER.warn("Failed to obtain git info", ex) return new GitInfo([ tag : '0.0', offset : '0', @@ -72,31 +72,6 @@ abstract class GitInfoValueSource implements ValueSource { } } - @Nullable - private static String getRemotePushUrl(Git git, String remoteName) { - def remotes = git.remoteList().call() - if (remotes.size() == 0) - return null - - // Get the origin remote - def originRemote = remotes.toList().stream() - .filter(r -> r.getName() == remoteName) - .findFirst() - .orElse(null) - - //We do not have an origin named remote - if (originRemote == null) return null - - // Get the origin push url. - def originUrl = originRemote.getURIs().toList().stream() - .findFirst() - .orElse(null) - - if (originUrl == null) return null // No origin URL - - return transformPushUrl(originUrl.toString()) - } - private static String transformPushUrl(String url) { if (url.startsWith("ssh")) { // Convert SSH urls to HTTPS diff --git a/src/main/groovy/net/neoforged/gradleutils/GradleUtils.groovy b/src/main/groovy/net/neoforged/gradleutils/GradleUtils.groovy index 955badf..2c5f9a1 100644 --- a/src/main/groovy/net/neoforged/gradleutils/GradleUtils.groovy +++ b/src/main/groovy/net/neoforged/gradleutils/GradleUtils.groovy @@ -22,10 +22,10 @@ package net.neoforged.gradleutils import groovy.transform.CompileStatic import groovy.transform.PackageScope +import net.neoforged.gradleutils.git.CommandLineGitProvider +import net.neoforged.gradleutils.git.GitProvider +import net.neoforged.gradleutils.git.JGitProvider import org.eclipse.jgit.api.Git -import org.eclipse.jgit.errors.RepositoryNotFoundException -import org.eclipse.jgit.lib.ObjectId -import org.eclipse.jgit.lib.Repository import org.gradle.api.Action import org.gradle.api.DefaultTask import org.gradle.api.Project @@ -57,10 +57,8 @@ class GradleUtils { } static Map gitInfo(File dir, String... globFilters) { - def git - try { - git = openGit(dir) - } catch (RepositoryNotFoundException e) { + GitProvider provider = openGitProvider(dir) + if (provider == null) { return [ tag: '0.0', offset: '0', @@ -70,10 +68,11 @@ class GradleUtils { abbreviatedId: '00000000' ] } - def tag = git.describe().setLong(true).setTags(true).setMatch(globFilters ?: new String[0]).call() + + def tag = provider.describe().longFormat(true).includeLightweightTags(true).matching(globFilters).run() def desc = rsplit(tag, '-', 2) ?: ['0.0', '0', '00000000'] - def head = git.repository.exactRef('HEAD') - final String longBranch = head.symbolic ? head?.target?.name : null // matches Repository.getFullBranch() but returning null when on a detached HEAD + def head = provider.head + def longBranch = provider.fullBranch Map ret = [:] ret.dir = dir.absolutePath @@ -82,12 +81,12 @@ class GradleUtils { ret.tag = ret.tag.substring(1) ret.offset = desc[1] ret.hash = desc[2] - ret.branch = longBranch != null ? Repository.shortenRefName(longBranch) : null - ret.commit = ObjectId.toString(head.objectId) - ret.abbreviatedId = head.objectId.abbreviate(8).name() + ret.branch = longBranch != null ? provider.shortenRef(longBranch) : null + ret.commit = head + ret.abbreviatedId = provider.abbreviateRef(head, 0) // Remove any lingering null values - ret.removeAll {it.value == null } + ret.removeAll { it.value == null } return ret } @@ -281,4 +280,8 @@ class GradleUtils { } } } + + static GitProvider openGitProvider(File projectDir) { + return CommandLineGitProvider.create(projectDir) ?: JGitProvider.create(projectDir) + } } diff --git a/src/main/groovy/net/neoforged/gradleutils/InternalAccessor.groovy b/src/main/groovy/net/neoforged/gradleutils/InternalAccessor.groovy index 9e88bba..ac8fab3 100644 --- a/src/main/groovy/net/neoforged/gradleutils/InternalAccessor.groovy +++ b/src/main/groovy/net/neoforged/gradleutils/InternalAccessor.groovy @@ -24,7 +24,7 @@ class InternalAccessor { } static String generateChangelog(ProviderFactory providers, VersionSpec versionConfig, Directory workingDirectory, - String earliestRevision) { + @Nullable String earliestRevision) { final changelog = providers.of(ChangelogGeneratorValueSource) { it.parameters { it.workingDirectory.set(workingDirectory) diff --git a/src/main/groovy/net/neoforged/gradleutils/VersionCalculator.groovy b/src/main/groovy/net/neoforged/gradleutils/VersionCalculator.groovy index 920fae8..b1f1e6d 100644 --- a/src/main/groovy/net/neoforged/gradleutils/VersionCalculator.groovy +++ b/src/main/groovy/net/neoforged/gradleutils/VersionCalculator.groovy @@ -8,10 +8,9 @@ package net.neoforged.gradleutils import groovy.transform.CompileStatic import groovy.transform.Immutable import groovy.transform.PackageScope +import net.neoforged.gradleutils.git.GitProvider import net.neoforged.gradleutils.specs.VersionSpec -import org.eclipse.jgit.api.DescribeCommand -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib.Constants +import org.eclipse.jgit.fnmatch.FileNameMatcher import org.eclipse.jgit.lib.Repository import org.gradle.api.GradleException @@ -32,15 +31,25 @@ class VersionCalculator { this.spec = spec } - String calculate(Git git, String rev = Constants.HEAD, boolean skipVersionPrefix = false, boolean skipBranchSuffix = false) { + String calculate(GitProvider git, String rev = "HEAD", boolean skipVersionPrefix = false, boolean skipBranchSuffix = false) { final describe = findTag(git, rev) - String tag = describe.tag + return calculateForTag( + git, + describe.tag, + describe.label, + describe.offset, + skipVersionPrefix, + skipBranchSuffix + ) + } + + String calculateForTag(GitProvider git, String tag, String label, int offset, boolean skipVersionPrefix = false, boolean skipBranchSuffix = false) { // Strip label from tag if (spec.tags.stripTagLabel.get()) { - final sepIdx = describe.tag.lastIndexOf(GENERAL_SEPARATOR as int) + final sepIdx = tag.lastIndexOf(GENERAL_SEPARATOR as int) if (sepIdx != -1) { - tag = describe.tag.substring(0, sepIdx) + tag = tag.substring(0, sepIdx) } } @@ -55,11 +64,11 @@ class VersionCalculator { version.append(tag) if (spec.tags.appendCommitOffset.get()) { - version.append(VERSION_SEPARATOR).append(describe.offset) + version.append(VERSION_SEPARATOR).append(offset) } - if (describe.label != null) { - version.append(GENERAL_SEPARATOR).append(describe.label) + if (label != null) { + version.append(GENERAL_SEPARATOR).append(label) } if (!skipBranchSuffix && spec.branches.suffixBranch.get()) { @@ -72,14 +81,35 @@ class VersionCalculator { return version.toString() } - private DescribeOutput findTag(Git git, String startingRev) { + String getDefaultLabel() { + return spec.tags.label.getOrNull() + } + + @Nullable + String getTagLabel(String tagName) { + if (spec.tags.extractLabel.get()) { + final int separatorIndex = tagName.lastIndexOf(GENERAL_SEPARATOR as int) + // TODO: should we ignore empty labels? (i.e. `1.0-`) + if (separatorIndex != -1) { + return tagName.substring(separatorIndex + 1) + } + } + return null + } + + boolean isLabelResetTag(String tagName) { + final cleanLabel = spec.tags.cleanMarkerLabel.getOrNull() + return cleanLabel != null && tagName.endsWith(GENERAL_SEPARATOR.toString() + cleanLabel) + } + + private DescribeOutput findTag(GitProvider git, String startingRev) { TagContextImpl context = new TagContextImpl() - context.label = spec.tags.label.getOrNull() + context.label = defaultLabel int trackedCommitCount = 0 String currentRev = startingRev while (true) { - final described = describe(git).setTarget(currentRev).call() + final described = describe(git).target(currentRev).run() if (described === null) { throw new GradleException("Cannot calculate the project version without a previous Git tag. Did you forget to run \"git fetch --tags\"?") } @@ -116,10 +146,8 @@ class VersionCalculator { } @Nullable - private String getBranchSuffix(Git git) { - final head = git.repository.exactRef('HEAD') - // Matches Repository.getFullBranch() but returning null when on a detached HEAD - final longBranch = head.symbolic ? head?.target?.name : null + private String getBranchSuffix(GitProvider git) { + final longBranch = git.fullBranch String branch = longBranch != null ? Repository.shortenRefName(longBranch) : '' if (branch in spec.branches.suffixExemptedBranches.get()) { @@ -134,6 +162,20 @@ class VersionCalculator { return branch } + boolean isIncludedTag(String tagName) { + final filters = spec.tags.includeFilters.get() + if (filters.isEmpty()) { + return true + } + + for (final def filter in filters) { + if (new FileNameMatcher(filter, null).append(tagName)) { + return true + } + } + return false + } + @Immutable static class DescribeOutput { final String tag @@ -141,12 +183,12 @@ class VersionCalculator { final String label } - private DescribeCommand describe(Git git) { + private GitProvider.DescribeCall describe(GitProvider git) { final includeFilters = spec.tags.includeFilters.get(). toArray(new String[0]) return git.describe() - .setLong(true) - .setTags(spec.tags.includeLightweightTags.get()) - .setMatch(includeFilters) + .longFormat(true) + .includeLightweightTags(spec.tags.includeLightweightTags.get()) + .matching(includeFilters) } static class TagContextImpl { diff --git a/src/main/groovy/net/neoforged/gradleutils/VersionCalculatorValueSource.groovy b/src/main/groovy/net/neoforged/gradleutils/VersionCalculatorValueSource.groovy index b4eb627..44b561c 100644 --- a/src/main/groovy/net/neoforged/gradleutils/VersionCalculatorValueSource.groovy +++ b/src/main/groovy/net/neoforged/gradleutils/VersionCalculatorValueSource.groovy @@ -7,10 +7,8 @@ package net.neoforged.gradleutils import groovy.transform.CompileStatic import groovy.transform.PackageScope +import net.neoforged.gradleutils.git.GitProvider import net.neoforged.gradleutils.specs.VersionSpec -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib.Repository -import org.eclipse.jgit.storage.file.FileRepositoryBuilder import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property import org.gradle.api.provider.ValueSource @@ -29,10 +27,8 @@ abstract class VersionCalculatorValueSource implements ValueSource matchPatterns = new ArrayList<>(); + protected String target = "HEAD"; + + protected AbstractDescribeCall() { + } + + @Override + public DescribeCall longFormat(boolean longFormat) { + this.longFormat = longFormat; + return this; + } + + @Override + public DescribeCall includeLightweightTags(boolean lightweight) { + this.includeLightweightTags = lightweight; + return this; + } + + @Override + public DescribeCall matching(String... patterns) { + if (patterns.length == 0) { + this.matchPatterns.clear(); + } else { + this.matchPatterns.addAll(Arrays.asList(patterns)); + } + return this; + } + + @Override + public DescribeCall target(String rev) { + this.target = rev; + return this; + } +} diff --git a/src/main/groovy/net/neoforged/gradleutils/git/CommandLineGitProvider.java b/src/main/groovy/net/neoforged/gradleutils/git/CommandLineGitProvider.java new file mode 100644 index 0000000..be7a62e --- /dev/null +++ b/src/main/groovy/net/neoforged/gradleutils/git/CommandLineGitProvider.java @@ -0,0 +1,342 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.gradleutils.git; + +import org.gradle.api.GradleException; +import org.gradle.api.logging.Logging; +import org.slf4j.Logger; + +import javax.annotation.Nullable; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.Charset; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +/** + * A provider which uses the command line {@code git} command. + */ +public class CommandLineGitProvider implements GitProvider { + private static final Logger LOGGER = Logging.getLogger(CommandLineGitProvider.class); + + private final File directory; + + @Nullable + public static GitProvider create(File directory) { + // Check if the git command exists and a git repo exists + try { + runGit(directory, false, Arrays.asList("rev-parse", "--is-inside-work-tree"), (exitCode, stdout) -> null); + } catch (Exception ignored) { + // Either the git command is not present or there is no git repo -- quit in both cases + return null; + } + return new CommandLineGitProvider(directory); + } + + CommandLineGitProvider(File directory) { + this.directory = directory; + } + + /** + * Expects the original git log format %H:%h:%ct:%B + * See https://git-scm.com/docs/pretty-formats + */ + private static List processGitLog(int exitCode, List stdout) { + List commits = new ArrayList<>(); + for (String commitLine : String.join("\n", stdout).split("\0")) { + String[] parts = commitLine.split(":", 4); + if (parts.length != 4) { + LOGGER.error("Received invalid commit object: {}", commitLine); + continue; + } + + String commitId = parts[0]; + String shortCommitId = parts[1]; + Instant commitTime = Instant.ofEpochSecond(Long.parseUnsignedLong(parts[2])); + String message = parts[3]; + if (!message.endsWith("\n")) { + message += "\n"; + } + + commits.add(new CommitData(commitId, shortCommitId, commitTime, message)); + } + return commits; + } + + @Override + public File getDotGitDirectory() { + return new File(runGitReadLine("rev-parse", "--absolute-git-dir")); + } + + @Override + public String abbreviateRef(String ref, int minimumLength) { + if (minimumLength != 0 && minimumLength < 4) { + throw new IllegalArgumentException("Minimum length must either be 0, or equal or greater than 4: " + minimumLength); + } + + List args = new ArrayList<>(); + args.add("rev-parse"); + if (minimumLength != 0) args.add("--short=" + minimumLength); + args.add(ref); + + return runGitReadLine(args); + } + + @Override + public String shortenRef(String ref) { + return runGitReadLine("rev-parse", "--symbolic-ref", ref); + } + + @Override + public String getHead() { + return runGitReadLine("rev-parse", "HEAD"); + } + + @Nullable + @Override + public String getFullBranch() { + return runGit(false, + Arrays.asList("symbolic-ref", "HEAD"), + (exitCode, stdout) -> { + if (exitCode != 0) return null; + return readSingleLine(exitCode, stdout); + }); + } + + @Nullable + @Override + public String getRemotePushUrl(String remoteName) { + return runGit(false, + Arrays.asList("remote", "get-url", "--push", remoteName), + (exitCode, stdout) -> { + if (exitCode != 0) return null; + return readSingleLine(exitCode, stdout); + }); + } + + @Override + public int getRemotesCount() { + final Integer ret = runGit(true, + Collections.singletonList("remote"), + (exitCode, stdout) -> stdout.size()); + return ret != null ? ret : 0; + } + + @Override + public List getCommits(String latestRev, @Nullable String earliestRev) { + List commits = new ArrayList<>(); + String revRange = earliestRev != null ? earliestRev + ".." + latestRev : latestRev; + commits.addAll(runGit(true, Arrays.asList("log", "--ignore-missing", "--no-show-signature", "--pretty=format:%H:%h:%ct:%B", "-z", revRange), CommandLineGitProvider::processGitLog)); + // The previous command does not include information about "earliestRev", we retrieve it separately + if (earliestRev != null) { + commits.addAll(runGit(true, Arrays.asList("log", "--ignore-missing", "--no-show-signature", "--pretty=format:%H:%h:%ct:%B", "--no-walk", earliestRev), CommandLineGitProvider::processGitLog)); + } + return commits; + } + + @Override + public List getTags(boolean includeLightweight) { + List args = Arrays.asList("for-each-ref", "refs/tags/", "--format", "%(objecttype):%(objectname):%(*objectname):%(refname:short)"); + return runGit(true, args, (exitCode, stdout) -> { + List tags = new ArrayList<>(); + for (String line : stdout) { + String[] parts = line.trim().split(":", 4); + if (parts.length == 4) { + String objectType = parts[0]; + String objectName = parts[3]; + if ("tag".equals(objectType)) { + // *objectname is the commit id + tags.add(new Tag(parts[2], objectName)); + } else if ("commit".equals(objectType) && includeLightweight) { + tags.add(new Tag(parts[1], objectName)); + } + } + } + return tags; + }); + } + + @Override + public DescribeCall describe() { + return new DescribeCallImpl(); + } + + @Override + public void close() { + // No operation -- we have nothing to close + } + + class DescribeCallImpl extends AbstractDescribeCall { + @Override + public String run() { + List args = new ArrayList<>(); + args.add("describe"); + + if (longFormat) args.add("--long"); + if (includeLightweightTags) args.add("--tags"); + + for (String pattern : matchPatterns) { + args.add("--match"); + args.add(pattern); + } + + args.add(target); + + return runGit(true, args, CommandLineGitProvider::readSingleLine); + } + } + + public interface ProcessResultProcessor { + T process(int exitCode, List stdout); + } + + private R runGit(boolean checkSuccess, + List args, + ProcessResultProcessor outputFunction) { + return runGit(this.directory, checkSuccess, args, outputFunction); + } + + private String runGitReadLine(List args) { + return runGit(this.directory, true, args, CommandLineGitProvider::readSingleLine); + } + + private String runGitReadLine(String... args) { + return runGitReadLine(Arrays.asList(args)); + } + + private static R runGit(File directory, + boolean checkSuccess, + List args, + ProcessResultProcessor outputFunction) { + List command = new ArrayList<>(args); + command.add(0, "git"); + + ProcessBuilder builder = new ProcessBuilder(command).directory(directory); + + StringBuilder combinedArgs = printableCommand(builder.command()); + LOGGER.info("Running git command: {}", combinedArgs); + + Process process; + try { + process = builder.start(); + } catch (IOException e) { + throw new GradleException("Failed to start " + combinedArgs + ": " + e.getMessage()); + } + + + // Combined output is easier for debugging problems + StringBuffer combinedOutput = new StringBuffer(); + // Stdout is easier for parsing output in the successful case + List stdout = new ArrayList<>(); + int exitCode; + + try { + // We provide no STDIN to the process + process.getOutputStream().close(); + + Thread stdoutReader = startLineReaderThread(process.getInputStream(), line -> { + stdout.add(line); + combinedOutput.append(line); + }); + Thread stderrReader = startLineReaderThread(process.getErrorStream(), combinedOutput::append); + + exitCode = process.waitFor(); + + stderrReader.join(); + stdoutReader.join(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to run " + combinedArgs + ": " + e, e); + } catch (Exception e) { + throw new IllegalStateException("Failed to run " + combinedArgs + ": " + e, e); + } + + if (checkSuccess && exitCode != 0) { + throw new RuntimeException("Failed running '" + combinedArgs + "'. Exit Code " + exitCode + + ", Output: " + combinedOutput); + } + + return outputFunction.process(exitCode, stdout); + } + + // This is not fully accurate as Java will escape arguments in a platform-specific way. But this at least + // gets us a copy-pastable command in 90% of cases. + private static StringBuilder printableCommand(List args) { + StringBuilder result = new StringBuilder(); + for (String arg : args) { + if (result.length() > 0) { + result.append(' '); + } + if (arg.contains(" ")) { + result.append('"').append(arg).append('"'); + } else { + result.append(arg); + } + } + return result; + } + + private static Thread startLineReaderThread(InputStream stream, Consumer lineHandler) { + Thread stdoutReader = new Thread(() -> { + BufferedReader reader = new BufferedReader(new InputStreamReader(stream, getNativeCharset())); + try { + String line; + while ((line = reader.readLine()) != null) { + lineHandler.accept(line); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } finally { + try { + stream.close(); + } catch (IOException e) { + LOGGER.error("Failed to close process output stream.", e); + } + } + }); + stdoutReader.setUncaughtExceptionHandler((t, e) -> LOGGER.error("Failed to read output of external process.", e)); + stdoutReader.setDaemon(true); + stdoutReader.start(); + return stdoutReader; + } + + private static String readSingleLine(int exitCode, List stdout) { + return stdout.get(0).trim(); + } + + /** + * Get the platform native charset. To see how this differs from the default charset, + * see https://openjdk.org/jeps/400. This property cannot be overriden via system + * property. + */ + private static Charset getNativeCharset() { + return NativeEncodingHolder.charset; + } + + private static class NativeEncodingHolder { + static final Charset charset; + + static { + String nativeEncoding = System.getProperty("native.encoding"); + if (nativeEncoding == null) { + // In Pre-JDK17 we can only fall back to undocumented properties to get the real charset + nativeEncoding = System.getProperty("sun.stdout.encoding"); + if (nativeEncoding == null) { + LOGGER.error("Failed to determine native character set on Pre-JDK17. Falling back to default charset."); + nativeEncoding = Charset.defaultCharset().name(); + } + } + charset = Charset.forName(nativeEncoding); + } + } +} diff --git a/src/main/groovy/net/neoforged/gradleutils/git/GitProvider.java b/src/main/groovy/net/neoforged/gradleutils/git/GitProvider.java new file mode 100644 index 0000000..7c5d0cf --- /dev/null +++ b/src/main/groovy/net/neoforged/gradleutils/git/GitProvider.java @@ -0,0 +1,200 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.gradleutils.git; + +import javax.annotation.Nullable; +import java.io.File; +import java.time.Instant; +import java.util.List; + +/** + * Provides a simplified interface to the Git version control system. + * + *

This is intended to be implemented with various backends, such as {@link CommandLineGitProvider} using the + * command-line {@code git} (when present) and {@link JGitProvider} using the JGit library, as a fallback.

+ * + *

The main purpose of this is to allow for better, more feature-complete backends to be used when available, and + * falling back to a less-feature-complete but known-working backend. For example, the JGit library + * lacks support for work trees, which the + * command-line {@code git} is fully capable of using.

+ */ +public interface GitProvider extends AutoCloseable { + File getDotGitDirectory(); + + /** + * Returns an abbreviated SHA-1 commit ID for the given ref. + * + * @param ref the ref + * @param minimumLength the minimum amount of characters, or {@code 0} to default to a reasonable value no lower than 4 + * @return an abbreviated SHA-1 commit ID for the given ref + * @throws IllegalArgumentException if the minimum length is not 0 and is negative or smaller than 4 + */ + String abbreviateRef(String ref, int minimumLength); + + /** + * Returns a shortened user-friendlier version of the given ref. + * + * @param ref the ref + * @return a shortened user-friendlier version of the given ref + */ + String shortenRef(String ref); + + /** + * Returns the commit ID for the revision pointed at by HEAD. + * + * @return the commit ID for the revision pointed at by HEAD + */ + String getHead(); + + /** + * Returns the full branch name (with prefix) pointed at by HEAD, or {@code null} if not available (such as during + * a detached HEAD state. + * + * @return the full branch name (with prefix) pointed at by HEAD, or {@code null} if not available (such as during + * a detached HEAD state + */ + @Nullable + String getFullBranch(); + + /** + * Returns the push URL for a remote, or {@code null} if the remote doesn't exist. + * + * @return the push URL for a remote, or {@code null} if the remote doesn't exist + */ + @Nullable + String getRemotePushUrl(String remoteName); + + /** + * Returns the number of remotes in the repository. + * + * @return the number of remotes in the repository + */ + int getRemotesCount(); + + /** + * Returns the commit information for the given revs, starting from the latest revision to (and including) + * the earliest revision. + * + *

Note that this is not equivalent to a command invocation of {@code git log {latest} ^{earliest}}, as this + * method will also include the information about the earliest commit.

+ * + * @param latestRev the latest revision + * @param earliestRev the earliest revision + * @return the commit information for the given refs, starting from the latest revision to (and including) + * the earliest revision + */ + List getCommits(String latestRev, @Nullable String earliestRev); + + /** + * List all tags in the repository. The tag names are stripped of their refs/tags/ prefix already. + */ + List getTags(boolean includeLightweight); + + class Tag { + private final String hash; + private final String name; + + public Tag(String hash, String name) { + this.hash = hash; + this.name = name; + } + + public String hash() { + return hash; + } + + public String name() { + return name; + } + + @Override + public String toString() { + return name; + } + } + + class CommitData { + private final String hash; + private final String shortHash; + private final Instant commitTime; + private final String message; + + CommitData(String hash, String shortHash, Instant commitTime, String message) { + this.hash = hash; + this.shortHash = shortHash; + this.commitTime = commitTime; + this.message = message; + } + + public String hash() { + return hash; + } + + public String shortHash() { + return shortHash; + } + + public Instant commitTime() { + return commitTime; + } + + public String message() { + return message; + } + + @Override + public String toString() { + return hash; + } + } + + /** + * Starts a describe call. + * + * @return a describe call + */ + DescribeCall describe(); + + /** + * A describe call. + */ + interface DescribeCall { + /** + * Sets whether to always use the long format. + * + * @param longFormat whether to always use the long format + * @return this object, for method chaining + */ + DescribeCall longFormat(boolean longFormat); + + /** + * Sets whether to include lightweight tags when calculating for the nearest tag. + * + * @param lightweight whether to include lightweight tags + * @return this object, for method chaining + */ + DescribeCall includeLightweightTags(boolean lightweight); + + /** + * Configures glob patterns to match when looking for certain tags. If this method is called with no arguments, + * then all existing patterns are cleared. + * + * @param patterns an array of glob patterns, which may be empty + * @return this object, for method chaining + */ + DescribeCall matching(String... patterns); + + // TODO: document + DescribeCall target(String rev); + + /** + * Returns the result of the describe call. + * + * @return the result of the describe call + */ + String run(); + } +} diff --git a/src/main/groovy/net/neoforged/gradleutils/git/JGitProvider.java b/src/main/groovy/net/neoforged/gradleutils/git/JGitProvider.java new file mode 100644 index 0000000..d176638 --- /dev/null +++ b/src/main/groovy/net/neoforged/gradleutils/git/JGitProvider.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.gradleutils.git; + +import org.eclipse.jgit.api.DescribeCommand; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.LogCommand; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.RefDatabase; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.RepositoryBuilder; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.RemoteConfig; + +import javax.annotation.Nullable; +import java.io.File; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * A provider which uses JGit, a pure Java implementation of Git. + * + * @see Git + */ +public class JGitProvider implements GitProvider { + private final Repository repository; + private final Git git; + + @Nullable + public static GitProvider create(File directory) { + try { + final Repository repo = new RepositoryBuilder() + .findGitDir(directory) + .setMustExist(true) + .build(); + + return new JGitProvider(repo); + } catch (Exception ignored) { + return null; + } + } + + public JGitProvider(Repository repository) { + this.repository = repository; + this.git = Git.wrap(repository); + } + + @Override + public void close() { + this.repository.close(); + this.git.close(); + } + + @Override + public File getDotGitDirectory() { + return repository.getDirectory(); + } + + @Override + public String abbreviateRef(String ref, int minimumLength) { + return call(() -> { + final ObjectId head = git.getRepository().exactRef("HEAD").getObjectId(); + try (ObjectReader reader = git.getRepository().newObjectReader()) { + if (minimumLength == 0) { + return reader.abbreviate(head); + } else { + return reader.abbreviate(head, minimumLength); + } + } + }).name(); + } + + @Override + public String shortenRef(String ref) { + return Repository.shortenRefName(ref); + } + + @Override + public String getHead() { + return call(() -> git.getRepository().exactRef("HEAD").getObjectId().name()); + } + + @Nullable + @Override + public String getFullBranch() { + return call(() -> { + final Ref head = git.getRepository().exactRef("HEAD"); + if (head.isSymbolic()) { + return head.getTarget().getName(); + } + return null; + }); + } + + @Nullable + @Override + public String getRemotePushUrl(String remoteName) { + return call(() -> git.remoteList().call().stream() + .filter(r -> r.getName().equals("origin")) + .findFirst() + .map(RemoteConfig::getPushURIs) + .orElse(Collections.emptyList()) + .stream() + .map(Object::toString) + .findFirst() + .orElse(null)); + } + + @Override + public int getRemotesCount() { + return call(() -> git.remoteList().call().size()); + } + + @Override + public List getCommits(String latestRev, @Nullable String earliestRev) { + // List all commits between latest and earliest commits -- including the two ends + final LogCommand logCommand; + try { + // Resolve both commits + final RevCommit latestCommit; + RevCommit earliestCommit = null; + try (RevWalk walk = new RevWalk(git.getRepository())) { + if (earliestRev != null) { + earliestCommit = walk.parseCommit(git.getRepository().resolve(earliestRev)); + } + latestCommit = walk.parseCommit(git.getRepository().resolve(latestRev)); + } + logCommand = git.log().add(latestCommit); + // Exclude all parents of earliest commit + if (earliestCommit != null) { + for (RevCommit parent : earliestCommit.getParents()) { + logCommand.not(parent); + } + } + + // List has order of latest (0) to earliest (list.size()) + final List commits = new ArrayList<>(); + for (RevCommit revCommit : logCommand.call()) { + String message = revCommit.getFullMessage(); + if (!message.endsWith("\n")) { + message += "\n"; + } + String commitId = revCommit.toObjectId().name(); + String shortCommitId = revCommit.toObjectId().abbreviate(7).name(); + Instant commitTime = Instant.ofEpochSecond(revCommit.getCommitTime()); + commits.add(new CommitData(commitId, shortCommitId, commitTime, message)); + } + return commits; + } catch (IOException | GitAPIException e) { + throw new RuntimeException(e); + } + } + + @Override + public List getTags(boolean includeLightweight) { + try { + RefDatabase refDatabase = git.getRepository().getRefDatabase(); + return git.tagList().call() + .stream() + .map(ref -> { + String name = ref.getName(); + if (!name.startsWith("refs/tags/")) { + return null; + } + name = name.substring("refs/tags/".length()); + Ref target = ref.getLeaf(); + try { + target = refDatabase.peel(target); + } catch (IOException e) { + throw new RuntimeException(e); + } + + // annotated tags -> peeled object id should be the commit id + ObjectId peeledObjectId = target.getPeeledObjectId(); + if (peeledObjectId != null) { + return new Tag(peeledObjectId.getName(), name); + } else if (includeLightweight) { + return new Tag(target.getObjectId().getName(), name); + } else { + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } catch (GitAPIException e) { + throw new RuntimeException(e); + } + } + + @Override + public DescribeCall describe() { + return new DescribeCallImpl(); + } + + class DescribeCallImpl extends AbstractDescribeCall { + private final DescribeCommand command = git.describe(); + + @Override + public String run() { + return call(() -> command.setLong(longFormat).setTags(includeLightweightTags).setMatch(matchPatterns.toArray(new String[0])).setTarget(this.target).call()); + } + } + + private R call(ThrowingCallable callable) { + try { + return callable.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private interface ThrowingCallable { + R call() throws Exception; + } +} diff --git a/src/main/groovy/net/neoforged/gradleutils/tasks/GenerateChangelogTask.java b/src/main/groovy/net/neoforged/gradleutils/tasks/GenerateChangelogTask.java index 67f0964..a6ce4aa 100644 --- a/src/main/groovy/net/neoforged/gradleutils/tasks/GenerateChangelogTask.java +++ b/src/main/groovy/net/neoforged/gradleutils/tasks/GenerateChangelogTask.java @@ -30,6 +30,7 @@ import org.gradle.api.provider.ProviderFactory; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.Nested; +import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.OutputFile; import org.gradle.api.tasks.TaskAction; import org.gradle.api.tasks.UntrackedTask; @@ -59,6 +60,7 @@ public GenerateChangelogTask() { public abstract RegularFileProperty getOutputFile(); @Input + @Optional public abstract Property getStartingRevision(); @Nested @@ -66,7 +68,7 @@ public GenerateChangelogTask() { @TaskAction public void generate() { - final String startingRev = getStartingRevision().get(); + final String startingRev = getStartingRevision().getOrNull(); final String changelog = InternalAccessor.generateChangelog(getProviders(), getVersionSpec().get(), getLayout().getProjectDirectory(), startingRev);