diff --git a/modules/nextflow/src/main/groovy/nextflow/config/Manifest.groovy b/modules/nextflow/src/main/groovy/nextflow/config/Manifest.groovy index 626ce52e26..aad584a3e1 100644 --- a/modules/nextflow/src/main/groovy/nextflow/config/Manifest.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/config/Manifest.groovy @@ -55,11 +55,23 @@ class Manifest { String getDefaultBranch() { - target.defaultBranch + target.defaultBranch ?: 'master' + } + + /** + * Gets the default revision to use + * Priority order: + * 1. defaultRevision if set + * 2. version if set + * 3. defaultBranch + * @return The revision to use + */ + String getDefaultRevision() { + target.defaultRevision ?: getVersion() ?: getDefaultBranch() } String getDescription() { - target.description + target.description } String getAuthor() { @@ -104,6 +116,10 @@ class Manifest { target.nextflowVersion } + /** + * Gets the version + * @return The version string or null if not set + */ String getVersion() { target.version } @@ -139,6 +155,7 @@ class Manifest { .map(c -> c.toMap()) .collect(Collectors.toList()) result.defaultBranch = getDefaultBranch() + result.defaultRevision = getDefaultRevision() result.description = getDescription() result.homePage = homePage result.gitmodules = getGitmodules() @@ -193,4 +210,100 @@ class Manifest { MAINTAINER, CONTRIBUTOR } + + /** + * Checks if the current version is a development version + * @return true if version ends with 'dev' or '-dev', false otherwise + */ + boolean isDevelopmentVersion() { + def version = getVersion() + if (!version) return false + return version.endsWith('dev') || version.endsWith('-dev') + } + + /** + * Checks if the current version is a release candidate + * @return true if version contains '-RC', false otherwise + */ + boolean isReleaseCandidate() { + def version = getVersion() + if (!version) return false + return version.contains('-RC') + } + + /** + * Checks if the current version is a hotfix + * @return true if version contains '-hotfix', false otherwise + */ + boolean isHotfix() { + def version = getVersion() + if (!version) return false + return version.contains('-hotfix') + } + + /** + * Validates if a version string is a valid semantic version + * @param version The version string to validate + * @return true if valid, false otherwise + */ + boolean isValidVersion(String version) { + if (!version) return false + // Matches standard versions like 1.0.0, release candidates like 1.0.0-RC1, development versions like 1.0.0dev, and hotfixes like 1.0.0-hotfix + version ==~ /^\d+\.\d+\.\d+(-RC\d+|-dev|dev|-hotfix)?$/ + } + + /** + * Compares two version strings + * @return -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 + */ + private int compareVersions(String v1, String v2) { + def v1Parts = v1.tokenize('.') + def v2Parts = v2.tokenize('.') + + for (int i = 0; i < Math.min(v1Parts.size(), v2Parts.size()); i++) { + def v1Part = v1Parts[i].replaceAll(/(-RC\d+|-dev|dev|-hotfix)$/, '') as int + def v2Part = v2Parts[i].replaceAll(/(-RC\d+|-dev|dev|-hotfix)$/, '') as int + if (v1Part != v2Part) { + return v1Part <=> v2Part + } + } + + v1Parts.size() <=> v2Parts.size() + } + + /** + * Checks if the current version is greater than the provided version + * @param otherVersion The version to compare against + * @return true if current version is greater, false otherwise + */ + boolean isVersionGreaterThan(String otherVersion) { + def version = getVersion() + if (!version || !otherVersion) return false + + // Strip any suffixes for comparison + def v1 = version.replaceAll(/(-RC\d+|-dev|dev|-hotfix)$/, '') + def v2 = otherVersion.replaceAll(/(-RC\d+|-dev|dev|-hotfix)$/, '') + + compareVersions(v1, v2) > 0 + } + + /** + * Checks if the current version is compatible with the provided version + * @param otherVersion The version to check compatibility with + * @return true if compatible, false otherwise + */ + boolean isVersionCompatibleWith(String otherVersion) { + def version = getVersion() + if (!version || !otherVersion) return false + + // Development versions are always compatible + if (isDevelopmentVersion()) return true + + // For release candidates and hotfixes, compare base versions + def v1 = version.replaceAll(/(-RC\d+|-dev|dev|-hotfix)$/, '') + def v2 = otherVersion.replaceAll(/(-RC\d+|-dev|dev|-hotfix)$/, '') + + // Version should be greater than or equal to the other version + compareVersions(v1, v2) >= 0 + } } diff --git a/modules/nextflow/src/main/groovy/nextflow/scm/AssetManager.groovy b/modules/nextflow/src/main/groovy/nextflow/scm/AssetManager.groovy index a1b7d57683..d07f7747ad 100644 --- a/modules/nextflow/src/main/groovy/nextflow/scm/AssetManager.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/scm/AssetManager.groovy @@ -937,9 +937,18 @@ class AssetManager { assert localPath def current = getCurrentRevision() - if( current != defaultBranch ) { + def defaultRev = getManifest().getDefaultRevision() + if( current != defaultRev ) { + // NOTE This is the issue if( !revision ) { - throw new AbortOperationException("Project `$project` is currently stuck on revision: $current -- you need to explicitly specify a revision with the option `-r` in order to use it") + Ref head = git.getRepository().findRef(Constants.HEAD); + + // try to resolve the the current object id to a tag name + Map names = git.nameRev().addPrefix( "refs/tags/" ).add(head.objectId).call() + def tag = names.get( head.objectId ) ?: head.objectId.name() + if( current != tag ) { + throw new AbortOperationException("Project `$project` is currently stuck on revision: $current -- you need to explicitly specify a revision with the option `-r` in order to use it") + } } } if( !revision || revision == current ) { diff --git a/modules/nextflow/src/test/groovy/nextflow/cli/CmdRunTest.groovy b/modules/nextflow/src/test/groovy/nextflow/cli/CmdRunTest.groovy index 2de2397181..0646b5f7f3 100644 --- a/modules/nextflow/src/test/groovy/nextflow/cli/CmdRunTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/cli/CmdRunTest.groovy @@ -313,7 +313,7 @@ class CmdRunTest extends Specification { } @Unroll - def 'should guss is repo' () { + def 'should guess is repo' () { expect: CmdRun.guessIsRepo(PATH) == EXPECTED diff --git a/modules/nextflow/src/test/groovy/nextflow/scm/AssetManagerTest.groovy b/modules/nextflow/src/test/groovy/nextflow/scm/AssetManagerTest.groovy index ac59065c25..38aaf45186 100644 --- a/modules/nextflow/src/test/groovy/nextflow/scm/AssetManagerTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/scm/AssetManagerTest.groovy @@ -17,36 +17,38 @@ package nextflow.scm import spock.lang.IgnoreIf - +import nextflow.cli.CmdRun import nextflow.exception.AbortOperationException import org.eclipse.jgit.api.Git import org.eclipse.jgit.lib.Config import org.junit.Rule +import spock.lang.Ignore +import spock.lang.PendingFeature import spock.lang.Requires import spock.lang.Specification import test.TemporaryPath import java.nio.file.Path import java.nio.file.Paths + /** + * Test suite for AssetManager class which handles Git repository operations + * and pipeline asset management. * * @author Paolo Di Tommaso */ @IgnoreIf({System.getenv('NXF_SMOKE')}) class AssetManagerTest extends Specification { - static String GIT_CONFIG_TEXT = ''' + static final String GIT_CONFIG_TEXT = ''' [remote "origin"] url = https://github.com/nextflow-io/nextflow.git fetch = +refs/heads/*:refs/remotes/origin/* [branch "master"] remote = origin merge = refs/heads/master - ''' - .stripIndent() - + '''.stripIndent() - - static final GIT_CONFIG_LONG = ''' + static final String GIT_CONFIG_LONG = ''' [core] repositoryformatversion = 0 filemode = true @@ -61,8 +63,7 @@ class AssetManagerTest extends Specification { merge = refs/heads/master [submodule "tests"] url = git@github.com:nextflow-io/tests.git - ''' - .stripIndent() + '''.stripIndent() @Rule TemporaryPath tempDir = new TemporaryPath() @@ -71,216 +72,141 @@ class AssetManagerTest extends Specification { AssetManager.root = tempDir.root.toFile() } - // Helper method to grab the default brasnch if set in ~/.gitconfig - String getLocalDefaultBranch() { + // Helper method to grab the default branch if set in ~/.gitconfig + private String getLocalDefaultBranch() { def defaultBranch = 'master' - def gitconfig = Paths.get(System.getProperty('user.home'),'.gitconfig'); + def gitconfig = Paths.get(System.getProperty('user.home'),'.gitconfig') if(gitconfig.exists()) { def config = new Config() config.fromText(gitconfig.text) defaultBranch = config.getString('init', null, 'defaultBranch') ?: 'master' } - return defaultBranch + defaultBranch } - def testList() { - - given: + def "should list available pipeline assets"() { + given: 'A set of pipeline directories' def folder = tempDir.getRoot() folder.resolve('cbcrg/pipe1').mkdirs() folder.resolve('cbcrg/pipe2').mkdirs() folder.resolve('ncbi/blast').mkdirs() - when: + when: 'Listing available assets' def list = AssetManager.list() - then: + + then: 'Should return sorted list of pipelines' list.sort() == ['cbcrg/pipe1','cbcrg/pipe2','ncbi/blast'] - expect: + and: 'Should find specific pipelines' AssetManager.find('blast') == 'ncbi/blast' AssetManager.find('pipe1') == 'cbcrg/pipe1' AssetManager.find('pipe') as Set == ['cbcrg/pipe1', 'cbcrg/pipe2'] as Set - } - - def testResolveName() { - - given: + def "should resolve pipeline names correctly"() { + given: 'A set of pipeline directories and manager instance' def folder = tempDir.getRoot() folder.resolve('cbcrg/pipe1').mkdirs() folder.resolve('cbcrg/pipe2').mkdirs() folder.resolve('ncbi/blast').mkdirs() - def manager = new AssetManager() - when: + when: 'Resolving exact path' def result = manager.resolveName('x/y') - then: + then: 'Should return exact path' result == 'x/y' - when: + when: 'Resolving simple name' result = manager.resolveName('blast') - then: + then: 'Should return full path' result == 'ncbi/blast' - when: + when: 'Resolving path with script' result = manager.resolveName('ncbi/blast/script.nf') - then: + then: 'Should return base path' result == 'ncbi/blast' - when: + when: 'Resolving script with simple name' result = manager.resolveName('blast/script.nf') - then: + then: 'Should return full path' result == 'ncbi/blast' - when: + when: 'Resolving ambiguous name' manager.resolveName('pipe') - then: + then: 'Should throw exception' thrown(AbortOperationException) - when: + when: 'Resolving deep path' manager.resolveName('pipe/alpha/beta') - then: + then: 'Should throw exception' thrown(AbortOperationException) - when: + when: 'Resolving path with parent reference' result = manager.resolveName('../blast/script.nf') - then: + then: 'Should throw exception' thrown(AbortOperationException) - when: + when: 'Resolving path with current directory reference' result = manager.resolveName('./blast/script.nf') - then: + then: 'Should throw exception' thrown(AbortOperationException) - } - @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) - def testPull() { - - given: + def "should pull repository successfully"() { + given: 'Asset manager with GitHub token' def folder = tempDir.getRoot() def token = System.getenv('NXF_GITHUB_ACCESS_TOKEN') def manager = new AssetManager().build('nextflow-io/hello', [providers: [github: [auth: token]]]) - when: + when: 'Downloading repository' manager.download() - then: + then: 'Git repository should exist' folder.resolve('nextflow-io/hello/.git').isDirectory() - when: + when: 'Downloading repository again' manager.download() - then: + then: 'Should not throw exception' noExceptionThrown() - } - @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) - def testPullTagTwice() { - - given: + def "should pull tag multiple times successfully"() { + given: 'Asset manager with GitHub token' def folder = tempDir.getRoot() def token = System.getenv('NXF_GITHUB_ACCESS_TOKEN') def manager = new AssetManager().build('nextflow-io/hello', [providers: [github: [auth: token]]]) - when: + when: 'Downloading specific tag' manager.download("v1.2") - then: + then: 'Git repository should exist' folder.resolve('nextflow-io/hello/.git').isDirectory() - when: + when: 'Downloading same tag again' manager.download("v1.2") - then: + then: 'Should not throw exception' noExceptionThrown() } - // The hashes used here are NOT associated with tags. @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) - def testPullHashTwice() { - - given: + def "should pull commit hash multiple times successfully"() { + given: 'Asset manager with GitHub token' def folder = tempDir.getRoot() def token = System.getenv('NXF_GITHUB_ACCESS_TOKEN') def manager = new AssetManager().build('nextflow-io/hello', [providers: [github: [auth: token]]]) - when: + when: 'Downloading specific commit' manager.download("6b9515aba6c7efc6a9b3f273ce116fc0c224bf68") - then: + then: 'Git repository should exist' folder.resolve('nextflow-io/hello/.git').isDirectory() - when: + when: 'Downloading same commit again' def result = manager.download("6b9515aba6c7efc6a9b3f273ce116fc0c224bf68") - then: + then: 'Should not throw exception and indicate no changes' noExceptionThrown() result == "Already-up-to-date" } - - // Downloading a branch first and then pulling the branch - // should work fine, unlike with tags. - @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) - def testPullBranchTwice() { - - given: - def folder = tempDir.getRoot() - def token = System.getenv('NXF_GITHUB_ACCESS_TOKEN') - def manager = new AssetManager().build('nextflow-io/hello', [providers: [github: [auth: token]]]) - - when: - manager.download("mybranch") - then: - folder.resolve('nextflow-io/hello/.git').isDirectory() - - when: - manager.download("mybranch") - then: - noExceptionThrown() - } - - // First clone a repo with a tag, then forget to include the -r argument - // when you execute nextflow. - // Note that while the download will work, execution will fail subsequently - // at a separate check - this just tests that we don't fail because of a detached head. - @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) - def testPullTagThenBranch() { - - given: - def folder = tempDir.getRoot() - def token = System.getenv('NXF_GITHUB_ACCESS_TOKEN') - def manager = new AssetManager().build('nextflow-io/hello', [providers: [github: [auth: token]]]) - - when: - manager.download("v1.2") - then: - folder.resolve('nextflow-io/hello/.git').isDirectory() - - when: - manager.download() - then: - noExceptionThrown() - } - - - @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) - def testClone() { - - given: - def dir = tempDir.getRoot() - def token = System.getenv('NXF_GITHUB_ACCESS_TOKEN') - def manager = new AssetManager().build('nextflow-io/hello', [providers:[github: [auth: token]]]) - - when: - manager.clone(dir.toFile()) - - then: - dir.resolve('README.md').exists() - dir.resolve('.git').isDirectory() - - } - def testGetScriptName() { given: @@ -367,113 +293,134 @@ class AssetManagerTest extends Specification { } - def 'should read manifest file' () { - - given: - def config = - ''' - manifest { - homePage = 'http://foo.com' - mainScript = 'hello.nf' - defaultBranch = 'super-stuff' - description = 'This pipeline do this and that' - author = 'Hi Dude' - } - ''' + def "should read manifest file correctly"() { + given: 'A manifest configuration file' + def config = ''' + manifest { + homePage = 'http://foo.com' + mainScript = 'hello.nf' + defaultBranch = 'super-stuff' + description = 'This pipeline do this and that' + author = 'Hi Dude' + } + ''' def dir = tempDir.getRoot() dir.resolve('foo/bar').mkdirs() dir.resolve('foo/bar/nextflow.config').text = config dir.resolve('foo/bar/.git').mkdir() dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT - when: + when: 'Building asset manager with manifest' def holder = new AssetManager() holder.build('foo/bar') - then: + + then: 'Manifest values should be correctly read' holder.getMainScriptName() == 'hello.nf' holder.manifest.getDefaultBranch() == 'super-stuff' holder.manifest.getHomePage() == 'http://foo.com' holder.manifest.getDescription() == 'This pipeline do this and that' holder.manifest.getAuthor() == 'Hi Dude' - } - def 'should return default main script file' () { + def "should read default tag from manifest file"() { + given: 'A manifest configuration with default tag' + def config = ''' + manifest { + homePage = 'http://foo.com' + mainScript = 'hello.nf' + defaultBranch = '1.0.0' + description = 'This pipeline do this and that' + author = 'Hi Dude' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT + + when: 'Building asset manager' + def holder = new AssetManager() + holder.build('foo/bar') - given: + then: 'Default branch should be the tag' + holder.manifest.getDefaultBranch() == '1.0.0' + } + + def "should return default main script file"() { + given: 'A basic configuration file' def dir = tempDir.getRoot() dir.resolve('foo/bar').mkdirs() dir.resolve('foo/bar/nextflow.config').text = 'empty: 1' dir.resolve('foo/bar/.git').mkdir() dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT - when: + when: 'Building asset manager' def holder = new AssetManager() holder.build('foo/bar') - then: + then: 'Should return default values' holder.getMainScriptName() == 'main.nf' holder.getHomePage() == 'https://github.com/foo/bar' holder.manifest.getDefaultBranch() == null holder.manifest.getDescription() == null - } - def 'should parse git config and return the remote url' () { - - given: + def "should parse git config and return remote URL"() { + given: 'A Git configuration file' def dir = tempDir.root dir.resolve('.git').mkdir() dir.resolve('.git/config').text = GIT_CONFIG_LONG - when: + when: 'Getting remote URL' def manager = new AssetManager().setLocalPath(dir.toFile()) - then: - manager.getGitConfigRemoteUrl() == 'git@github.com:nextflow-io/nextflow.git' + then: 'Should return correct remote URL' + manager.getGitConfigRemoteUrl() == 'git@github.com:nextflow-io/nextflow.git' } - def 'should parse git config and return the remote host' () { - - given: + def "should parse git config and return remote domain"() { + given: 'A Git configuration file' def dir = tempDir.root dir.resolve('.git').mkdir() dir.resolve('.git/config').text = GIT_CONFIG_LONG - when: + when: 'Getting remote domain' def manager = new AssetManager().setLocalPath(dir.toFile()) - then: - manager.getGitConfigRemoteDomain() == 'github.com' + then: 'Should return correct remote domain' + manager.getGitConfigRemoteDomain() == 'github.com' } - def 'should create a script file object' () { - - given: + def "should create script file object correctly"() { + given: 'A Git repository with files' def dir = tempDir.root - // create the repo dir dir.resolve('main.nf').text = "println 'Hello world'" - dir.resolve('nextflow.config').text = 'manifest { }' + dir.resolve('nextflow.config').text = ''' + manifest { + defaultBranch = 'master' + } + ''' dir.resolve('foo.nf').text = 'this is foo content' + and: 'Initialize Git repository' def init = Git.init() - def repo = init.setDirectory( dir.toFile() ).call() + def repo = init.setDirectory(dir.toFile()).call() repo.add().addFilepattern('.').call() def commit = repo.commit().setSign(false).setAll(true).setMessage('First commit').call() repo.close() - // append fake remote data + and: 'Add remote configuration' dir.resolve('.git/config').text = GIT_CONFIG_TEXT - when: + when: 'Creating script file object for main script' def p = Mock(RepositoryProvider) { getRepositoryUrl() >> 'https://github.com/nextflow-io/nextflow' } - and: def manager = new AssetManager(provider: p) .setLocalPath(dir.toFile()) .setProject('nextflow-io/nextflow') - and: def script = manager.getScriptFile() - then: + + then: 'Script file object should have correct properties' script.localPath == dir script.commitId == commit.name() script.revision == getLocalDefaultBranch() @@ -482,15 +429,14 @@ class AssetManagerTest extends Specification { script.repository == 'https://github.com/nextflow-io/nextflow' script.projectName == 'nextflow-io/nextflow' - when: + when: 'Creating script file object for specific script' p = Mock(RepositoryProvider) { getRepositoryUrl() >> 'https://github.com/nextflow-io/nextflow' } - and: manager = new AssetManager(provider: p) .setLocalPath(dir.toFile()) .setProject('nextflow-io/nextflow') - and: script = manager.getScriptFile('foo.nf') - then: + + then: 'Script file object should have correct properties' script.localPath == dir script.commitId == commit.name() script.revision == getLocalDefaultBranch() @@ -498,10 +444,9 @@ class AssetManagerTest extends Specification { script.text == "this is foo content" script.repository == 'https://github.com/nextflow-io/nextflow' script.projectName == 'nextflow-io/nextflow' - } - def 'should return project name from git url' () { + def "should return project name from git url"() { AssetManager manager String result @@ -546,7 +491,7 @@ class AssetManagerTest extends Specification { } @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) - def 'should download branch specified'() { + def "should download branch specified"() { given: def folder = tempDir.getRoot() @@ -567,7 +512,7 @@ class AssetManagerTest extends Specification { } @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) - def 'should fetch main script from branch specified'() { + def "should fetch main script from branch specified"() { given: def token = System.getenv('NXF_GITHUB_ACCESS_TOKEN') @@ -581,7 +526,7 @@ class AssetManagerTest extends Specification { } @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) - def 'should download tag specified'() { + def "should download tag specified"() { given: def folder = tempDir.getRoot() @@ -602,7 +547,7 @@ class AssetManagerTest extends Specification { } @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) - def 'should identify default branch when downloading repo'() { + def "should use a default tag"() { given: def folder = tempDir.getRoot() @@ -623,32 +568,470 @@ class AssetManagerTest extends Specification { noExceptionThrown() } - @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) - def 'can filter remote branches'() { + def "should work with defaultBranch = master"() { given: - def folder = tempDir.getRoot() - def token = System.getenv('NXF_GITHUB_ACCESS_TOKEN') - def manager = new AssetManager().build('nextflow-io/hello', [providers: [github: [auth: token]]]) - manager.download() - def branches = manager.getBranchList() + def config = ''' + manifest { + defaultBranch = 'master' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT when: - def remote_head = branches.find { it.name == 'refs/remotes/origin/HEAD' } + def holder = new AssetManager() + holder.build('foo/bar') + + then: + holder.manifest.getDefaultBranch() == 'master' + holder.manifest.getDefaultRevision() == 'master' + } + + def "should work with defaultRevision"() { + given: + def config = ''' + manifest { + defaultRevision = '1.0.0' + defaultBranch = 'master' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT + + when: + def holder = new AssetManager() + holder.build('foo/bar') + then: - remote_head != null - !AssetManager.isRemoteBranch(remote_head) + holder.manifest.getDefaultRevision() == '1.0.0' + holder.manifest.getDefaultBranch() == 'master' + } + + def "should use version as defaultRevision when available"() { + given: + def config = ''' + manifest { + version = '2.0.0' + defaultBranch = 'master' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT when: - def remote_master = branches.find { it.name == 'refs/remotes/origin/master' } + def holder = new AssetManager() + holder.build('foo/bar') + then: - remote_master != null - AssetManager.isRemoteBranch(remote_master) + holder.manifest.getVersion() == '2.0.0' + holder.manifest.getDefaultRevision() == '2.0.0' + holder.manifest.getDefaultBranch() == 'master' + } + + def "should prioritize defaultRevision over version"() { + given: + def config = ''' + manifest { + version = '2.0.0' // Development version for main branch + defaultRevision = '1.0.0' // Latest stable version + defaultBranch = 'master' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT when: - def local_master = branches.find { it.name == 'refs/heads/master' } + def holder = new AssetManager() + holder.build('foo/bar') + then: - local_master != null - !AssetManager.isRemoteBranch(local_master) + holder.manifest.getVersion() == '2.0.0' + holder.manifest.getDefaultRevision() == '1.0.0' + holder.manifest.getDefaultBranch() == 'master' } + def "should handle development version with stable defaultRevision"() { + given: + def config = ''' + manifest { + version = '2.3.0dev' + defaultRevision = '2.2.0' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT + + when: + def holder = new AssetManager() + holder.build('foo/bar') + + then: + holder.manifest.getVersion() == '2.3.0dev' + holder.manifest.getDefaultRevision() == '2.2.0' + holder.manifest.isDevelopmentVersion() == true + } + + def "should handle commit hash in defaultRevision"() { + given: + def config = ''' + manifest { + defaultRevision = '6b9515aba6c7efc6a9b3f273ce116fc0c224bf68' + version = '2.0.0' // Version should be ignored when defaultRevision is set + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT + + when: + def holder = new AssetManager() + holder.build('foo/bar') + + then: + holder.manifest.getVersion() == '2.0.0' + holder.manifest.getDefaultRevision() == '6b9515aba6c7efc6a9b3f273ce116fc0c224bf68' + holder.manifest.getDefaultBranch() == 'master' + } + + def "should handle development version with stable defaultRevision"() { + given: + def config = ''' + manifest { + version = '2.3.0dev' + defaultRevision = '2.2.0' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT + + when: + def holder = new AssetManager() + holder.build('foo/bar') + + then: + holder.manifest.getVersion() == '2.3.0dev' + holder.manifest.getDefaultRevision() == '2.2.0' + holder.manifest.isDevelopmentVersion() == true + } + + def "should correctly compare development and release versions"() { + given: + def config = ''' + manifest { + version = '2.3.0dev' + defaultRevision = '2.2.0' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT + + when: + def holder = new AssetManager() + holder.build('foo/bar') + + then: + holder.manifest.isVersionGreaterThan('2.2.0') == true + holder.manifest.isVersionCompatibleWith('2.2.0') == true + } + + @PendingFeature + def "should not warn if project uses a tag as a defaultBranch"() { + given: + def ENV = [FOO: '/something', NXF_DEBUG: 'true'] + + when: + new CmdRun(revision: 'xyz') + + then: + def warning = capture + .toString() + .readLines() + .findResults { line -> line.contains('WARN') ? line : null } + .join('\n') + and: + !warning + noExceptionThrown() + } + + // Test version = '2.3.0-RC1' with defaultRevision + def "should handle release candidate versions"() { + given: + def config = ''' + manifest { + version = '2.3.0-RC1' + defaultRevision = '2.2.0' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT + + when: + def holder = new AssetManager() + holder.build('foo/bar') + + then: + holder.manifest.getVersion() == '2.3.0-RC1' + holder.manifest.getDefaultRevision() == '2.2.0' + holder.manifest.isVersionGreaterThan('2.2.0') == true + } + + // Test version = '2.2.1-hotfix' with defaultRevision = '2.2.0' + def "should handle hotfix versions"() { + given: + def config = ''' + manifest { + version = '2.2.1-hotfix' + defaultRevision = '2.2.0' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT + + when: + def holder = new AssetManager() + holder.build('foo/bar') + + then: + holder.manifest.getVersion() == '2.2.1-hotfix' + holder.manifest.getDefaultRevision() == '2.2.0' + holder.manifest.isVersionGreaterThan('2.2.0') == true + } + + // Test handling of feature branches while maintaining stable defaultRevision + def "should support multiple development branches"() { + given: + def config = ''' + manifest { + version = '2.3.0-dev' + defaultRevision = '2.2.0' + defaultBranch = 'feature/new-feature' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT + + when: + def holder = new AssetManager() + holder.build('foo/bar') + + then: + holder.manifest.getVersion() == '2.3.0-dev' + holder.manifest.getDefaultRevision() == '2.2.0' + holder.manifest.getDefaultBranch() == 'feature/new-feature' + } + + // Test downgrading defaultRevision for emergency rollbacks + def "should handle version rollback scenarios"() { + given: + def config = ''' + manifest { + version = '2.2.0' + defaultRevision = '2.3.0' // Attempting to rollback to older version + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT + + when: + def holder = new AssetManager() + holder.build('foo/bar') + + then: + holder.manifest.getVersion() == '2.2.0' + holder.manifest.getDefaultRevision() == '2.3.0' + holder.manifest.isVersionGreaterThan('2.3.0') == false + } + + // Test that development version is always ahead of defaultRevision + def "should validate version and defaultRevision compatibility"() { + given: + def config = ''' + manifest { + version = '2.1.0' // Version older than defaultRevision + defaultRevision = '2.2.0' + } + ''' + def dir = tempDir.getRoot() + dir.resolve('foo/bar').mkdirs() + dir.resolve('foo/bar/nextflow.config').text = config + dir.resolve('foo/bar/.git').mkdir() + dir.resolve('foo/bar/.git/config').text = GIT_CONFIG_TEXT + + when: + def holder = new AssetManager() + holder.build('foo/bar') + + then: + holder.manifest.getVersion() == '2.1.0' + holder.manifest.getDefaultRevision() == '2.2.0' + holder.manifest.isVersionGreaterThan('2.2.0') == false + holder.manifest.isVersionCompatibleWith('2.2.0') == false + } + + @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) + def "should pull branch multiple times successfully"() { + given: 'Asset manager with GitHub token' + def folder = tempDir.getRoot() + def token = System.getenv('NXF_GITHUB_ACCESS_TOKEN') + def manager = new AssetManager().build('nextflow-io/hello', [providers: [github: [auth: token]]]) + + when: 'Downloading branch first time' + manager.download("mybranch") + then: 'Git repository should exist' + folder.resolve('nextflow-io/hello/.git').isDirectory() + + when: 'Downloading branch second time' + manager.download("mybranch") + then: 'Should not throw exception' + noExceptionThrown() + } + + @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) + def "should handle tag to branch transition"() { + given: 'Asset manager with GitHub token' + def folder = tempDir.getRoot() + def token = System.getenv('NXF_GITHUB_ACCESS_TOKEN') + def manager = new AssetManager().build('nextflow-io/hello', [providers: [github: [auth: token]]]) + + when: 'Downloading specific tag' + manager.download("v1.2") + then: 'Git repository should exist' + folder.resolve('nextflow-io/hello/.git').isDirectory() + + when: 'Downloading without revision (default branch)' + manager.download() + then: 'Should not throw exception' + noExceptionThrown() + } + + @Requires({System.getenv('NXF_GITHUB_ACCESS_TOKEN')}) + def "should clone repository successfully"() { + given: 'Asset manager with GitHub token' + def dir = tempDir.getRoot() + def token = System.getenv('NXF_GITHUB_ACCESS_TOKEN') + def manager = new AssetManager().build('nextflow-io/hello', [providers:[github: [auth: token]]]) + + when: 'Cloning repository' + manager.clone(dir.toFile()) + + then: 'Repository files should exist' + dir.resolve('README.md').exists() + dir.resolve('.git').isDirectory() + } + + def "should get script name correctly"() { + given: 'Test directories and configurations' + def dir = tempDir.getRoot() + dir.resolve('sub1').mkdir() + dir.resolve('sub1/nextflow.config').text = "manifest.mainScript = 'pippo.nf'" + dir.resolve('sub2').mkdir() + + when: 'Getting script name from directory with config' + def holder = new AssetManager() + holder.localPath = dir.resolve('sub1').toFile() + then: 'Should return configured script name' + holder.getMainScriptName() == 'pippo.nf' + + when: 'Getting script name from directory without config' + holder = new AssetManager() + holder.localPath = dir.resolve('sub2').toFile() + then: 'Should return default script name' + holder.getMainScriptName() == 'main.nf' + + when: 'Getting script name with resolved project' + holder = new AssetManager() + holder.localPath = dir.resolve('sub1').toFile() + holder.resolveName('nextflow/hello') + then: 'Should return configured script name' + holder.getMainScriptName() == 'pippo.nf' + + when: 'Getting script name with specific script path' + holder = new AssetManager() + holder.localPath = dir.resolve('sub1').toFile() + holder.resolveName('nextflow/hello/my-script.nf') + then: 'Should return specified script name' + holder.getMainScriptName() == 'my-script.nf' + + when: 'Getting script name with deep path' + holder = new AssetManager() + holder.localPath = dir.resolve('sub1').toFile() + then: 'Should resolve name and return script path' + holder.resolveName('nextflow-io/hello/x/y/z/my-script.nf') == 'nextflow-io/hello' + holder.getMainScriptName() == 'x/y/z/my-script.nf' + + when: 'Getting script name with simple path' + holder = new AssetManager() + holder.localPath = dir.resolve('sub1').toFile() + then: 'Should resolve name and return script name' + holder.resolveName('nextflow-io/hello/my-script.nf') == 'nextflow-io/hello' + holder.getMainScriptName() == 'my-script.nf' + + when: 'Getting script name with short path' + holder = new AssetManager() + holder.localPath = dir.resolve('sub1').toFile() + then: 'Should resolve name and return script name' + holder.resolveName('hello/my-script.nf') == 'nextflow-io/hello' + holder.getMainScriptName() == 'my-script.nf' + } + + def "should create provider for different platforms"() { + when: 'Creating GitHub provider' + def manager = new AssetManager() + def repo = manager.createHubProvider('github') + then: 'Should return GitHub provider' + repo instanceof GithubRepositoryProvider + + when: 'Creating Bitbucket provider' + manager = new AssetManager() + repo = manager.createHubProvider('bitbucket') + then: 'Should return Bitbucket provider' + repo instanceof BitbucketRepositoryProvider + + when: 'Creating GitLab provider' + manager = new AssetManager() + repo = manager.createHubProvider('gitlab') + then: 'Should return GitLab provider' + repo instanceof GitlabRepositoryProvider + + when: 'Creating unknown provider' + manager = [:] as AssetManager + manager.createHubProvider('xxx') + then: 'Should throw exception' + thrown(AbortOperationException) + } }