Skip to content

Commit 397798c

Browse files
fix: reject dirty terminal worktree finalization (#943)
Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 701a94c commit 397798c

3 files changed

Lines changed: 61 additions & 2 deletions

File tree

inc/Workspace/WorkspaceMetadataReconciliation.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1675,15 +1675,25 @@ private function worktree_metadata_reconciliation_budget_arg( string $command ):
16751675
* @return int|\WP_Error Dirty file count, or WP_Error when git probe failed.
16761676
*/
16771677
private function probe_worktree_dirty_count( string $path, int $timeout_seconds = 0 ): int|\WP_Error {
1678+
$paths = $this->probe_worktree_dirty_paths($path, $timeout_seconds);
1679+
return is_wp_error($paths) ? $paths : count($paths);
1680+
}
1681+
1682+
/**
1683+
* Probe dirty paths with the porcelain contract shared by cleanup and lifecycle gates.
1684+
*
1685+
* @param string $path Worktree path.
1686+
* @return string[]|\WP_Error Dirty porcelain entries, or WP_Error when git probe failed.
1687+
*/
1688+
private function probe_worktree_dirty_paths( string $path, int $timeout_seconds = 0 ): array|\WP_Error {
16781689
if ( '' === $path || ! is_dir($path) ) {
16791690
return new \WP_Error('worktree_path_missing', 'worktree path is not a directory', array( 'status' => 400 ));
16801691
}
16811692
$result = $this->run_git($path, 'status --porcelain', $timeout_seconds);
16821693
if ( is_wp_error($result) ) {
16831694
return $result;
16841695
}
1685-
$lines = array_filter(array_map('trim', explode("\n", (string) ( $result['output'] ?? '' ))));
1686-
return count($lines);
1696+
return array_values(array_filter(array_map('trim', explode("\n", (string) ( $result['output'] ?? '' )))));
16871697
}
16881698

16891699
/**

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,26 @@ public function worktree_finalize( string $handle, string $state, ?string $pr =
552552
),
553553
$metadata
554554
);
555+
if ( WorktreeContextInjector::has_cleanup_signal($metadata) ) {
556+
$dirty_paths = $this->probe_worktree_dirty_paths($wt_path, self::CLEANUP_GIT_PROBE_TIMEOUT);
557+
if ( $dirty_paths instanceof \WP_Error ) {
558+
return $dirty_paths;
559+
}
560+
if ( array() !== $dirty_paths ) {
561+
return new \WP_Error(
562+
'worktree_dirty',
563+
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)),
564+
array(
565+
'status' => 409,
566+
'handle' => $parsed['dir_name'],
567+
'path' => $wt_path,
568+
'dirty_count' => count($dirty_paths),
569+
'dirty_paths' => array_slice($dirty_paths, 0, 25),
570+
'hint' => 'Run git status --short in the worktree, resolve every listed change, then retry finalization.',
571+
)
572+
);
573+
}
574+
}
555575
WorktreeContextInjector::store_lifecycle_metadata($parsed['dir_name'], $metadata);
556576

557577
$stored = WorktreeContextInjector::get_metadata($parsed['dir_name']) ?? array();

tests/worktree-add-lifecycle.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,35 @@ function create_primary_checkout( string $workspace_root ): void {
335335
assert_true('https://example.test/issues/environment' === ( $wpdb->rows['homeboy@audit-primitives-environment-tracker']['task_url'] ?? '' ), 'environment tracker metadata was not persisted');
336336
putenv('DATAMACHINE_TASK_URL');
337337

338+
$handle = 'homeboy@audit-primitives-20260616';
339+
340+
file_put_contents($result['path'] . '/untracked.txt', "untracked\n");
341+
$untracked_finalization = $workspace->worktree_finalize($handle, 'merged');
342+
assert_true(is_wp_error($untracked_finalization), 'untracked worktree finalization reported success');
343+
assert_true('worktree_dirty' === $untracked_finalization->get_error_code(), 'untracked finalization did not return worktree_dirty');
344+
assert_true(1 === ( $untracked_finalization->get_error_data()['dirty_count'] ?? 0 ), 'untracked finalization did not report the dirty count');
345+
assert_true(in_array('?? untracked.txt', $untracked_finalization->get_error_data()['dirty_paths'] ?? array(), true), 'untracked finalization did not report the dirty path');
346+
assert_true('active' === ( $wpdb->rows[$handle]['lifecycle_state'] ?? '' ), 'dirty terminal finalization mutated lifecycle metadata');
347+
$active_update = $workspace->worktree_finalize($handle, 'active');
348+
assert_true(! is_wp_error($active_update), 'dirty active lifecycle update must remain permissive');
349+
unlink($result['path'] . '/untracked.txt');
350+
351+
file_put_contents($result['path'] . '/README.md', "unstaged\n");
352+
$unstaged_finalization = $workspace->worktree_finalize($handle, 'closed');
353+
assert_true(is_wp_error($unstaged_finalization) && 'worktree_dirty' === $unstaged_finalization->get_error_code(), 'unstaged worktree finalization did not fail closed');
354+
run_command('git checkout -- README.md', $result['path']);
355+
356+
file_put_contents($result['path'] . '/README.md', "staged\n");
357+
run_command('git add README.md', $result['path']);
358+
$staged_finalization = $workspace->worktree_finalize($handle, 'cleanup_eligible');
359+
assert_true(is_wp_error($staged_finalization) && 'worktree_dirty' === $staged_finalization->get_error_code(), 'staged worktree finalization did not fail closed');
360+
assert_true('active' === ( $wpdb->rows[$handle]['lifecycle_state'] ?? '' ), 'staged terminal finalization mutated lifecycle metadata');
361+
run_command('git reset --hard HEAD', $result['path']);
362+
363+
$clean_finalization = $workspace->worktree_finalize($handle, 'merged');
364+
assert_true(! is_wp_error($clean_finalization), 'clean terminal worktree finalization failed');
365+
assert_true('cleanup_eligible' === ( $clean_finalization['lifecycle_state'] ?? '' ), 'clean terminal finalization did not expose cleanup eligibility');
366+
338367
$show = $workspace->show_repo('homeboy@audit-primitives-20260616');
339368
assert_true(! is_wp_error($show), 'persisted worktree is not visible to show_repo');
340369
assert_true(0 < $wpdb->get_row_calls, 'persisted worktree metadata did not use direct inventory lookup');

0 commit comments

Comments
 (0)