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
52 changes: 52 additions & 0 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -1594,6 +1594,42 @@ private function registerAbilities(): void {
)
);

AbilityRegistry::register(
'datamachine-code/workspace-worktree-inventory-prune-missing',
array(
'label' => 'Prune Missing Worktree Inventory Rows',
'description' => 'Delete DB-backed worktree inventory rows flagged missing_path whose on-disk path is still absent. Re-probes each candidate before deletion and protects rows with unpushed commits or an open PR unless force is set.',
'category' => 'datamachine-code-workspace',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'dry_run' => array(
'type' => 'boolean',
'description' => 'Preview candidates and skips without deleting any rows. Default false.',
),
'force' => array(
'type' => 'boolean',
'description' => 'Allow pruning rows with unpushed_count > 0 or a non-empty pr_url. Default false.',
),
),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'pruned_at' => array( 'type' => 'string' ),
'dry_run' => array( 'type' => 'boolean' ),
'deleted' => array( 'type' => 'array' ),
'skipped' => array( 'type' => 'array' ),
'summary' => array( 'type' => 'object' ),
),
),
'execute_callback' => array( self::class, 'worktreeInventoryPruneMissing' ),
'permission_callback' => fn() => PermissionHelper::can_manage(),
'meta' => array( 'show_in_rest' => false ),
)
);

AbilityRegistry::register(
'datamachine-code/workspace-cleanup-run',
array(
Expand Down Expand Up @@ -3978,6 +4014,22 @@ public static function worktreeInventoryRefresh( array $input ): array|\WP_Error
return $workspace->worktree_inventory_refresh();
}

/**
* Prune missing_path inventory rows whose on-disk path is still absent.
*
* @param array $input Input parameters.
* @return array<string,mixed>|\WP_Error
*/
public static function worktreeInventoryPruneMissing( array $input ): array|\WP_Error {
$workspace = new Workspace();
return $workspace->worktree_inventory_prune_missing(
array(
'dry_run' => ! empty($input['dry_run']),
'force' => ! empty($input['force']),
)
);
}

/**
* Schedule a background workspace cleanup system task.
*
Expand Down
93 changes: 90 additions & 3 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2328,7 +2328,16 @@ public function hygiene( array $args, array $assoc_args ): void { // phpcs:ign
* ## OPTIONS
*
* <operation>
* : Inventory operation. Currently: refresh.
* : Inventory operation. One of: refresh, prune-missing.
*
* [--dry-run]
* : (prune-missing) Preview candidates and skips without deleting rows.
*
* [--yes]
* : (prune-missing) Skip the confirmation prompt before deleting rows.
*
* [--force]
* : (prune-missing) Allow pruning rows with unpushed commits or an open PR.
*
* [--format=<format>]
* : Output format.
Expand All @@ -2343,15 +2352,35 @@ public function hygiene( array $args, array $assoc_args ): void { // phpcs:ign
*
* wp datamachine-code workspace inventory refresh --format=json
*
* # Preview ghost missing_path rows before deleting
* wp datamachine-code workspace inventory prune-missing --dry-run
*
* # Delete confirmed-absent ghost rows
* wp datamachine-code workspace inventory prune-missing --yes
*
* @subcommand inventory
*/
public function inventory( array $args, array $assoc_args ): void {
$operation = $args[0] ?? '';
if ( 'refresh' !== $operation ) {
WP_CLI::error('Usage: wp datamachine-code workspace inventory refresh [--format=<format>]');
if ( 'refresh' === $operation ) {
$this->inventory_refresh( $assoc_args );
return;
}

if ( 'prune-missing' === $operation ) {
$this->inventory_prune_missing( $assoc_args );
return;
}

WP_CLI::error('Usage: wp datamachine-code workspace inventory <refresh|prune-missing> [--format=<format>]');
}

/**
* Refresh the DB-backed worktree inventory from the current filesystem view.
*
* @param array<string,mixed> $assoc_args Associative args.
*/
private function inventory_refresh( array $assoc_args ): void {
$ability = wp_get_ability('datamachine-code/workspace-worktree-inventory-refresh');
if ( ! $ability ) {
WP_CLI::error('Workspace inventory refresh ability not available.');
Expand All @@ -2373,6 +2402,64 @@ public function inventory( array $args, array $assoc_args ): void {
WP_CLI::success(sprintf('Inventory refreshed: %d upserted, %d marked missing.', (int) ( $summary['upserted'] ?? 0 ), (int) ( $summary['marked_missing'] ?? 0 )));
}

/**
* Prune DB-backed inventory rows flagged missing_path whose path is still absent.
*
* @param array<string,mixed> $assoc_args Associative args.
*/
private function inventory_prune_missing( array $assoc_args ): void {
$dry_run = ! empty($assoc_args['dry-run']);
$force = ! empty($assoc_args['force']);

$ability = wp_get_ability('datamachine-code/workspace-worktree-inventory-prune-missing');
if ( ! $ability ) {
WP_CLI::error('Workspace inventory prune-missing ability not available.');
return;
}

// A dry-run preview never mutates, so it does not need confirmation.
if ( ! $dry_run && empty($assoc_args['yes']) ) {
$preview = $ability->execute(array( 'dry_run' => true ));
if ( is_wp_error($preview) ) {
WP_CLI::error($preview->get_error_message());
return;
}

$preview_summary = (array) ( $preview['summary'] ?? array() );
$would_delete = (int) ( $preview_summary['deleted'] ?? 0 );
$would_skip = (int) ( $preview_summary['skipped'] ?? 0 );
if ( 0 === $would_delete ) {
WP_CLI::success(sprintf('Nothing to prune: 0 rows would be deleted, %d skipped.', $would_skip));
return;
}

WP_CLI::confirm(sprintf('Prune %d missing_path inventory row(s) (%d skipped)? Pass --yes to skip this prompt.', $would_delete, $would_skip));
}

$result = $ability->execute(
array(
'dry_run' => $dry_run,
'force' => $force,
)
);
if ( is_wp_error($result) ) {
WP_CLI::error($result->get_error_message());
return;
}

if ( 'json' === (string) ( $assoc_args['format'] ?? '' ) ) {
$this->renderer()->json($result);
return;
}

$summary = (array) ( $result['summary'] ?? array() );
if ( $dry_run ) {
WP_CLI::success(sprintf('Inventory prune (dry-run): %d would be deleted, %d skipped.', (int) ( $summary['deleted'] ?? 0 ), (int) ( $summary['skipped'] ?? 0 )));
} else {
WP_CLI::success(sprintf('Inventory pruned: %d deleted, %d skipped.', (int) ( $summary['deleted'] ?? 0 ), (int) ( $summary['skipped'] ?? 0 )));
}
}

/**
* Show detailed info about a workspace repo.
*
Expand Down
124 changes: 122 additions & 2 deletions inc/Storage/WorktreeInventoryRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,126 @@ public function mark_missing( string $handle ): bool {
return false !== $result;
}

/**
* Prune inventory rows flagged missing_path whose on-disk path is still absent.
*
* Safety guards:
* - Re-probes each candidate's path on disk; only deletes when the path is
* STILL absent (a stale missing_path flag alone is not trusted).
* - Refuses to delete rows with unpushed_count > 0 or a non-empty pr_url
* unless 'force' is true; such rows are reported as skipped.
*
* @param array{dry_run?: bool, force?: bool} $opts Options.
* @return array<string,mixed> Result with deleted/skipped lists and summary.
*/
public function pruneMissing( array $opts = array() ): array {
$dry_run = ! empty($opts['dry_run']);
$force = ! empty($opts['force']);

$rows = $this->missing_path_rows();
$deleted = array();
$skipped = array();

foreach ( $rows as $row ) {
$handle = (string) ( $row['handle'] ?? '' );
$path = (string) ( $row['path'] ?? '' );

// Re-probe the disk: only reap when the path is STILL absent.
if ( '' !== $path && is_dir($path) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_dir
$skipped[] = array(
'handle' => $handle,
'path' => $path,
'reason' => 'path_present_on_disk',
);
continue;
}

// Protect rows carrying unpushed work or an open PR unless forced.
if ( ! $force ) {
$unpushed = isset($row['unpushed_count']) ? (int) $row['unpushed_count'] : 0;
$pr_url = isset($row['pr_url']) ? trim( (string) $row['pr_url'] ) : '';

if ( $unpushed > 0 ) {
$skipped[] = array(
'handle' => $handle,
'path' => $path,
'reason' => 'unpushed_count',
'unpushed_count' => $unpushed,
);
continue;
}

if ( '' !== $pr_url ) {
$skipped[] = array(
'handle' => $handle,
'path' => $path,
'reason' => 'pr_url',
'pr_url' => $pr_url,
);
continue;
}
}

if ( $dry_run ) {
$deleted[] = array(
'handle' => $handle,
'path' => $path,
'repo' => (string) ( $row['repo'] ?? '' ),
);
continue;
}

if ( $this->delete($handle) ) {
$deleted[] = array(
'handle' => $handle,
'path' => $path,
'repo' => (string) ( $row['repo'] ?? '' ),
);
} else {
$skipped[] = array(
'handle' => $handle,
'path' => $path,
'reason' => 'delete_failed',
);
}
}

return array(
'success' => true,
'pruned_at' => gmdate('c'),
'dry_run' => $dry_run,
'deleted' => $deleted,
'skipped' => $skipped,
'summary' => array(
'deleted' => count($deleted),
'skipped' => count($skipped),
'total' => count($rows),
),
);
}

/**
* Fetch all rows currently flagged missing_path.
*
* @return array<int,array<string,mixed>>
*/
private function missing_path_rows(): array {
global $wpdb;

if ( ! isset($wpdb) || ! method_exists($wpdb, 'get_results') ) {
return array();
}

$table = self::table_name();
// phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input.
$sql = "SELECT * FROM {$table} WHERE missing_path = 1 ORDER BY handle ASC";
// phpcs:enable WordPress.DB.PreparedSQL

// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static string, no user input.
$rows = $wpdb->get_results($sql, ARRAY_A);
return is_array($rows) ? array_map(array( $this, 'decode_row' ), $rows) : array();
}

/**
* Fetch all rows, optionally filtered by repo.
*
Expand Down Expand Up @@ -275,11 +395,11 @@ private function normalize_row( array $row ): array {
'last_seen_at' => $this->datetime($row['last_seen_at'] ?? $metadata['last_seen_at'] ?? null),
'last_probe_at' => current_time('mysql', true),
'last_probe_status' => ! empty($row['missing_path']) ? 'missing_path' : 'present',
'dirty_count' => isset($row['dirty']) ? ( null === $row['dirty'] ? null : (int) $row['dirty'] ) : ( isset($row['dirty_count']) ? (int) $row['dirty_count'] : null ),
'dirty_count' => isset($row['dirty']) ? (int) $row['dirty'] : ( isset($row['dirty_count']) ? (int) $row['dirty_count'] : null ),
'unpushed_count' => isset($row['unpushed_count']) ? (int) $row['unpushed_count'] : null,
'artifact_count' => isset($row['artifacts']) && is_array($row['artifacts']) ? count($row['artifacts']) : ( isset($row['artifact_count']) ? (int) $row['artifact_count'] : null ),
'artifact_size_bytes' => isset($row['artifact_size_bytes']) ? (int) $row['artifact_size_bytes'] : null,
'size_bytes' => isset($row['size_bytes']) ? ( null === $row['size_bytes'] ? null : (int) $row['size_bytes'] ) : null,
'size_bytes' => isset($row['size_bytes']) ? (int) $row['size_bytes'] : null,
'cleanup_signal' => $this->cleanup_signal($row, $metadata),
'missing_path' => ! empty($row['missing_path']) ? 1 : 0,
'metadata' => JsonCodec::encode_or_default($metadata),
Expand Down
13 changes: 13 additions & 0 deletions inc/Workspace/WorkspaceWorktreeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,19 @@ public function worktree_inventory_refresh(): array|\WP_Error {
);
}

/**
* Prune DB-backed inventory rows flagged missing_path whose path is still absent.
*
* Re-probes each candidate on disk, protects rows with unpushed work or an
* open PR unless forced, and deletes the confirmed-absent survivors.
*
* @param array{dry_run?: bool, force?: bool} $opts Options.
* @return array<string,mixed>|\WP_Error
*/
public function worktree_inventory_prune_missing( array $opts = array() ): array|\WP_Error {
return $this->worktree_inventory()->pruneMissing($opts);
}

/**
* Build a single inventory row for a known workspace handle.
*
Expand Down
8 changes: 8 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@ parameters:
- stubs/phpstan/datamachine-base-command.php
- stubs/phpstan/datamachine-plugin-settings.php
- stubs/phpstan/datamachine-system-task.php
ignoreErrors:
# The inventory repository intentionally guards $wpdb with isset()/method_exists()
# for multisite safety, which narrows the global to class-string|object and makes
# PHPStan reject the subsequent $wpdb method calls. The runtime object is always a
# \wpdb here; these calls are safe.
-
identifier: method.nonObject
path: inc/Storage/WorktreeInventoryRepository.php
Loading
Loading