@@ -2343,53 +2343,24 @@ private function build_current_remote_tracking_clean_cleanup_evidence( string $h
23432343 }
23442344 }
23452345
2346- if ( in_array ($ branch , $ this ->protected_base_branch_names (), true ) ) {
2347- return new \WP_Error ('primary_protected_branch ' , 'refusing to auto-finalize a protected primary branch worktree ' );
2348- }
2349-
2350- $ validation = $ this ->validate_containment ($ path , $ this ->workspace_path );
2351- if ( ! $ validation ['valid ' ] ) {
2352- return new \WP_Error ('external_worktree ' , 'worktree path is outside the workspace root ' );
2353- }
2354-
2355- $ real_path = (string ) ( $ validation ['real_path ' ] ?? '' );
2356- if ( '' === $ real_path || ! is_dir ($ real_path ) ) {
2357- return new \WP_Error ('missing_worktree ' , 'worktree path no longer exists ' );
2358- }
2359-
2360- $ git_marker = rtrim ($ real_path , '/ ' ) . '/.git ' ;
2361- if ( is_dir ($ git_marker ) ) {
2362- return new \WP_Error ('primary_checkout ' , 'refusing to mark a primary checkout cleanup_eligible ' );
2363- }
2364- if ( ! is_file ($ git_marker ) ) {
2365- return new \WP_Error ('not_a_worktree ' , 'worktree marker missing ' );
2366- }
2367-
2368- $ current_branch = $ this ->resolve_worktree_branch_from_head_file ($ real_path );
2369- if ( $ branch !== $ current_branch ) {
2370- return new \WP_Error ('branch_identity_mismatch ' , 'worktree branch identity changed before apply ' );
2371- }
2372-
2373- $ dirty = $ this ->probe_worktree_dirty_count ($ real_path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
2374- if ( is_wp_error ($ dirty ) ) {
2375- return $ dirty ;
2376- }
2377- if ( 0 !== (int ) $ dirty ) {
2378- return new \WP_Error ('dirty_worktree ' , 'worktree is dirty ' );
2379- }
2380-
2381- $ unpushed = $ this ->count_unpushed_commits ($ real_path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
2382- if ( is_wp_error ($ unpushed ) ) {
2383- return $ unpushed ;
2384- }
2385- if ( 0 !== (int ) $ unpushed ) {
2386- return new \WP_Error ('unpushed_commits ' , 'worktree has unpushed commits ' );
2346+ $ facts = $ this ->validate_current_cleanup_worktree (
2347+ $ repo ,
2348+ $ path ,
2349+ $ branch ,
2350+ array (
2351+ 'require_clean ' => true ,
2352+ 'missing_primary_code ' => 'primary_missing ' ,
2353+ 'dirty_error_message ' => 'worktree is dirty ' ,
2354+ 'unpushed_error_message ' => 'worktree has unpushed commits ' ,
2355+ )
2356+ );
2357+ if ( is_wp_error ($ facts ) ) {
2358+ return $ facts ;
23872359 }
23882360
2389- $ primary_path = $ this ->get_primary_path ($ repo );
2390- if ( '' === $ primary_path || ! is_dir ($ primary_path . '/.git ' ) ) {
2391- return new \WP_Error ('primary_missing ' , 'primary checkout missing ' );
2392- }
2361+ $ dirty = (int ) $ facts ['dirty ' ];
2362+ $ unpushed = (int ) $ facts ['unpushed ' ];
2363+ $ primary_path = (string ) $ facts ['primary_path ' ];
23932364
23942365 $ remote_ref = 'refs/remotes/origin/ ' . $ branch ;
23952366 $ remote = $ this ->run_git ($ primary_path , sprintf ('rev-parse --verify --quiet %s ' , escapeshellarg ($ remote_ref )), self ::CLEANUP_GIT_PROBE_TIMEOUT );
@@ -2420,50 +2391,22 @@ private function build_current_remote_tracking_clean_cleanup_evidence( string $h
24202391 * @return array<string,mixed>|\WP_Error
24212392 */
24222393 private function build_current_effective_clean_cleanup_evidence ( string $ repo , string $ wt_path ): array |\WP_Error {
2423- $ validation = $ this ->validate_containment ( $ wt_path , $ this -> workspace_path );
2424- if ( ! $ validation [ ' valid ' ] ) {
2425- return new \ WP_Error ( ' external_worktree ' , ' worktree path is outside the workspace root ' ) ;
2394+ $ facts = $ this ->validate_current_cleanup_worktree ( $ repo , $ wt_path );
2395+ if ( is_wp_error ( $ facts ) ) {
2396+ return $ facts ;
24262397 }
24272398
2428- $ real_path = (string ) ( $ validation ['real_path ' ] ?? '' );
2429- if ( '' === $ real_path || ! is_dir ($ real_path ) ) {
2430- return new \WP_Error ('missing_worktree ' , 'worktree path no longer exists ' );
2431- }
2399+ $ real_path = (string ) $ facts ['real_path ' ];
2400+ $ primary_path = (string ) $ facts ['primary_path ' ];
2401+ $ dirty = (int ) $ facts ['dirty ' ];
2402+ $ unpushed = (int ) $ facts ['unpushed ' ];
2403+ $ branch = (string ) $ facts ['branch ' ];
24322404
2433- $ git_marker = rtrim ($ real_path , '/ ' ) . '/.git ' ;
2434- if ( is_dir ($ git_marker ) ) {
2435- return new \WP_Error ('primary_checkout ' , 'refusing to mark a primary checkout cleanup_eligible ' );
2436- }
2437- if ( ! is_file ($ git_marker ) ) {
2438- return new \WP_Error ('not_a_worktree ' , 'worktree marker missing ' );
2439- }
2440-
2441- $ primary_path = $ this ->get_primary_path ($ repo );
2442- if ( ! is_dir ($ primary_path . '/.git ' ) ) {
2443- return new \WP_Error ('missing_primary ' , 'primary checkout missing ' );
2444- }
2445-
2446- $ dirty = $ this ->probe_worktree_dirty_count ($ real_path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
2447- if ( is_wp_error ($ dirty ) ) {
2448- return $ dirty ;
2449- }
2450- $ unpushed = $ this ->count_unpushed_commits ($ real_path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
2451- if ( is_wp_error ($ unpushed ) ) {
2452- return $ unpushed ;
2453- }
24542405 $ default_ref = $ this ->resolve_remote_default_ref ($ primary_path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
24552406 if ( ! is_string ($ default_ref ) || '' === $ default_ref ) {
24562407 return new \WP_Error ('missing_default_ref ' , 'primary checkout default ref could not be resolved ' );
24572408 }
24582409
2459- $ branch = (string ) $ this ->resolve_worktree_branch_from_head_file ($ real_path );
2460- if ( '' === $ branch ) {
2461- return new \WP_Error ('missing_branch_identity ' , 'worktree branch identity could not be resolved ' );
2462- }
2463- if ( in_array ($ branch , $ this ->protected_base_branch_names (), true ) ) {
2464- return new \WP_Error ('primary_protected_branch ' , 'refusing to auto-finalize a protected primary branch worktree ' );
2465- }
2466-
24672410 $ upstream_equivalence = ( 0 === (int ) $ dirty && 0 === (int ) $ unpushed )
24682411 ? $ this ->build_clean_upstream_equivalence_evidence ($ primary_path , $ real_path , $ default_ref , $ branch )
24692412 : $ this ->build_dirty_unpushed_upstream_equivalence_evidence ($ primary_path , $ real_path , $ default_ref );
@@ -2501,53 +2444,24 @@ private function build_current_merged_to_default_cleanup_evidence( string $handl
25012444 }
25022445 }
25032446
2504- if ( in_array ($ branch , $ this ->protected_base_branch_names (), true ) ) {
2505- return new \WP_Error ('primary_protected_branch ' , 'refusing to auto-finalize a protected primary branch worktree ' );
2506- }
2507-
2508- $ validation = $ this ->validate_containment ($ path , $ this ->workspace_path );
2509- if ( ! $ validation ['valid ' ] ) {
2510- return new \WP_Error ('external_worktree ' , 'worktree path is outside the workspace root ' );
2511- }
2512-
2513- $ real_path = (string ) ( $ validation ['real_path ' ] ?? '' );
2514- if ( '' === $ real_path || ! is_dir ($ real_path ) ) {
2515- return new \WP_Error ('missing_worktree ' , 'worktree path no longer exists ' );
2516- }
2517-
2518- $ git_marker = rtrim ($ real_path , '/ ' ) . '/.git ' ;
2519- if ( is_dir ($ git_marker ) ) {
2520- return new \WP_Error ('primary_checkout ' , 'refusing to mark a primary checkout cleanup_eligible ' );
2521- }
2522- if ( ! is_file ($ git_marker ) ) {
2523- return new \WP_Error ('not_a_worktree ' , 'worktree marker missing ' );
2524- }
2525-
2526- $ current_branch = $ this ->resolve_worktree_branch_from_head_file ($ real_path );
2527- if ( $ branch !== $ current_branch ) {
2528- return new \WP_Error ('branch_identity_mismatch ' , 'worktree branch identity changed before apply ' );
2529- }
2530-
2531- $ primary_path = $ this ->get_primary_path ($ repo );
2532- if ( ! is_dir ($ primary_path . '/.git ' ) ) {
2533- return new \WP_Error ('missing_primary ' , 'primary checkout missing ' );
2534- }
2535-
2536- $ dirty = $ this ->probe_worktree_dirty_count ($ real_path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
2537- if ( is_wp_error ($ dirty ) ) {
2538- return $ dirty ;
2539- }
2540- if ( (int ) $ dirty > 0 ) {
2541- return new \WP_Error ('dirty_worktree ' , 'refusing to mark dirty worktree cleanup_eligible from merged-to-default evidence ' );
2447+ $ facts = $ this ->validate_current_cleanup_worktree (
2448+ $ repo ,
2449+ $ path ,
2450+ $ branch ,
2451+ array (
2452+ 'require_clean ' => true ,
2453+ 'dirty_error_message ' => 'refusing to mark dirty worktree cleanup_eligible from merged-to-default evidence ' ,
2454+ 'unpushed_error_message ' => 'refusing to mark worktree with unpushed commits cleanup_eligible from merged-to-default evidence ' ,
2455+ )
2456+ );
2457+ if ( is_wp_error ($ facts ) ) {
2458+ return $ facts ;
25422459 }
25432460
2544- $ unpushed = $ this ->count_unpushed_commits ($ real_path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
2545- if ( is_wp_error ($ unpushed ) ) {
2546- return $ unpushed ;
2547- }
2548- if ( (int ) $ unpushed > 0 ) {
2549- return new \WP_Error ('unpushed_commits ' , 'refusing to mark worktree with unpushed commits cleanup_eligible from merged-to-default evidence ' );
2550- }
2461+ $ real_path = (string ) $ facts ['real_path ' ];
2462+ $ primary_path = (string ) $ facts ['primary_path ' ];
2463+ $ dirty = (int ) $ facts ['dirty ' ];
2464+ $ unpushed = (int ) $ facts ['unpushed ' ];
25512465
25522466 $ default_ref = $ this ->resolve_remote_default_ref ($ primary_path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
25532467 if ( ! is_string ($ default_ref ) || '' === $ default_ref ) {
@@ -2597,6 +2511,83 @@ private function build_current_merged_to_default_cleanup_evidence( string $handl
25972511 );
25982512 }
25992513
2514+ /**
2515+ * Revalidate the current worktree state before writing cleanup metadata.
2516+ *
2517+ * @param string $repo Repository name.
2518+ * @param string $path Worktree path.
2519+ * @param string|null $expected_branch Expected branch, or null to resolve it from the worktree.
2520+ * @param array<string,mixed> $opts Validation options.
2521+ * @return array<string,mixed>|\WP_Error
2522+ */
2523+ private function validate_current_cleanup_worktree ( string $ repo , string $ path , ?string $ expected_branch = null , array $ opts = array () ): array |\WP_Error {
2524+ if ( null !== $ expected_branch && in_array ($ expected_branch , $ this ->protected_base_branch_names (), true ) ) {
2525+ return new \WP_Error ('primary_protected_branch ' , 'refusing to auto-finalize a protected primary branch worktree ' );
2526+ }
2527+
2528+ $ validation = $ this ->validate_containment ($ path , $ this ->workspace_path );
2529+ if ( ! $ validation ['valid ' ] ) {
2530+ return new \WP_Error ('external_worktree ' , 'worktree path is outside the workspace root ' );
2531+ }
2532+
2533+ $ real_path = (string ) ( $ validation ['real_path ' ] ?? '' );
2534+ if ( '' === $ real_path || ! is_dir ($ real_path ) ) {
2535+ return new \WP_Error ('missing_worktree ' , 'worktree path no longer exists ' );
2536+ }
2537+
2538+ $ git_marker = rtrim ($ real_path , '/ ' ) . '/.git ' ;
2539+ if ( is_dir ($ git_marker ) ) {
2540+ return new \WP_Error ('primary_checkout ' , 'refusing to mark a primary checkout cleanup_eligible ' );
2541+ }
2542+ if ( ! is_file ($ git_marker ) ) {
2543+ return new \WP_Error ('not_a_worktree ' , 'worktree marker missing ' );
2544+ }
2545+
2546+ $ current_branch = (string ) $ this ->resolve_worktree_branch_from_head_file ($ real_path );
2547+ if ( null !== $ expected_branch && $ expected_branch !== $ current_branch ) {
2548+ return new \WP_Error ('branch_identity_mismatch ' , 'worktree branch identity changed before apply ' );
2549+ }
2550+ if ( null === $ expected_branch && '' === $ current_branch ) {
2551+ return new \WP_Error ('missing_branch_identity ' , 'worktree branch identity could not be resolved ' );
2552+ }
2553+ if ( null === $ expected_branch && in_array ($ current_branch , $ this ->protected_base_branch_names (), true ) ) {
2554+ return new \WP_Error ('primary_protected_branch ' , 'refusing to auto-finalize a protected primary branch worktree ' );
2555+ }
2556+
2557+ $ primary_path = $ this ->get_primary_path ($ repo );
2558+ $ missing_primary_code = (string ) ( $ opts ['missing_primary_code ' ] ?? 'missing_primary ' );
2559+ if ( '' === $ primary_path || ! is_dir ($ primary_path . '/.git ' ) ) {
2560+ return new \WP_Error ($ missing_primary_code , 'primary checkout missing ' );
2561+ }
2562+
2563+ $ dirty = $ this ->probe_worktree_dirty_count ($ real_path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
2564+ if ( is_wp_error ($ dirty ) ) {
2565+ return $ dirty ;
2566+ }
2567+
2568+ $ unpushed = $ this ->count_unpushed_commits ($ real_path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
2569+ if ( is_wp_error ($ unpushed ) ) {
2570+ return $ unpushed ;
2571+ }
2572+
2573+ if ( ! empty ($ opts ['require_clean ' ]) ) {
2574+ if ( 0 !== (int ) $ dirty ) {
2575+ return new \WP_Error ('dirty_worktree ' , (string ) ( $ opts ['dirty_error_message ' ] ?? 'worktree is dirty ' ));
2576+ }
2577+ if ( 0 !== (int ) $ unpushed ) {
2578+ return new \WP_Error ('unpushed_commits ' , (string ) ( $ opts ['unpushed_error_message ' ] ?? 'worktree has unpushed commits ' ));
2579+ }
2580+ }
2581+
2582+ return array (
2583+ 'real_path ' => $ real_path ,
2584+ 'primary_path ' => $ primary_path ,
2585+ 'branch ' => null !== $ expected_branch ? $ expected_branch : $ current_branch ,
2586+ 'dirty ' => (int ) $ dirty ,
2587+ 'unpushed ' => (int ) $ unpushed ,
2588+ );
2589+ }
2590+
26002591 /**
26012592 * Build a skip row for finalized active/no-signal apply.
26022593 *
0 commit comments