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
2 changes: 2 additions & 0 deletions inc/Workspace/Workspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
require_once __DIR__ . '/WorkspaceArtifactCleanup.php';
require_once __DIR__ . '/WorkspaceCleanupPlan.php';
require_once __DIR__ . '/WorkspaceGitOperations.php';
require_once __DIR__ . '/WorkspaceGitIdentityPolicy.php';
require_once __DIR__ . '/WorkspaceHygieneReport.php';
require_once __DIR__ . '/WorkspaceMetadataReconciliation.php';
require_once __DIR__ . '/WorkspaceRepositoryLifecycle.php';
Expand All @@ -41,6 +42,7 @@ class Workspace {
use WorkspaceArtifactCleanup;
use WorkspaceCleanupPlan;
use WorkspaceGitOperations;
use WorkspaceGitIdentityPolicy;
use WorkspaceHygieneReport;
use WorkspaceMetadataReconciliation;
use WorkspaceRepositoryLifecycle;
Expand Down
118 changes: 118 additions & 0 deletions inc/Workspace/WorkspaceGitIdentityPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php
/**
* Repository-local Git identity policy for managed workspaces.
*
* @package DataMachineCode\Workspace
*/

namespace DataMachineCode\Workspace;

use DataMachineCode\Support\GitHubRemote;

defined('ABSPATH') || exit;

trait WorkspaceGitIdentityPolicy {

/**
* Require the effective identity to match a configured host policy.
*
* Integrations can return `array{ name: string, email: string }` from the
* `datamachine_code_git_identity_policy` filter. The parsed GitHub descriptor
* and original remote URL let integrations decide which repositories need it.
*/
private function enforce_repository_git_identity( string $repo_path ): ?\WP_Error {
$identity = $this->repository_git_identity_policy($repo_path);
if ( is_wp_error($identity) ) {
return $identity;
}
if ( null === $identity ) {
return null;
}

$name_result = $this->run_git($repo_path, 'config --get user.name');
$email_result = $this->run_git($repo_path, 'config --get user.email');
$name = $this->git_config_value($name_result);
$email = $this->git_config_value($email_result);
if ( $identity['name'] !== $name || $identity['email'] !== $email ) {
return new \WP_Error(
'repository_git_identity_mismatch',
'Refusing to commit because the effective Git user.name and user.email do not satisfy this repository host identity policy.',
array( 'status' => 409 )
);
}

return null;
}

/**
* Configure a managed worktree when its remote-specific policy provides identity.
*/
private function configure_repository_git_identity( string $repo_path ): ?\WP_Error {
$identity = $this->repository_git_identity_policy($repo_path);
if ( is_wp_error($identity) ) {
return $identity;
}
if ( null === $identity ) {
return null;
}

// Worktree config prevents an identity selected for one branch from changing its primary checkout.
foreach ( array(
'config extensions.worktreeConfig true',
'config --worktree user.name ' . escapeshellarg($identity['name']),
'config --worktree user.email ' . escapeshellarg($identity['email']),
) as $command ) {
$result = $this->run_git($repo_path, $command);
if ( is_wp_error($result) ) {
return $result;
}
}

return null;
}

/**
* Resolve a configured identity policy for the repository's origin host.
*
* @return array{name:string,email:string}|null|\WP_Error
*/
private function repository_git_identity_policy( string $repo_path ): array|null|\WP_Error {
$remote_result = $this->run_git($repo_path, 'remote get-url origin');
if ( is_wp_error($remote_result) ) {
return null;
}

$remote = trim((string) ($remote_result['output'] ?? ''));
$descriptor = GitHubRemote::descriptor($remote);
if ( null === $descriptor || ! function_exists('apply_filters') ) {
return null;
}

$identity = apply_filters('datamachine_code_git_identity_policy', null, $descriptor, $remote);
if ( null === $identity ) {
return null;
}
if ( ! is_array($identity) ) {
return new \WP_Error('invalid_git_identity_policy', 'Git identity policy must return an array with non-empty name and email values.', array( 'status' => 500 ));
}

$name = trim((string) ($identity['name'] ?? ''));
$email = trim((string) ($identity['email'] ?? ''));
if ( '' === $name || '' === $email ) {
return new \WP_Error('invalid_git_identity_policy', 'Git identity policy must provide non-empty name and email values.', array( 'status' => 500 ));
}

return array( 'name' => $name, 'email' => $email );
}

/**
* Extract the effective Git config value.
*/
private function git_config_value( array|\WP_Error $result ): string {
if ( is_wp_error($result) ) {
return '';
}

return trim((string) ($result['output'] ?? ''));
}
}
8 changes: 8 additions & 0 deletions inc/Workspace/WorkspaceGitOperations.php
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,10 @@ public function git_commit( string $handle, string $message, bool $allow_primary
if ( is_wp_error($attestation) ) {
return $attestation;
}
$identity_check = $this->enforce_repository_git_identity($repo_path);
if ( null !== $identity_check ) {
return $identity_check;
}

$commit = $this->run_git($repo_path, 'commit -m ' . escapeshellarg($message));
if ( is_wp_error($commit) ) {
Expand Down Expand Up @@ -953,6 +957,10 @@ public function pr_rebase( string $handle, string|int|null $pr = null, bool $squ
if ( '' !== $body ) {
$commit_cmd .= ' -m ' . escapeshellarg($body);
}
$identity_check = $this->enforce_repository_git_identity($repo_path);
if ( null !== $identity_check ) {
return $identity_check;
}
$commit = $this->run_git($repo_path, $commit_cmd);
if ( is_wp_error($commit) ) {
return $commit;
Expand Down
6 changes: 6 additions & 0 deletions inc/Workspace/WorkspaceWorktreeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,12 @@ private function worktree_add_locked(
return $result;
}

$identity_configuration = $this->configure_repository_git_identity($wt_path);
if ( null !== $identity_configuration ) {
$this->rollback_rejected_worktree($primary_path, $wt_path, $branch, $created_branch);
return $identity_configuration;
}

$response = array(
'success' => true,
'handle' => $wt_handle,
Expand Down
92 changes: 92 additions & 0 deletions tests/workspace-git-identity-policy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

if ( ! defined('ABSPATH') ) {
define('ABSPATH', __DIR__ . '/fixtures/');
}

final class WP_Error {
public function __construct( private string $code, private string $message = '', private array $data = array() ) {}
public function get_error_code(): string { return $this->code; }
}

function is_wp_error( mixed $value ): bool {
return $value instanceof WP_Error;
}

$GLOBALS['workspace_git_identity_filter'] = null;
function apply_filters( string $hook, mixed $value, mixed ...$args ): mixed {
$filter = $GLOBALS['workspace_git_identity_filter'];
return 'datamachine_code_git_identity_policy' === $hook && is_callable($filter) ? $filter($value, ...$args) : $value;
}

require_once dirname(__DIR__) . '/inc/Support/GitHubRemote.php';
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceGitIdentityPolicy.php';

use DataMachineCode\Workspace\WorkspaceGitIdentityPolicy;

function identity_policy_assert( bool $condition, string $message ): void {
if ( ! $condition ) {
throw new RuntimeException($message);
}
}

function identity_policy_run( string $path, string $command ): array|WP_Error {
$lines = array();
$status = 0;
exec('GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null git -C ' . escapeshellarg($path) . ' ' . $command . ' 2>&1', $lines, $status);
return 0 === $status ? array( 'output' => implode("\n", $lines) ) : new WP_Error('git_failed', implode("\n", $lines));
}

function identity_policy_output( array|WP_Error $result ): string {
return is_wp_error($result) ? '' : (string) ($result['output'] ?? '');
}

$harness = new class {
use WorkspaceGitIdentityPolicy;
public function enforce( string $path ): ?WP_Error { return $this->enforce_repository_git_identity($path); }
public function configure( string $path ): ?WP_Error { return $this->configure_repository_git_identity($path); }
private function run_git( string $path, string $command ): array|WP_Error { return identity_policy_run($path, $command); }
};

$root = sys_get_temp_dir() . '/dmc-git-identity-' . getmypid() . '-' . bin2hex(random_bytes(4));
$primary = $root . '/primary';
$worktree = $root . '/primary@identity';

try {
mkdir($primary, 0777, true);
identity_policy_run($primary, 'init -b main');
identity_policy_run($primary, '-c user.name=Fixture -c user.email=fixture@example.test commit --allow-empty -m initial');
identity_policy_run($primary, 'remote add origin git@github.com:Example-Owner/example-repo.git');
identity_policy_run($primary, 'worktree add -b feature/identity ' . escapeshellarg($worktree));

$GLOBALS['workspace_git_identity_filter'] = static function ( mixed $identity, array $descriptor, string $remote ): ?array {
identity_policy_assert('Example-Owner/example-repo' === $descriptor['slug'], 'Policy receives the parsed GitHub remote descriptor.');
identity_policy_assert('git@github.com:Example-Owner/example-repo.git' === $remote, 'Policy receives the original remote URL.');
return array( 'name' => 'Managed Identity', 'email' => 'managed@example.test' );
};
identity_policy_assert(null === $harness->configure($worktree), 'Policy-backed managed worktree configures a valid identity.');
identity_policy_assert('Managed Identity' === trim(identity_policy_output(identity_policy_run($worktree, 'config --get user.name'))), 'Managed worktree uses the policy name.');
identity_policy_assert('managed@example.test' === trim(identity_policy_output(identity_policy_run($worktree, 'config --get user.email'))), 'Managed worktree uses the policy email.');
identity_policy_assert('' === trim(identity_policy_output(identity_policy_run($primary, 'config --get user.name'))), 'Worktree policy identity does not alter the primary checkout.');
identity_policy_assert(null === $harness->enforce($worktree), 'Configured host accepts its configured effective identity.');

identity_policy_run($worktree, 'config --worktree user.name ' . escapeshellarg('Wrong Identity'));
identity_policy_assert('repository_git_identity_mismatch' === $harness->enforce($worktree)?->get_error_code(), 'Configured host rejects a mismatched effective identity.');
identity_policy_run($worktree, 'config --worktree --unset user.name');
identity_policy_assert('repository_git_identity_mismatch' === $harness->enforce($worktree)?->get_error_code(), 'Configured host rejects a missing effective identity.');

$GLOBALS['workspace_git_identity_filter'] = null;
$bare = $root . '/bare';
mkdir($bare, 0777, true);
identity_policy_run($bare, 'init -b main');
identity_policy_run($bare, 'remote add origin git@github.com:Example-Owner/unconfigured-repo.git');
identity_policy_assert(null === $harness->enforce($bare), 'Unconfigured host preserves ambient Git identity behavior.');

echo "workspace-git-identity-policy: ok\n";
} finally {
if ( is_dir($root) ) {
exec('rm -rf ' . escapeshellarg($root));
}
}