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
8 changes: 7 additions & 1 deletion inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -2511,7 +2511,7 @@ public static function worktreeCleanup( array $input ): array|\WP_Error {
/**
* Reconcile unmanaged worktree lifecycle metadata.
*
* @param array $input Input parameters (dry_run, apply_plan).
* @param array $input Input parameters (dry_run, apply_plan, limit, offset).
* @return array
*/
public static function worktreeReconcileMetadata( array $input ): array|\WP_Error {
Expand All @@ -2523,6 +2523,12 @@ public static function worktreeReconcileMetadata( array $input ): array|\WP_Erro
if ( isset( $input['apply_plan'] ) && is_array( $input['apply_plan'] ) ) {
$opts['apply_plan'] = $input['apply_plan'];
}
if ( array_key_exists( 'limit', $input ) ) {
$opts['limit'] = (int) $input['limit'];
}
if ( array_key_exists( 'offset', $input ) ) {
$opts['offset'] = (int) $input['offset'];
}

return $workspace->worktree_reconcile_metadata( $opts );
}
Expand Down
39 changes: 33 additions & 6 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -1998,14 +1998,15 @@ private function renderGitOperationResult( string $operation, array $result, arr
* unpushed_commits, missing_metadata, external_worktree, age_filter, unknown_age.
*
* [--limit=<count>]
* : For `cleanup-artifacts --dry-run`, maximum worktrees to scan in this
* page. Defaults to 100 — keeps dry-run bounded on workspaces with
* hundreds of worktrees. Use 0 plus `--exhaustive` for a full audit.
* : For `cleanup-artifacts --dry-run` and `reconcile-metadata --dry-run`,
* maximum worktrees to scan in this page. Artifact cleanup defaults to
* 100; metadata reconciliation uses this only when pagination is requested.
* Use 0 plus `--exhaustive` for a full artifact audit.
*
* [--offset=<count>]
* : For `cleanup-artifacts --dry-run`, pagination offset (0-indexed) into
* the inventory ordering. Walk pages by passing the previous response's
* `pagination.next_offset`.
* : For `cleanup-artifacts --dry-run` and `reconcile-metadata --dry-run`,
* pagination offset (0-indexed) into the inventory ordering. Walk pages by
* passing the previous response's `pagination.next_offset`.
*
* [--exhaustive]
* : For `cleanup-artifacts --dry-run`, scan every worktree AND run per-worktree
Expand Down Expand Up @@ -2098,6 +2099,7 @@ private function renderGitOperationResult( string $operation, array $result, arr
*
* # Adopt/reconcile unmanaged worktree metadata before cleanup
* wp datamachine-code workspace worktree reconcile-metadata --dry-run --format=json
* wp datamachine-code workspace worktree reconcile-metadata --dry-run --limit=25 --offset=0 --format=json
* wp datamachine-code workspace worktree reconcile-metadata --apply --format=json
*
* # Ignore dirty working-tree safety (caution)
Expand Down Expand Up @@ -2287,6 +2289,12 @@ public function worktree( array $args, array $assoc_args ): void {
case 'reconcile-metadata':
$input['dry_run'] = ! empty( $assoc_args['dry-run'] );
$input['apply'] = ! empty( $assoc_args['apply'] );
if ( isset( $assoc_args['limit'] ) ) {
$input['limit'] = (int) $assoc_args['limit'];
}
if ( isset( $assoc_args['offset'] ) ) {
$input['offset'] = (int) $assoc_args['offset'];
}
if ( ! empty( $assoc_args['apply-plan'] ) ) {
$input['apply_plan'] = $this->read_worktree_json_plan( (string) $assoc_args['apply-plan'], 'metadata reconciliation' );
}
Expand Down Expand Up @@ -3045,6 +3053,18 @@ private function render_worktree_metadata_reconciliation_result( array $result,
$limit = $verbose ? PHP_INT_MAX : 10;

WP_CLI::log( 'Summary:' );
if ( isset( $result['pagination'] ) && is_array( $result['pagination'] ) ) {
$pagination = (array) $result['pagination'];
WP_CLI::log( sprintf(
'Page: offset=%d limit=%d scanned=%d total=%d next_offset=%s complete=%s',
(int) ( $pagination['offset'] ?? 0 ),
(int) ( $pagination['limit'] ?? 0 ),
(int) ( $pagination['scanned'] ?? 0 ),
(int) ( $pagination['total'] ?? 0 ),
null === ( $pagination['next_offset'] ?? null ) ? '-' : (string) $pagination['next_offset'],
! empty( $pagination['complete'] ) ? 'yes' : 'no'
) );
}
$summary_rows = array(
array(
'metric' => 'inspected',
Expand Down Expand Up @@ -3159,6 +3179,13 @@ private function render_worktree_metadata_reconciliation_result( array $result,

WP_CLI::log( '' );
if ( ! empty( $result['dry_run'] ) ) {
if ( isset( $result['pagination']['next_offset'] ) ) {
WP_CLI::log( sprintf(
'Next page: wp datamachine-code workspace worktree reconcile-metadata --dry-run --limit=%d --offset=%d --format=json',
(int) ( $result['pagination']['limit'] ?? 0 ),
(int) $result['pagination']['next_offset']
) );
}
WP_CLI::success( sprintf( '%d metadata reconciliation proposal(s). Review JSON output before applying; --apply-plan remains a low-level escape hatch until DB-backed cleanup runs land.', count( $proposals ) ) );
return;
}
Expand Down
87 changes: 77 additions & 10 deletions inc/Workspace/Workspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ class Workspace {
*/
public const ARTIFACT_CLEANUP_DEFAULT_LIMIT = 100;

/**
* Default metadata reconciliation dry-run page size when pagination is used.
*/
private const METADATA_RECONCILE_DEFAULT_LIMIT = 100;

/**
* @var string Resolved workspace path.
*/
Expand Down Expand Up @@ -3798,16 +3803,21 @@ private function build_worktree_probe_timeout_skip( string $handle, string $repo
/**
* Reconcile lifecycle metadata for unmanaged worktrees without removing anything.
*
* Dry-runs build a reviewed plan from the current full git worktree listing.
* Dry-runs build a reviewed plan from the current git worktree listing.
* Passing `limit` and/or `offset` bounds expensive per-worktree probes to
* only that page; omitting both preserves the historical full scan.
* Applying a plan revalidates handle/path/repo/branch before writing metadata.
*
* @param array $opts Options: dry_run bool, apply_plan array.
* @param array $opts Options: dry_run bool, apply_plan array, limit int, offset int.
* @return array<string,mixed>|\WP_Error
*/
public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_Error {
$dry_run = ! empty( $opts['dry_run'] );
$apply = ! empty( $opts['apply'] );
$apply_plan = isset( $opts['apply_plan'] ) && is_array( $opts['apply_plan'] ) ? $opts['apply_plan'] : null;
$paged = array_key_exists( 'limit', $opts ) || array_key_exists( 'offset', $opts );
$limit = $paged ? ( array_key_exists( 'limit', $opts ) ? (int) $opts['limit'] : self::METADATA_RECONCILE_DEFAULT_LIMIT ) : 0;
$offset = $paged ? max( 0, (int) ( $opts['offset'] ?? 0 ) ) : 0;

if ( null !== $apply_plan ) {
return $this->apply_worktree_metadata_reconciliation_plan( $apply_plan );
Expand All @@ -3816,22 +3826,35 @@ public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_
if ( ! $dry_run && ! $apply ) {
return new \WP_Error( 'metadata_reconcile_requires_review', 'Metadata reconciliation is dry-run-first. Pass --dry-run to review JSON output, or pass apply=true for DMC-owned lifecycle reconciliation.', array( 'status' => 400 ) );
}
if ( $paged && $limit <= 0 ) {
return new \WP_Error( 'invalid_metadata_reconcile_limit', 'Metadata reconciliation --limit must be greater than 0.', array( 'status' => 400 ) );
}

$listing = $this->worktree_list();
$listing = $this->worktree_list(
null,
null,
$paged ? array(
'include_status' => false,
'include_disk' => false,
) : array()
);
if ( is_wp_error( $listing ) ) {
return $listing;
}

$all_worktrees = array_values( array_filter(
(array) ( $listing['worktrees'] ?? array() ),
fn( $wt ) => empty( $wt['is_primary'] )
) );
$total_worktrees = count( $all_worktrees );
$page_worktrees = $paged ? array_slice( $all_worktrees, $offset, $limit ) : $all_worktrees;

$proposals = array();
$skipped = array();
$github_cache = array();
$fetched = array();

foreach ( (array) ( $listing['worktrees'] ?? array() ) as $wt ) {
if ( ! empty( $wt['is_primary'] ) ) {
continue;
}

foreach ( $page_worktrees as $wt ) {
$proposal = $this->build_worktree_metadata_reconciliation_row( $wt, $github_cache, $fetched );
if ( isset( $proposal['proposal'] ) ) {
$proposals[] = $proposal['proposal'];
Expand All @@ -3841,6 +3864,7 @@ public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_
}

$classified_skips = $this->classify_worktree_metadata_reconciliation_skips( $skipped );
$pagination = $paged ? $this->build_worktree_metadata_reconciliation_pagination( $total_worktrees, count( $page_worktrees ), $limit, $offset ) : null;

$plan = array(
'success' => true,
Expand All @@ -3853,8 +3877,16 @@ public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_
'skipped' => $skipped,
'still_unsafe' => $classified_skips['still_unsafe'],
'external_worktrees' => $classified_skips['external_worktrees'],
'summary' => $this->build_worktree_metadata_reconciliation_summary( count( (array) ( $listing['worktrees'] ?? array() ) ), $proposals, array(), $skipped ),
'summary' => $this->build_worktree_metadata_reconciliation_summary( $paged ? count( $page_worktrees ) : count( (array) ( $listing['worktrees'] ?? array() ) ), $proposals, array(), $skipped ),
);
if ( null !== $pagination ) {
$plan['pagination'] = $pagination;
$plan['evidence'] = array(
'scope' => 'paginated metadata reconciliation dry-run',
'note' => 'Only this page ran per-worktree dirty, unpushed, merge-signal, and GitHub probes. Run the next_offset page until complete for full inventory review.',
'fields_skipped_by_listing' => (array) ( $listing['fields_skipped'] ?? array() ),
);
}

if ( $apply ) {
return $this->apply_worktree_metadata_reconciliation_plan( $plan );
Expand Down Expand Up @@ -3928,7 +3960,22 @@ private function build_worktree_metadata_reconciliation_row( array $wt, array &$
);
}

$dirty = (int) ( $wt['dirty'] ?? 0 );
$dirty = $wt['dirty'] ?? null;
if ( null === $dirty ) {
$dirty = $this->probe_worktree_dirty_count( $path, self::CLEANUP_GIT_PROBE_TIMEOUT );
if ( is_wp_error( $dirty ) ) {
return array(
'skip' => array_merge(
$base_row,
array(
'reason_code' => 'probe_timeout',
'reason' => 'dirty-state probe failed - leaving lifecycle unchanged: ' . $dirty->get_error_message(),
)
),
);
}
}
$dirty = (int) $dirty;
$unpushed = $this->count_unpushed_commits( $path );
if ( is_wp_error( $unpushed ) ) {
return array(
Expand Down Expand Up @@ -4429,6 +4476,26 @@ private function build_reconcile_apply_skip( array $planned, ?array $current, st
);
}

/**
* Build metadata reconciliation pagination evidence.
*
* @return array<string,mixed>
*/
private function build_worktree_metadata_reconciliation_pagination( int $total, int $scanned, int $limit, int $offset ): array {
$next_offset = $offset + $scanned;
$complete = $next_offset >= $total;

return array(
'total' => $total,
'offset' => $offset,
'limit' => $limit,
'scanned' => $scanned,
'partial' => ! $complete,
'complete' => $complete,
'next_offset' => $complete ? null : $next_offset,
);
}

/**
* Probe the dirty file count for a single worktree path.
*
Expand Down
10 changes: 4 additions & 6 deletions inc/Workspace/WorktreeContextInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -998,18 +998,16 @@ private static function resolve_origin_task( array $args = array() ): ?array {
*/
public static function get_metadata( string $handle ): ?array {
$db_metadata = self::get_inventory_metadata( $handle );
if ( null !== $db_metadata ) {
return $db_metadata;
}

if ( ! function_exists( 'get_option' ) ) {
return null;
return $db_metadata;
}
$all = get_option( self::METADATA_OPTION, array() );
if ( ! is_array( $all ) || empty( $all[ $handle ] ) || ! is_array( $all[ $handle ] ) ) {
return null;
return $db_metadata;
}
return $all[ $handle ];

return is_array( $db_metadata ) ? array_merge( $db_metadata, $all[ $handle ] ) : $all[ $handle ];
}

/**
Expand Down
16 changes: 16 additions & 0 deletions tests/smoke-worktree-metadata-reconcile.php
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,16 @@ function size_format( $bytes ): string {
$stale_plan = $plan;
$unsafe_plan = $plan;

$page = $ws->worktree_reconcile_metadata( array( 'dry_run' => true, 'limit' => 2, 'offset' => 2 ) );
$assert( true, ! is_wp_error( $page ) && ( $page['success'] ?? false ), 'paginated dry-run succeeds' );
$assert( 2, (int) ( $page['pagination']['limit'] ?? 0 ), 'paginated dry-run reports limit' );
$assert( 2, (int) ( $page['pagination']['offset'] ?? 0 ), 'paginated dry-run reports offset' );
$assert( 2, (int) ( $page['pagination']['scanned'] ?? 0 ), 'paginated dry-run scans only requested page' );
$assert( true, (bool) ( $page['pagination']['partial'] ?? false ), 'paginated dry-run reports continuation' );
$assert( 4, (int) ( $page['pagination']['next_offset'] ?? 0 ), 'paginated dry-run advances next offset' );
$assert( 2, (int) ( $page['summary']['inspected'] ?? 0 ), 'paginated dry-run summary is page-scoped' );
$assert( true, isset( $page['evidence']['fields_skipped_by_listing'] ), 'paginated dry-run exposes listing evidence' );

$inventory_before = $ws->worktree_cleanup_merged( array( 'dry_run' => true, 'inventory_only' => true, 'skip_github' => true ) );
$assert( 4, (int) ( $inventory_before['summary']['skipped_by_reason']['needs_metadata_reconcile'] ?? 0 ), 'inventory cleanup sees missing metadata before apply' );

Expand All @@ -341,6 +351,12 @@ function size_format( $bytes ): string {
$stored_gone = \DataMachineCode\Workspace\WorktreeContextInjector::get_metadata( 'demo@upstream-gone' );
$assert( 'cleanup_eligible', $stored_gone['lifecycle_state'] ?? '', 'apply stores cleanup_eligible for upstream-gone worktree' );

$all_metadata = get_option( \DataMachineCode\Workspace\WorktreeContextInjector::METADATA_OPTION, array() );
$all_metadata['demo@unmanaged-missing']['durability_marker'] = 'option-repair-wins';
update_option( \DataMachineCode\Workspace\WorktreeContextInjector::METADATA_OPTION, $all_metadata, false );
$durable = \DataMachineCode\Workspace\WorktreeContextInjector::get_metadata( 'demo@unmanaged-missing' );
$assert( 'option-repair-wins', $durable['durability_marker'] ?? '', 'option-backed metadata repair remains visible over DB fallback' );

$auto_apply = $ws->worktree_reconcile_metadata( array( 'apply' => true ) );
$assert( true, ! is_wp_error( $auto_apply ) && ( $auto_apply['success'] ?? false ), 'DMC-owned reconciliation apply path runs without a manual plan' );

Expand Down