Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions inc/Workspace/WorkspaceMetadataReconciliation.php
Original file line number Diff line number Diff line change
Expand Up @@ -1675,15 +1675,25 @@ 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 ));
}
$result = $this->run_git($path, 'status --porcelain', $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'] ?? '' )))));
}

/**
Expand Down
20 changes: 20 additions & 0 deletions inc/Workspace/WorkspaceWorktreeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
29 changes: 29 additions & 0 deletions tests/worktree-add-lifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down