From 2ed183bacf5e00197ffc52f477c3c8e904036552 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 09:55:04 -0400 Subject: [PATCH 1/3] fix: clear workspace phpstan baseline --- inc/Workspace/Workspace.php | 119 ++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 66 deletions(-) diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index 3ce213f..1db5726 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,6 +648,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $protected_branches = array( 'main', 'master', 'trunk', 'develop', 'HEAD' ); $candidates = array(); $skipped = array(); + /** @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); @@ -675,7 +661,9 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro // 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. + /** @var array $fetched */ $fetched = array(); + /** @var array $fetch_timeouts */ $fetch_timeouts = array(); foreach ( $worktrees as $wt ) { @@ -857,7 +845,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, @@ -927,14 +915,14 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro continue; } - if ( isset($fetch_timeouts[ $repo ]) ) { + if ( isset($fetch_timeouts[ $repo ]) && is_wp_error($fetch_timeouts[ $repo ]) ) { $skipped[] = $this->build_worktree_probe_failure_skip($handle, $repo, $branch, $wt_path, $created_at, $metadata, $disk_fields, $fetch_timeouts[ $repo ]); continue; } 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 +966,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 +982,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 +1199,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 +1306,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 +1356,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 +1366,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 +1455,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,7 +1479,7 @@ 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 ) { @@ -1605,8 +1585,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 +1634,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 +1761,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 +1854,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 +1946,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 +2038,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 +2227,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; @@ -3000,7 +2981,7 @@ private function build_clean_upstream_equivalence_evidence( string $primary_path $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; } @@ -3425,7 +3406,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 +3648,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, @@ -4026,7 +4007,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 +4040,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); @@ -4542,7 +4523,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 +4540,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 +4550,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 +4631,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; } @@ -4687,7 +4676,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; @@ -5161,7 +5150,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 +5175,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); @@ -5197,17 +5186,14 @@ private function run_git( string $repo_path, string $git_args, int $timeout_seco * * @param mixed $result Git result. * @return bool + * @phpstan-assert-if-true \WP_Error $result */ 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(); } /** @@ -5283,6 +5269,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 +5336,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. * From f4e8983ba5ffeda972e31040e95f63c818c1bbd3 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 10:04:24 -0400 Subject: [PATCH 2/3] fix: narrow workspace lint cleanup --- inc/Workspace/Workspace.php | 92 +++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index 1db5726..6139e09 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -646,23 +646,26 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro } $protected_branches = array( 'main', 'master', 'trunk', 'develop', 'HEAD' ); - $candidates = array(); - $skipped = array(); + $candidates = array(); + $skipped = array(); + /** @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; + $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. /** @var array $fetched */ - $fetched = array(); + $fetched = array(); + /** @var array $fetch_timeouts */ $fetch_timeouts = array(); @@ -915,14 +918,14 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro continue; } - if ( isset($fetch_timeouts[ $repo ]) && is_wp_error($fetch_timeouts[ $repo ]) ) { + if ( isset($fetch_timeouts[ $repo ]) ) { $skipped[] = $this->build_worktree_probe_failure_skip($handle, $repo, $branch, $wt_path, $created_at, $metadata, $disk_fields, $fetch_timeouts[ $repo ]); continue; } if ( empty($fetched[ $repo ]) ) { $fetch = $this->run_git($primary_path, 'fetch --prune --quiet origin', self::CLEANUP_GIT_PROBE_TIMEOUT); - if ( is_wp_error($fetch) && $this->is_git_timeout_error($fetch) ) { + if ( $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; @@ -1585,7 +1588,7 @@ 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 ) => (string) ( $row['handle'] ?? '' ), $removed))), + '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, ), @@ -2449,7 +2452,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'); } @@ -2696,7 +2699,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; @@ -2768,7 +2771,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'], @@ -2786,7 +2789,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'] ?? '' )); } @@ -2945,7 +2948,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, '-') ) { @@ -2964,7 +2967,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; } @@ -2974,7 +2977,7 @@ 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; } @@ -3052,7 +3055,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, '-') ) { @@ -3068,19 +3071,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(array_filter($paths))); $evidence['dirty_paths']['total'] = count($paths); $inspect_paths = array_slice($paths, 0, $path_inspection_limit); $evidence['dirty_paths']['inspected'] = count($inspect_paths); @@ -3131,7 +3134,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(); } @@ -3200,7 +3203,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', @@ -3217,7 +3220,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', @@ -3355,9 +3358,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'] ?? '' ); @@ -3838,7 +3841,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) ) { @@ -3856,7 +3859,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'; @@ -4164,7 +4167,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 ) { @@ -4234,8 +4238,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); } /** @@ -4310,7 +4313,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( @@ -4631,7 +4634,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 ( is_wp_error($fetch) && $this->is_git_timeout_error($fetch) ) { + if ( $this->is_git_timeout_error($fetch) ) { $fetch_timeouts[ $repo ] = $fetch; return null; } @@ -4651,7 +4654,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; } @@ -4706,7 +4709,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; } @@ -4731,7 +4734,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) ) { @@ -4971,7 +4974,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; @@ -5062,7 +5065,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; @@ -5185,9 +5188,8 @@ 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 - * @phpstan-assert-if-true \WP_Error $result - */ + * @return bool + */ private function is_git_timeout_error( mixed $result ): bool { if ( ! is_wp_error($result) ) { return false; From 79438b864a44ca1583ad09ed969eb542136c8064 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 7 Jun 2026 10:13:10 -0400 Subject: [PATCH 3/3] fix: satisfy workspace lint narrowings --- inc/Workspace/Workspace.php | 72 ++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 42 deletions(-) diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index 6139e09..64d2275 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -646,18 +646,18 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro } $protected_branches = array( 'main', 'master', 'trunk', 'develop', 'HEAD' ); - $candidates = array(); - $skipped = array(); + $candidates = array(); + $skipped = array(); /** @var array $github_cache */ $github_cache = array(); - $all_worktrees = array_values(array_filter( (array) $listing['worktrees'], fn( $wt ) => empty($wt['is_primary']))); + $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; + $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); @@ -918,14 +918,14 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro continue; } - if ( isset($fetch_timeouts[ $repo ]) ) { + if ( isset($fetch_timeouts[ $repo ]) ) { $skipped[] = $this->build_worktree_probe_failure_skip($handle, $repo, $branch, $wt_path, $created_at, $metadata, $disk_fields, $fetch_timeouts[ $repo ]); continue; } 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; @@ -1488,21 +1488,10 @@ public function worktree_bounded_cleanup_eligible_apply( array $opts = array() ) 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'] ?? '' ); @@ -2699,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) ) { + if ( ! is_wp_error($branch_probe) ) { $actual_branch = trim( (string) ( $branch_probe['output'] ?? '' ) ); if ( '' !== $actual_branch ) { $branch = $actual_branch; @@ -3083,7 +3072,7 @@ private function build_dirty_unpushed_upstream_equivalence_evidence( string $pri } } - $paths = array_values(array_unique(array_filter($paths))); + $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); @@ -4634,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; } @@ -4734,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 ( is_wp_error($probe) && $this->is_git_timeout_error($probe) ) { + if ( is_wp_error($probe) && $this->is_git_timeout_error($probe) ) { return null; } if ( is_wp_error($probe) ) { @@ -4774,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 { @@ -4782,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(), @@ -4870,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(), @@ -4899,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'] ?? '' )); @@ -4933,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 { @@ -4958,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. */ @@ -5043,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 { @@ -5240,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; } }