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
19 changes: 16 additions & 3 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4320,12 +4320,25 @@ function ( $wt ) {
return;

case 'prune':
$pruned = $result['pruned'] ?? array();
if ( empty($pruned) ) {
$pruned = (array) ( $result['pruned'] ?? array() );
$stale_inventory = (array) ( $result['stale_inventory'] ?? array() );
$stale_marker_blockers = (array) ( $result['stale_marker_blockers'] ?? array() );
if ( empty($pruned) && empty($stale_inventory) && empty($stale_marker_blockers) ) {
WP_CLI::log('Nothing to prune.');
return;
}
WP_CLI::success(sprintf('Pruned worktree registry across: %s', implode(', ', $pruned)));
if ( ! empty($pruned) ) {
WP_CLI::success(sprintf('Pruned worktree registry across: %s', implode(', ', $pruned)));
}
if ( ! empty($stale_inventory) ) {
WP_CLI::success(sprintf('Removed %d stale worktree inventory artifact%s.', count($stale_inventory), 1 === count($stale_inventory) ? '' : 's'));
}
if ( ! empty($stale_marker_blockers) ) {
WP_CLI::warning(sprintf('Found %d path-present stale worktree marker blocker%s; left checkout paths in place for review.', count($stale_marker_blockers), 1 === count($stale_marker_blockers) ? '' : 's'));
foreach ( $stale_marker_blockers as $blocker ) {
WP_CLI::log(sprintf(' - %s at %s', (string) ( $blocker['handle'] ?? '' ), (string) ( $blocker['path'] ?? '' )));
}
}
return;

case 'cleanup':
Expand Down
109 changes: 103 additions & 6 deletions inc/Workspace/WorkspaceWorktreeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -1111,12 +1111,14 @@ function () use ( $primary_path, $wt_path, $force, $wt_handle ) {
/**
* Prune stale worktree registry entries across all primaries.
*
* @return array{success: bool, pruned: array, skipped?: array, next_commands?: array, inventory?: array}|\WP_Error
* @return array{success: bool, pruned: array, skipped?: array, next_commands?: array, inventory?: array, stale_inventory?: array, stale_marker_blockers?: array}|\WP_Error
*/
public function worktree_prune(): array|\WP_Error {
$pruned = array();
$skipped = array();
$next_commands = array();
$stale_rows = array();
$marker_blocks = array();

if ( ! is_dir($this->workspace_path) ) {
return array(
Expand Down Expand Up @@ -1159,12 +1161,107 @@ public function worktree_prune(): array|\WP_Error {
return $refresh;
}

$inventory_diagnostics = $this->prune_stale_worktree_inventory_rows();
if ( $inventory_diagnostics instanceof \WP_Error ) {
return $inventory_diagnostics;
}

$stale_rows = (array) ( $inventory_diagnostics['stale_inventory'] ?? array() );
$marker_blocks = (array) ( $inventory_diagnostics['stale_marker_blockers'] ?? array() );
foreach ( (array) ( $inventory_diagnostics['next_commands'] ?? array() ) as $command ) {
$next_commands[] = (string) $command;
}

return array(
'success' => true,
'pruned' => $pruned,
'skipped' => $skipped,
'next_commands' => array_values(array_unique($next_commands)),
'inventory' => $refresh,
'stale_inventory' => $stale_rows,
'stale_marker_blockers' => $marker_blocks,
);
}

/**
* Repair safe stale inventory rows and report marker blockers that need review.
*
* `git worktree prune` only repairs Git's own metadata. DMC can also retain
* cleanup-eligible inventory rows for worktrees that no longer exist, and
* those rows can block bounded cleanup even after Git reports nothing to
* prune. Missing-path rows are safe to forget because no checkout remains on
* disk; path-present stale markers are reported but left in place.
*
* @return array<string,mixed>|\WP_Error
*/
private function prune_stale_worktree_inventory_rows(): array|\WP_Error {
$repository = $this->worktree_inventory();
$stale_inventory = array();
$stale_marker_blockers = array();
$next_commands = array();

foreach ( $repository->list() as $row ) {
$handle = (string) ( $row['handle'] ?? '' );
$repo = (string) ( $row['repo'] ?? '' );
$path = (string) ( $row['path'] ?? '' );
$parsed = '' !== $handle ? $this->parse_handle($handle) : array( 'is_worktree' => false );
if ( '' === $handle || empty($parsed['is_worktree']) ) {
continue;
}

if ( ! empty($row['missing_path']) && ( '' === $path || ! is_dir($path) ) ) {
if ( $repository->delete($handle) ) {
WorktreeContextInjector::forget_metadata($handle);
$stale_inventory[] = array(
'handle' => $handle,
'repo' => $repo,
'path' => $path,
'reason_code' => 'registry_artifact',
'reason' => 'inventory row pointed at a missing worktree path and was removed from DMC metadata',
);
}
continue;
}

$marker = rtrim($path, '/') . '/.git';
if ( ! is_file($marker) ) {
continue;
}

$contents = file_get_contents($marker); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a validated local .git marker, not a remote URL.
if ( false === $contents || ! preg_match('/^gitdir:\s*(.+)$/mi', $contents, $matches) ) {
continue;
}

$gitdir = trim($matches[1]);
if ( ! str_contains($gitdir, '/.git/worktrees/') && ! str_contains($gitdir, '\\.git\\worktrees\\') ) {
continue;
}

if ( file_exists($gitdir) ) {
continue;
}

$primary_path = '' !== $repo ? $this->get_primary_path($repo) : (string) ( $row['primary_path'] ?? '' );
$stale_marker_blockers[] = array(
'handle' => $handle,
'repo' => $repo,
'path' => $path,
'primary_path' => $primary_path,
'gitdir' => $gitdir,
'reason_code' => 'stale_worktree_marker',
'reason' => 'worktree path still exists, but its .git marker points at a missing primary .git/worktrees entry; leaving checkout in place for operator review',
'hint' => 'Inspect the path before removal. If it is only a stale artifact, use a reviewed cleanup-artifacts apply-plan or remove the worktree through DMC once metadata is repaired.',
);
if ( '' !== $primary_path ) {
$next_commands[] = sprintf('git -C %s worktree prune -v', escapeshellarg($primary_path));
}
}

return array(
'success' => true,
'pruned' => $pruned,
'skipped' => $skipped,
'next_commands' => array_values(array_unique($next_commands)),
'inventory' => $refresh,
'stale_inventory' => $stale_inventory,
'stale_marker_blockers' => $stale_marker_blockers,
'next_commands' => array_values(array_unique($next_commands)),
);
}

Expand Down
165 changes: 150 additions & 15 deletions tests/smoke-worktree-prune-no-git.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,113 @@ public static function get(): ?self
if (! defined('ABSPATH') ) {
define('ABSPATH', $tmp . '/wp/');
}
if (! defined('DATAMACHINE_WORKSPACE_PATH') ) {
define('DATAMACHINE_WORKSPACE_PATH', $tmp . '/workspace');
}
if (! defined('DATAMACHINE_WORKSPACE_PATH') ) {
define('DATAMACHINE_WORKSPACE_PATH', $tmp . '/workspace');
}
if (! defined('ARRAY_A') ) {
define('ARRAY_A', 'ARRAY_A');
}

if (! function_exists('current_time') ) {
function current_time( string $type, bool $gmt = false ): string // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
{
return '2026-06-14 00:00:00';
}
}
if (! function_exists('wp_json_encode') ) {
function wp_json_encode( mixed $value, int $flags = 0, int $depth = 512 ): string|false
{
return json_encode($value, $flags, $depth);
}
}

class DatamachineCodePruneFakeWpdb
{
public string $prefix = 'wp_';
public int $insert_id = 0;
public int $rows_affected = 0;
public string $last_error = '';

/**
* @var array<string,array<string,mixed>>
*/
public array $rows = array();
public array $lock_rows = array();

public function get_var( string $query ): string|int|null
{
if ( str_contains($query, 'SHOW TABLES LIKE') ) {
return $this->prefix . 'datamachine_code_locks';
}

if ( str_contains($query, 'COUNT(*)') ) {
return 0;
}

return null;
}

public function query( string $query ): int
{
$this->rows_affected = 0;
return 0;
}

public function replace( string $table, array $data ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
{
$this->rows[ (string) $data['handle'] ] = $data;
return 1;
}

public function insert( string $table, array $data, ?array $format = null ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
{
++$this->insert_id;
$data['id'] = $this->insert_id;
$this->lock_rows[ $this->insert_id ] = $data;
return 1;
}

public function delete( string $table, array $where ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
{
unset($this->rows[ (string) $where['handle'] ]);
return 1;
}

public function update( string $table, array $data, array $where, ?array $format = null, ?array $where_format = null ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
{
if ( str_contains($table, 'datamachine_code_locks') ) {
$id = (int) ( $where['id'] ?? 0 );
if ( isset($this->lock_rows[ $id ]) ) {
$this->lock_rows[ $id ] = array_merge($this->lock_rows[ $id ], $data);
return 1;
}
return 0;
}

$handle = (string) ( $where['handle'] ?? '' );
if ( ! isset($this->rows[ $handle ]) ) {
return 0;
}

$this->rows[ $handle ] = array_merge($this->rows[ $handle ], $data);
return 1;
}

public function get_results( string $sql, string $output ): array // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
{
$rows = array_values($this->rows);
usort($rows, fn( array $a, array $b ): int => strcmp((string) $a['handle'], (string) $b['handle']));
return $rows;
}

public function prepare( string $query, mixed ...$args ): string
{
foreach ( $args as $arg ) {
$query = preg_replace('/%s/', "'" . addslashes((string) $arg) . "'", $query, 1);
}
return $query;
}
}

if (! class_exists('WP_Error') ) {
class WP_Error
Expand Down Expand Up @@ -70,15 +174,43 @@ function is_wp_error( $value ): bool
echo " fail {$label}\n";
};

$old_path = getenv('PATH');
putenv('PATH=/nonexistent-dmc-no-git');

mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo/.git', 0777, true);

require __DIR__ . '/../inc/Support/RuntimeCapabilities.php';
require __DIR__ . '/../inc/Support/ProcessRunner.php';
require __DIR__ . '/../inc/Support/GitRunner.php';
require __DIR__ . '/../inc/Support/PathSecurity.php';
$old_path = getenv('PATH');
putenv('PATH=/nonexistent-dmc-no-git');

mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo/.git', 0777, true);
mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo@stale-marker', 0777, true);
file_put_contents(DATAMACHINE_WORKSPACE_PATH . '/demo@stale-marker/.git', 'gitdir: ' . DATAMACHINE_WORKSPACE_PATH . '/demo/.git/worktrees/demo@stale-marker' . "\n");

$GLOBALS['wpdb'] = new DatamachineCodePruneFakeWpdb();
$GLOBALS['wpdb']->rows['demo@missing-path'] = array(
'handle' => 'demo@missing-path',
'repo' => 'demo',
'branch' => 'missing-path',
'path' => DATAMACHINE_WORKSPACE_PATH . '/demo@missing-path',
'primary_path' => DATAMACHINE_WORKSPACE_PATH . '/demo',
'is_primary' => 0,
'is_worktree' => 1,
'missing_path' => 1,
'metadata' => array(),
'updated_at' => '2026-06-13 00:00:00',
);
$GLOBALS['wpdb']->rows['demo@stale-marker'] = array(
'handle' => 'demo@stale-marker',
'repo' => 'demo',
'branch' => 'stale-marker',
'path' => DATAMACHINE_WORKSPACE_PATH . '/demo@stale-marker',
'primary_path' => DATAMACHINE_WORKSPACE_PATH . '/demo',
'is_primary' => 0,
'is_worktree' => 1,
'missing_path' => 0,
'metadata' => array(),
'updated_at' => '2026-06-13 00:00:00',
);

require __DIR__ . '/../inc/Support/RuntimeCapabilities.php';
require __DIR__ . '/../inc/Support/ProcessRunner.php';
require __DIR__ . '/../inc/Support/GitRunner.php';
require __DIR__ . '/../inc/Support/PathSecurity.php';
require __DIR__ . '/../inc/Workspace/WorktreeContextInjector.php';
require __DIR__ . '/../inc/Workspace/WorkspaceMutationLock.php';
require __DIR__ . '/../inc/Workspace/Workspace.php';
Expand All @@ -89,9 +221,12 @@ function is_wp_error( $value ): bool
$result = $workspace->worktree_prune();

$assert('prune returns success instead of git-unavailable error', ! is_wp_error($result) && true === ( $result['success'] ?? false ));
$assert('prune records skipped primary', ! is_wp_error($result) && 'demo' === ( $result['skipped'][0]['repo'] ?? '' ));
$assert('prune returns host git command', ! is_wp_error($result) && str_contains((string) ( $result['next_commands'][0] ?? '' ), 'git -C'));
$assert('inventory refresh still runs', ! is_wp_error($result) && isset($result['inventory']['summary']));
$assert('prune records skipped primary', ! is_wp_error($result) && 'demo' === ( $result['skipped'][0]['repo'] ?? '' ));
$assert('prune returns host git command', ! is_wp_error($result) && str_contains((string) ( $result['next_commands'][0] ?? '' ), 'git -C'));
$assert('inventory refresh still runs', ! is_wp_error($result) && isset($result['inventory']['summary']));
$assert('prune removes missing-path inventory artifact', ! is_wp_error($result) && 'demo@missing-path' === ( $result['stale_inventory'][0]['handle'] ?? '' ) && ! isset($GLOBALS['wpdb']->rows['demo@missing-path']));
$assert('prune reports path-present stale marker blocker', ! is_wp_error($result) && 'stale_worktree_marker' === ( $result['stale_marker_blockers'][0]['reason_code'] ?? '' ));
$assert('prune leaves path-present stale marker row for review', isset($GLOBALS['wpdb']->rows['demo@stale-marker']));

putenv(false === $old_path ? 'PATH' : 'PATH=' . $old_path);

Expand Down
Loading