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
69 changes: 69 additions & 0 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,58 @@ private function registerAbilities(): void {
)
);

wp_register_ability(
'datamachine/workspace-delete',
array(
'label' => 'Delete Workspace Path',
'description' => 'Delete a tracked or untracked file or directory from a workspace repository. Tracked paths are removed via git rm; untracked paths are unlinked from disk. Mutating ops on the primary checkout require allow_primary_mutation=true.',
'category' => 'datamachine-code-workspace',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'repo' => array(
'type' => 'string',
'description' => 'Workspace handle: `<repo>` (primary) or `<repo>@<branch-slug>` (worktree).',
),
'path' => array(
'type' => 'string',
'description' => 'Relative path within the repo (file or directory).',
),
'recursive' => array(
'type' => 'boolean',
'description' => 'Required when target is a directory. Default false.',
),
'allow_primary_mutation' => array(
'type' => 'boolean',
'description' => 'Permit mutation on the primary checkout (default false). Worktrees are always allowed.',
),
),
'required' => array( 'repo', 'path' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'name' => array( 'type' => 'string' ),
'repo' => array( 'type' => 'string' ),
'path' => array( 'type' => 'string' ),
'deleted' => array(
'type' => 'array',
'description' => 'Every relative path removed (recursive deletes report each entry).',
'items' => array( 'type' => 'string' ),
),
'was_tracked' => array(
'type' => 'boolean',
'description' => 'True when the path was removed via git rm; false for untracked filesystem deletes.',
),
),
),
'execute_callback' => array( self::class, 'deletePath' ),
'permission_callback' => fn() => PermissionHelper::can_manage(),
'meta' => array( 'show_in_rest' => false ),
)
);

wp_register_ability(
'datamachine/workspace-git-commit',
array(
Expand Down Expand Up @@ -1202,6 +1254,23 @@ public static function gitAdd( array $input ): array|\WP_Error {
return $workspace->git_add( $input['name'] ?? '', $paths, ! empty( $input['allow_primary_mutation'] ) );
}

/**
* Delete a tracked or untracked path from a workspace repository.
*
* @param array $input Input parameters with 'repo', 'path', optional 'recursive', 'allow_primary_mutation'.
* @return array
*/
public static function deletePath( array $input ): array|\WP_Error {
$workspace = new Workspace();

return $workspace->delete_path(
$input['repo'] ?? '',
$input['path'] ?? '',
! empty( $input['recursive'] ),
! empty( $input['allow_primary_mutation'] )
);
}

/**
* Commit staged changes in a workspace repository.
*
Expand Down
146 changes: 146 additions & 0 deletions inc/Workspace/Workspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,152 @@ public function git_add( string $handle, array $paths, bool $allow_primary_mutat
);
}

/**
* Delete a tracked or untracked path inside a workspace repository.
*
* Tracked paths are removed via `git rm` so the deletion lands in the
* working tree and the index in one shot. Untracked paths fall back to a
* filesystem unlink (file) or a recursive directory removal (directory,
* only when $recursive is true). Sensitive-path, traversal, allowlist,
* and primary-mutation gates mirror `git_add`.
*
* @param string $handle Workspace handle.
* @param string $path Relative path within the repo.
* @param bool $recursive Required when target is a directory.
* @param bool $allow_primary_mutation Whether the primary checkout may be mutated.
* @return array{success: bool, name: string, repo: string, path: string, deleted: array<int,string>, was_tracked: bool}|\WP_Error
*/
public function delete_path( string $handle, string $path, bool $recursive = false, bool $allow_primary_mutation = false ): array|\WP_Error {
$parsed = $this->parse_handle( $handle );
$repo_name = $parsed['repo'];
$repo_path = $this->resolve_repo_path( $handle );
if ( is_wp_error( $repo_path ) ) {
return $repo_path;
}

$policy_check = $this->ensure_git_mutation_allowed( $repo_name );
if ( is_wp_error( $policy_check ) ) {
return $policy_check;
}

$primary_check = $this->ensure_primary_mutation_allowed( $parsed, $allow_primary_mutation );
if ( is_wp_error( $primary_check ) ) {
return $primary_check;
}

$relative = trim( $path );
if ( '' === $relative ) {
return new \WP_Error( 'missing_path', 'Path is required for delete.', array( 'status' => 400 ) );
}

if ( $this->has_traversal( $relative ) || str_starts_with( $relative, '/' ) ) {
return new \WP_Error( 'invalid_path', sprintf( 'Invalid path for delete: %s', $relative ), array( 'status' => 400 ) );
}

if ( $this->is_sensitive_path( $relative ) ) {
return new \WP_Error( 'sensitive_path', sprintf( 'Refusing to delete sensitive path: %s', $relative ), array( 'status' => 403 ) );
}

$allowed_roots = $this->get_repo_allowed_paths( $repo_name );
if ( ! empty( $allowed_roots ) && ! $this->is_path_allowed( $relative, $allowed_roots ) ) {
return new \WP_Error( 'path_not_allowed', sprintf( 'Path "%s" is outside configured allowlist.', $relative ), array( 'status' => 403 ) );
}

$absolute = $repo_path . '/' . $relative;
if ( ! file_exists( $absolute ) && ! is_link( $absolute ) ) {
return new \WP_Error( 'not_found', sprintf( 'Path not found: %s', $relative ), array( 'status' => 404 ) );
}

$is_dir = is_dir( $absolute ) && ! is_link( $absolute );
if ( $is_dir && ! $recursive ) {
return new \WP_Error( 'directory_requires_recursive', sprintf( 'Path "%s" is a directory; pass recursive=true to delete.', $relative ), array( 'status' => 400 ) );
}

$ls_files = $this->run_git( $repo_path, 'ls-files --error-unmatch -- ' . escapeshellarg( $relative ) );
$is_tracked = ! is_wp_error( $ls_files );

$deleted = array();
if ( $is_tracked ) {
$flags = $is_dir ? '-r ' : '';
$result = $this->run_git( $repo_path, 'rm ' . $flags . '-- ' . escapeshellarg( $relative ) );
if ( is_wp_error( $result ) ) {
return $result;
}
foreach ( explode( "\n", $result['output'] ?? '' ) as $line ) {
if ( preg_match( '/^rm \'(.+)\'$/', trim( $line ), $matches ) ) {
$deleted[] = $matches[1];
}
}
if ( empty( $deleted ) ) {
$deleted[] = $relative;
}
} else {
if ( $is_dir ) {
$removed = $this->remove_directory_recursive( $absolute, $repo_path );
if ( is_wp_error( $removed ) ) {
return $removed;
}
$deleted = $removed;
} else {
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
if ( ! unlink( $absolute ) ) {
return new \WP_Error( 'delete_failed', sprintf( 'Failed to delete file: %s', $relative ), array( 'status' => 500 ) );
}
$deleted[] = $relative;
}
}

return array(
'success' => true,
'name' => $parsed['dir_name'],
'repo' => $repo_name,
'path' => $relative,
'deleted' => $deleted,
'was_tracked' => $is_tracked,
);
}

/**
* Recursively remove an untracked directory under a repo, returning the
* list of relative paths removed (deepest first).
*
* @param string $absolute Absolute path to remove.
* @param string $repo_path Repo root for relative-path computation.
* @return array<int,string>|\WP_Error
*/
private function remove_directory_recursive( string $absolute, string $repo_path ): array|\WP_Error {
$deleted = array();
$entries = @scandir( $absolute );
if ( false === $entries ) {
return new \WP_Error( 'scandir_failed', sprintf( 'Failed to read directory: %s', $absolute ), array( 'status' => 500 ) );
}
foreach ( $entries as $entry ) {
if ( '.' === $entry || '..' === $entry ) {
continue;
}
$child = $absolute . '/' . $entry;
if ( is_dir( $child ) && ! is_link( $child ) ) {
$nested = $this->remove_directory_recursive( $child, $repo_path );
if ( is_wp_error( $nested ) ) {
return $nested;
}
$deleted = array_merge( $deleted, $nested );
} else {
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
if ( ! unlink( $child ) ) {
return new \WP_Error( 'delete_failed', sprintf( 'Failed to delete file: %s', $child ), array( 'status' => 500 ) );
}
$deleted[] = ltrim( substr( $child, strlen( $repo_path ) ), '/' );
}
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
if ( ! rmdir( $absolute ) ) {
return new \WP_Error( 'delete_failed', sprintf( 'Failed to remove directory: %s', $absolute ), array( 'status' => 500 ) );
}
$deleted[] = ltrim( substr( $absolute, strlen( $repo_path ) ), '/' );
return $deleted;
}

/**
* Commit staged changes in a workspace repository.
*
Expand Down