Skip to content
Open
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
55 changes: 47 additions & 8 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -2588,20 +2588,51 @@ private function registerAbilities(): void {
'datamachine-code/workspace-worktree-prune',
array(
'label' => 'Prune Workspace Worktrees',
'description' => 'Run git worktree prune across all primary checkouts to drop stale registry entries.',
'description' => 'Preview or prune stale Git worktree registry entries across managed primaries. Defaults to a bounded dry-run. Pass dry_run=false to apply. Does not delete live worktrees.',
'category' => 'datamachine-code-workspace',
'input_schema' => array(
'type' => 'object',
'properties' => array(),
'properties' => array(
'dry_run' => array(
'type' => 'boolean',
'description' => 'If true or omitted, preview stale registrations without pruning. Pass false to apply.',
),
'limit' => array(
'type' => 'integer',
'description' => 'Maximum primaries to inspect. Defaults to 25, maximum 200.',
),
'after_repo' => array(
'type' => 'string',
'description' => 'Last inspected primary for bounded keyset continuation.',
),
'until_budget' => array(
'type' => 'string',
'description' => 'Optional compact wall-clock budget such as 30s.',
),
),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'pruned' => array(
'success' => array( 'type' => 'boolean' ),
'dry_run' => array( 'type' => 'boolean' ),
'pruned' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
'would_prune' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
'candidates' => array( 'type' => 'array' ),
'scanned_primary_count' => array( 'type' => 'integer' ),
'next_commands' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
'apply_command' => array( 'type' => array( 'string', 'null' ) ),
'partial' => array( 'type' => 'boolean' ),
'continuation' => array( 'type' => 'object' ),
),
),
'execute_callback' => array( self::class, 'worktreePrune' ),
Expand Down Expand Up @@ -5415,17 +5446,25 @@ public static function worktreeRemove( array $input ): array|\WP_Error {
/**
* Prune stale worktree registry entries.
*
* @param array $input Unused.
* @param array $input Prune options.
* @return array
*/
public static function worktreePrune( array $input ): array|\WP_Error { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
public static function worktreePrune( array $input ): array|\WP_Error {
$opts = array(
'dry_run' => ! array_key_exists( 'dry_run', $input ) || ! empty( $input['dry_run'] ),
'limit' => isset( $input['limit'] ) ? (int) $input['limit'] : 25,
'until_budget' => isset( $input['until_budget'] ) && '' !== trim( (string) $input['until_budget'] ) ? trim( (string) $input['until_budget'] ) : '30s',
);
if ( isset( $input['after_repo'] ) && '' !== trim( (string) $input['after_repo'] ) ) {
$opts['after_repo'] = trim( (string) $input['after_repo'] );
}
if ( RemoteWorkspaceBackend::has_registered_state() && RemoteWorkspaceBackend::should_handle() ) {
$result = ( new RemoteWorkspaceBackend() )->worktree_prune();
$result = ( new RemoteWorkspaceBackend() )->worktree_prune( $opts );
return self::decorate_remote_workspace_result( 'worktree_prune', $result );
}

$workspace = new Workspace();
return $workspace->worktree_prune();
return $workspace->worktree_prune( $opts );
}

/**
Expand Down
57 changes: 50 additions & 7 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -432,8 +432,15 @@ public static function worktree_command_definitions(): array {
),
'prune' => array(
'shortdesc' => 'Prune stale Git worktree metadata.',
'longdesc' => "Prunes stale Git worktree registry entries across managed primaries.\n\n## EXAMPLES\n\n wp datamachine-code workspace worktree prune --format=json",
'synopsis' => array( $format ),
'longdesc' => "Previews stale Git worktree registry entries across managed primaries. Apply with --yes. Existing checkouts are never deleted.\n\n## EXAMPLES\n\n wp datamachine-code workspace worktree prune --dry-run --format=json\n wp datamachine-code workspace worktree prune --yes --format=json",
'synopsis' => array(
$flag( 'dry-run', 'Preview stale Git registrations without pruning. Default when --yes is omitted.' ),
$flag( 'yes', 'Apply the reviewed prune.' ),
$option( 'limit', 'Maximum primaries to inspect. Default 25, maximum 200.' ),
$option( 'after-repo', 'Last inspected primary for bounded keyset continuation.' ),
$option( 'until-budget', 'Optional compact wall-clock budget such as 30s.' ),
$format,
),
),
'refresh-context' => array(
'shortdesc' => 'Refresh a worktree\'s injected site context.',
Expand Down Expand Up @@ -4489,7 +4496,7 @@ private function renderGitOperationResult( string $operation, array $result, arr
* : Lifecycle state to record when finalizing a worktree.
*
* [--dry-run]
* : Preview cleanup candidates without removing anything (cleanup and locks only).
* : Preview without mutation (prune, cleanup, and locks). Prune defaults to dry-run unless --yes is passed.
*
* [--prune-stale]
* : For `locks`, prune expired DB lock rows and old unlocked filesystem lock
Expand Down Expand Up @@ -4669,8 +4676,9 @@ private function renderGitOperationResult( string $operation, array $result, arr
* wp datamachine-code workspace worktree remove data-machine fix/foo --force
* wp datamachine-code workspace worktree remove data-machine@fix-foo --force
*
* # Prune stale worktree registry entries across all primaries
* wp datamachine-code workspace worktree prune
* # Preview stale Git worktree registry entries, then apply the reviewed prune
* wp datamachine-code workspace worktree prune --dry-run --format=json
* wp datamachine-code workspace worktree prune --yes --format=json
*
* # Inspect and safely prune stale workspace mutation locks
* wp datamachine-code workspace worktree locks --format=json
Expand Down Expand Up @@ -5139,6 +5147,19 @@ private function worktree( array $args, array $assoc_args ): void {
$input['include_disk'] = false;
break;

case 'prune':
$input['dry_run'] = ! empty( $assoc_args['dry-run'] ) || empty( $assoc_args['yes'] );
if ( isset( $assoc_args['limit'] ) ) {
$input['limit'] = (int) $assoc_args['limit'];
}
if ( isset( $assoc_args['after-repo'] ) && '' !== trim( (string) $assoc_args['after-repo'] ) ) {
$input['after_repo'] = trim( (string) $assoc_args['after-repo'] );
}
if ( isset( $assoc_args['until-budget'] ) && '' !== trim( (string) $assoc_args['until-budget'] ) ) {
$input['until_budget'] = trim( (string) $assoc_args['until-budget'] );
}
break;

case 'remove':
// Accept either the two-arg `<repo> <branch>` form or a single
// `<repo>@<branch-slug>` handle (as printed by `list`/`path`/cleanup
Expand Down Expand Up @@ -5858,14 +5879,33 @@ function ( $wt ) {
return;

case 'prune':
if ( 'json' === (string) ( $assoc_args['format'] ?? '' ) ) {
$this->renderer()->json( $result );
return;
}
$dry_run = ! empty( $result['dry_run'] );
$pruned = (array) ( $result['pruned'] ?? array() );
$would_prune = (array) ( $result['would_prune'] ?? array() );
$candidates = (array) ( $result['candidates'] ?? array() );
$stale_inventory = (array) ( $result['stale_inventory'] ?? array() );
$stale_marker_blockers = (array) ( $result['stale_marker_blockers'] ?? array() );
if ( empty( $pruned ) && empty( $stale_inventory ) && empty( $stale_marker_blockers ) ) {
if ( empty( $pruned ) && empty( $would_prune ) && empty( $candidates ) && empty( $stale_inventory ) && empty( $stale_marker_blockers ) ) {
WP_CLI::log( 'Nothing to prune.' );
if ( ! empty( $result['continuation']['available'] ) && ! empty( $result['continuation']['after_repo'] ) ) {
WP_CLI::log( 'More primaries: rerun with --after-repo=' . (string) $result['continuation']['after_repo'] . ' --dry-run --format=json' );
}
return;
}
if ( ! empty( $pruned ) ) {
if ( $dry_run ) {
$repos = array() !== $would_prune ? $would_prune : array_values( array_unique( array_map( static fn( array $row ): string => (string) ( $row['repo'] ?? '' ), $candidates ) ) );
WP_CLI::log( sprintf( 'Would prune %d stale Git registration%s across: %s', count( $candidates ), 1 === count( $candidates ) ? '' : 's', implode( ', ', $repos ) ) );
foreach ( $candidates as $candidate ) {
WP_CLI::log( sprintf( ' - %s (%s)', (string) ( $candidate['path'] ?? '' ), (string) ( $candidate['reason'] ?? 'prunable' ) ) );
}
if ( ! empty( $result['apply_command'] ) ) {
WP_CLI::log( 'Apply: ' . (string) $result['apply_command'] );
}
} elseif ( ! empty( $pruned ) ) {
WP_CLI::success( sprintf( 'Pruned worktree registry across: %s', implode( ', ', $pruned ) ) );
}
if ( ! empty( $stale_inventory ) ) {
Expand All @@ -5877,6 +5917,9 @@ function ( $wt ) {
WP_CLI::log( sprintf( ' - %s at %s', (string) ( $blocker['handle'] ?? '' ), (string) ( $blocker['path'] ?? '' ) ) );
}
}
if ( ! empty( $result['continuation']['available'] ) && ! empty( $result['continuation']['after_repo'] ) ) {
WP_CLI::log( 'More primaries: rerun with --after-repo=' . (string) $result['continuation']['after_repo'] . ( $dry_run ? ' --dry-run' : ' --yes' ) . ' --format=json' );
}
return;

case 'cleanup':
Expand Down
33 changes: 33 additions & 0 deletions inc/Workspace/GitCheckout.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,39 @@ public static function deletion_protection( string $candidate, string $workspace
return null;
}

/**
* Parse `git worktree list --porcelain` into registrations Git can prune.
*
* @return array<int,array{path:string,reason:string}>
*/
public static function prunable_registrations_from_porcelain( string $porcelain ): array {
$registrations = array();
$current_path = '';
$lines = preg_split( '/\r?\n/', $porcelain );
foreach ( false === $lines ? array() : $lines as $line ) {
if ( str_starts_with( $line, 'worktree ' ) ) {
$current_path = trim( substr( $line, strlen( 'worktree ' ) ) );
continue;
}
if ( ! str_starts_with( $line, 'prunable ' ) || '' === $current_path ) {
continue;
}
$registrations[] = array(
'path' => $current_path,
'reason' => trim( substr( $line, strlen( 'prunable ' ) ) ),
);
}

return $registrations;
}

/** Git args that preview or immediately drop proven-stale worktree registrations. */
public static function prune_git_args( bool $dry_run ): string {
return $dry_run
? 'worktree prune --dry-run -v --expire=now'
: 'worktree prune -v --expire=now';
}

private static function path_contains( string $container, string $path ): bool {
$container = rtrim($container, '/') . '/';
$path = rtrim($path, '/') . '/';
Expand Down
29 changes: 22 additions & 7 deletions inc/Workspace/RemoteWorkspaceBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -464,29 +464,44 @@ private function find_worktree_handle_by_repo_branch( array $state, string $repo
/**
* Prune remote worktree state whose primary repo registration disappeared.
*
* @param array{dry_run?:bool} $opts Prune options.
* @return array<string,mixed>
*/
public function worktree_prune(): array {
$state = $this->state();
$pruned = array();
public function worktree_prune( array $opts = array() ): array {
$dry_run = ! empty( $opts['dry_run'] );
$state = $this->state();
$pruned = array();
$candidates = array();
foreach ( $state['worktrees'] as $handle => $worktree ) {
$repo_name = is_array($worktree) ? (string) ( $worktree['repo_name'] ?? '' ) : '';
if ( '' !== $repo_name && isset($state['repos'][ $repo_name ]) ) {
continue;
}

$candidates[] = array(
'handle' => (string) $handle,
'repo' => $repo_name,
);
if ( $dry_run ) {
$pruned[] = (string) $handle;
continue;
}

unset($state['worktrees'][ $handle ]);
$pruned[] = (string) $handle;
}

if ( array() !== $pruned ) {
if ( ! $dry_run && array() !== $candidates ) {
$this->save_state($state);
}

return array(
'success' => true,
'backend' => 'github_api',
'pruned' => $pruned,
'success' => true,
'backend' => 'github_api',
'dry_run' => $dry_run,
'pruned' => $dry_run ? array() : $pruned,
'would_prune' => $dry_run ? $pruned : array(),
'candidates' => $candidates,
);
}

Expand Down
Loading