From c9f589b99a1bff91563cd66726d33711f71c55ad Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 22 Jul 2026 18:06:46 +1000 Subject: [PATCH 1/2] [#364] Added repeatable, comma-separated and regex '--cleanup-pattern' values. --- README.md | 18 +-- src/Commands/ArtifactCommand.php | 56 +++++++-- src/Git/ArtifactGitRepository.php | 87 ++++++++++++- tests/Functional/CleanupStaleBranchesTest.php | 94 ++++++++++++++ tests/Unit/ArtifactCommandTest.php | 2 +- tests/Unit/ArtifactGitRepositoryTest.php | 116 +++++++++++++++--- 6 files changed, 338 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index ba530ac..554729c 100644 --- a/README.md +++ b/README.md @@ -183,12 +183,16 @@ successful push: --cleanup-age=3 ``` -Any branch in the _destination_ repository that matches `--cleanup-pattern` and -whose last commit is older than `--cleanup-age` days is deleted. The branch that -was just pushed and the _destination_ repository's default branch are never -deleted. `--cleanup-pattern` is required - it is the only way to identify the -branches created by your deployments - and is matched as a shell glob. Add -`--dry-run` to preview deletions without performing them. +Each `--cleanup-pattern` value is either a comma-separated list of shell globs +(e.g. `feature/*,bugfix/*`) or a single regular expression wrapped in slashes +(e.g. `/^feature\/[^\/]+$/`). The option is also repeatable, so patterns can be +supplied across several flags or straight from an environment variable. A branch +in the _destination_ repository is deleted when it matches any of the patterns +and its last commit is older than `--cleanup-age` days. The branch that was just +pushed and the _destination_ repository's default branch are never deleted. +`--cleanup-pattern` is required - it is the only way to identify the branches +created by your deployments. Add `--dry-run` to preview deletions without +performing them. Because deletion uses standard Git, this works with any remote (GitHub, GitLab, Bitbucket, self-hosted), not only GitHub. @@ -295,7 +299,7 @@ fully-configured [example in the Vortex project](https://github.com/drevops/vort | `--ansi` | | Force ANSI output. Use `--no-ansi` to disable | | `--branch` | `[branch]` | Destination branch with optional tokens (see below) | | `--cleanup-age` | `7` | Age in days after which a matching remote branch is considered stale; used with `--cleanup-stale` | -| `--cleanup-pattern` | | Glob pattern of remote branches eligible for stale cleanup (e.g. `deployment/*`); required with `--cleanup-stale` | +| `--cleanup-pattern` | | Remote branches eligible for stale cleanup; repeatable, each value a comma-separated glob list or a `/regex/`; a branch matching any is eligible; required with `--cleanup-stale` | | `--cleanup-stale` | | Delete stale remote branches that match `--cleanup-pattern` and are older than `--cleanup-age` days | | `--dry-run` | | Run without pushing to the remote repository | | `--fail-on-missing-branch` | | Fail artifact packaging if source branch cannot be determined. By default, artifact packaging is skipped gracefully | diff --git a/src/Commands/ArtifactCommand.php b/src/Commands/ArtifactCommand.php index 3203e10..a094905 100644 --- a/src/Commands/ArtifactCommand.php +++ b/src/Commands/ArtifactCommand.php @@ -132,9 +132,14 @@ class ArtifactCommand extends Command { protected bool $cleanupStale = FALSE; /** - * Glob pattern of remote branches eligible for stale cleanup. + * Patterns of remote branches eligible for stale cleanup. + * + * Each entry is a shell glob or a regex literal, as distinguished by + * ArtifactGitRepository::isRegexPattern(). + * + * @var list */ - protected string $cleanupPattern = ''; + protected array $cleanupPatterns = []; /** * Age in days after which a matching remote branch is considered stale. @@ -194,7 +199,7 @@ protected function configure(): void { ->addOption('src', NULL, InputOption::VALUE_REQUIRED, 'Directory where source repository is located. If not specified, root directory is used.') ->addOption('fail-on-missing-branch', NULL, InputOption::VALUE_NONE, 'Fail artifact packaging if source branch cannot be determined. By default, artifact packaging is skipped gracefully.') ->addOption('cleanup-stale', NULL, InputOption::VALUE_NONE, 'Delete stale branches in the remote repository that match --cleanup-pattern and are older than --cleanup-age days.') - ->addOption('cleanup-pattern', NULL, InputOption::VALUE_REQUIRED, 'Glob pattern of remote branches eligible for stale cleanup (e.g. "deployment/*"). Required when --cleanup-stale is set.') + ->addOption('cleanup-pattern', NULL, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Remote branches eligible for stale cleanup. Repeatable; each value is a comma-separated list of globs (e.g. "feature/*,bugfix/*") or a single regex literal (e.g. "/^feature\/.+$/"). A branch matching any pattern is eligible. Required when --cleanup-stale is set.') ->addOption('cleanup-age', NULL, InputOption::VALUE_REQUIRED, 'Age in days after which a matching remote branch is considered stale.', (string) self::CLEANUP_STALE_AGE_DEFAULT); // @formatter:on // phpcs:enable Generic.Functions.FunctionCallArgumentSpacing.TooMuchSpaceAfterComma @@ -374,7 +379,7 @@ protected function cleanupStaleBranches(): void { $protected_branches = [$this->destinationBranch, $default_branch]; - $stale = ArtifactGitRepository::filterStaleBranches($branches, $this->cleanupPattern, $this->cleanupAge * 86400, $this->now, $protected_branches); + $stale = ArtifactGitRepository::filterStaleBranches($branches, $this->cleanupPatterns, $this->cleanupAge * 86400, $this->now, $protected_branches); if ($stale === []) { $this->output->writeln('No stale branches to clean up.'); @@ -427,11 +432,44 @@ protected function resolveOptions(string $url, array $options): void { $this->cleanupStale = !empty($options['cleanup-stale']); if ($this->cleanupStale) { - $pattern = isset($options['cleanup-pattern']) && is_string($options['cleanup-pattern']) ? trim($options['cleanup-pattern']) : ''; - if ($pattern === '') { + $raw_patterns = $options['cleanup-pattern'] ?? []; + $raw_patterns = is_array($raw_patterns) ? $raw_patterns : [$raw_patterns]; + $raw_patterns = array_filter($raw_patterns, 'is_string'); + + $patterns = []; + foreach ($raw_patterns as $raw_pattern) { + $raw_pattern = trim($raw_pattern); + if ($raw_pattern === '') { + continue; + } + + // A value is either a single regex literal or a comma-separated list of + // globs, so only globs are split - commas inside a regex are preserved. + if (ArtifactGitRepository::isRegexPattern($raw_pattern)) { + $patterns[] = $raw_pattern; + continue; + } + + foreach (explode(',', $raw_pattern) as $glob) { + $glob = trim($glob); + if ($glob !== '') { + $patterns[] = $glob; + } + } + } + + $patterns = array_values(array_unique($patterns)); + if ($patterns === []) { throw new \RuntimeException('The --cleanup-pattern option is required when --cleanup-stale is set.'); } - $this->cleanupPattern = $pattern; + + foreach ($patterns as $pattern) { + if (ArtifactGitRepository::isRegexPattern($pattern) && !ArtifactGitRepository::isValidRegex($pattern)) { + throw new \RuntimeException(sprintf('The --cleanup-pattern value "%s" is not a valid regular expression.', $pattern)); + } + } + + $this->cleanupPatterns = $patterns; $age = $options['cleanup-age'] ?? NULL; if (!is_numeric($age) || (float) $age != (int) $age || (int) $age < 1) { @@ -516,7 +554,9 @@ protected function showInfo(): void { $lines[] = (' Gitignore file: ' . ($this->gitignoreFile ?: 'No')); $lines[] = (' Will push: ' . ($this->isDryRun ? 'No' : 'Yes')); if ($this->cleanupStale) { - $lines[] = (' Cleanup stale: ' . sprintf('Yes (pattern "%s", older than %d days)', $this->cleanupPattern, $this->cleanupAge)); + $label = count($this->cleanupPatterns) === 1 ? 'pattern' : 'patterns'; + $list = implode(', ', array_map(static fn(string $pattern): string => sprintf('"%s"', $pattern), $this->cleanupPatterns)); + $lines[] = (' Cleanup stale: ' . sprintf('Yes (%s %s, older than %d days)', $label, $list, $this->cleanupAge)); } $lines[] = ('----------------------------------------------------------------------'); diff --git a/src/Git/ArtifactGitRepository.php b/src/Git/ArtifactGitRepository.php index 5901cc4..a8481b9 100644 --- a/src/Git/ArtifactGitRepository.php +++ b/src/Git/ArtifactGitRepository.php @@ -518,8 +518,9 @@ public static function isValidRemote(string $uri, string $type = 'any'): bool { * * @param array $branches * Branch names keyed to the Unix timestamp of their tip commit. - * @param string $pattern - * Glob pattern; only branches matching it are eligible. + * @param array $patterns + * Cleanup patterns (globs or regex literals); a branch matching any of them + * is eligible. * @param int $max_age * Maximum allowed age in seconds; branches older than this are stale. * @param int $now @@ -530,7 +531,7 @@ public static function isValidRemote(string $uri, string $type = 'any'): bool { * @return array * Names of stale branches, sorted for deterministic output. */ - public static function filterStaleBranches(array $branches, string $pattern, int $max_age, int $now, array $protected_branches = []): array { + public static function filterStaleBranches(array $branches, array $patterns, int $max_age, int $now, array $protected_branches = []): array { $stale = []; foreach ($branches as $name => $timestamp) { @@ -540,7 +541,7 @@ public static function filterStaleBranches(array $branches, string $pattern, int continue; } - if (!self::matchesGlob($pattern, $name)) { + if (!self::matchesAnyPattern($patterns, $name)) { continue; } @@ -556,6 +557,84 @@ public static function filterStaleBranches(array $branches, string $pattern, int return $stale; } + /** + * Determine whether a cleanup token is a regex rather than a glob. + * + * A Git branch name can never begin with a slash, so a leading slash + * unambiguously marks a PCRE literal (delimiters and flags included), e.g. + * '/^feature\/[^\/]+$/'. Everything else is treated as a shell-style glob. + * + * @param string $pattern + * The cleanup token to inspect. + * + * @return bool + * TRUE when the token should be interpreted as a regular expression. + */ + public static function isRegexPattern(string $pattern): bool { + return str_starts_with($pattern, '/'); + } + + /** + * Test whether a string is a usable PCRE pattern. + * + * @param string $pattern + * A regex literal including its delimiters and any flags. + * + * @return bool + * TRUE when the pattern compiles. + */ + public static function isValidRegex(string $pattern): bool { + set_error_handler(static fn(): bool => TRUE); + + try { + return preg_match($pattern, '') !== FALSE; + } + finally { + restore_error_handler(); + } + } + + /** + * Test whether a branch name matches any of the given patterns. + * + * @param array $patterns + * Cleanup patterns, each a shell glob or a regex literal (see + * ::isRegexPattern()). + * @param string $subject + * The branch name to test. + * + * @return bool + * TRUE when the subject matches at least one pattern. + */ + protected static function matchesAnyPattern(array $patterns, string $subject): bool { + foreach ($patterns as $pattern) { + if (self::matchesPattern($pattern, $subject)) { + return TRUE; + } + } + + return FALSE; + } + + /** + * Test whether a branch name matches a single cleanup pattern. + * + * @param string $pattern + * A shell glob or a regex literal (see ::isRegexPattern()). + * @param string $subject + * The branch name to test. + * + * @return bool + * TRUE when the subject matches the pattern. + */ + protected static function matchesPattern(string $pattern, string $subject): bool { + if (self::isRegexPattern($pattern)) { + return preg_match($pattern, $subject) === 1; + } + + return self::matchesGlob($pattern, $subject); + } + /** * Test whether a branch name matches a shell-style glob pattern. * diff --git a/tests/Functional/CleanupStaleBranchesTest.php b/tests/Functional/CleanupStaleBranchesTest.php index 1a12f21..4d931f4 100644 --- a/tests/Functional/CleanupStaleBranchesTest.php +++ b/tests/Functional/CleanupStaleBranchesTest.php @@ -45,6 +45,76 @@ public function testPrunesStaleBranches(): void { $this->gitAssertBranchesExist($this->dst, ['deployment/fresh', 'feature/keep', 'testbranch', $this->currentBranch]); } + public function testPrunesStaleBranchesCommaSeparatedGlobs(): void { + $this->gitCommitFileWithDate($this->dst, 'init', $this->now - 86400, 'Initial'); + $this->gitCreateBranchWithCommitDate($this->dst, 'feature/old', $this->now - 10 * 86400); + $this->gitCreateBranchWithCommitDate($this->dst, 'bugfix/old', $this->now - 10 * 86400); + $this->gitCreateBranchWithCommitDate($this->dst, 'release/keep', $this->now - 10 * 86400); + $this->gitCheckout($this->dst, $this->currentBranch); + + $this->gitCreateFixtureCommits(2); + + $output = $this->assertArtifactCommandSuccess([ + '--cleanup-stale' => TRUE, + '--cleanup-pattern' => 'feature/*,,bugfix/*', + '--cleanup-age' => '3', + ]); + + $this->assertStringContainsString('Cleanup stale: Yes (patterns "feature/*", "bugfix/*", older than 3 days)', $output); + $this->assertStringContainsString('Deleted stale branch "bugfix/old"', $output); + $this->assertStringContainsString('Deleted stale branch "feature/old"', $output); + + $this->gitAssertBranchesNotExist($this->dst, ['feature/old', 'bugfix/old']); + $this->gitAssertBranchesExist($this->dst, ['release/keep', 'testbranch', $this->currentBranch]); + } + + public function testPrunesStaleBranchesMultiplePatterns(): void { + $this->gitCommitFileWithDate($this->dst, 'init', $this->now - 86400, 'Initial'); + $this->gitCreateBranchWithCommitDate($this->dst, 'feature/old', $this->now - 10 * 86400); + $this->gitCreateBranchWithCommitDate($this->dst, 'bugfix/old', $this->now - 10 * 86400); + $this->gitCreateBranchWithCommitDate($this->dst, 'release/keep', $this->now - 10 * 86400); + $this->gitCheckout($this->dst, $this->currentBranch); + + $this->gitCreateFixtureCommits(2); + + $output = $this->assertArtifactCommandSuccess([ + '--cleanup-stale' => TRUE, + '--cleanup-pattern' => ['feature/*', 'bugfix/*'], + '--cleanup-age' => '3', + ]); + + $this->assertStringContainsString('Cleanup stale: Yes (patterns "feature/*", "bugfix/*", older than 3 days)', $output); + $this->assertStringContainsString('Deleted stale branch "bugfix/old"', $output); + $this->assertStringContainsString('Deleted stale branch "feature/old"', $output); + + $this->gitAssertBranchesNotExist($this->dst, ['feature/old', 'bugfix/old']); + $this->gitAssertBranchesExist($this->dst, ['release/keep', 'testbranch', $this->currentBranch]); + } + + public function testPrunesStaleBranchesRegex(): void { + $this->gitCommitFileWithDate($this->dst, 'init', $this->now - 86400, 'Initial'); + $this->gitCreateBranchWithCommitDate($this->dst, 'feature/single', $this->now - 10 * 86400); + $this->gitCreateBranchWithCommitDate($this->dst, 'feature/nested/deep', $this->now - 10 * 86400); + $this->gitCreateBranchWithCommitDate($this->dst, 'bugfix/one', $this->now - 10 * 86400); + $this->gitCheckout($this->dst, $this->currentBranch); + + $this->gitCreateFixtureCommits(2); + + $output = $this->assertArtifactCommandSuccess([ + '--cleanup-stale' => TRUE, + '--cleanup-pattern' => '/^feature\/[^\/]+$/', + '--cleanup-age' => '3', + ]); + + $this->assertStringContainsString('Cleanup stale: Yes (pattern "/^feature\/[^\/]+$/", older than 3 days)', $output); + $this->assertStringContainsString('Deleted stale branch "feature/single"', $output); + + $this->gitAssertBranchesNotExist($this->dst, ['feature/single']); + // The regex allows a single path segment only, so the nested branch and the + // bugfix branch are preserved even though both are stale. + $this->gitAssertBranchesExist($this->dst, ['feature/nested/deep', 'bugfix/one', 'testbranch', $this->currentBranch]); + } + public function testDryRunDoesNotPrune(): void { $this->gitCommitFileWithDate($this->dst, 'init', $this->now - 86400, 'Initial'); $this->gitCreateBranchWithCommitDate($this->dst, 'deployment/old', $this->now - 10 * 86400); @@ -119,6 +189,30 @@ public function testRequiresPattern(): void { $this->assertStringContainsString('The --cleanup-pattern option is required when --cleanup-stale is set.', $output); } + public function testRequiresNonEmptyPattern(): void { + $this->gitCreateFixtureCommits(2); + + $output = $this->runArtifactCommand([ + '--cleanup-stale' => TRUE, + '--cleanup-pattern' => [' ', ''], + '--branch' => 'testbranch', + ], TRUE); + + $this->assertStringContainsString('The --cleanup-pattern option is required when --cleanup-stale is set.', $output); + } + + public function testRejectsInvalidRegex(): void { + $this->gitCreateFixtureCommits(2); + + $output = $this->runArtifactCommand([ + '--cleanup-stale' => TRUE, + '--cleanup-pattern' => '/(/', + '--branch' => 'testbranch', + ], TRUE); + + $this->assertStringContainsString('The --cleanup-pattern value "/(/" is not a valid regular expression.', $output); + } + #[DataProvider('dataProviderRejectsInvalidAge')] public function testRejectsInvalidAge(string $age): void { $this->gitCreateFixtureCommits(2); diff --git a/tests/Unit/ArtifactCommandTest.php b/tests/Unit/ArtifactCommandTest.php index 570aaae..f00cfec 100644 --- a/tests/Unit/ArtifactCommandTest.php +++ b/tests/Unit/ArtifactCommandTest.php @@ -78,7 +78,7 @@ protected function createCleanupCommand(MockObject $repo, BufferedOutput $output $this->setProtectedValue($command, 'repo', $repo); $this->setProtectedValue($command, 'cleanupStale', TRUE); - $this->setProtectedValue($command, 'cleanupPattern', '*'); + $this->setProtectedValue($command, 'cleanupPatterns', ['*']); $this->setProtectedValue($command, 'cleanupAge', 3); $this->setProtectedValue($command, 'now', 100000000); $this->setProtectedValue($command, 'remoteName', 'dst'); diff --git a/tests/Unit/ArtifactGitRepositoryTest.php b/tests/Unit/ArtifactGitRepositoryTest.php index 9957fd4..9572faa 100644 --- a/tests/Unit/ArtifactGitRepositoryTest.php +++ b/tests/Unit/ArtifactGitRepositoryTest.php @@ -125,8 +125,8 @@ public static function dataProviderIsValidBranchName(): array { /** * @param array $branches * Branches keyed to tip timestamps. - * @param string $pattern - * Glob pattern to match eligible branches. + * @param array $patterns + * Glob or regex patterns to match eligible branches. * @param int $max_age * Maximum allowed age in seconds. * @param int $now @@ -137,8 +137,8 @@ public static function dataProviderIsValidBranchName(): array { * Expected stale branch names. */ #[DataProvider('dataProviderFilterStaleBranches')] - public function testFilterStaleBranches(array $branches, string $pattern, int $max_age, int $now, array $protected_branches, array $expected): void { - $this->assertSame($expected, ArtifactGitRepository::filterStaleBranches($branches, $pattern, $max_age, $now, $protected_branches)); + public function testFilterStaleBranches(array $branches, array $patterns, int $max_age, int $now, array $protected_branches, array $expected): void { + $this->assertSame($expected, ArtifactGitRepository::filterStaleBranches($branches, $patterns, $max_age, $now, $protected_branches)); } /** @@ -150,24 +150,110 @@ public static function dataProviderFilterStaleBranches(): array { $day = 86400; return [ - 'empty' => [[], '*', 3 * $day, $now, [], []], - 'no pattern match' => [['feature/x' => $now - 10 * $day], 'deployment/*', 3 * $day, $now, [], []], - 'stale match' => [['deployment/a' => $now - 10 * $day], 'deployment/*', 3 * $day, $now, [], ['deployment/a']], - 'fresh kept' => [['deployment/a' => $now - $day], 'deployment/*', 3 * $day, $now, [], []], - 'boundary equal kept' => [['deployment/a' => $now - 3 * $day], 'deployment/*', 3 * $day, $now, [], []], - 'boundary just over' => [['deployment/a' => $now - 3 * $day - 1], 'deployment/*', 3 * $day, $now, [], ['deployment/a']], - 'protected excluded' => [['deployment/a' => $now - 10 * $day], 'deployment/*', 3 * $day, $now, ['deployment/a'], []], - 'future timestamp kept' => [['deployment/a' => $now + 5 * $day], 'deployment/*', 3 * $day, $now, [], []], + 'empty' => [[], ['*'], 3 * $day, $now, [], []], + 'empty patterns' => [['deployment/a' => $now - 10 * $day], [], 3 * $day, $now, [], []], + 'no pattern match' => [['feature/x' => $now - 10 * $day], ['deployment/*'], 3 * $day, $now, [], []], + 'stale match' => [['deployment/a' => $now - 10 * $day], ['deployment/*'], 3 * $day, $now, [], ['deployment/a']], + 'fresh kept' => [['deployment/a' => $now - $day], ['deployment/*'], 3 * $day, $now, [], []], + 'boundary equal kept' => [['deployment/a' => $now - 3 * $day], ['deployment/*'], 3 * $day, $now, [], []], + 'boundary just over' => [['deployment/a' => $now - 3 * $day - 1], ['deployment/*'], 3 * $day, $now, [], ['deployment/a']], + 'protected excluded' => [['deployment/a' => $now - 10 * $day], ['deployment/*'], 3 * $day, $now, ['deployment/a'], []], + 'future timestamp kept' => [['deployment/a' => $now + 5 * $day], ['deployment/*'], 3 * $day, $now, [], []], 'sorted output' => [ ['deployment/c' => $now - 10 * $day, 'deployment/a' => $now - 10 * $day, 'deployment/b' => $now - 10 * $day], - 'deployment/*', 3 * $day, $now, [], ['deployment/a', 'deployment/b', 'deployment/c'], + ['deployment/*'], 3 * $day, $now, [], ['deployment/a', 'deployment/b', 'deployment/c'], ], - 'numeric branch name' => [['123' => $now - 10 * $day], '*', 3 * $day, $now, [], ['123']], + 'numeric branch name' => [['123' => $now - 10 * $day], ['*'], 3 * $day, $now, [], ['123']], 'wildcard all but protected' => [ ['a' => $now - 10 * $day, 'b' => $now - 10 * $day], - '*', 3 * $day, $now, ['b'], ['a'], + ['*'], 3 * $day, $now, ['b'], ['a'], + ], + 'multiple globs across prefixes' => [ + ['feature/a' => $now - 10 * $day, 'bugfix/b' => $now - 10 * $day, 'release/c' => $now - 10 * $day], + ['feature/*', 'bugfix/*'], 3 * $day, $now, [], ['bugfix/b', 'feature/a'], + ], + 'overlapping globs dedup' => [ + ['feature/a' => $now - 10 * $day], + ['feature/*', 'feature/a'], 3 * $day, $now, [], ['feature/a'], + ], + 'multiple globs none match' => [ + ['release/c' => $now - 10 * $day], + ['feature/*', 'bugfix/*'], 3 * $day, $now, [], [], + ], + 'multiple globs protected excluded' => [ + ['feature/a' => $now - 10 * $day, 'bugfix/b' => $now - 10 * $day], + ['feature/*', 'bugfix/*'], 3 * $day, $now, ['bugfix/b'], ['feature/a'], + ], + 'regex anchored match' => [ + ['feature/x' => $now - 10 * $day, 'xfeature/y' => $now - 10 * $day], + ['/^feature\/.+$/'], 3 * $day, $now, [], ['feature/x'], + ], + 'regex excludes nested slash' => [ + ['feature/a' => $now - 10 * $day, 'feature/a/b' => $now - 10 * $day], + ['/^feature\/[^\/]+$/'], 3 * $day, $now, [], ['feature/a'], + ], + 'glob spans nested slash' => [ + ['feature/a' => $now - 10 * $day, 'feature/a/b' => $now - 10 * $day], + ['feature/*'], 3 * $day, $now, [], ['feature/a', 'feature/a/b'], + ], + 'glob and regex mixed' => [ + ['feature/a' => $now - 10 * $day, 'bugfix/b' => $now - 10 * $day, 'release/c' => $now - 10 * $day], + ['feature/*', '/^bugfix\/.+$/'], 3 * $day, $now, [], ['bugfix/b', 'feature/a'], ], ]; } + /** + * @param string $pattern + * Cleanup token. + * @param bool $expected + * Whether the token is a regex literal. + */ + #[DataProvider('dataProviderIsRegexPattern')] + public function testIsRegexPattern(string $pattern, bool $expected): void { + $this->assertSame($expected, ArtifactGitRepository::isRegexPattern($pattern)); + } + + /** + * @return array + * Test data. + */ + public static function dataProviderIsRegexPattern(): array { + return [ + 'empty' => ['', FALSE], + 'glob wildcard' => ['*', FALSE], + 'glob prefix' => ['feature/*', FALSE], + 'glob question' => ['feature/?', FALSE], + 'regex literal' => ['/^feature\/.+$/', TRUE], + 'regex with flags' => ['/^feature\/.+$/i', TRUE], + 'bare slash' => ['/', TRUE], + ]; + } + + /** + * @param string $pattern + * Regex literal to validate. + * @param bool $expected + * Whether the pattern compiles. + */ + #[DataProvider('dataProviderIsValidRegex')] + public function testIsValidRegex(string $pattern, bool $expected): void { + $this->assertSame($expected, ArtifactGitRepository::isValidRegex($pattern)); + } + + /** + * @return array + * Test data. + */ + public static function dataProviderIsValidRegex(): array { + return [ + 'valid anchored' => ['/^feature\/.+$/', TRUE], + 'valid with flags' => ['/^feature\/.+$/i', TRUE], + 'valid char class' => ['/^feature\/[^\/]+$/', TRUE], + 'missing closing delimiter' => ['/^feature', FALSE], + 'unmatched paren' => ['/(/', FALSE], + 'unknown modifier' => ['/^a$/z', FALSE], + ]; + } + } From fcf79d92804fa9ad25fc24fbb9647214224453b3 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 22 Jul 2026 18:15:45 +1000 Subject: [PATCH 2/2] Re-triggered CI to clear a flaky check.