@@ -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 );
0 commit comments