From 2ab488362df7812a2326848afeeea836b0b10c44 Mon Sep 17 00:00:00 2001 From: Rob Syme Date: Thu, 11 Dec 2025 14:10:29 -0500 Subject: [PATCH] Fix leading **/ glob to match zero-or-more directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Java's glob pattern `**/foo` requires at least one directory component before `foo`, but users expect it to also match `foo` at the root level. This change transforms leading `**/` patterns to `{,**/}` which means "either nothing OR any number of directories", correctly matching: - `foo` (zero directories) - `bar/foo` (one directory) - `bar/baz/foo` (multiple directories) This fixes issues where `files("path/**/subdir/*.txt")` would fail to find files in `path/subdir/` while finding files in `path/x/subdir/`. Fixes #5948 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 Signed-off-by: Rob Syme --- .../src/main/nextflow/file/FileHelper.groovy | 8 +++- .../test/nextflow/file/FileHelperTest.groovy | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/modules/nf-commons/src/main/nextflow/file/FileHelper.groovy b/modules/nf-commons/src/main/nextflow/file/FileHelper.groovy index 83df9c6034..382bc6c44e 100644 --- a/modules/nf-commons/src/main/nextflow/file/FileHelper.groovy +++ b/modules/nf-commons/src/main/nextflow/file/FileHelper.groovy @@ -859,7 +859,13 @@ class FileHelper { final syntax = options.syntax ?: 'glob' final relative = options.relative == true - final matcher = getPathMatcherFor("$syntax:${filePattern}", folder.fileSystem) + // Transform leading ** to match zero-or-more directories (not just one-or-more) + // Java's glob ** at pattern start requires at least one directory component, + // but users expect **/foo to also match foo at the root level + final adjustedPattern = filePattern.startsWith('**/') + ? "{,**/}" + filePattern.substring(3) + : filePattern + final matcher = getPathMatcherFor("$syntax:${adjustedPattern}", folder.fileSystem) final singleParam = action.getMaximumNumberOfParameters() == 1 Files.walkFileTree(folder, walkOptions, Integer.MAX_VALUE, new SimpleFileVisitor() { diff --git a/modules/nf-commons/src/test/nextflow/file/FileHelperTest.groovy b/modules/nf-commons/src/test/nextflow/file/FileHelperTest.groovy index 7846caadc5..5364b492f3 100644 --- a/modules/nf-commons/src/test/nextflow/file/FileHelperTest.groovy +++ b/modules/nf-commons/src/test/nextflow/file/FileHelperTest.groovy @@ -624,6 +624,48 @@ class FileHelperTest extends Specification { } + def 'should match files at root and subdirectories with double-star glob'() { + // Tests that **/ pattern matches files at the root level (zero directories) + // as well as files in subdirectories (one or more directories) + // See: https://github.com/nextflow-io/nextflow/issues/5948 + + given: + def folder = Files.createTempDirectory('test') + + // Create files at root level + folder.resolve('data.bin').text = 'root data' + folder.resolve('other.txt').text = 'other file' + + // Create files in subdirectory + folder.resolve('subdir').mkdir() + folder.resolve('subdir').resolve('data.bin').text = 'subdir data' + + // Create files in nested subdirectory + folder.resolve('subdir').resolve('nested').mkdir() + folder.resolve('subdir').resolve('nested').resolve('data.bin').text = 'nested data' + + when: 'using **/ pattern should match files at all levels including root' + def result = [] + FileHelper.visitFiles(folder, '**/data.bin', relative: true) { result << it.toString() } + + then: 'files at root AND in subdirectories are matched' + result.sort() == ['data.bin', 'subdir/data.bin', 'subdir/nested/data.bin'] + + when: 'using **/ with intermediate directory should also match at all levels' + result = [] + folder.resolve('InterOp').mkdir() + folder.resolve('InterOp').resolve('metrics.bin').text = 'root interop' + folder.resolve('subdir').resolve('InterOp').mkdir() + folder.resolve('subdir').resolve('InterOp').resolve('metrics.bin').text = 'subdir interop' + FileHelper.visitFiles(folder, '**/InterOp/*.bin', relative: true) { result << it.toString() } + + then: 'InterOp directories at root AND in subdirectories are matched' + result.sort() == ['InterOp/metrics.bin', 'subdir/InterOp/metrics.bin'] + + cleanup: + folder?.deleteDir() + } + def 'should copy file path to foreign file system' () {