Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
56 changes: 48 additions & 8 deletions src/Commands/ArtifactCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
*/
protected string $cleanupPattern = '';
protected array $cleanupPatterns = [];

/**
* Age in days after which a matching remote branch is considered stale.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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('<info>No stale branches to clean up.</info>');
Expand Down Expand 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) {
Expand Down Expand Up @@ -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[] = ('----------------------------------------------------------------------');

Expand Down
87 changes: 83 additions & 4 deletions src/Git/ArtifactGitRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -518,8 +518,9 @@ public static function isValidRemote(string $uri, string $type = 'any'): bool {
*
* @param array<string, int> $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<string> $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
Expand All @@ -530,7 +531,7 @@ public static function isValidRemote(string $uri, string $type = 'any'): bool {
* @return array<string>
* 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) {
Expand All @@ -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;
}

Expand All @@ -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<string> $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.
*
Expand Down
94 changes: 94 additions & 0 deletions tests/Functional/CleanupStaleBranchesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/ArtifactCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading