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
42 changes: 37 additions & 5 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ private function registerAbilities(): void {
'datamachine-code/workspace-list',
array(
'label' => 'List Workspace Repos',
'description' => 'List repositories in the agent workspace.',
'description' => 'List repositories in the agent workspace. Primary rows include local-ref freshness metadata; refresh stale primaries before using them for verification.',
'category' => 'datamachine-code-workspace',
'input_schema' => array(
'type' => 'object',
Expand Down Expand Up @@ -134,6 +134,7 @@ private function registerAbilities(): void {
// Local-only repos have no remote; detached HEAD has no branch.
'remote' => array( 'type' => array( 'string', 'null' ) ),
'branch' => array( 'type' => array( 'string', 'null' ) ),
'primary_freshness' => self::primaryFreshnessSchema(),
),
),
),
Expand Down Expand Up @@ -178,7 +179,7 @@ private function registerAbilities(): void {
'datamachine-code/workspace-show',
array(
'label' => 'Show Workspace Repo',
'description' => 'Show detailed info about a workspace repository (branch, remote, latest commit, dirty status).',
'description' => 'Show detailed info about a workspace repository (branch, remote, latest commit, dirty status, and primary freshness).',
'category' => 'datamachine-code-workspace',
'input_schema' => array(
'type' => 'object',
Expand All @@ -204,6 +205,7 @@ private function registerAbilities(): void {
'remote' => array( 'type' => array( 'string', 'null' ) ),
'commit' => array( 'type' => array( 'string', 'null' ) ),
'dirty' => array( 'type' => 'integer' ),
'primary_freshness' => self::primaryFreshnessSchema(),
),
),
'execute_callback' => array( self::class, 'showRepo' ),
Expand Down Expand Up @@ -383,7 +385,7 @@ private function registerAbilities(): void {
'datamachine-code/workspace-clone',
array(
'label' => 'Clone Workspace Repo',
'description' => 'Clone a git repository into the workspace as a primary checkout. Worktrees are created separately via `workspace-worktree-add`.',
'description' => 'Clone a git repository into the workspace as a primary checkout only when no primary for that remote already exists. If the remote exists, refresh/reuse that primary and create a worktree via `workspace-worktree-add`.',
'category' => 'datamachine-code-workspace',
'input_schema' => array(
'type' => 'object',
Expand All @@ -404,6 +406,10 @@ private function registerAbilities(): void {
'type' => 'string',
'description' => 'Optional environment variable name containing a bearer token for HTTPS clone authentication.',
),
'allow_duplicate_remote' => array(
'type' => 'boolean',
'description' => 'Explicitly allow cloning a second top-level primary for a remote already present in the workspace. Default false; use only for deliberate release/proof checkouts.',
),
),
'required' => array( 'url' ),
),
Expand Down Expand Up @@ -2575,6 +2581,31 @@ private static function remote_workspace_guidance( string $operation, array $res
return array();
}

/**
* Shared schema for primary checkout freshness metadata.
*
* @return array<string,mixed>
*/
private static function primaryFreshnessSchema(): array {
return array(
'type' => array( 'object', 'null' ),
'properties' => array(
'status' => array(
'type' => 'string',
'description' => 'One of current, stale, diverged, ahead, detached, no_upstream, or unknown.',
),
'branch' => array( 'type' => array( 'string', 'null' ) ),
'upstream' => array( 'type' => array( 'string', 'null' ) ),
'behind' => array( 'type' => array( 'integer', 'null' ) ),
'ahead' => array( 'type' => array( 'integer', 'null' ) ),
'detached' => array( 'type' => 'boolean' ),
'local_refs' => array( 'type' => 'boolean' ),
'fetch_checked' => array( 'type' => 'boolean' ),
'suggested_command' => array( 'type' => array( 'string', 'null' ) ),
),
);
}

/**
* Clone a git repository into the workspace.
*
Expand All @@ -2595,8 +2626,9 @@ public static function cloneRepo( array $input ): array|\WP_Error {
$input['url'] ?? '',
$input['name'] ?? null,
array(
'full' => (bool) ( $input['full'] ?? false ),
'auth_token_env' => $input['auth_token_env'] ?? '',
'full' => (bool) ( $input['full'] ?? false ),
'auth_token_env' => $input['auth_token_env'] ?? '',
'allow_duplicate_remote' => ! empty($input['allow_duplicate_remote']),
)
);
}
Expand Down
35 changes: 26 additions & 9 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,22 +166,25 @@ public function list_repos( array $args, array $assoc_args ): void {

$items = array_map(
function ( $repo ) {
$freshness = is_array($repo['primary_freshness'] ?? null) ? $repo['primary_freshness'] : null;
return array(
'name' => $repo['name'],
'kind' => ! empty($repo['is_worktree']) ? 'worktree' : 'primary',
'repo' => $repo['repo'] ?? $repo['name'],
'branch' => $repo['branch'] ?? '-',
'remote' => $repo['remote'] ?? '-',
'git' => $repo['git'] ? 'yes' : 'no',
'path' => $repo['path'],
'name' => $repo['name'],
'kind' => ! empty($repo['is_worktree']) ? 'worktree' : 'primary',
'repo' => $repo['repo'] ?? $repo['name'],
'branch' => $repo['branch'] ?? '-',
'freshness' => is_array($freshness) ? (string) ( $freshness['status'] ?? '-' ) : '-',
'behind' => is_array($freshness) && null !== ( $freshness['behind'] ?? null ) ? (string) $freshness['behind'] : '-',
'remote' => $repo['remote'] ?? '-',
'git' => $repo['git'] ? 'yes' : 'no',
'path' => $repo['path'],
);
},
$result['repos']
);

$this->format_items(
$items,
array( 'name', 'kind', 'repo', 'branch', 'remote', 'git' ),
array( 'name', 'kind', 'repo', 'branch', 'freshness', 'behind', 'remote', 'git' ),
$assoc_args,
'name'
);
Expand All @@ -201,6 +204,9 @@ function ( $repo ) {
* [--full]
* : Disable the default blobless partial clone (`--filter=blob:none`). Useful for servers that do not support partial clone or when all blobs are needed immediately.
*
* [--allow-duplicate-remote]
* : Explicitly allow cloning a second top-level primary for a remote already present in the workspace. Use only for deliberate release/proof checkouts.
*
* ## EXAMPLES
*
* # Clone a repo
Expand All @@ -222,7 +228,8 @@ public function clone_repo( array $args, array $assoc_args ): void {
$args[0],
$assoc_args['name'] ?? null,
array(
'full' => isset($assoc_args['full']),
'full' => isset($assoc_args['full']),
'allow_duplicate_remote' => isset($assoc_args['allow-duplicate-remote']),
'progress_callback' => static function ( array $event ): void {
$elapsed = number_format( (float) ( $event['elapsed'] ?? 0 ), 1);
WP_CLI::log(sprintf('[clone %ss] %s', $elapsed, (string) ( $event['message'] ?? '' )));
Expand Down Expand Up @@ -1092,6 +1099,16 @@ public function show( array $args, array $assoc_args ): void {
WP_CLI::log(sprintf('Branch: %s', $result['branch'] ?? '-'));
WP_CLI::log(sprintf('Remote: %s', $result['remote'] ?? '-'));
WP_CLI::log(sprintf('Latest: %s', $result['commit'] ?? '-'));
if ( empty($result['is_worktree']) && is_array($result['primary_freshness'] ?? null) ) {
$freshness = $result['primary_freshness'];
WP_CLI::log(sprintf('Freshness: %s', (string) ( $freshness['status'] ?? 'unknown' )));
WP_CLI::log(sprintf('Upstream: %s', (string) ( $freshness['upstream'] ?? '-' )));
WP_CLI::log(sprintf('Behind: %s', null === ( $freshness['behind'] ?? null ) ? '-' : (string) $freshness['behind']));
WP_CLI::log(sprintf('Ahead: %s', null === ( $freshness['ahead'] ?? null ) ? '-' : (string) $freshness['ahead']));
if ( ! empty($freshness['suggested_command']) ) {
WP_CLI::log(sprintf('Refresh: %s', (string) $freshness['suggested_command']));
}
}

$dirty = $result['dirty'] ?? 0;
WP_CLI::log(sprintf('Dirty: %s', ( 0 === $dirty ) ? 'no' : "yes ({$dirty} files)"));
Expand Down
3 changes: 2 additions & 1 deletion inc/Runtime/AgentsMdSections.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ private static function register_datamachine_section( string $wp ): void {
- **GitHub:** `{$wp} datamachine-code github issues|pulls|repos|status|view|close|review-flow|comment` — list/read GitHub state, manage issues, install review flows, and comment on reviews.
- **Git sync:** `{$wp} datamachine-code gitsync bind|list|status|pull|submit|push|policy|unbind` — bind site-owned directories to remotes; `submit` opens or updates the PR path, while `push` writes directly to the configured branch.
- **Editing inside a worktree:** any tool. Local agents on the same disk should use native file I/O and raw `git`; routing edits through workspace abilities is ceremony, not safety.
- **Workflow:** `workspace clone <repo>` → `worktree add <repo> <branch>` → edit files in the worktree with any tool → commit → push → PR.
- **Workflow:** reuse the existing primary when one exists for the remote; otherwise `workspace clone <repo>` once → `worktree add <repo> <branch>` → edit files in the worktree with any tool → commit → push → PR.
- **Primary freshness:** before using a primary checkout for investigation or verification, inspect `workspace list|show|hygiene` freshness metadata. If the primary is stale, run `workspace git pull <repo> --allow-primary-mutation` or create the worktree from an explicit remote ref with `worktree add <repo> <branch> --from=origin/<base>`. Do not clone a second top-level primary for the same remote just to get fresh code.
- **Why worktrees:** parallel-session isolation on disk. Multiple agents cook features in the same repo without stepping on each other.
- **Primary is read-only.** Never edit `<workspace>/<repo>` (no `@slug`). Mutating ops on bare `<repo>` handles via the CLI require `--allow-primary-mutation`. The primary tracks the deployed branch — operate on a worktree.
- **Rule:** Never modify files under `wp-content/plugins/` or `wp-content/themes/` directly. Those paths are **read-only reference**. All code changes go through the workspace so they are tracked in git and reviewed via pull requests.
Expand Down
5 changes: 5 additions & 0 deletions inc/Workspace/Workspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ class Workspace {
*/
public const ARTIFACT_CLEANUP_DEFAULT_LIMIT = 100;

/**
* Default cap on top-level workspace entries sized by hygiene reports.
*/
public const HYGIENE_DEFAULT_SIZE_LIMIT = 1000;

/**
* @var string Resolved workspace path.
*/
Expand Down
181 changes: 181 additions & 0 deletions inc/Workspace/WorkspaceCoreUtilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,187 @@ public function get_primary_path( string $repo ): string {
return $this->workspace_path . '/' . $this->sanitize_name($repo);
}

/**
* Normalize a git remote URL for same-repository comparisons.
*
* @param string $url Git remote URL.
* @return string Normalized URL-ish key.
*/
private function normalize_git_remote_url( string $url ): string {
$url = trim($url);
$url = rtrim($url, '/');
$url = preg_replace('/\.git$/', '', $url) ?? $url;

if ( preg_match('/^([^@\s]+)@([^:\s]+):(.+)$/', $url, $matches) ) {
$url = 'ssh://' . $matches[2] . '/' . $matches[3];
}

$parts = function_exists('wp_parse_url') ? wp_parse_url($url) : parse_url($url);
if ( is_array($parts) && ! empty($parts['host']) ) {
$host = strtolower( (string) $parts['host']);
$path = trim( (string) ( $parts['path'] ?? '' ), '/');
$path = preg_replace('/\.git$/', '', $path) ?? $path;
return strtolower($host . '/' . $path);
}

return strtolower($url);
}

/**
* Find an existing primary checkout whose origin remote matches the URL.
*
* @param string $url Git remote URL to match.
* @param string $exclude_name Optional primary name to ignore.
* @return array{name: string, path: string, remote: string}|null
*/
private function find_primary_by_remote( string $url, string $exclude_name = '' ): ?array {
$needle = $this->normalize_git_remote_url($url);
if ( '' === $needle || ! is_dir($this->workspace_path) ) {
return null;
}

$entries = scandir($this->workspace_path);
if ( ! is_array($entries) ) {
return null;
}

foreach ( $entries as $entry ) {
if ( '.' === $entry || '..' === $entry || str_contains($entry, '@') || $entry === $exclude_name ) {
continue;
}

$path = $this->workspace_path . '/' . $entry;
if ( ! is_dir($path) || ! file_exists($path . '/.git') ) {
continue;
}

$remote = $this->git_get_remote($path);
if ( null !== $remote && $needle === $this->normalize_git_remote_url($remote) ) {
return array(
'name' => $entry,
'path' => $path,
'remote' => $remote,
);
}
}

return null;
}

/**
* Build local-ref freshness metadata for a primary checkout.
*
* This intentionally does not fetch. Read-only status/list/hygiene calls should
* reveal stale local remote refs without mutating repo state or requiring auth.
*
* @param string $repo_path Primary checkout path.
* @param string $handle Workspace primary handle.
* @return array<string,mixed>|null
*/
private function build_primary_freshness_report( string $repo_path, string $handle ): ?array {
if ( ! file_exists($repo_path . '/.git') ) {
return null;
}

$status_result = $this->run_git($repo_path, 'status --porcelain=v1 --branch --untracked-files=no');
if ( is_wp_error($status_result) ) {
return array(
'status' => 'unknown',
'branch' => null,
'upstream' => null,
'behind' => null,
'ahead' => null,
'detached' => false,
'local_refs' => true,
'fetch_checked' => false,
);
}

$header = strtok((string) ( $status_result['output'] ?? '' ), "\n");
$header = false === $header ? '' : trim($header);
$branch = null;
$detached = false;
$upstream = null;
$behind = 0;
$ahead = 0;
$status = 'unknown';

if ( preg_match('/^## HEAD \(no branch\)/', $header) ) {
$detached = true;
$status = 'detached';
} elseif ( preg_match('/^## (.+?)(?:\.\.\.([^\s\[]+))?(?: \[(.+)\])?$/', $header, $matches) ) {
$branch = trim((string) $matches[1]);
$upstream = isset($matches[2]) && '' !== $matches[2] ? trim((string) $matches[2]) : null;
$divergence = isset($matches[3]) ? (string) $matches[3] : '';

if ( preg_match('/behind (\d+)/', $divergence, $behind_match) ) {
$behind = (int) $behind_match[1];
}
if ( preg_match('/ahead (\d+)/', $divergence, $ahead_match) ) {
$ahead = (int) $ahead_match[1];
}

if ( null === $upstream ) {
$status = 'no_upstream';
} elseif ( $behind > 0 && $ahead > 0 ) {
$status = 'diverged';
} elseif ( $behind > 0 ) {
$status = 'stale';
} elseif ( $ahead > 0 ) {
$status = 'ahead';
} else {
$status = 'current';
}
}

$report = array(
'status' => $status,
'branch' => $branch,
'upstream' => $upstream,
'behind' => null === $upstream ? null : $behind,
'ahead' => null === $upstream ? null : $ahead,
'detached' => $detached,
'local_refs' => true,
'fetch_checked' => false,
);

if ( $this->primary_freshness_needs_refresh($status) ) {
$report['suggested_command'] = $this->primary_refresh_command($handle);
}

return $report;
}

/**
* Build the canonical command for refreshing a primary checkout.
*
* @param string $handle Primary workspace handle.
* @return string WP-CLI command.
*/
private function primary_refresh_command( string $handle ): string {
return sprintf('wp datamachine-code workspace git pull %s --allow-primary-mutation', $handle);
}

/**
* Whether a primary freshness status needs a pull/reconciliation refresh.
*
* @param string $status Freshness status.
* @return bool True when refresh guidance should be shown.
*/
private function primary_freshness_needs_refresh( string $status ): bool {
return in_array($status, array( 'stale', 'diverged' ), true);
}

/**
* Whether a primary freshness status should be surfaced in hygiene attention.
*
* @param string $status Freshness status.
* @return bool True when attention should be shown.
*/
private function primary_freshness_needs_attention( string $status ): bool {
return in_array($status, array( 'stale', 'diverged', 'detached', 'no_upstream', 'unknown' ), true);
}

/**
* Ensure the workspace directory exists with correct permissions.
*
Expand Down
Loading
Loading