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
7 changes: 6 additions & 1 deletion inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -2300,6 +2300,10 @@ private function registerAbilities(): void {
'type' => 'boolean',
'description' => 'Allow apply on dirty worktrees. Unpushed-commit gate is never overridden.',
),
'discard_unpushed' => array(
'type' => 'boolean',
'description' => 'Explicitly discard unpushed commits for bounded cleanup-eligible rows. This is a data-loss mode and is separate from force.',
),
'via_jobs' => array(
'type' => 'boolean',
'description' => 'Schedule each candidate as a single-row worktree_cleanup_chunk job for resumable async apply.',
Expand Down Expand Up @@ -4120,14 +4124,15 @@ public static function worktreeCleanupArtifacts( array $input ): array|\WP_Error
/**
* Apply only worktrees with explicit lifecycle cleanup_eligible metadata in a bounded batch.
*
* @param array $input Input parameters (dry_run, limit, older_than, sort, force, via_jobs, remove_timeout, source).
* @param array $input Input parameters (dry_run, limit, older_than, sort, force, discard_unpushed, via_jobs, remove_timeout, source).
* @return array<string,mixed>|\WP_Error
*/
public static function worktreeBoundedCleanupEligibleApply( array $input ): array|\WP_Error {
$workspace = new Workspace();
$opts = array(
'dry_run' => ! empty($input['dry_run']),
'force' => ! empty($input['force']),
'discard_unpushed' => ! empty($input['discard_unpushed']),
'via_jobs' => ! empty($input['via_jobs']),
'include_repaired_metadata' => ! empty($input['include_repaired_metadata']),
);
Expand Down
36 changes: 30 additions & 6 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -3326,6 +3326,11 @@ private function renderGitOperationResult( string $operation, array $result, arr
* include repaired metadata rows as operator-approved cleanup candidates.
* Apply still runs fresh dirty/unpushed/containment/primary safety probes.
*
* [--discard-unpushed]
* : With bounded-cleanup-eligible-apply only, explicitly discard unpushed
* commits after reviewed cleanup eligibility and fresh safety probes. This
* is a data-loss mode and is not implied by --force.
*
* [--older-than=<duration>]
* : Limit cleanup candidates to worktrees with lifecycle `created_at`
* metadata older than the compact duration (cleanup only, e.g. 7d, 24h).
Expand Down Expand Up @@ -3484,6 +3489,7 @@ private function renderGitOperationResult( string $operation, array $result, arr
* # (cheap inventory only — no full git worktree scan, no GitHub lookup)
* wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25
* wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --limit=25
* wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --discard-unpushed --limit=25
* wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --via-jobs --limit=10 --older-than=7d
* wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --include-repaired-metadata --older-than=7d --limit=25
* wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --include-repaired-metadata --older-than=7d --limit=25
Expand Down Expand Up @@ -3855,6 +3861,7 @@ public function worktree( array $args, array $assoc_args ): void {
case 'bounded-cleanup-eligible-apply':
$input['dry_run'] = ! empty($assoc_args['dry-run']);
$input['force'] = ! empty($assoc_args['force']);
$input['discard_unpushed'] = ! empty($assoc_args['discard-unpushed']);
$input['via_jobs'] = ! empty($assoc_args['via-jobs']);
$input['include_repaired_metadata'] = ! empty($assoc_args['include-repaired-metadata']);
$input['source'] = self::CLEANUP_CLI_SOURCE;
Expand Down Expand Up @@ -6460,15 +6467,32 @@ private function render_worktree_bounded_cleanup_eligible_apply_result( array $r
WP_CLI::log('Removed worktrees:');
$rows = array_map(
fn( $row ) => array(
'handle' => $row['handle'] ?? '',
'repo' => $row['repo'] ?? '',
'branch' => $row['branch'] ?? '',
'size' => $this->format_bytes($row['size_bytes'] ?? null),
'path' => $row['path'] ?? '',
'handle' => $row['handle'] ?? '',
'repo' => $row['repo'] ?? '',
'branch' => $row['branch'] ?? '',
'size' => $this->format_bytes($row['size_bytes'] ?? null),
'unpushed' => (int) ( $row['unpushed_before_remove'] ?? 0 ),
'path' => $row['path'] ?? '',
),
$removed
);
$this->format_items($rows, array( 'handle', 'repo', 'branch', 'size', 'path' ), array( 'format' => 'table' ), 'handle');
$this->format_items($rows, array( 'handle', 'repo', 'branch', 'size', 'unpushed', 'path' ), array( 'format' => 'table' ), 'handle');
}

$evidence = (array) ( $result['evidence'] ?? array() );
$discarded_unpushed = (array) ( $evidence['discarded_unpushed'] ?? array() );
if ( ! empty($discarded_unpushed) ) {
WP_CLI::warning('Discarded unpushed commits for cleanup-eligible worktrees. Evidence follows.');
$rows = array_map(
fn( $row ) => array(
'handle' => $row['handle'] ?? '',
'unpushed_before' => (int) ( $row['unpushed_before_remove'] ?? 0 ),
'path_exists_after' => ! empty($row['path_exists_after']) ? 'yes' : 'no',
'path' => $row['path'] ?? '',
),
$discarded_unpushed
);
$this->format_items($rows, array( 'handle', 'unpushed_before', 'path_exists_after', 'path' ), array( 'format' => 'table' ), 'handle');
}

if ( ! empty($skipped) ) {
Expand Down
3 changes: 3 additions & 0 deletions inc/Tasks/WorktreeCleanupChunkTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ public function executeTask( int $jobId, array $params ): void {
'worktrees' => $workspace->worktree_cleanup_merged(
array(
'apply_plan' => array( 'candidates' => $rows ),
'direct_apply_plan' => true,
'force' => ! empty($params['force']),
'discard_unpushed' => ! empty($params['discard_unpushed']),
'skip_github' => array_key_exists('skip_github', $params) ? (bool) $params['skip_github'] : true,
'include_repaired_metadata' => ! empty($params['include_repaired_metadata']),
'stale_liveness_only' => ! empty($params['stale_liveness_only']),
Expand Down
123 changes: 78 additions & 45 deletions inc/Workspace/WorkspaceWorktreeCleanupEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ trait WorkspaceWorktreeCleanupEngine {
public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Error {
$dry_run = ! empty($opts['dry_run']);
$force = ! empty($opts['force']);
$discard_unpushed = ! empty($opts['discard_unpushed']);
$skip_github = ! empty($opts['skip_github']);
$direct_apply_plan = ! empty($opts['direct_apply_plan']);
$inventory_only = ! empty($opts['inventory_only']);
Expand Down Expand Up @@ -112,7 +113,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro
$force = false;

if ( $direct_apply_plan && ! $dry_run ) {
return $this->apply_worktree_cleanup_plan_candidates($planned_candidates, $force, $started_at, $stale_liveness_only, $remove_timeout_seconds);
return $this->apply_worktree_cleanup_plan_candidates($planned_candidates, $force, $started_at, $stale_liveness_only, $remove_timeout_seconds, $discard_unpushed);
}
}

Expand Down Expand Up @@ -942,13 +943,14 @@ private function get_wp_error_data( \WP_Error $error ): mixed {
* batch so the operator can keep going (next call without changes
* re-derives the same list cheaply).
*
* @param array $opts Options: dry_run, limit, older_than, sort, force, via_jobs, source.
* @param array $opts Options: dry_run, limit, older_than, sort, force, discard_unpushed, via_jobs, source.
* @return array<string,mixed>|\WP_Error
*/
public function worktree_bounded_cleanup_eligible_apply( array $opts = array() ): array|\WP_Error {
$started_at = microtime(true);
$dry_run = ! empty($opts['dry_run']);
$force = ! empty($opts['force']);
$discard_unpushed = ! empty($opts['discard_unpushed']);
$via_jobs = ! empty($opts['via_jobs']);
$include_repaired_metadata = ! empty($opts['include_repaired_metadata']);
$older_than = isset($opts['older_than']) ? trim( (string) $opts['older_than']) : '';
Expand Down Expand Up @@ -1020,28 +1022,30 @@ public function worktree_bounded_cleanup_eligible_apply( array $opts = array() )
),
'continuation' => $continuation,
'evidence' => array(
'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000),
'inventory_total' => count($all_candidates),
'planned_handles' => array_values(array_filter(array_map(fn( $row ) => is_array($row) ? (string) ( $row['handle'] ?? '' ) : '', $batch))),
'remove_timeout' => $remove_timeout_seconds,
'source' => $source,
'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000),
'inventory_total' => count($all_candidates),
'planned_handles' => array_values(array_filter(array_map(fn( $row ) => is_array($row) ? (string) ( $row['handle'] ?? '' ) : '', $batch))),
'discard_unpushed' => $discard_unpushed,
'remove_timeout' => $remove_timeout_seconds,
'source' => $source,
),
);
}

if ( $via_jobs ) {
return $this->schedule_bounded_cleanup_eligible_chunks($batch, $deferred, $force, $source, $started_at, $continuation, $include_repaired_metadata, $remove_timeout_seconds);
return $this->schedule_bounded_cleanup_eligible_chunks($batch, $deferred, $force, $source, $started_at, $continuation, $include_repaired_metadata, $remove_timeout_seconds, $discard_unpushed);
}

$processed = 0;
$removed = array();
$skipped = $inventory_skipped;
$bytes_reclaimed = 0;
$timeout_handles = array();
$processed = 0;
$removed = array();
$skipped = $inventory_skipped;
$bytes_reclaimed = 0;
$timeout_handles = array();
$discarded_unpushed = array();

foreach ( $batch as $candidate ) {
++$processed;
$revalidated = $this->revalidate_bounded_cleanup_eligible_candidate($candidate, $force);
$revalidated = $this->revalidate_bounded_cleanup_eligible_candidate($candidate, $force, false, $discard_unpushed);
if ( isset($revalidated['skipped']) ) {
$skipped[] = $revalidated['skipped'];
continue;
Expand Down Expand Up @@ -1090,17 +1094,32 @@ function () use ( $repo, $branch, $wt_path, $force, $remove_timeout_seconds ) {
continue;
}

$removed[] = array_merge(
$unpushed_count = (int) ( $validated['unpushed'] ?? 0 );
$removed_row = array_merge(
array(
'handle' => (string) ( $candidate['handle'] ?? '' ),
'repo' => $repo,
'branch' => $branch,
'path' => $wt_path,
'size_bytes' => $size,
'reason_code' => 'cleanup_eligible',
'handle' => (string) ( $candidate['handle'] ?? '' ),
'repo' => $repo,
'branch' => $branch,
'path' => $wt_path,
'size_bytes' => $size,
'reason_code' => 'cleanup_eligible',
'unpushed_before_remove' => $unpushed_count,
'discarded_unpushed_commits' => $discard_unpushed && $unpushed_count > 0,
'path_exists_after' => is_dir($wt_path),
),
is_array($candidate['metadata'] ?? null) ? array( 'metadata' => $candidate['metadata'] ) : array()
);
$removed[] = $removed_row;
if ( $discard_unpushed && $unpushed_count > 0 ) {
$discarded_unpushed[] = array(
'handle' => (string) ( $candidate['handle'] ?? '' ),
'repo' => $repo,
'branch' => $branch,
'path' => $wt_path,
'unpushed_before_remove' => $unpushed_count,
'path_exists_after' => is_dir($wt_path),
);
}
$bytes_reclaimed += max(0, $size);
}

Expand All @@ -1124,20 +1143,23 @@ function () use ( $repo, $branch, $wt_path, $force, $remove_timeout_seconds ) {
'removed' => $removed,
'skipped' => $skipped,
'summary' => array(
'processed' => $processed,
'removed' => count($removed),
'skipped' => count($skipped),
'bytes_reclaimed' => $bytes_reclaimed,
'limit' => $limit,
'processed' => $processed,
'removed' => count($removed),
'skipped' => count($skipped),
'bytes_reclaimed' => $bytes_reclaimed,
'limit' => $limit,
'discarded_unpushed' => count($discarded_unpushed),
),
'continuation' => $continuation,
'evidence' => array(
'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000),
'inventory_total' => count($all_candidates),
'removed_handles' => array_values(array_filter(array_map(fn( $row ) => (string) $row['handle'], $removed))),
'skipped_handles' => array_values(array_filter(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $skipped))),
'remove_timeout' => $remove_timeout_seconds,
'source' => $source,
'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000),
'inventory_total' => count($all_candidates),
'removed_handles' => array_values(array_filter(array_map(fn( $row ) => (string) $row['handle'], $removed))),
'skipped_handles' => array_values(array_filter(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $skipped))),
'discard_unpushed' => $discard_unpushed,
'discarded_unpushed' => $discarded_unpushed,
'remove_timeout' => $remove_timeout_seconds,
'source' => $source,
),
);
}
Expand All @@ -1150,15 +1172,15 @@ function () use ( $repo, $branch, $wt_path, $force, $remove_timeout_seconds ) {
* @param float $started_at Start timestamp.
* @return array<string,mixed>
*/
private function apply_worktree_cleanup_plan_candidates( array $candidates, bool $force, float $started_at, bool $stale_liveness_only = false, int $remove_timeout_seconds = self::CLEANUP_GIT_REMOVE_TIMEOUT ): array {
private function apply_worktree_cleanup_plan_candidates( array $candidates, bool $force, float $started_at, bool $stale_liveness_only = false, int $remove_timeout_seconds = self::CLEANUP_GIT_REMOVE_TIMEOUT, bool $discard_unpushed = false ): array {
$processed = 0;
$removed = array();
$skipped = array();
$bytes_reclaimed = 0;

foreach ( $candidates as $candidate ) {
++$processed;
$revalidated = $this->revalidate_bounded_cleanup_eligible_candidate($candidate, $force, $stale_liveness_only);
$revalidated = $this->revalidate_bounded_cleanup_eligible_candidate($candidate, $force, $stale_liveness_only, $discard_unpushed);
if ( isset($revalidated['skipped']) ) {
$skipped[] = $revalidated['skipped'];
continue;
Expand Down Expand Up @@ -1253,7 +1275,7 @@ function () use ( $repo, $branch, $wt_path, $force, $remove_timeout_seconds ) {
* @param bool $force Allow dirty worktrees.
* @return array<string,mixed>
*/
private function revalidate_bounded_cleanup_eligible_candidate( array $candidate, bool $force, bool $stale_liveness_only = false ): array {
private function revalidate_bounded_cleanup_eligible_candidate( array $candidate, bool $force, bool $stale_liveness_only = false, bool $discard_unpushed = false ): array {
$handle = (string) ( $candidate['handle'] ?? '' );
$repo = (string) ( $candidate['repo'] ?? '' );
$branch = (string) ( $candidate['branch'] ?? '' );
Expand Down Expand Up @@ -1435,21 +1457,27 @@ private function revalidate_bounded_cleanup_eligible_candidate( array $candidate
);
}

if ( $unpushed > 0 && ! $allow_effective_clean_removal ) {
if ( $unpushed > 0 && ! $allow_effective_clean_removal && ! $discard_unpushed ) {
return array(
'skipped' => array(
'handle' => $handle,
'repo' => $repo,
'branch' => $branch,
'path' => $wt_path,
'reason_code' => 'unpushed_commits',
'reason' => sprintf('%d unpushed commit(s) — bounded cleanup-eligible apply refuses to remove even with force=true', $unpushed),
'reason' => sprintf('%d unpushed commit(s) — bounded cleanup-eligible apply refuses to remove without discard_unpushed=true', $unpushed),
'unpushed' => $unpushed,
),
);
}

return array_merge($candidate, array( 'path' => $real_path ));
return array_merge(
$candidate,
array(
'path' => $real_path,
'unpushed' => (int) $unpushed,
)
);
}

/**
Expand All @@ -1467,7 +1495,7 @@ private function revalidate_bounded_cleanup_eligible_candidate( array $candidate
* @param array<string,mixed> $continuation Continuation envelope.
* @return array<string,mixed>|\WP_Error
*/
private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $deferred, bool $force, string $source, float $started_at, array $continuation, bool $include_repaired_metadata = false, int $remove_timeout_seconds = self::CLEANUP_GIT_REMOVE_TIMEOUT ): array|\WP_Error {
private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $deferred, bool $force, string $source, float $started_at, array $continuation, bool $include_repaired_metadata = false, int $remove_timeout_seconds = self::CLEANUP_GIT_REMOVE_TIMEOUT, bool $discard_unpushed = false ): array|\WP_Error {
if ( ! class_exists('\DataMachine\Engine\Tasks\TaskScheduler') ) {
return new \WP_Error('task_scheduler_unavailable', 'Data Machine TaskScheduler is unavailable; cannot schedule bounded cleanup-eligible apply chunks.', array( 'status' => 500 ));
}
Expand Down Expand Up @@ -1520,6 +1548,7 @@ private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $
'chunk_index' => count($item_params),
'rows' => array( $row ),
'force' => $force,
'discard_unpushed' => $discard_unpushed,
'skip_github' => true,
'include_repaired_metadata' => $include_repaired_metadata,
'remove_timeout' => $remove_timeout_seconds,
Expand Down Expand Up @@ -1559,12 +1588,13 @@ private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $
),
'continuation' => $continuation,
'evidence' => array(
'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000),
'planned_handles' => array_values(array_filter(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $batch))),
'batch_job_id' => (int) ( $batch_result['batch_job_id'] ?? 0 ),
'direct_job_ids' => $batch_result['job_ids'] ?? array(),
'remove_timeout' => $remove_timeout_seconds,
'source' => $source,
'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000),
'planned_handles' => array_values(array_filter(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $batch))),
'batch_job_id' => (int) ( $batch_result['batch_job_id'] ?? 0 ),
'direct_job_ids' => $batch_result['job_ids'] ?? array(),
'discard_unpushed' => $discard_unpushed,
'remove_timeout' => $remove_timeout_seconds,
'source' => $source,
),
);
}
Expand Down Expand Up @@ -1616,6 +1646,9 @@ private function build_bounded_cleanup_resume_command( int $limit, array $opts,
if ( ! empty($opts['force']) ) {
$parts[] = '--force';
}
if ( ! empty($opts['discard_unpushed']) ) {
$parts[] = '--discard-unpushed';
}
if ( ! empty($opts['include_repaired_metadata']) ) {
$parts[] = '--include-repaired-metadata';
}
Expand Down
Loading
Loading