diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index 3ce213f..64d2275 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -70,22 +70,12 @@ class Workspace { */ private const CLEANUP_SUMMARY_TOP_LIMIT = 10; - /** - * Default number of workspace entries to size in hygiene reports. - */ - private const HYGIENE_DEFAULT_SIZE_LIMIT = 1000; - /** * Default cap on worktrees scanned for an artifact cleanup dry-run when no * `limit` is provided. Keeps dry-run bounded and fast on huge workspaces. */ public const ARTIFACT_CLEANUP_DEFAULT_LIMIT = 100; - /** - * Default metadata reconciliation dry-run page size when pagination is used. - */ - private const METADATA_RECONCILE_DEFAULT_LIMIT = 100; - /** * @var string Resolved workspace path. */ @@ -366,6 +356,7 @@ private function recover_worktree_identity_from_metadata( array $wt ): array { 'repo' => '', 'branch_slug' => null, 'is_worktree' => false, + 'dir_name' => '', ); $stored = array( @@ -377,13 +368,13 @@ private function recover_worktree_identity_from_metadata( array $wt ): array { $hydrated = array(); if ( '' === $repo && '' !== $stored['repo'] ) { - if ( (string) ( $parsed['repo'] ?? '' ) === $stored['repo'] ) { + if ( $parsed['repo'] === $stored['repo'] ) { $repo = $stored['repo']; $hydrated[] = 'repo'; } else { $conflicts['repo'] = array( 'reason' => 'metadata_repo_does_not_match_handle', - 'handle_repo' => (string) ( $parsed['repo'] ?? '' ), + 'handle_repo' => $parsed['repo'], 'metadata' => $stored['repo'], ); } @@ -562,13 +553,7 @@ public function ensure_exists(): array|\WP_Error { * @type bool $skip_github If true, only use upstream-gone signal (no API calls). * @type string $older_than Optional duration such as 7d, 24h, or 30m. Candidates newer than this are skipped. * } - * @return array{ - * success: bool, - * dry_run: bool, - * candidates: array, - * removed: array, - * skipped: array, - * }|\WP_Error + * @return array|\WP_Error */ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Error { $dry_run = ! empty($opts['dry_run']); @@ -663,19 +648,25 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $protected_branches = array( 'main', 'master', 'trunk', 'develop', 'HEAD' ); $candidates = array(); $skipped = array(); - $github_cache = array(); - $all_worktrees = array_values(array_filter( (array) $listing['worktrees'], fn( $wt ) => empty($wt['is_primary']))); - $total_worktrees = count($all_worktrees); - $worktrees = array_slice($all_worktrees, $offset, $limit); - $checked = 0; - $processed = 0; - $removed_count = 0; + + /** @var array $github_cache */ + $github_cache = array(); + + $all_worktrees = array_values(array_filter( (array) $listing['worktrees'], fn( $wt ) => empty($wt['is_primary']))); + $total_worktrees = count($all_worktrees); + $worktrees = array_slice($all_worktrees, $offset, $limit); + $checked = 0; + $processed = 0; + $removed_count = 0; $this->emit_worktree_cleanup_progress($progress, 'start', '', $checked, $total_worktrees, $candidates, $skipped, $removed_count, $started_at); // Fetch + prune each primary once per repo, but keep status/disk probes inside // the row loop so budgeted dry-runs can return partial evidence promptly. - $fetched = array(); + /** @var array $fetched */ + $fetched = array(); + + /** @var array $fetch_timeouts */ $fetch_timeouts = array(); foreach ( $worktrees as $wt ) { @@ -857,7 +848,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'dirty_obsolete_paths' => $obsolete_dirty['paths'], 'merge_signal' => $obsolete_dirty['merge_signal'], 'pr_url' => $obsolete_dirty['pr_url'] ?? null, - 'default_ref' => $obsolete_dirty['default_ref'] ?? null, + 'default_ref' => $obsolete_dirty['default_ref'], 'hint' => 'Dirty edits only touch paths the default branch no longer has. After review, rerun cleanup with force=true to remove this worktree.', 'created_at' => $created_at, 'metadata' => $metadata, @@ -934,7 +925,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro if ( empty($fetched[ $repo ]) ) { $fetch = $this->run_git($primary_path, 'fetch --prune --quiet origin', self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( $this->is_git_timeout_error($fetch) ) { + if ( is_wp_error($fetch) && $this->is_git_timeout_error($fetch) ) { $fetch_timeouts[ $repo ] = $fetch; $skipped[] = $this->build_worktree_probe_failure_skip($handle, $repo, $branch, $wt_path, $created_at, $metadata, $disk_fields, $fetch); continue; @@ -978,7 +969,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro continue; } - if ( 'probe-timeout' === ( $signal['signal'] ?? '' ) ) { + if ( 'probe-timeout' === $signal['signal'] ) { $skipped[] = array_merge( array( 'handle' => $handle, @@ -994,7 +985,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro continue; } - if ( 'github-unknown' === ( $signal['signal'] ?? '' ) ) { + if ( 'github-unknown' === $signal['signal'] ) { $skipped[] = array_merge( array( 'handle' => $handle, @@ -1211,7 +1202,7 @@ private function build_worktree_cleanup_pagination( int $offset, ?int $limit, in null === $budget_context ? null : '--until-budget=' . (string) ( $budget_context['label'] ?? '' ), '--format=json', ); - $command = implode(' ', array_values(array_filter($parts, fn( $part ) => null !== $part && '' !== $part))); + $command = implode(' ', array_values(array_filter($parts, fn( $part ) => null !== $part))); } return array( @@ -1318,12 +1309,12 @@ private function classify_worktree_git_probe_failure( string $handle, string $re } $parsed = $this->parse_handle($handle); - if ( ! empty($parsed['is_worktree']) && '' !== $repo && (string) ( $parsed['repo'] ?? '' ) !== $repo ) { + if ( ! empty($parsed['is_worktree']) && '' !== $repo && $parsed['repo'] !== $repo ) { return array( 'reason_code' => 'owner_repo_mismatch', - 'reason' => sprintf('worktree handle repo (%s) does not match inventory repo (%s) - %s: %s', (string) ( $parsed['repo'] ?? '' ), $repo, $safety_outcome, $message), + 'reason' => sprintf('worktree handle repo (%s) does not match inventory repo (%s) - %s: %s', $parsed['repo'], $repo, $safety_outcome, $message), 'hint' => 'Run workspace worktree reconcile-metadata --dry-run to review stale registry ownership, then prune or repair the mismatched row.', - 'handle_repo' => (string) ( $parsed['repo'] ?? '' ), + 'handle_repo' => $parsed['repo'], 'inventory_repo' => $repo, 'git_error_code' => $error_code, ); @@ -1368,11 +1359,7 @@ private function classify_worktree_git_probe_failure( string $handle, string $re * @return string */ private function get_wp_error_code( \WP_Error $error ): string { - if ( method_exists($error, 'get_error_code') ) { - return (string) $error->get_error_code(); - } - - return isset($error->code) ? (string) $error->code : ''; + return (string) $error->get_error_code(); } /** @@ -1382,11 +1369,7 @@ private function get_wp_error_code( \WP_Error $error ): string { * @return mixed */ private function get_wp_error_data( \WP_Error $error ): mixed { - if ( method_exists($error, 'get_error_data') ) { - return $error->get_error_data(); - } - - return $error->data ?? null; + return $error->get_error_data(); } /** @@ -1475,7 +1458,7 @@ public function worktree_bounded_cleanup_eligible_apply( array $opts = array() ) 'generated_at' => gmdate('c'), 'candidates' => $batch, 'removed' => array(), - 'skipped' => array_values($inventory_skipped), + 'skipped' => $inventory_skipped, 'summary' => array( 'processed' => count($batch), 'removed' => 0, @@ -1499,27 +1482,16 @@ public function worktree_bounded_cleanup_eligible_apply( array $opts = array() ) $processed = 0; $removed = array(); - $skipped = array_values($inventory_skipped); + $skipped = $inventory_skipped; $bytes_reclaimed = 0; foreach ( $batch as $candidate ) { ++$processed; $revalidated = $this->revalidate_bounded_cleanup_eligible_candidate($candidate, $force); - if ( is_array($revalidated) && isset($revalidated['skipped']) ) { + if ( isset($revalidated['skipped']) ) { $skipped[] = $revalidated['skipped']; continue; } - if ( $revalidated instanceof \WP_Error ) { - $skipped[] = array( - 'handle' => (string) ( $candidate['handle'] ?? '' ), - 'repo' => (string) ( $candidate['repo'] ?? '' ), - 'branch' => (string) ( $candidate['branch'] ?? '' ), - 'path' => (string) ( $candidate['path'] ?? '' ), - 'reason_code' => $revalidated->get_error_code(), - 'reason' => $revalidated->get_error_message(), - ); - continue; - } $validated = $revalidated; $repo = (string) ( $validated['repo'] ?? '' ); @@ -1605,8 +1577,8 @@ function () use ( $repo, $branch, $wt_path, $force ) { '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 ) => is_array($row) ? (string) ( $row['handle'] ?? '' ) : '', $removed))), - 'skipped_handles' => array_values(array_filter(array_map(fn( $row ) => is_array($row) ? (string) ( $row['handle'] ?? '' ) : '', $skipped))), + '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))), 'source' => $source, ), ); @@ -1654,6 +1626,7 @@ public function worktree_active_no_signal_report( array $opts = array() ): array $total = count($active); $page = array_slice($active, $offset, $limit); + /** @var array $github_cache */ $github_cache = array(); $probe_cache = array( 'default_ref' => array(), @@ -1780,7 +1753,7 @@ public function worktree_active_no_signal_finalized_apply( array $opts = array() $metadata = $this->build_active_no_signal_finalized_metadata($row); if ( is_wp_error($metadata) ) { - $skipped[] = $this->build_active_no_signal_finalized_apply_skip($row, $metadata->get_error_code(), $metadata->get_error_message()); + $skipped[] = $this->build_active_no_signal_finalized_apply_skip($row, (string) $metadata->get_error_code(), $metadata->get_error_message()); continue; } @@ -1873,7 +1846,7 @@ public function worktree_active_no_signal_equivalent_clean_apply( array $opts = $metadata = $this->build_active_no_signal_equivalent_clean_metadata($row); if ( is_wp_error($metadata) ) { - $skipped[] = $this->build_active_no_signal_finalized_apply_skip($row, $metadata->get_error_code(), $metadata->get_error_message()); + $skipped[] = $this->build_active_no_signal_finalized_apply_skip($row, (string) $metadata->get_error_code(), $metadata->get_error_message()); continue; } @@ -1965,7 +1938,7 @@ public function worktree_active_no_signal_merged_apply( array $opts = array() ): $metadata = $this->build_active_no_signal_merged_to_default_metadata($row); if ( is_wp_error($metadata) ) { - $skipped[] = $this->build_active_no_signal_finalized_apply_skip($row, $metadata->get_error_code(), $metadata->get_error_message()); + $skipped[] = $this->build_active_no_signal_finalized_apply_skip($row, (string) $metadata->get_error_code(), $metadata->get_error_message()); continue; } @@ -2057,7 +2030,7 @@ public function worktree_active_no_signal_remote_clean_apply( array $opts = arra $metadata = $this->build_active_no_signal_remote_clean_metadata($row); if ( is_wp_error($metadata) ) { - $skipped[] = $this->build_active_no_signal_finalized_apply_skip($row, $metadata->get_error_code(), $metadata->get_error_message()); + $skipped[] = $this->build_active_no_signal_finalized_apply_skip($row, (string) $metadata->get_error_code(), $metadata->get_error_message()); continue; } @@ -2246,7 +2219,7 @@ private function build_active_no_signal_finalized_metadata( array $row ): array| 'pr_url' => $pr_url, 'pr_number' => (int) ( $current_pr['number'] ?? 0 ), ), - fn( $value ) => null !== $value && '' !== $value + fn( $value ) => '' !== $value ); return $metadata; @@ -2468,7 +2441,7 @@ private function build_current_remote_tracking_clean_cleanup_evidence( string $h $remote_ref = 'refs/remotes/origin/' . $branch; $remote = $this->run_git($primary_path, sprintf('rev-parse --verify --quiet %s', escapeshellarg($remote_ref)), self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( is_wp_error($remote) || $this->is_git_timeout_error($remote) ) { + if ( is_wp_error($remote) ) { return new \WP_Error('remote_tracking_missing', 'remote tracking branch no longer exists'); } @@ -2715,7 +2688,7 @@ private function build_active_no_signal_evidence_row( array $row, array &$github $branch = $head_branch; } else { $branch_probe = $this->run_git($path, 'branch --show-current', self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( ! is_wp_error($branch_probe) && ! $this->is_git_timeout_error($branch_probe) ) { + if ( ! is_wp_error($branch_probe) ) { $actual_branch = trim( (string) ( $branch_probe['output'] ?? '' ) ); if ( '' !== $actual_branch ) { $branch = $actual_branch; @@ -2787,7 +2760,7 @@ function () use ( $primary_path, $remote_ref, &$probe_cache ) { return $this->cached_active_no_signal_remote_tracking_probe($primary_path, $remote_ref, $probe_cache); } ); - $out['remote_tracking'] = ! is_wp_error($remote) && ! $this->is_git_timeout_error($remote); + $out['remote_tracking'] = ! is_wp_error($remote); $default_ref = $this->time_worktree_probe( $out['probe_timings_ms'], @@ -2805,7 +2778,7 @@ function () use ( $primary_path, $default_ref, $branch, &$probe_cache ) { return $this->cached_active_no_signal_commits_outside_default_probe($primary_path, $default_ref, $branch, $probe_cache); } ); - if ( ! is_wp_error($outside) && ! $this->is_git_timeout_error($outside) ) { + if ( ! is_wp_error($outside) ) { $out['commits_outside_default'] = (int) trim( (string) ( $outside['output'] ?? '' )); } @@ -2964,7 +2937,7 @@ private function build_clean_upstream_equivalence_evidence( string $primary_path ); $cherry = $this->run_git($wt_path, sprintf('cherry %s HEAD', escapeshellarg($default_ref)), self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( ! is_wp_error($cherry) && ! $this->is_git_timeout_error($cherry) ) { + if ( ! is_wp_error($cherry) ) { $lines = array_values(array_filter(array_map('trim', explode("\n", (string) ( $cherry['output'] ?? '' ))))); foreach ( $lines as $line ) { if ( str_starts_with($line, '-') ) { @@ -2983,7 +2956,7 @@ private function build_clean_upstream_equivalence_evidence( string $primary_path } $head = $this->run_git($wt_path, 'rev-parse --verify HEAD', self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( is_wp_error($head) || $this->is_git_timeout_error($head) ) { + if ( is_wp_error($head) ) { return $evidence; } @@ -2993,14 +2966,14 @@ private function build_clean_upstream_equivalence_evidence( string $primary_path } $contains = $this->run_git($primary_path, sprintf('branch -r --contains %s', escapeshellarg(trim( (string) ( $head['output'] ?? '' )))), self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( is_wp_error($contains) || $this->is_git_timeout_error($contains) ) { + if ( is_wp_error($contains) ) { return $evidence; } $remote_branches = array_values(array_filter(array_map('trim', explode("\n", (string) ( $contains['output'] ?? '' ))))); foreach ( $remote_branches as $remote_branch ) { $normalized_remote_branch = preg_replace('/^origin\//', '', $remote_branch); - $remote_branch = '' === $normalized_remote_branch || null === $normalized_remote_branch ? $remote_branch : $normalized_remote_branch; + $remote_branch = null === $normalized_remote_branch ? $remote_branch : $normalized_remote_branch; if ( '' === $remote_branch || str_starts_with($remote_branch, 'HEAD -> ') || $remote_branch === $default_short || $remote_branch === $branch ) { continue; } @@ -3071,7 +3044,7 @@ private function build_dirty_unpushed_upstream_equivalence_evidence( string $pri ); $cherry = $this->time_worktree_probe($evidence['probe_timings_ms'], 'git_cherry', fn() => $this->run_git($wt_path, sprintf('cherry %s HEAD', escapeshellarg($default_ref)), self::CLEANUP_GIT_PROBE_TIMEOUT)); - if ( ! is_wp_error($cherry) && ! $this->is_git_timeout_error($cherry) ) { + if ( ! is_wp_error($cherry) ) { $lines = array_values(array_filter(array_map('trim', explode("\n", (string) ( $cherry['output'] ?? '' ))))); foreach ( $lines as $line ) { if ( str_starts_with($line, '-') ) { @@ -3087,19 +3060,19 @@ private function build_dirty_unpushed_upstream_equivalence_evidence( string $pri $tracked = $this->time_worktree_probe($evidence['probe_timings_ms'], 'tracked_dirty_paths', fn() => $this->run_git($wt_path, 'diff --name-only HEAD', self::CLEANUP_GIT_PROBE_TIMEOUT)); $paths = array(); - if ( ! is_wp_error($tracked) && ! $this->is_git_timeout_error($tracked) ) { + if ( ! is_wp_error($tracked) ) { $paths = array_merge($paths, array_values(array_filter(array_map('trim', explode("\n", (string) ( $tracked['output'] ?? '' )))))); } $untracked = $this->time_worktree_probe($evidence['probe_timings_ms'], 'untracked_paths', fn() => $this->run_git($wt_path, 'ls-files --others --exclude-standard', self::CLEANUP_GIT_PROBE_TIMEOUT)); - if ( ! is_wp_error($untracked) && ! $this->is_git_timeout_error($untracked) ) { + if ( ! is_wp_error($untracked) ) { foreach ( array_values(array_filter(array_map('trim', explode("\n", (string) ( $untracked['output'] ?? '' ))))) as $path ) { $paths[] = $path; ++$evidence['dirty_paths']['untracked']; } } - $paths = array_values(array_unique(array_filter($paths, fn( $path ) => '' !== (string) $path))); + $paths = array_values(array_unique($paths)); $evidence['dirty_paths']['total'] = count($paths); $inspect_paths = array_slice($paths, 0, $path_inspection_limit); $evidence['dirty_paths']['inspected'] = count($inspect_paths); @@ -3150,7 +3123,7 @@ private function classify_dirty_paths_against_default( string $primary_path, str $path_args = implode(' ', array_map('escapeshellarg', $paths)); $existing = $this->run_git($primary_path, sprintf('ls-tree -r --name-only %s -- %s', escapeshellarg($default_ref), $path_args), self::CLEANUP_GIT_PROBE_TIMEOUT); $changed = $this->run_git($wt_path, sprintf('diff --name-only %s -- %s', escapeshellarg($default_ref), $path_args), self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( is_wp_error($existing) || is_wp_error($changed) || $this->is_git_timeout_error($existing) || $this->is_git_timeout_error($changed) ) { + if ( is_wp_error($existing) || is_wp_error($changed) ) { return array(); } @@ -3219,7 +3192,7 @@ private function classify_dirty_unpushed_effective_status( array $evidence ): st private function classify_dirty_path_against_default( string $primary_path, string $wt_path, string $default_ref, string $path ): array { $kind = $this->is_generated_or_artifact_path($path) ? 'generated_or_artifact' : 'source_like'; $exists = $this->run_git($primary_path, sprintf('cat-file -e %s', escapeshellarg($default_ref . ':' . $path)), self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( $this->is_git_timeout_error($exists) ) { + if ( is_wp_error($exists) && $this->is_git_timeout_error($exists) ) { return array( 'path' => $path, 'bucket' => 'unknown', @@ -3236,7 +3209,7 @@ private function classify_dirty_path_against_default( string $primary_path, stri } $diff = $this->run_git($wt_path, sprintf('diff --name-only %s -- %s', escapeshellarg($default_ref), escapeshellarg($path)), self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( $this->is_git_timeout_error($diff) ) { + if ( is_wp_error($diff) && $this->is_git_timeout_error($diff) ) { return array( 'path' => $path, 'bucket' => 'unknown', @@ -3374,9 +3347,9 @@ private function describe_active_no_signal_action( array $row ): string { * * @param array $candidate Inventory candidate row. * @param bool $force Allow dirty worktrees. - * @return array|\WP_Error - */ - private function revalidate_bounded_cleanup_eligible_candidate( array $candidate, bool $force ): array|\WP_Error { + * @return array + */ + private function revalidate_bounded_cleanup_eligible_candidate( array $candidate, bool $force ): array { $handle = (string) ( $candidate['handle'] ?? '' ); $repo = (string) ( $candidate['repo'] ?? '' ); $branch = (string) ( $candidate['branch'] ?? '' ); @@ -3425,7 +3398,7 @@ private function revalidate_bounded_cleanup_eligible_candidate( array $candidate ); } - $real_path = (string) $validation['real_path']; + $real_path = (string) ( $validation['real_path'] ?? '' ); if ( '' === $real_path || ! is_dir($real_path) ) { return array( 'skipped' => array( @@ -3667,7 +3640,7 @@ 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 ) => is_array($row) ? (string) ( $row['handle'] ?? '' ) : '', $batch))), + '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(), 'source' => $source, @@ -3857,7 +3830,7 @@ private function build_no_merge_signal_evidence( string $primary_path, string $b $remote_ref = 'refs/remotes/origin/' . $branch; $remote = $this->run_git($primary_path, sprintf('rev-parse --verify --quiet %s', escapeshellarg($remote_ref)), self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( ! is_wp_error($remote) && ! $this->is_git_timeout_error($remote) ) { + if ( ! is_wp_error($remote) ) { $evidence['remote_branch'] = 'still_exists'; $evidence['classification'] = 'remote_branch_still_exists'; } elseif ( $this->is_git_timeout_error($remote) ) { @@ -3875,7 +3848,7 @@ private function build_no_merge_signal_evidence( string $primary_path, string $b sprintf('rev-list --count %s..%s', escapeshellarg($default_ref), escapeshellarg('refs/heads/' . $branch)), self::CLEANUP_GIT_PROBE_TIMEOUT ); - if ( ! is_wp_error($outside) && ! $this->is_git_timeout_error($outside) ) { + if ( ! is_wp_error($outside) ) { $outside_count = (int) trim( (string) ( $outside['output'] ?? '' )); $evidence['commits_outside_default'] = $outside_count; $evidence['local_default_relation'] = 0 === $outside_count ? 'default_contained' : 'has_unique_commits'; @@ -4026,7 +3999,7 @@ private function worktree_cleanup_skipped_next_commands( array $skipped_by_reaso * @param string|null $created_at Lifecycle timestamp. * @return string|null Stale reason code. */ - private function detect_worktree_stale_reason( bool $is_worktree, int $dirty_files, ?int $age_days, ?string $created_at, array $probes = array() ): ?string { + protected function detect_worktree_stale_reason( bool $is_worktree, int $dirty_files, ?int $age_days, ?string $created_at, array $probes = array() ): ?string { if ( ! $is_worktree ) { return null; } @@ -4059,7 +4032,7 @@ private function detect_worktree_stale_reason( bool $is_worktree, int $dirty_fil * @param array|null $metadata Stored lifecycle/context metadata. * @return array */ - private function build_worktree_disk_report( string $repo, string $path, bool $is_worktree, ?string $created_at, ?array $metadata ): array { + protected function build_worktree_disk_report( string $repo, string $path, bool $is_worktree, ?string $created_at, ?array $metadata ): array { $size_bytes = $this->estimate_path_size_bytes($path); $last_touched_at = $this->detect_worktree_last_touched_at($path, $metadata, $created_at); $age_days = $this->calculate_age_days($created_at); @@ -4183,7 +4156,8 @@ private function classify_artifact_only_dirty_worktree( string $repo, string $pa $path_part = trim(substr($line, 3)); if ( str_contains($path_part, ' -> ') ) { - $path_part = trim( (string) substr(strrchr($path_part, '>'), 1)); + $rename_target = strrchr($path_part, '>'); + $path_part = false === $rename_target ? '' : trim(substr($rename_target, 1)); } $path_part = trim($path_part, ' /'); if ( '' === $path_part ) { @@ -4253,8 +4227,7 @@ private function get_worktree_artifact_profile( string $repo, string $path ): ar * @param string $repo Repo name. * @param string $path Worktree path. */ - $filtered = apply_filters('datamachine_code_worktree_artifact_profile', $profile, $repo, $path); - return is_array($filtered) ? $filtered : $profile; + return apply_filters('datamachine_code_worktree_artifact_profile', $profile, $repo, $path); } /** @@ -4329,7 +4302,7 @@ private function sort_worktree_cleanup_rows( array $rows, string $sort ): array * @return array> */ private function summarize_top_worktree_rows( array $rows, string $field ): array { - $rows = array_values(array_filter($rows, fn( $row ) => isset($row[ $field ]) && null !== $row[ $field ] && (int) $row[ $field ] > 0)); + $rows = array_values(array_filter($rows, fn( $row ) => isset($row[ $field ]) && (int) $row[ $field ] > 0)); usort($rows, fn( $a, $b ) => (int) ( $b[ $field ] ?? 0 ) <=> (int) ( $a[ $field ] ?? 0 )); return array_map( @@ -4542,7 +4515,15 @@ private function remove_worktree_by_path( string $repo, string $branch, string $ // A worktree's .git is a FILE pointing at the primary's .git dir. // A directory .git means we're looking at a primary checkout — never // touch those. - $git_marker = rtrim($validation['real_path'], '/') . '/.git'; + $real_path = (string) ( $validation['real_path'] ?? '' ); + $git_marker = rtrim($real_path, '/') . '/.git'; + if ( '' === $real_path ) { + return new \WP_Error( + 'path_outside_workspace', + sprintf('Refusing to remove "%s": path did not resolve inside workspace.', $wt_path), + array( 'status' => 403 ) + ); + } if ( ! is_file($git_marker) ) { return new \WP_Error( 'not_a_worktree', @@ -4551,7 +4532,7 @@ private function remove_worktree_by_path( string $repo, string $branch, string $ ); } - $cmd = sprintf('worktree remove %s%s', $force ? '--force ' : '', escapeshellarg($validation['real_path'])); + $cmd = sprintf('worktree remove %s%s', $force ? '--force ' : '', escapeshellarg($real_path)); $result = $this->run_git($primary_path, $cmd); if ( is_wp_error($result) ) { @@ -4561,8 +4542,8 @@ private function remove_worktree_by_path( string $repo, string $branch, string $ // If the directory survived `git worktree remove` (can happen for // locked worktrees, or when the worktree was already detached), prune // the directory manually so cleanup is effective. - if ( is_dir($validation['real_path']) ) { - $escaped = escapeshellarg($validation['real_path']); + if ( is_dir($real_path) ) { + $escaped = escapeshellarg($real_path); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec exec(sprintf('rm -rf %s 2>&1', $escaped)); } @@ -4642,7 +4623,7 @@ private function classify_dirty_obsolete_on_default_branch( // the caller's `$fetched` tracker so this never double-fetches. if ( empty($fetched[ $repo ]) ) { $fetch = $this->run_git($primary_path, 'fetch --prune --quiet origin', self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( $this->is_git_timeout_error($fetch) ) { + if ( is_wp_error($fetch) && $this->is_git_timeout_error($fetch) ) { $fetch_timeouts[ $repo ] = $fetch; return null; } @@ -4662,7 +4643,7 @@ private function classify_dirty_obsolete_on_default_branch( sprintf('rev-parse --verify --quiet %s', escapeshellarg($default_ref . '^{commit}')), self::CLEANUP_GIT_PROBE_TIMEOUT ); - if ( $this->is_git_timeout_error($default_resolve) || is_wp_error($default_resolve) ) { + if ( is_wp_error($default_resolve) ) { return null; } @@ -4687,7 +4668,7 @@ private function classify_dirty_obsolete_on_default_branch( if ( ! is_array($signal) ) { return null; } - $signal_kind = (string) ( $signal['signal'] ?? '' ); + $signal_kind = (string) $signal['signal']; $merged_signals = array( 'upstream-gone', 'local-merged', 'pr-merged', 'cleanup_eligible', 'repaired_metadata' ); if ( ! in_array($signal_kind, $merged_signals, true) ) { return null; @@ -4717,7 +4698,7 @@ private function classify_dirty_obsolete_on_default_branch( 'diff --name-only HEAD', self::CLEANUP_GIT_PROBE_TIMEOUT ); - if ( $this->is_git_timeout_error($tracked) || is_wp_error($tracked) ) { + if ( is_wp_error($tracked) ) { return null; } @@ -4742,7 +4723,7 @@ private function classify_dirty_obsolete_on_default_branch( sprintf('cat-file -e %s', escapeshellarg($default_ref . ':' . $path)), self::CLEANUP_GIT_PROBE_TIMEOUT ); - if ( $this->is_git_timeout_error($probe) ) { + if ( is_wp_error($probe) && $this->is_git_timeout_error($probe) ) { return null; } if ( is_wp_error($probe) ) { @@ -4782,7 +4763,7 @@ private function classify_dirty_obsolete_on_default_branch( * @param string $repo Primary repo directory name. * @param string $branch Branch name. * @param bool $skip_github If true, skip GitHub API lookup. - * @param array $github_cache Run-local cache for GitHub repo lookups. + * @param array $github_cache Run-local cache for GitHub repo lookups. * @return array{signal: string, reason: string, pr_url?: string}|null */ private function detect_merge_signal( string $primary_path, string $repo, string $branch, bool $skip_github, array &$github_cache = array() ): ?array { @@ -4790,7 +4771,7 @@ private function detect_merge_signal( string $primary_path, string $repo, string $format = '%(upstream:track)'; $result = $this->run_git($primary_path, sprintf('for-each-ref --format=%s %s', escapeshellarg($format), escapeshellarg($ref)), self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( $this->is_git_timeout_error($result) ) { + if ( is_wp_error($result) && $this->is_git_timeout_error($result) ) { return array( 'signal' => 'probe-timeout', 'reason' => $result->get_error_message(), @@ -4878,7 +4859,7 @@ private function detect_local_merged_signal( string $primary_path, string $branc sprintf('rev-list --count %s..%s', escapeshellarg($default_ref), escapeshellarg($branch_ref)), self::CLEANUP_GIT_PROBE_TIMEOUT ); - if ( $this->is_git_timeout_error($result) ) { + if ( is_wp_error($result) && $this->is_git_timeout_error($result) ) { return array( 'signal' => 'probe-timeout', 'reason' => $result->get_error_message(), @@ -4907,11 +4888,8 @@ private function detect_local_merged_signal( string $primary_path, string $branc */ private function resolve_remote_default_ref( string $primary_path, int $timeout_seconds = 0 ): string|\WP_Error|null { $result = $this->run_git($primary_path, 'symbolic-ref --quiet refs/remotes/origin/HEAD', $timeout_seconds); - if ( $this->is_git_timeout_error($result) ) { - return $result; - } if ( is_wp_error($result) ) { - return null; + return $this->is_git_timeout_error($result) ? $result : null; } $ref = trim( (string) ( $result['output'] ?? '' )); @@ -4941,7 +4919,7 @@ private function resolve_github_slug( string $primary_path ): ?string { * * @param string $slug owner/repo. * @param string $branch Branch name. - * @param array $github_cache Run-local cache keyed by owner/repo. + * @param array $github_cache Run-local cache keyed by owner/repo. * @return array|null|\WP_Error PR data, null when no PR matched, or lookup failure. */ private function find_closed_pr_for_branch( string $slug, string $branch, array &$github_cache = array() ): array|\WP_Error|null { @@ -4966,7 +4944,7 @@ private function find_closed_pr_for_branch( string $slug, string $branch, array * * @param string $slug owner/repo. * @param string $branch Branch name. - * @param array $github_cache Run-local cache keyed by owner/repo and branch. + * @param array $github_cache Run-local cache keyed by owner/repo and branch. * @param bool $finalized_only If true, ignore open PRs. * @return array|null|\WP_Error PR data, null when no matching PR exists, or lookup failure. */ @@ -4982,7 +4960,7 @@ private function find_pr_for_branch_direct( string $slug, string $branch, array } $parts = explode('/', $slug, 2); - $owner = $parts[0] ?? ''; + $owner = $parts[0]; if ( '' === $owner || empty($parts[1]) ) { $github_cache[ $cache_key ] = null; return null; @@ -5051,7 +5029,7 @@ private function find_pr_for_branch_direct( string $slug, string $branch, array * Load and cache closed same-repo PRs for a GitHub repo. * * @param string $slug owner/repo. - * @param array $github_cache Run-local cache keyed by owner/repo. + * @param array $github_cache Run-local cache keyed by owner/repo. * @return array|null|\WP_Error Branch-name map, null when GitHub is unavailable, or lookup failure. */ private function get_cleanup_github_lookup( string $slug, array &$github_cache ): array|\WP_Error|null { @@ -5073,7 +5051,7 @@ private function get_cleanup_github_lookup( string $slug, array &$github_cache ) } $parts = explode('/', $slug, 2); - $owner = $parts[0] ?? ''; + $owner = $parts[0]; if ( '' === $owner || empty($parts[1]) ) { $github_cache[ $slug ] = null; return null; @@ -5161,7 +5139,7 @@ public function validate_containment( string $target, string $container ): array * @param string $url Git URL. * @return string|null Derived name or null. */ - private function derive_repo_name( string $url ): ?string { + protected function derive_repo_name( string $url ): ?string { // Handle https://github.com/org/repo.git and git@github.com:org/repo.git $name = basename($url); $name = preg_replace('/\.git$/', '', $name); @@ -5186,7 +5164,7 @@ private function sanitize_name( string $name ): string { * @param string $repo_path Resolved repository path. * @param string $git_args Git arguments (without leading "git"). * @param int $timeout_seconds Optional timeout in seconds. - * @return array + * @return array|\WP_Error */ private function run_git( string $repo_path, string $git_args, int $timeout_seconds = 0 ): array|\WP_Error { return GitRunner::run($repo_path, $git_args, $timeout_seconds); @@ -5196,18 +5174,14 @@ private function run_git( string $repo_path, string $git_args, int $timeout_seco * Determine whether a git result is a timeout error. * * @param mixed $result Git result. - * @return bool - */ + * @return bool + */ private function is_git_timeout_error( mixed $result ): bool { if ( ! is_wp_error($result) ) { return false; } - if ( method_exists($result, 'get_error_code') ) { - return 'git_command_timeout' === $result->get_error_code(); - } - - return isset($result->code) && 'git_command_timeout' === $result->code; + return 'git_command_timeout' === $result->get_error_code(); } /** @@ -5252,14 +5226,16 @@ private function count_unpushed_commits( string $wt_path, int $timeout_seconds = foreach ( $commands as $command ) { $result = $this->run_git($wt_path, $command, $timeout_seconds); - if ( $this->is_git_timeout_error($result) ) { - return $result; - } - if ( ! is_wp_error($result) ) { - $output = trim( (string) ( $result['output'] ?? '' )); - if ( '' !== $output && ctype_digit($output) ) { - return (int) $output; + if ( is_wp_error($result) ) { + if ( $this->is_git_timeout_error($result) ) { + return $result; } + continue; + } + + $output = trim( (string) ( $result['output'] ?? '' )); + if ( '' !== $output && ctype_digit($output) ) { + return (int) $output; } } @@ -5283,6 +5259,7 @@ private function ensure_group_permissions( string $path ): void { foreach ( $groups as $group ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec $exists = exec(sprintf('getent group %s >/dev/null 2>&1 && echo 1 || echo 0', escapeshellarg($group))); + $exists = false === $exists ? '' : $exists; if ( '1' === trim($exists) ) { $web_group = $group; break; @@ -5349,7 +5326,7 @@ private function protect_directory( string $path ): void { * @param string $path Absolute path of the entry that changed. * @return void */ - private function emit_workspace_changed( string $op, string $repo, string $name, string $path ): void { + protected function emit_workspace_changed( string $op, string $repo, string $name, string $path ): void { /** * Fires after a successful Workspace mutation lands on disk. *