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
59 changes: 59 additions & 0 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,46 @@ private function registerAbilities(): void {
'meta' => array( 'show_in_rest' => false ),
)
);

wp_register_ability(
'datamachine/workspace-worktree-cleanup-artifacts',
array(
'label' => 'Cleanup Worktree Artifacts',
'description' => 'Remove profile-derived, reconstructable artifact directories inside workspace worktrees. Requires a dry-run plan before deletion and revalidates exact paths before applying.',
'category' => 'datamachine-code-workspace',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'dry_run' => array(
'type' => 'boolean',
'description' => 'If true, return the artifact cleanup plan without deleting anything.',
),
'force' => array(
'type' => 'boolean',
'description' => 'If true, allow artifact cleanup in dirty or unpushed worktrees. Active plugin/theme symlink targets remain protected.',
),
'apply_plan' => array(
'type' => 'object',
'description' => 'Decoded artifact cleanup dry-run report to apply after revalidating every worktree and artifact path.',
),
),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'dry_run' => array( 'type' => 'boolean' ),
'candidates' => array( 'type' => 'array' ),
'removed' => array( 'type' => 'array' ),
'skipped' => array( 'type' => 'array' ),
'summary' => array( 'type' => 'object' ),
),
),
'execute_callback' => array( self::class, 'worktreeCleanupArtifacts' ),
'permission_callback' => fn() => PermissionHelper::can_manage(),
'meta' => array( 'show_in_rest' => false ),
)
);
};

if ( doing_action( 'wp_abilities_api_init' ) ) {
Expand Down Expand Up @@ -1637,6 +1677,25 @@ public static function worktreeReconcileMetadata( array $input ): array|\WP_Erro
return $workspace->worktree_reconcile_metadata( $opts );
}

/**
* Remove profile-derived artifacts inside workspace worktrees.
*
* @param array $input Input parameters (dry_run, force, apply_plan).
* @return array
*/
public static function worktreeCleanupArtifacts( array $input ): array|\WP_Error {
$workspace = new Workspace();
$opts = array(
'dry_run' => ! empty( $input['dry_run'] ),
'force' => ! empty( $input['force'] ),
);
if ( isset( $input['apply_plan'] ) && is_array( $input['apply_plan'] ) ) {
$opts['apply_plan'] = $input['apply_plan'];
}

return $workspace->worktree_cleanup_artifacts( $opts );
}

/**
* Read git log entries for a workspace repository.
*
Expand Down
154 changes: 142 additions & 12 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,10 @@ private function renderGitOperationResult( string $operation, array $result, arr
* wp datamachine workspace worktree cleanup --dry-run --format=json > cleanup-plan.json
* wp datamachine workspace worktree cleanup --apply-plan=cleanup-plan.json
*
* # Review and apply artifact-only cleanup without removing worktrees
* wp datamachine workspace worktree cleanup-artifacts --dry-run --format=json > artifact-plan.json
* wp datamachine workspace worktree cleanup-artifacts --apply-plan=artifact-plan.json
*
* # Local-only detection (no GitHub API call)
* wp datamachine workspace worktree cleanup --skip-github
*
Expand Down Expand Up @@ -1237,21 +1241,22 @@ public function worktree( array $args, array $assoc_args ): void {
$operation = $args[0] ?? '';

if ( '' === $operation ) {
WP_CLI::error( 'Usage: wp datamachine workspace worktree <add|list|remove|prune|cleanup|reconcile-metadata|refresh-context|finalize|mark-cleanup-eligible> [<repo>] [<branch>] [--flags]' );
WP_CLI::error( 'Usage: wp datamachine workspace worktree <add|list|remove|prune|cleanup|cleanup-artifacts|reconcile-metadata|refresh-context|finalize|mark-cleanup-eligible> [<repo>] [<branch>] [--flags]' );
return;
}

$ability_name = match ( $operation ) {
'add' => 'datamachine/workspace-worktree-add',
'list' => 'datamachine/workspace-worktree-list',
'remove' => 'datamachine/workspace-worktree-remove',
'prune' => 'datamachine/workspace-worktree-prune',
'cleanup' => 'datamachine/workspace-worktree-cleanup',
'reconcile-metadata' => 'datamachine/workspace-worktree-reconcile-metadata',
'refresh-context' => 'datamachine/workspace-worktree-refresh-context',
'finalize' => 'datamachine/workspace-worktree-finalize',
'add' => 'datamachine/workspace-worktree-add',
'list' => 'datamachine/workspace-worktree-list',
'remove' => 'datamachine/workspace-worktree-remove',
'prune' => 'datamachine/workspace-worktree-prune',
'cleanup' => 'datamachine/workspace-worktree-cleanup',
'cleanup-artifacts' => 'datamachine/workspace-worktree-cleanup-artifacts',
'reconcile-metadata' => 'datamachine/workspace-worktree-reconcile-metadata',
'refresh-context' => 'datamachine/workspace-worktree-refresh-context',
'finalize' => 'datamachine/workspace-worktree-finalize',
'mark-cleanup-eligible' => 'datamachine/workspace-worktree-finalize',
default => '',
default => '',
};

if ( '' === $ability_name ) {
Expand Down Expand Up @@ -1366,13 +1371,20 @@ public function worktree( array $args, array $assoc_args ): void {
$input['sort'] = trim( (string) $assoc_args['sort'] );
}
break;

case 'reconcile-metadata':
$input['dry_run'] = ! empty( $assoc_args['dry-run'] );
if ( ! empty( $assoc_args['apply-plan'] ) ) {
$input['apply_plan'] = $this->read_worktree_json_plan( (string) $assoc_args['apply-plan'], 'metadata reconciliation' );
}
break;

case 'cleanup-artifacts':
$input['dry_run'] = ! empty( $assoc_args['dry-run'] );
$input['force'] = ! empty( $assoc_args['force'] );
if ( ! empty( $assoc_args['apply-plan'] ) ) {
$input['apply_plan'] = $this->read_worktree_cleanup_plan( (string) $assoc_args['apply-plan'] );
}
break;
}

$result = $ability->execute( $input );
Expand Down Expand Up @@ -1451,11 +1463,14 @@ private function renderWorktreeResult( string $operation, array $result, array $
case 'cleanup':
$this->render_worktree_cleanup_result( $result, $assoc_args );
return;

case 'reconcile-metadata':
$this->render_worktree_metadata_reconciliation_result( $result, $assoc_args );
return;

case 'cleanup-artifacts':
$this->render_worktree_artifact_cleanup_result( $result, $assoc_args );
return;

case 'add':
WP_CLI::success( $result['message'] ?? 'Worktree created.' );
if ( isset( $result['disk_budget'] ) && is_array( $result['disk_budget'] ) ) {
Expand Down Expand Up @@ -1944,6 +1959,121 @@ private function render_worktree_metadata_reconciliation_result( array $result,
WP_CLI::success( sprintf( 'Wrote metadata for %d worktree(s); %d skipped.', count( $written ), count( $skipped ) ) );
}

/**
* Render artifact-only cleanup output.
*
* @param array $result Artifact cleanup ability result.
* @param array $assoc_args CLI assoc args.
* @return void
*/
private function render_worktree_artifact_cleanup_result( array $result, array $assoc_args ): void {
$format = isset( $assoc_args['format'] ) ? (string) $assoc_args['format'] : 'table';
if ( 'json' === $format ) {
$json = wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
WP_CLI::log( false === $json ? '{}' : $json );
return;
}

$candidates = (array) ( $result['candidates'] ?? array() );
$removed = (array) ( $result['removed'] ?? array() );
$skipped = (array) ( $result['skipped'] ?? array() );
$summary = (array) ( $result['summary'] ?? array() );
$dry_run = ! empty( $result['dry_run'] );

if ( empty( $candidates ) && empty( $removed ) && empty( $skipped ) ) {
WP_CLI::log( 'No worktree artifacts found.' );
return;
}

WP_CLI::log( 'Artifact cleanup summary:' );
$this->format_items(
array(
array(
'metric' => 'would_remove_artifacts',
'count' => (int) ( $summary['would_remove_artifacts'] ?? 0 ),
),
array(
'metric' => 'removed_artifacts',
'count' => (int) ( $summary['removed_artifacts'] ?? 0 ),
),
array(
'metric' => 'skipped_worktrees',
'count' => (int) ( $summary['skipped'] ?? count( $skipped ) ),
),
array(
'metric' => 'artifact_size',
'count' => $this->format_bytes( $summary['artifact_size_bytes'] ?? null ),
),
),
array( 'metric', 'count' ),
array( 'format' => 'table' ),
'metric'
);

if ( ! empty( $candidates ) ) {
WP_CLI::log( '' );
WP_CLI::log( $dry_run ? 'Would remove artifacts:' : 'Artifact candidates:' );
$this->format_items( $this->flatten_artifact_cleanup_rows( $candidates ), array( 'handle', 'repo', 'branch', 'artifact', 'size', 'path' ), array( 'format' => 'table' ), 'handle' );
}

if ( ! empty( $removed ) ) {
WP_CLI::log( '' );
WP_CLI::log( 'Removed artifacts:' );
$this->format_items( $this->flatten_artifact_cleanup_rows( $removed ), array( 'handle', 'repo', 'branch', 'artifact', 'size', 'path' ), array( 'format' => 'table' ), 'handle' );
}

if ( ! empty( $skipped ) ) {
WP_CLI::log( '' );
WP_CLI::log( 'Skipped worktrees:' );
$rows = array_map(
fn( $row ) => array(
'handle' => $row['handle'] ?? '',
'repo' => $row['repo'] ?? '',
'branch' => $row['branch'] ?? '',
'artifacts' => count( (array) ( $row['artifacts'] ?? array() ) ),
'reason_code' => $row['reason_code'] ?? '',
'reason' => $row['reason'] ?? '',
),
$skipped
);
$this->format_items( $rows, array( 'handle', 'repo', 'branch', 'artifacts', 'reason_code', 'reason' ), array( 'format' => 'table' ), 'handle' );
}

WP_CLI::log( '' );
if ( $dry_run ) {
WP_CLI::success( sprintf( '%d artifact(s) would be removed. Save JSON and re-run with --apply-plan=<file> to apply.', (int) ( $summary['would_remove_artifacts'] ?? 0 ) ) );
return;
}
WP_CLI::success( sprintf( 'Removed %d artifact(s); %d worktree(s) skipped.', (int) ( $summary['removed_artifacts'] ?? 0 ), count( $skipped ) ) );
}

/**
* Flatten artifact cleanup worktree rows into table rows.
*
* @param array<int,array> $rows Worktree artifact rows.
* @return array<int,array<string,mixed>>
*/
private function flatten_artifact_cleanup_rows( array $rows ): array {
$flat = array();
foreach ( $rows as $row ) {
foreach ( (array) ( $row['artifacts'] ?? array() ) as $artifact ) {
if ( ! is_array( $artifact ) ) {
continue;
}
$flat[] = array(
'handle' => $row['handle'] ?? '',
'repo' => $row['repo'] ?? '',
'branch' => $row['branch'] ?? '',
'artifact' => $artifact['path'] ?? '',
'size' => $this->format_bytes( $artifact['size_bytes'] ?? null ),
'path' => rtrim( (string) ( $row['path'] ?? '' ), '/' ) . '/' . ltrim( (string) ( $artifact['path'] ?? '' ), '/' ),
);
}
}

return $flat;
}

/**
* Read and decode a cleanup plan file for --apply-plan.
*
Expand Down
Loading