@@ -450,42 +450,35 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra
450450 $ mixed_dirty_row = array_values ($ mixed_dirty_skips )[0 ] ?? array ();
451451 $ assert ('dirty_worktree ' , $ mixed_dirty_row ['reason_code ' ] ?? '' , 'mixed source/artifact dirt stays protected as generic dirty worktree ' );
452452
453- // unmerged-feature should be skipped (no merge signal)
454- $ unmerged = array_filter ($ plan ['skipped ' ] ?? array (), fn ( $ s ) => ( $ s ['handle ' ] ?? '' ) === 'demo@unmerged-feature ' );
455- $ assert (1 , count ($ unmerged ), 'unmerged worktree skipped with exactly one entry ' );
456- $ unmerged_row = array_values ($ unmerged )[0 ] ?? array ();
457- $ assert ('no_merge_signal ' , $ unmerged_row ['reason_code ' ] ?? '' , 'unmerged skip exposes stable reason code ' );
453+ $ assert_contains ($ plan ['candidates ' ] ?? array (), 'demo@unmerged-feature ' , 'clean remote-backed unmerged worktree flagged as local-only cleanup candidate ' );
454+ $ unmerged_candidate = array_values (array_filter ($ plan ['candidates ' ] ?? array (), fn ( $ s ) => ( $ s ['handle ' ] ?? '' ) === 'demo@unmerged-feature ' ))[0 ] ?? array ();
455+ $ assert ('remote-tracking-clean ' , $ unmerged_candidate ['reason_code ' ] ?? '' , 'unmerged remote-backed candidate exposes stable reason code ' );
456+ $ assert ('remote-tracking-clean ' , $ unmerged_candidate ['signal ' ] ?? '' , 'unmerged remote-backed candidate records cleanup signal ' );
458457 $ detached = array_values (array_filter ($ plan ['skipped ' ] ?? array (), fn ( $ s ) => ( $ s ['handle ' ] ?? '' ) === 'demo@feature-detached-stored ' ))[0 ] ?? array ();
459458 $ assert ('detached_worktree ' , $ detached ['reason_code ' ] ?? '' , 'detached worktree with stored branch metadata is classified explicitly ' );
460459 $ assert ('feature/detached-stored ' , $ detached ['branch ' ] ?? '' , 'detached worktree recovers branch from stored metadata ' );
461460 $ assert (array ( 'branch ' ), $ detached ['hydrated_fields ' ] ?? array (), 'detached worktree reports hydrated branch field ' );
462461 $ protected = array_values (array_filter ($ plan ['skipped ' ] ?? array (), fn ( $ s ) => ( $ s ['handle ' ] ?? '' ) === 'demo@develop ' ))[0 ] ?? array ();
463462 $ assert ('protected_base_branch_worktree ' , $ protected ['reason_code ' ] ?? '' , 'protected branch worktree gets actionable protected-base diagnostic ' );
464- $ assert ('remote_branch_still_exists ' , $ unmerged_row ['merge_signal_evidence ' ]['classification ' ] ?? '' , 'unmerged skip distinguishes existing remote branch ' );
465- $ assert ('still_exists ' , $ unmerged_row ['merge_signal_evidence ' ]['remote_branch ' ] ?? '' , 'unmerged skip records remote branch evidence ' );
466- $ assert (true , str_contains ($ unmerged_row ['active_review_command ' ] ?? '' , 'active-no-signal-report ' ), 'unmerged skip includes active/no-signal review command ' );
467- $ assert (true , str_contains ($ unmerged_row ['active_review_commands ' ]['finalized_apply_dry_run ' ] ?? '' , 'active-no-signal-finalized-apply --dry-run ' ), 'unmerged skip includes finalized PR dry-run apply command ' );
463+ $ assert (true , str_contains ($ unmerged_candidate ['reason ' ] ?? '' , 'origin ' ), 'unmerged remote-backed candidate explains remote preservation ' );
468464
469465 $ external_rows = array_values (array_filter ($ plan ['skipped ' ] ?? array (), fn ( $ s ) => ( $ s ['reason_code ' ] ?? '' ) === 'external_worktree ' ));
470466 $ external_row = $ external_rows [0 ] ?? array ();
471467 $ assert ('external_worktree ' , $ external_row ['reason_code ' ] ?? '' , 'external worktree exposes stable reason code ' );
472468 $ assert (true , str_contains ($ external_row ['hint ' ] ?? '' , 'outside the DMC workspace ' ), 'external worktree includes remediation hint ' );
473469
474- $ assert (4 , (int ) ( $ plan ['summary ' ]['would_remove ' ] ?? 0 ), 'summary counts cleanup candidates ' );
470+ $ assert (6 , (int ) ( $ plan ['summary ' ]['would_remove ' ] ?? 0 ), 'summary counts cleanup candidates ' );
475471 $ assert (2 , (int ) ( $ plan ['summary ' ]['skipped_by_reason ' ]['dirty_worktree ' ] ?? 0 ), 'summary counts dirty skips by reason ' );
476472 $ assert (1 , (int ) ( $ plan ['summary ' ]['skipped_by_reason ' ]['artifact_only_dirty_worktree ' ] ?? 0 ), 'summary counts artifact-only dirty skips separately ' );
477473 $ assert (1 , (int ) ( $ plan ['summary ' ]['cleanup_buckets ' ]['artifact_only_dirty_worktree ' ] ?? 0 ), 'cleanup buckets expose artifact-only dirty count ' );
478474 $ assert (true , in_array ('artifact_only_dirty_worktree ' , array_column ($ plan ['summary ' ]['skipped_next_commands ' ] ?? array (), 'reason_code ' ), true ), 'summary exposes artifact-only dirty next command ' );
479- $ assert (true , isset ($ plan ['summary ' ]['skipped_by_reason ' ]['no_merge_signal ' ]), 'summary includes no_merge_signal bucket ' );
475+ $ assert (false , isset ($ plan ['summary ' ]['skipped_by_reason ' ]['no_merge_signal ' ]), 'summary has no no_merge_signal bucket when clean remote-backed worktrees are removable ' );
480476 $ assert (4096 , (int ) ( $ plan ['summary ' ]['total_size_bytes ' ] ?? -1 ), 'summary counts artifact-only dirty worktree size bytes ' );
481477 $ assert (4096 , (int ) ( $ plan ['summary ' ]['artifact_size_bytes ' ] ?? -1 ), 'summary counts artifact-only dirty artifact size bytes ' );
482478 $ top_by_size = (array ) ( $ plan ['summary ' ]['top_by_size ' ] ?? array () );
483479 $ assert ('demo@artifact-only-dirty ' , $ top_by_size [0 ]['handle ' ] ?? '' , 'summary top-by-size includes artifact-only dirty worktree ' );
484480 $ next_commands = (array ) ( $ plan ['summary ' ]['skipped_next_commands ' ] ?? array () );
485- $ assert (true , in_array ('no_merge_signal ' , array_column ($ next_commands , 'reason_code ' ), true ), 'summary includes no_merge_signal next command ' );
486- $ no_merge_commands = array_values (array_filter ($ next_commands , fn ( $ row ) => ( $ row ['reason_code ' ] ?? '' ) === 'no_merge_signal ' ))[0 ] ?? array ();
487- $ assert (true , str_contains ($ no_merge_commands ['command ' ] ?? '' , 'active-no-signal-report ' ), 'no_merge_signal next command routes to active/no-signal evidence report ' );
488- $ assert (true , str_contains ($ no_merge_commands ['commands ' ]['equivalent_clean_apply_dry_run ' ] ?? '' , 'active-no-signal-equivalent-clean-apply --dry-run ' ), 'no_merge_signal next commands include equivalent-clean dry-run apply ' );
481+ $ assert (false , in_array ('no_merge_signal ' , array_column ($ next_commands , 'reason_code ' ), true ), 'summary omits no_merge_signal next command when no rows need active/no-signal review ' );
489482 $ assert (true , array_key_exists ('total_size_bytes ' , $ plan ['summary ' ] ?? array ()), 'summary exposes total worktree size bytes field ' );
490483 $ assert (true , array_key_exists ('artifact_size_bytes ' , $ plan ['summary ' ] ?? array ()), 'summary exposes artifact size bytes field ' );
491484 $ assert (true , array_key_exists ('top_by_size ' , $ plan ['summary ' ] ?? array ()), 'summary exposes top worktrees by size field ' );
@@ -670,10 +663,11 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra
670663 )
671664 );
672665 $ assert (true , ! is_wp_error ($ age_plan ) && ( $ age_plan ['success ' ] ?? false ), 'age-filtered dry_run returns success ' );
673- $ assert (1 , (int ) ( $ age_plan ['summary ' ]['would_remove ' ] ?? 0 ), 'older_than keeps only old cleanup candidate ' );
666+ $ assert (2 , (int ) ( $ age_plan ['summary ' ]['would_remove ' ] ?? 0 ), 'older_than keeps old merged and remote-backed cleanup candidates ' );
674667 $ assert (1 , (int ) ( $ age_plan ['summary ' ]['age_filter ' ]['excluded ' ] ?? 0 ), 'age filter summary counts newer candidate exclusion ' );
675- $ assert (2 , (int ) ( $ age_plan ['summary ' ]['age_filter ' ]['unknown_age ' ] ?? 0 ), 'age filter summary counts unknown-age candidate exclusions ' );
668+ $ assert (3 , (int ) ( $ age_plan ['summary ' ]['age_filter ' ]['unknown_age ' ] ?? 0 ), 'age filter summary counts unknown-age candidate exclusions ' );
676669 $ assert_contains ($ age_plan ['candidates ' ] ?? array (), 'demo@merged-autodelete ' , 'older_than keeps old merged worktree ' );
670+ $ assert_contains ($ age_plan ['candidates ' ] ?? array (), 'demo@unmerged-feature ' , 'older_than keeps old clean remote-backed worktree ' );
677671 $ recent_age_rows = array_values (array_filter ($ age_plan ['skipped ' ] ?? array (), fn ( $ s ) => ( $ s ['handle ' ] ?? '' ) === 'demo@merged-recent ' ));
678672 $ assert ('age_filter ' , $ recent_age_rows [0 ]['reason_code ' ] ?? '' , 'newer merged worktree is skipped by age_filter ' );
679673 $ assert ('excluded ' , $ recent_age_rows [0 ]['age_filter ' ]['decision ' ] ?? '' , 'age-filter skip row exposes excluded decision ' );
@@ -820,8 +814,8 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra
820814 // Primary survives.
821815 $ assert (true , is_dir ($ primary . '/.git ' ), 'primary .git survives cleanup ' );
822816
823- // Protected branches: unmerged/dirty worktrees survive.
824- $ assert (true , is_dir ($ tmp . '/demo@unmerged-feature ' ), 'unmerged worktree survives cleanup ' );
817+ // Dirty/external worktrees survive; clean remote-backed worktrees are local-only cleanup candidates .
818+ $ assert (false , is_dir ($ tmp . '/demo@unmerged-feature ' ), 'clean remote-backed unmerged worktree is removed locally ' );
825819 $ assert (true , is_dir ($ tmp . '/demo@dirty-branch ' ), 'dirty worktree survives cleanup ' );
826820 $ assert (true , is_dir ($ tmp . '/demo@merged-stale-plan ' ), 'dirty stale-plan worktree survives cleanup ' );
827821 $ assert (true , is_dir ($ tmp . '-external/demo-external ' ), 'external worktree survives cleanup ' );
0 commit comments