Skip to content

Commit b1aefd1

Browse files
authored
Merge pull request #405 from Extra-Chill/fix/worktree-base-branch-warnings
Warn on base branches checked out in worktrees
2 parents 4212b8b + 36657b1 commit b1aefd1

4 files changed

Lines changed: 92 additions & 8 deletions

File tree

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2515,12 +2515,19 @@ function ( $wt ) {
25152515
}
25162516
$this->format_items( $items, $fields, $assoc_args, 'handle' );
25172517
$duplicates = (array) ( $result['duplicates'] ?? array() );
2518+
$base_branch_worktrees = (array) ( $result['base_branch_worktrees'] ?? array() );
25182519
if ( ! empty( $duplicates ) && ! in_array( (string) ( $assoc_args['format'] ?? '' ), array( 'json', 'yaml' ), true ) ) {
25192520
WP_CLI::log( sprintf( 'Duplicate task ownership groups: %d', count( $duplicates ) ) );
25202521
foreach ( $duplicates as $group ) {
25212522
WP_CLI::log( sprintf( ' - [%s=%s] %s', (string) ( $group['kind'] ?? '' ), (string) ( $group['key'] ?? '' ), implode( ', ', (array) ( $group['handles'] ?? array() ) ) ) );
25222523
}
25232524
}
2525+
if ( ! empty( $base_branch_worktrees ) && ! in_array( (string) ( $assoc_args['format'] ?? '' ), array( 'json', 'yaml' ), true ) ) {
2526+
WP_CLI::warning( sprintf( 'Base branch checked out in %d non-primary worktree%s; gh pr merge --delete-branch can merge remotely but fail local cleanup.', count( $base_branch_worktrees ), 1 === count( $base_branch_worktrees ) ? '' : 's' ) );
2527+
foreach ( $base_branch_worktrees as $warning ) {
2528+
WP_CLI::log( sprintf( ' - %s (%s) at %s', (string) ( $warning['handle'] ?? '' ), (string) ( $warning['branch'] ?? '' ), (string) ( $warning['path'] ?? '' ) ) );
2529+
}
2530+
}
25242531
return;
25252532

25262533
case 'prune':

inc/Workspace/WorkspaceHygieneReport.php

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ public function workspace_hygiene_report( array $opts = array() ): array|\WP_Err
7575
$cleanup = null;
7676
}
7777
}
78+
$worktree_summary = $this->summarize_workspace_worktrees( $worktrees, $cleanup );
79+
7880
return array(
7981
'success' => true,
8082
'generated_at' => gmdate( 'c' ),
@@ -86,7 +88,7 @@ public function workspace_hygiene_report( array $opts = array() ): array|\WP_Err
8688
'freshness' => $this->worktree_inventory()->freshness(),
8789
'refresh' => $inventory_refresh,
8890
),
89-
'worktrees' => $this->summarize_workspace_worktrees( $worktrees, $cleanup ),
91+
'worktrees' => $worktree_summary,
9092
'worktree_status_mode' => $worktree_status_mode,
9193
'top_repos_by_worktrees' => $this->top_repos_by_worktree_count( $worktrees, 10 ),
9294
'top_repos_by_size' => $this->top_repos_by_size( (array) ( $size_report['entries'] ?? array() ), 10 ),
@@ -97,6 +99,7 @@ public function workspace_hygiene_report( array $opts = array() ): array|\WP_Err
9799
$include_sizes ? (string) ( $size_report['mode_note'] ?? '' ) : 'Size scan disabled by request.',
98100
$include_worktree_status ? 'Full worktree status enabled; this may run git status across every worktree.' : 'Worktree status uses cheap top-level inventory; pass --include-worktree-status for full git status.',
99101
$include_cleanup ? 'Cleanup summary uses inventory-only dry-run detection (--inventory-only --skip-github); no per-worktree git probes or GitHub API lookups are required.' : 'Cleanup dry-run disabled by request.',
102+
! empty( $worktree_summary['base_branch_worktree_count'] ) ? 'One or more non-primary worktrees have a base branch checked out; gh pr merge --delete-branch can merge remotely but fail local cleanup.' : '',
100103
) ) ),
101104
);
102105
}
@@ -342,14 +345,15 @@ private function build_workspace_inventory_rows(): array {
342345
$session_view = WorktreeContextInjector::summarize_session( is_array( $metadata ) ? $metadata : null );
343346
$task_view = is_array( $metadata ) && is_array( $metadata['origin_task'] ?? null ) ? $metadata['origin_task'] : null;
344347

345-
$rows[] = array(
348+
$row = array(
346349
'handle' => $parsed['dir_name'],
347350
'repo' => $parsed['repo'],
348351
'kind' => $kind,
349352
'is_worktree' => $is_worktree,
350353
'is_primary' => 'primary' === $kind,
351354
'external' => false,
352355
'branch_slug' => $parsed['branch_slug'],
356+
'branch' => is_array( $metadata ) && ! empty( $metadata['branch'] ) ? (string) $metadata['branch'] : (string) ( $parsed['branch_slug'] ?? '' ),
353357
'path' => $path,
354358
'dirty' => 0,
355359
'created_at' => is_array( $metadata ) ? ( $metadata['created_at'] ?? null ) : null,
@@ -366,6 +370,13 @@ private function build_workspace_inventory_rows(): array {
366370
'missing_metadata' => $is_worktree && ! is_array( $metadata ),
367371
'metadata' => $metadata,
368372
);
373+
374+
$base_branch_warning = $this->base_branch_worktree_warning( $row );
375+
if ( null !== $base_branch_warning ) {
376+
$row['base_branch_warning'] = $base_branch_warning;
377+
}
378+
379+
$rows[] = $row;
369380
}
370381

371382
return $rows;
@@ -523,6 +534,8 @@ private function summarize_workspace_worktrees( array $worktrees, ?array $cleanu
523534
'protected_dirty' => 0,
524535
'protected_unpushed' => 0,
525536
'missing_metadata' => 0,
537+
'base_branch_worktree_count' => 0,
538+
'base_branch_worktrees' => array(),
526539
'by_liveness' => array(
527540
WorktreeContextInjector::LIVENESS_LIVE => 0,
528541
WorktreeContextInjector::LIVENESS_STOPPED => 0,
@@ -553,6 +566,11 @@ private function summarize_workspace_worktrees( array $worktrees, ?array $cleanu
553566
++$summary['missing_metadata'];
554567
}
555568

569+
if ( ! empty( $row['base_branch_warning'] ) && is_array( $row['base_branch_warning'] ) ) {
570+
++$summary['base_branch_worktree_count'];
571+
$summary['base_branch_worktrees'][] = $row['base_branch_warning'];
572+
}
573+
556574
if ( ! empty( $row['is_worktree'] ) ) {
557575
$liveness = (string) ( $row['liveness'] ?? WorktreeContextInjector::LIVENESS_UNKNOWN );
558576
if ( ! isset( $summary['by_liveness'][ $liveness ] ) ) {

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,11 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra
807807
$disk
808808
);
809809

810+
$base_branch_warning = $this->base_branch_worktree_warning( $row );
811+
if ( null !== $base_branch_warning ) {
812+
$row['base_branch_warning'] = $base_branch_warning;
813+
}
814+
810815
if ( ! empty( $skipped_groups ) ) {
811816
$row['fields_skipped'] = $skipped_groups;
812817
}
@@ -815,16 +820,60 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra
815820
}
816821
}
817822

818-
$duplicates = WorktreeContextInjector::find_duplicate_task_ownership( $worktrees );
823+
$duplicates = WorktreeContextInjector::find_duplicate_task_ownership( $worktrees );
824+
$base_branch_worktrees = array_values( array_filter( array_map(
825+
fn( $row ) => $row['base_branch_warning'] ?? null,
826+
$worktrees
827+
) ) );
819828

820829
return array(
821-
'success' => true,
822-
'worktrees' => $worktrees,
823-
'duplicates' => $duplicates,
824-
'fields_skipped' => $skipped_groups,
830+
'success' => true,
831+
'worktrees' => $worktrees,
832+
'duplicates' => $duplicates,
833+
'base_branch_worktrees' => $base_branch_worktrees,
834+
'fields_skipped' => $skipped_groups,
835+
);
836+
}
837+
838+
/**
839+
* Return warning metadata when a non-primary worktree holds a base branch.
840+
*
841+
* GitHub CLI merge flows may try to check out or delete the PR base branch
842+
* during local cleanup. If another worktree has that branch checked out, the
843+
* remote merge can succeed while local cleanup reports a fatal git error.
844+
*
845+
* @param array<string,mixed> $row Worktree listing row.
846+
* @return array<string,string>|null
847+
*/
848+
private function base_branch_worktree_warning( array $row ): ?array {
849+
if ( empty( $row['is_worktree'] ) || ! empty( $row['is_primary'] ) || ! empty( $row['external'] ) ) {
850+
return null;
851+
}
852+
853+
$branch = (string) ( $row['branch'] ?? '' );
854+
if ( '' === $branch || ! in_array( $branch, $this->protected_base_branch_names(), true ) ) {
855+
return null;
856+
}
857+
858+
return array(
859+
'handle' => (string) ( $row['handle'] ?? '' ),
860+
'repo' => (string) ( $row['repo'] ?? '' ),
861+
'branch' => $branch,
862+
'path' => (string) ( $row['path'] ?? '' ),
863+
'reason_code' => 'base_branch_checked_out_in_worktree',
864+
'message' => sprintf( 'Worktree %s has base branch %s checked out; gh pr merge --delete-branch may merge remotely but fail local cleanup.', (string) ( $row['handle'] ?? '' ), $branch ),
825865
);
826866
}
827867

868+
/**
869+
* Branch names that should normally be held by primaries, not feature worktrees.
870+
*
871+
* @return array<int,string>
872+
*/
873+
private function protected_base_branch_names(): array {
874+
return array( 'main', 'master', 'trunk', 'develop' );
875+
}
876+
828877
/**
829878
* Refresh the DB-backed worktree inventory from the current filesystem/git view.
830879
*

tests/smoke-worktree-list-scaling.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ function size_format( $bytes ): string {
188188
$run( sprintf( 'git worktree add %s feat-one', escapeshellarg( $tmp . '/demo@feat-one' ) ), $primary );
189189
$run( sprintf( 'git worktree add %s feat-two', escapeshellarg( $tmp . '/demo@feat-two' ) ), $primary );
190190
$run( sprintf( 'git worktree add %s feat-three', escapeshellarg( $tmp . '/demo@feat-three' ) ), $primary );
191+
$run( 'git checkout -b local-maintenance', $primary );
192+
$run( sprintf( 'git worktree add %s main', escapeshellarg( $tmp . '/demo@main' ) ), $primary );
191193

192194
// Dirty one of them so the status probe has something to count.
193195
file_put_contents( $tmp . '/demo@feat-two/scratch.txt', 'dirty' );
@@ -232,7 +234,7 @@ function size_format( $bytes ): string {
232234
$assert( true, ! is_wp_error( $cheap_res ) && ( $cheap_res['success'] ?? false ), 'cheap listing returns success' );
233235
$assert( array( 'status', 'disk' ), $cheap_res['fields_skipped'] ?? null, 'cheap listing reports both probe groups skipped' );
234236
$cheap_rows = $cheap_res['worktrees'] ?? array();
235-
$assert( 4, count( $cheap_rows ), 'cheap listing still enumerates every worktree' );
237+
$assert( 5, count( $cheap_rows ), 'cheap listing still enumerates every worktree' );
236238
$cheap_two = array_values( array_filter( $cheap_rows, fn( $r ) => ( $r['handle'] ?? '' ) === 'demo@feat-two' ) )[0] ?? array();
237239
// dirty=null is the structural signal that the per-row git status probe was skipped.
238240
$assert( true, array_key_exists( 'dirty', $cheap_two ) && null === $cheap_two['dirty'], 'cheap listing leaves dirty as null (status probe skipped)' );
@@ -245,6 +247,14 @@ function size_format( $bytes ): string {
245247
$assert( 'demo', $cheap_two['repo'] ?? '', 'cheap row carries repo' );
246248
$assert( 'feat-two', $cheap_two['branch'] ?? '', 'cheap row carries branch' );
247249
$assert( true, ! empty( $cheap_two['head'] ), 'cheap row carries head' );
250+
$cheap_main = array_values( array_filter( $cheap_rows, fn( $r ) => ( $r['handle'] ?? '' ) === 'demo@main' ) )[0] ?? array();
251+
$assert( 'base_branch_checked_out_in_worktree', $cheap_main['base_branch_warning']['reason_code'] ?? '', 'cheap listing flags non-primary worktree checked out on main' );
252+
$assert( 1, count( $cheap_res['base_branch_worktrees'] ?? array() ), 'cheap listing exposes base branch worktree warnings at top level' );
253+
$hygiene_res = $ws->workspace_hygiene_report( array(
254+
'include_cleanup' => false,
255+
'include_sizes' => false,
256+
) );
257+
$assert( 1, (int) ( $hygiene_res['worktrees']['base_branch_worktree_count'] ?? 0 ), 'hygiene report summarizes base branch worktree warnings' );
248258

249259
// Lifecycle / metadata-driven stale_reason still works without probes.
250260
// feat-two has NO lifecycle metadata → missing_metadata still fires.

0 commit comments

Comments
 (0)