From 6bf98fbe7846bcbe45a041881db54125f5886a6a Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:11:10 -0400 Subject: [PATCH] fix: reject dirty terminal worktree finalization --- .../WorkspaceMetadataReconciliation.php | 14 ++++++++-- inc/Workspace/WorkspaceWorktreeLifecycle.php | 20 +++++++++++++ tests/worktree-add-lifecycle.php | 28 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/inc/Workspace/WorkspaceMetadataReconciliation.php b/inc/Workspace/WorkspaceMetadataReconciliation.php index d613e12..23aef42 100644 --- a/inc/Workspace/WorkspaceMetadataReconciliation.php +++ b/inc/Workspace/WorkspaceMetadataReconciliation.php @@ -1675,6 +1675,17 @@ private function worktree_metadata_reconciliation_budget_arg( string $command ): * @return int|\WP_Error Dirty file count, or WP_Error when git probe failed. */ private function probe_worktree_dirty_count( string $path, int $timeout_seconds = 0 ): int|\WP_Error { + $paths = $this->probe_worktree_dirty_paths($path, $timeout_seconds); + return is_wp_error($paths) ? $paths : count($paths); + } + + /** + * Probe dirty paths with the porcelain contract shared by cleanup and lifecycle gates. + * + * @param string $path Worktree path. + * @return string[]|\WP_Error Dirty porcelain entries, or WP_Error when git probe failed. + */ + private function probe_worktree_dirty_paths( string $path, int $timeout_seconds = 0 ): array|\WP_Error { if ( '' === $path || ! is_dir($path) ) { return new \WP_Error('worktree_path_missing', 'worktree path is not a directory', array( 'status' => 400 )); } @@ -1682,8 +1693,7 @@ private function probe_worktree_dirty_count( string $path, int $timeout_seconds if ( is_wp_error($result) ) { return $result; } - $lines = array_filter(array_map('trim', explode("\n", (string) ( $result['output'] ?? '' )))); - return count($lines); + return array_values(array_filter(array_map('trim', explode("\n", (string) ( $result['output'] ?? '' ))))); } /** diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 3eb4437..111886a 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -542,6 +542,26 @@ public function worktree_finalize( string $handle, string $state, ?string $pr = ), $metadata ); + if ( WorktreeContextInjector::has_cleanup_signal($metadata) ) { + $dirty_paths = $this->probe_worktree_dirty_paths($wt_path, self::CLEANUP_GIT_PROBE_TIMEOUT); + if ( $dirty_paths instanceof \WP_Error ) { + return $dirty_paths; + } + if ( array() !== $dirty_paths ) { + return new \WP_Error( + 'worktree_dirty', + sprintf('Refusing to mark worktree "%s" terminal because git status reports %d dirty path(s). Commit, stash, or discard the changes, then finalize again.', $parsed['dir_name'], count($dirty_paths)), + array( + 'status' => 409, + 'handle' => $parsed['dir_name'], + 'path' => $wt_path, + 'dirty_count' => count($dirty_paths), + 'dirty_paths' => array_slice($dirty_paths, 0, 25), + 'hint' => 'Run git status --short in the worktree, resolve every listed change, then retry finalization.', + ) + ); + } + } WorktreeContextInjector::store_lifecycle_metadata($parsed['dir_name'], $metadata); $stored = WorktreeContextInjector::get_metadata($parsed['dir_name']) ?? array(); diff --git a/tests/worktree-add-lifecycle.php b/tests/worktree-add-lifecycle.php index dcb5a02..b0953fc 100644 --- a/tests/worktree-add-lifecycle.php +++ b/tests/worktree-add-lifecycle.php @@ -294,6 +294,34 @@ function create_primary_checkout( string $workspace_root ): void { assert_true(is_dir($result['path']), 'successful worktree_add path is not accessible'); assert_true(isset($wpdb->rows['homeboy@audit-primitives-20260616']), 'successful worktree_add was not persisted'); assert_true('refused' !== ( $result['disk_budget']['status'] ?? '' ), 'normal worktree_add should pass the disk budget gate without hard refusal'); + $handle = 'homeboy@audit-primitives-20260616'; + + file_put_contents($result['path'] . '/untracked.txt', "untracked\n"); + $untracked_finalization = $workspace->worktree_finalize($handle, 'merged'); + assert_true(is_wp_error($untracked_finalization), 'untracked worktree finalization reported success'); + assert_true('worktree_dirty' === $untracked_finalization->get_error_code(), 'untracked finalization did not return worktree_dirty'); + assert_true(1 === ( $untracked_finalization->get_error_data()['dirty_count'] ?? 0 ), 'untracked finalization did not report the dirty count'); + assert_true(in_array('?? untracked.txt', $untracked_finalization->get_error_data()['dirty_paths'] ?? array(), true), 'untracked finalization did not report the dirty path'); + assert_true('active' === ( $wpdb->rows[$handle]['lifecycle_state'] ?? '' ), 'dirty terminal finalization mutated lifecycle metadata'); + $active_update = $workspace->worktree_finalize($handle, 'active'); + assert_true(! is_wp_error($active_update), 'dirty active lifecycle update must remain permissive'); + unlink($result['path'] . '/untracked.txt'); + + file_put_contents($result['path'] . '/README.md', "unstaged\n"); + $unstaged_finalization = $workspace->worktree_finalize($handle, 'closed'); + assert_true(is_wp_error($unstaged_finalization) && 'worktree_dirty' === $unstaged_finalization->get_error_code(), 'unstaged worktree finalization did not fail closed'); + run_command('git checkout -- README.md', $result['path']); + + file_put_contents($result['path'] . '/README.md', "staged\n"); + run_command('git add README.md', $result['path']); + $staged_finalization = $workspace->worktree_finalize($handle, 'cleanup_eligible'); + assert_true(is_wp_error($staged_finalization) && 'worktree_dirty' === $staged_finalization->get_error_code(), 'staged worktree finalization did not fail closed'); + assert_true('active' === ( $wpdb->rows[$handle]['lifecycle_state'] ?? '' ), 'staged terminal finalization mutated lifecycle metadata'); + run_command('git reset --hard HEAD', $result['path']); + + $clean_finalization = $workspace->worktree_finalize($handle, 'merged'); + assert_true(! is_wp_error($clean_finalization), 'clean terminal worktree finalization failed'); + assert_true('cleanup_eligible' === ( $clean_finalization['lifecycle_state'] ?? '' ), 'clean terminal finalization did not expose cleanup eligibility'); $show = $workspace->show_repo('homeboy@audit-primitives-20260616'); assert_true(! is_wp_error($show), 'persisted worktree is not visible to show_repo');