diff --git a/src/integTest/groovy/nebula/plugin/release/ReleasePluginShallowCloneIntegrationSpec.groovy b/src/integTest/groovy/nebula/plugin/release/ReleasePluginShallowCloneIntegrationSpec.groovy new file mode 100644 index 0000000..47acdb2 --- /dev/null +++ b/src/integTest/groovy/nebula/plugin/release/ReleasePluginShallowCloneIntegrationSpec.groovy @@ -0,0 +1,163 @@ +package nebula.plugin.release + +import nebula.test.IntegrationTestKitSpec +import spock.lang.Subject + +@Subject(ReleasePlugin) +class ReleasePluginShallowCloneIntegrationSpec extends IntegrationTestKitSpec { + + def 'shallow clone without unshallow flag produces default dev version'() { + given: + def origin = setupOriginRepoWithTag('1.0.0') + shallowClone(origin, projectDir, 1) + writeBuildFile() + + when: + def results = runTasks('devSnapshot') + + then: + results.output.contains('0.1.0-dev.') + } + + def 'shallow clone with unshallowEnabled finds correct tag and infers proper version'() { + given: + def origin = setupOriginRepoWithTag('1.0.0') + shallowClone(origin, projectDir, 1) + writeBuildFile() + enableUnshallow() + + when: + def results = runTasks('devSnapshot') + + then: + results.output.contains('Shallow clone detected: deepening by 30 commits') + results.output.contains('Found version tag after deepening') + results.output.contains('1.0.1-dev.') + } + + def 'shallow clone that already has tag within depth works without deepening'() { + given: + def origin = setupOriginRepoWithTagOnHead('2.0.0') + shallowClone(origin, projectDir, 1) + writeBuildFile() + enableUnshallow() + + when: + def results = runTasks('devSnapshot') + + then: + !results.output.contains('Shallow clone detected') + results.output.contains('2.0.0') + } + + private File setupOriginRepoWithTag(String tagVersion) { + File origin = new File(projectDir.parent, "${projectDir.name}-origin") + if (origin.exists()) origin.deleteDir() + origin.mkdirs() + + git(origin, 'init') + git(origin, 'checkout', '-b', 'master') + configureGitUser(origin) + + new File(origin, 'initial.txt').text = 'initial' + git(origin, 'add', '.') + git(origin, 'commit', '-m', 'Initial commit') + + // Create a tag + git(origin, 'tag', '-a', "v${tagVersion}", '-m', "Release ${tagVersion}") + + // Add more commits after the tag so that shallow clone won't see it + (1..5).each { i -> + new File(origin, "file${i}.txt").text = "content ${i}" + git(origin, 'add', '.') + git(origin, 'commit', '-m', "Commit ${i} after tag") + } + + return origin + } + + private File setupOriginRepoWithTagOnHead(String tagVersion) { + File origin = new File(projectDir.parent, "${projectDir.name}-origin") + if (origin.exists()) origin.deleteDir() + origin.mkdirs() + + git(origin, 'init') + git(origin, 'checkout', '-b', 'master') + configureGitUser(origin) + + new File(origin, 'initial.txt').text = 'initial' + git(origin, 'add', '.') + git(origin, 'commit', '-m', 'Initial commit') + + // Tag on the latest commit (HEAD) + git(origin, 'tag', '-a', "v${tagVersion}", '-m', "Release ${tagVersion}") + + return origin + } + + private void shallowClone(File origin, File targetDir, int depth) { + // Clean the target dir but keep it existing (IntegrationTestKitSpec needs it) + targetDir.listFiles()?.each { + if (it.isDirectory()) it.deleteDir() else it.delete() + } + def process = new ProcessBuilder('git', 'clone', '--depth', "${depth}", '--branch', 'master', origin.absolutePath, targetDir.absolutePath) + .redirectErrorStream(true) + .start() + def output = process.inputStream.text + process.waitFor() + if (process.exitValue() != 0) { + throw new RuntimeException("Failed to shallow clone: ${output}") + } + } + + private void writeBuildFile() { + buildFile << """\ + plugins { + id 'com.netflix.nebula.release' + id 'java' + } + + ext.dryRun = true + group = 'test' + + task showVersion { + doLast { + logger.lifecycle "Version in task: \${version.toString()}" + } + } + """.stripIndent() + new File(projectDir, '.gitignore') << '''.gradle-test-kit +.gradle +build/ +gradle.properties'''.stripIndent() + + configureGitUser(projectDir) + git(projectDir, 'add', '.') + git(projectDir, 'commit', '-m', 'Add build files') + } + + private void enableUnshallow() { + new File(projectDir, "gradle.properties").text = "nebula.release.features.unshallowEnabled=true\n" + } + + private static void configureGitUser(File dir) { + git(dir, 'config', 'user.email', 'test@example.com') + git(dir, 'config', 'user.name', 'Test User') + git(dir, 'config', 'commit.gpgsign', 'false') + git(dir, 'config', 'tag.gpgsign', 'false') + } + + private static String git(File dir, String... args) { + def command = ['git'] + args.toList() + def process = new ProcessBuilder(command) + .directory(dir) + .redirectErrorStream(true) + .start() + def output = process.inputStream.text + process.waitFor() + if (process.exitValue() != 0) { + throw new RuntimeException("Git command failed: ${command.join(' ')}\n${output}") + } + return output + } +} diff --git a/src/main/groovy/nebula/plugin/release/FeatureFlags.groovy b/src/main/groovy/nebula/plugin/release/FeatureFlags.groovy index 4d6cc10..e6d0d02 100644 --- a/src/main/groovy/nebula/plugin/release/FeatureFlags.groovy +++ b/src/main/groovy/nebula/plugin/release/FeatureFlags.groovy @@ -6,11 +6,16 @@ import org.gradle.api.Project class FeatureFlags { public static final String NEBULA_RELEASE_REPLACE_DEV_SNAPSHOT_WITH_IMMUTABLE_SNAPSHOT = "nebula.release.features.replaceDevWithImmutableSnapshot" public static final String NEBULA_RELEASE_IMMUTABLE_SNAPSHOT_TIMESTAMP_PRECISION = "nebula.release.features.immutableSnapshot.timestampPrecision" + public static final String NEBULA_RELEASE_UNSHALLOW_ENABLED = "nebula.release.features.unshallowEnabled" static boolean isDevSnapshotReplacementEnabled(Project project) { return project.findProperty(NEBULA_RELEASE_REPLACE_DEV_SNAPSHOT_WITH_IMMUTABLE_SNAPSHOT)?.toString()?.toBoolean() } + static boolean isUnshallowEnabled(Project project) { + return project.findProperty(NEBULA_RELEASE_UNSHALLOW_ENABLED)?.toString()?.toBoolean() + } + static TimestampPrecision immutableSnapshotTimestampPrecision(Project project) { return project.hasProperty(NEBULA_RELEASE_IMMUTABLE_SNAPSHOT_TIMESTAMP_PRECISION) ? TimestampPrecision.from(project.findProperty(NEBULA_RELEASE_IMMUTABLE_SNAPSHOT_TIMESTAMP_PRECISION).toString()) diff --git a/src/main/groovy/nebula/plugin/release/ReleasePlugin.groovy b/src/main/groovy/nebula/plugin/release/ReleasePlugin.groovy index 086a216..9d085ad 100644 --- a/src/main/groovy/nebula/plugin/release/ReleasePlugin.groovy +++ b/src/main/groovy/nebula/plugin/release/ReleasePlugin.groovy @@ -77,6 +77,10 @@ class ReleasePlugin implements Plugin { return } + if (FeatureFlags.isUnshallowEnabled(project) && gitBuildService.get().isShallowRepository()) { + gitBuildService.get().deepenUntilTagFound('origin') + } + if (project == project.rootProject) { // Verify user git config only when using release tags and 'release.useLastTag' property is not used boolean shouldVerifyUserGitConfig = isReleaseTaskThatRequiresTagging(project.gradle.startParameter.taskNames) && !isUsingLatestTag(project) diff --git a/src/main/groovy/nebula/plugin/release/git/GitBuildService.groovy b/src/main/groovy/nebula/plugin/release/git/GitBuildService.groovy index 6ad7920..d9e738e 100644 --- a/src/main/groovy/nebula/plugin/release/git/GitBuildService.groovy +++ b/src/main/groovy/nebula/plugin/release/git/GitBuildService.groovy @@ -8,10 +8,12 @@ import nebula.plugin.release.git.command.DescribeHeadWithTag import nebula.plugin.release.git.command.DescribeHeadWithTagWithExclude import nebula.plugin.release.git.command.EmailFromLog import nebula.plugin.release.git.command.FetchChanges +import nebula.plugin.release.git.command.FetchDeepen import nebula.plugin.release.git.command.GetGitConfigValue import nebula.plugin.release.git.command.HeadTags import nebula.plugin.release.git.command.IsCurrentBranchBehindRemote import nebula.plugin.release.git.command.IsGitRepo +import nebula.plugin.release.git.command.IsShallowRepository import nebula.plugin.release.git.command.IsTrackingRemoteBranch import nebula.plugin.release.git.command.PushTag import nebula.plugin.release.git.command.RevListCountHead @@ -274,6 +276,57 @@ abstract class GitBuildService implements BuildService { } } + /** + * Checks if the current repository is a shallow clone + * @return true if the repository is shallow + */ + boolean isShallowRepository() { + try { + def isShallowProvider = providerFactory.of(IsShallowRepository.class) { + it.parameters.rootDir.set(gitRootDir) + } + return Boolean.valueOf(isShallowProvider.get().toString()) + } catch (Exception e) { + return false + } + } + + /** + * Fetches additional history from a remote using --deepen + * @param remote + * @param depth number of commits to deepen + */ + void fetchDeepen(String remote, int depth) { + try { + providerFactory.of(FetchDeepen.class) { + it.parameters.rootDir.set(gitRootDir) + it.parameters.remote.set(remote) + it.parameters.depth.set(depth.toString()) + }.get() + } catch (Exception e) { + LOGGER.warn("Failed to deepen clone from remote {}: {}", remote, e.message) + } + } + + /** + * Incrementally deepens a shallow clone until a version tag is found + * @param remote the remote to fetch from + */ + void deepenUntilTagFound(String remote) { + int maxIterations = 10 + int depthPerIteration = 30 + for (int i = 1; i <= maxIterations; i++) { + LOGGER.warn("Shallow clone detected: deepening by {} commits (iteration {}/{})", depthPerIteration, i, maxIterations) + fetchDeepen(remote, depthPerIteration) + String described = describeHeadWithTags(false) + if (described != null) { + LOGGER.warn("Found version tag after deepening by {} commits", i * depthPerIteration) + return + } + } + LOGGER.warn("Could not find a version tag after deepening by {} commits", maxIterations * depthPerIteration) + } + /** * Checks if the current branch is tracking a remote branch * @param remote diff --git a/src/main/groovy/nebula/plugin/release/git/command/GitReadCommand.groovy b/src/main/groovy/nebula/plugin/release/git/command/GitReadCommand.groovy index c30c009..063ed98 100644 --- a/src/main/groovy/nebula/plugin/release/git/command/GitReadCommand.groovy +++ b/src/main/groovy/nebula/plugin/release/git/command/GitReadCommand.groovy @@ -314,6 +314,22 @@ abstract class StatusPorcelain extends GitReadCommand { /** * Retrieves a given Git config key with its value for a given scope */ +/** + * Checks if the current repository is a shallow clone + * ex. git rev-parse --is-shallow-repository -> true/false + */ +abstract class IsShallowRepository extends GitReadCommand { + @Override + String obtain() { + try { + return executeGitCommand("rev-parse", "--is-shallow-repository") + .replaceAll("\n", "").trim() + } catch (Exception e) { + return "false" + } + } +} + abstract class GetGitConfigValue extends GitReadCommand { private static final Logger logger = LoggerFactory.getLogger(GetGitConfigValue) @Override diff --git a/src/main/groovy/nebula/plugin/release/git/command/GitWriteCommand.groovy b/src/main/groovy/nebula/plugin/release/git/command/GitWriteCommand.groovy index f463d27..e408cac 100644 --- a/src/main/groovy/nebula/plugin/release/git/command/GitWriteCommand.groovy +++ b/src/main/groovy/nebula/plugin/release/git/command/GitWriteCommand.groovy @@ -62,6 +62,21 @@ abstract class CreateTag extends GitWriteCommand { } +/** + * Fetches additional history from a remote using --deepen + * ex. git fetch --deepen=30 origin + */ +abstract class FetchDeepen extends GitWriteCommand { + @Override + String obtain() { + try { + return executeGitCommand("fetch", "--deepen=${parameters.depth.get()}".toString(), parameters.remote.get()) + } catch (Exception e) { + return null + } + } +} + /** * Creates a tag with a given message */ diff --git a/src/main/groovy/nebula/plugin/release/git/command/GitWriteCommandParameters.groovy b/src/main/groovy/nebula/plugin/release/git/command/GitWriteCommandParameters.groovy index fa4b46b..9055e64 100644 --- a/src/main/groovy/nebula/plugin/release/git/command/GitWriteCommandParameters.groovy +++ b/src/main/groovy/nebula/plugin/release/git/command/GitWriteCommandParameters.groovy @@ -8,4 +8,5 @@ interface GitWriteCommandParameters extends ValueSourceParameters { Property getRemote() Property getTag() Property getTagMessage() + Property getDepth() }