Skip to content

Commit a353631

Browse files
authored
[#23] Added '--cleanup-stale' option to prune stale remote branches. (#337)
1 parent 4f42e64 commit a353631

7 files changed

Lines changed: 660 additions & 1 deletion

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,31 @@ repository would create a branch `deployment/1.2.3` in the destination
168168
repository. The addition of the new tags would create new unique branches in the
169169
destination repository.
170170

171+
#### Cleanup of stale branches
172+
173+
In `branch` mode the _destination_ repository accumulates a new branch for every
174+
deployment. Enable `--cleanup-stale` to remove old ones automatically after a
175+
successful push:
176+
177+
```shell
178+
./git-artifact git@github.com:yourorg/your-repo-destination.git \
179+
--mode=branch \
180+
--branch="deployment/[tags:.]" \
181+
--cleanup-stale \
182+
--cleanup-pattern="deployment/*" \
183+
--cleanup-age=3
184+
```
185+
186+
Any branch in the _destination_ repository that matches `--cleanup-pattern` and
187+
whose last commit is older than `--cleanup-age` days is deleted. The branch that
188+
was just pushed and the _destination_ repository's default branch are never
189+
deleted. `--cleanup-pattern` is required - it is the only way to identify the
190+
branches created by your deployments - and is matched as a shell glob. Add
191+
`--dry-run` to preview deletions without performing them.
192+
193+
Because deletion uses standard Git, this works with any remote (GitHub, GitLab,
194+
Bitbucket, self-hosted), not only GitHub.
195+
171196
## 📥 Installation
172197

173198
### As a standalone binary
@@ -269,6 +294,9 @@ fully-configured [example in the Vortex project](https://github.com/drevops/vort
269294
|----------------------------|---------------------|---------------------------------------------------------------------------------------------------------------------|
270295
| `--ansi` | | Force ANSI output. Use `--no-ansi` to disable |
271296
| `--branch` | `[branch]` | Destination branch with optional tokens (see below) |
297+
| `--cleanup-age` | `7` | Age in days after which a matching remote branch is considered stale; used with `--cleanup-stale` |
298+
| `--cleanup-pattern` | | Glob pattern of remote branches eligible for stale cleanup (e.g. `deployment/*`); required with `--cleanup-stale` |
299+
| `--cleanup-stale` | | Delete stale remote branches that match `--cleanup-pattern` and are older than `--cleanup-age` days |
272300
| `--dry-run` | | Run without pushing to the remote repository |
273301
| `--fail-on-missing-branch` | | Fail artifact packaging if source branch cannot be determined. By default, artifact packaging is skipped gracefully |
274302
| `--gitignore` | | Path to the `.gitignore` file to replace the current `.gitignore` |

src/Commands/ArtifactCommand.php

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ class ArtifactCommand extends Command {
3232

3333
const MODE_FORCE_PUSH = 'force-push';
3434

35+
const CLEANUP_STALE_AGE_DEFAULT = 7;
36+
3537
/**
3638
* Current Git repository.
3739
*/
@@ -124,6 +126,21 @@ class ArtifactCommand extends Command {
124126
*/
125127
protected bool $pushSuccessful = FALSE;
126128

129+
/**
130+
* Flag to enable deletion of stale branches in the remote repository.
131+
*/
132+
protected bool $cleanupStale = FALSE;
133+
134+
/**
135+
* Glob pattern of remote branches eligible for stale cleanup.
136+
*/
137+
protected string $cleanupPattern = '';
138+
139+
/**
140+
* Age in days after which a matching remote branch is considered stale.
141+
*/
142+
protected int $cleanupAge = self::CLEANUP_STALE_AGE_DEFAULT;
143+
127144
/**
128145
* Internal option to set current timestamp.
129146
*/
@@ -175,7 +192,10 @@ protected function configure(): void {
175192
->addOption('root', NULL, InputOption::VALUE_REQUIRED, 'Path to the root for file path resolution. If not specified, current directory is used.')
176193
->addOption('show-changes', NULL, InputOption::VALUE_NONE, 'Show changes made to the repo during packaging in the output.')
177194
->addOption('src', NULL, InputOption::VALUE_REQUIRED, 'Directory where source repository is located. If not specified, root directory is used.')
178-
->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.');
195+
->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.')
196+
->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.')
197+
->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.')
198+
->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);
179199
// @formatter:on
180200
// phpcs:enable Generic.Functions.FunctionCallArgumentSpacing.TooMuchSpaceAfterComma
181201
// phpcs:enable Drupal.WhiteSpace.Comma.TooManySpaces
@@ -275,6 +295,8 @@ protected function doExecute(): void {
275295

276296
$this->output->writeln(sprintf('<info>Pushed branch "%s" with commit message "%s"</info>', $this->destinationBranch, $this->commitMessage));
277297
}
298+
299+
$this->cleanupStaleBranches();
278300
}
279301
catch (GitException $exception) {
280302
$result = $exception->getRunnerResult();
@@ -318,6 +340,69 @@ protected function doExecute(): void {
318340
}
319341
}
320342

343+
/**
344+
* Delete stale branches in the remote repository.
345+
*
346+
* Eligible branches are those matching the configured pattern whose tip
347+
* commit is older than the configured age. The branch that was just pushed
348+
* and the remote's default branch are always preserved. Cleanup is
349+
* best-effort: any failure is logged and never fails the deployment, which
350+
* has already succeeded by this point.
351+
*/
352+
protected function cleanupStaleBranches(): void {
353+
if (!$this->cleanupStale) {
354+
return;
355+
}
356+
357+
try {
358+
$branches = $this->repo->getRemoteBranchesInfo($this->remoteName);
359+
}
360+
catch (GitException $exception) {
361+
$this->output->writeln('<comment>Unable to list remote branches for cleanup.</comment>');
362+
$this->logger->warning('Unable to list remote branches for cleanup: ' . $exception->getMessage());
363+
364+
return;
365+
}
366+
367+
$default_branch = $this->repo->getRemoteDefaultBranch($this->remoteName);
368+
if ($default_branch === NULL) {
369+
$this->output->writeln('<comment>Unable to determine the remote default branch; skipping stale cleanup for safety.</comment>');
370+
$this->logger->warning('Unable to determine the remote default branch; skipping stale cleanup for safety.');
371+
372+
return;
373+
}
374+
375+
$protected_branches = [$this->destinationBranch, $default_branch];
376+
377+
$stale = ArtifactGitRepository::filterStaleBranches($branches, $this->cleanupPattern, $this->cleanupAge * 86400, $this->now, $protected_branches);
378+
379+
if ($stale === []) {
380+
$this->output->writeln('<info>No stale branches to clean up.</info>');
381+
$this->logger->notice('No stale branches to clean up.');
382+
383+
return;
384+
}
385+
386+
foreach ($stale as $branch) {
387+
if ($this->isDryRun) {
388+
$this->output->writeln(sprintf('<info>Would delete stale branch "%s"</info>', $branch));
389+
$this->logger->notice(sprintf('Would delete stale branch "%s"', $branch));
390+
391+
continue;
392+
}
393+
394+
try {
395+
$this->repo->deleteRemoteBranch($this->remoteName, $branch);
396+
$this->output->writeln(sprintf('<info>Deleted stale branch "%s"</info>', $branch));
397+
$this->logger->notice(sprintf('Deleted stale branch "%s"', $branch));
398+
}
399+
catch (GitException $exception) {
400+
$this->output->writeln(sprintf('<comment>Failed to delete stale branch "%s"</comment>', $branch));
401+
$this->logger->warning(sprintf('Failed to delete stale branch "%s": %s', $branch, $exception->getMessage()));
402+
}
403+
}
404+
}
405+
321406
/**
322407
* Resolve and validate CLI options values into internal values.
323408
*
@@ -340,6 +425,21 @@ protected function resolveOptions(string $url, array $options): void {
340425
$this->failOnMissingBranch = !empty($options['fail-on-missing-branch']);
341426
$this->logFile = empty($options['log']) || !is_string($options['log']) ? '' : $this->fsGetAbsolutePath($options['log']);
342427

428+
$this->cleanupStale = !empty($options['cleanup-stale']);
429+
if ($this->cleanupStale) {
430+
$pattern = isset($options['cleanup-pattern']) && is_string($options['cleanup-pattern']) ? trim($options['cleanup-pattern']) : '';
431+
if ($pattern === '') {
432+
throw new \RuntimeException('The --cleanup-pattern option is required when --cleanup-stale is set.');
433+
}
434+
$this->cleanupPattern = $pattern;
435+
436+
$age = $options['cleanup-age'] ?? NULL;
437+
if (!is_numeric($age) || (float) $age != (int) $age || (int) $age < 1) {
438+
throw new \RuntimeException('The --cleanup-age option must be a positive integer number of days.');
439+
}
440+
$this->cleanupAge = (int) $age;
441+
}
442+
343443
$this->setMode(is_string($options['mode']) ? $options['mode'] : '', $options);
344444

345445
$this->sourceDir = empty($options['src']) || !is_string($options['src']) ? $this->fsGetRootDir() : $this->fsGetAbsolutePath($options['src']);
@@ -415,6 +515,9 @@ protected function showInfo(): void {
415515
$lines[] = (' Remote branch: ' . $this->destinationBranch);
416516
$lines[] = (' Gitignore file: ' . ($this->gitignoreFile ?: 'No'));
417517
$lines[] = (' Will push: ' . ($this->isDryRun ? 'No' : 'Yes'));
518+
if ($this->cleanupStale) {
519+
$lines[] = (' Cleanup stale: ' . sprintf('Yes (pattern "%s", older than %d days)', $this->cleanupPattern, $this->cleanupAge));
520+
}
418521
$lines[] = ('----------------------------------------------------------------------');
419522

420523
$this->output->writeln($lines);

src/Git/ArtifactGitRepository.php

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,86 @@ protected function listRemotes(): array {
217217
return $this->extractFromCommand(['remote']) ?: [];
218218
}
219219

220+
/**
221+
* Get remote branches with their tip commit timestamps.
222+
*
223+
* A shallow fetch populates the remote-tracking refs; only the tip commit
224+
* metadata is required to determine branch age, so the fetch is limited to a
225+
* depth of one.
226+
*
227+
* @param string $remote
228+
* Remote name.
229+
*
230+
* @return array<string, int>
231+
* Branch names keyed to the Unix timestamp of their tip commit.
232+
*/
233+
public function getRemoteBranchesInfo(string $remote): array {
234+
$this->run('fetch', '--depth=1', $remote);
235+
236+
$format = '%(refname:lstrip=3)|%(committerdate:unix)';
237+
$lines = $this->extractFromCommand(['for-each-ref', '--format=' . $format, 'refs/remotes/' . $remote]) ?: [];
238+
239+
$branches = [];
240+
foreach ($lines as $line) {
241+
if (!str_contains($line, '|')) {
242+
continue;
243+
}
244+
245+
[$name, $timestamp] = explode('|', $line, 2);
246+
247+
if ($name === '' || $name === 'HEAD' || !is_numeric($timestamp)) {
248+
continue;
249+
}
250+
251+
$branches[$name] = (int) $timestamp;
252+
}
253+
254+
return $branches;
255+
}
256+
257+
/**
258+
* Get the default branch of a remote.
259+
*
260+
* @param string $remote
261+
* Remote name.
262+
*
263+
* @return string|null
264+
* The default branch name, or NULL if it cannot be determined.
265+
*/
266+
public function getRemoteDefaultBranch(string $remote): ?string {
267+
try {
268+
$lines = $this->extractFromCommand(['ls-remote', '--symref', $remote, 'HEAD']) ?: [];
269+
}
270+
catch (GitException) {
271+
return NULL;
272+
}
273+
274+
foreach ($lines as $line) {
275+
if (preg_match('#^ref:\s+refs/heads/(\S+)\s+HEAD#', $line, $matches)) {
276+
return $matches[1];
277+
}
278+
}
279+
280+
return NULL;
281+
}
282+
283+
/**
284+
* Delete a branch in the remote repository.
285+
*
286+
* @param string $remote
287+
* Remote name.
288+
* @param string $branch
289+
* Branch name to delete.
290+
*
291+
* @return static
292+
* The git repository.
293+
*/
294+
public function deleteRemoteBranch(string $remote, string $branch): static {
295+
$this->run('push', $remote, '--delete', $branch);
296+
297+
return $this;
298+
}
299+
220300
/**
221301
* Get tag pointing to HEAD.
222302
*
@@ -433,6 +513,69 @@ public static function isValidRemote(string $uri, string $type = 'any'): bool {
433513
};
434514
}
435515

516+
/**
517+
* Filter branches down to those eligible for stale cleanup.
518+
*
519+
* @param array<string, int> $branches
520+
* Branch names keyed to the Unix timestamp of their tip commit.
521+
* @param string $pattern
522+
* Glob pattern; only branches matching it are eligible.
523+
* @param int $max_age
524+
* Maximum allowed age in seconds; branches older than this are stale.
525+
* @param int $now
526+
* Reference "current" Unix timestamp to measure age against.
527+
* @param array<string> $protected_branches
528+
* Branch names that must never be returned.
529+
*
530+
* @return array<string>
531+
* Names of stale branches, sorted for deterministic output.
532+
*/
533+
public static function filterStaleBranches(array $branches, string $pattern, int $max_age, int $now, array $protected_branches = []): array {
534+
$stale = [];
535+
536+
foreach ($branches as $name => $timestamp) {
537+
$name = (string) $name;
538+
539+
if (in_array($name, $protected_branches, TRUE)) {
540+
continue;
541+
}
542+
543+
if (!self::matchesGlob($pattern, $name)) {
544+
continue;
545+
}
546+
547+
if (($now - $timestamp) <= $max_age) {
548+
continue;
549+
}
550+
551+
$stale[] = $name;
552+
}
553+
554+
sort($stale);
555+
556+
return $stale;
557+
}
558+
559+
/**
560+
* Test whether a branch name matches a shell-style glob pattern.
561+
*
562+
* Converts the glob into a regular expression so the match honours the
563+
* same '*' and '?' semantics as a shell glob without using fnmatch().
564+
*
565+
* @param string $pattern
566+
* The glob pattern to match against (e.g. 'deployment/*').
567+
* @param string $subject
568+
* The string to test.
569+
*
570+
* @return bool
571+
* TRUE when the subject matches the pattern.
572+
*/
573+
protected static function matchesGlob(string $pattern, string $subject): bool {
574+
$regex = '/^' . str_replace(['\*', '\?'], ['.*', '.'], preg_quote($pattern, '/')) . '$/';
575+
576+
return (bool) preg_match($regex, $subject);
577+
}
578+
436579
/**
437580
* Reset to the previous commit.
438581
*/

0 commit comments

Comments
 (0)