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 1da5258..8048197 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -552,6 +552,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 3b48bc6..3e994a2 100644 --- a/tests/worktree-add-lifecycle.php +++ b/tests/worktree-add-lifecycle.php @@ -335,6 +335,35 @@ function create_primary_checkout( string $workspace_root ): void { assert_true('https://example.test/issues/environment' === ( $wpdb->rows['homeboy@audit-primitives-environment-tracker']['task_url'] ?? '' ), 'environment tracker metadata was not persisted'); putenv('DATAMACHINE_TASK_URL'); + $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'); assert_true(0 < $wpdb->get_row_calls, 'persisted worktree metadata did not use direct inventory lookup');