Skip to content

Commit 75bdbf6

Browse files
feat(workspace): enforce configured Git identities (#938)
Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 3e7db85 commit 75bdbf6

5 files changed

Lines changed: 226 additions & 0 deletions

File tree

inc/Workspace/Workspace.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
require_once __DIR__ . '/WorkspaceArtifactCleanup.php';
2222
require_once __DIR__ . '/WorkspaceCleanupPlan.php';
2323
require_once __DIR__ . '/WorkspaceGitOperations.php';
24+
require_once __DIR__ . '/WorkspaceGitIdentityPolicy.php';
2425
require_once __DIR__ . '/WorkspaceHygieneReport.php';
2526
require_once __DIR__ . '/WorkspaceMetadataReconciliation.php';
2627
require_once __DIR__ . '/WorkspaceRepositoryLifecycle.php';
@@ -41,6 +42,7 @@ class Workspace {
4142
use WorkspaceArtifactCleanup;
4243
use WorkspaceCleanupPlan;
4344
use WorkspaceGitOperations;
45+
use WorkspaceGitIdentityPolicy;
4446
use WorkspaceHygieneReport;
4547
use WorkspaceMetadataReconciliation;
4648
use WorkspaceRepositoryLifecycle;
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
/**
3+
* Repository-local Git identity policy for managed workspaces.
4+
*
5+
* @package DataMachineCode\Workspace
6+
*/
7+
8+
namespace DataMachineCode\Workspace;
9+
10+
use DataMachineCode\Support\GitHubRemote;
11+
12+
defined('ABSPATH') || exit;
13+
14+
trait WorkspaceGitIdentityPolicy {
15+
16+
/**
17+
* Require the effective identity to match a configured host policy.
18+
*
19+
* Integrations can return `array{ name: string, email: string }` from the
20+
* `datamachine_code_git_identity_policy` filter. The parsed GitHub descriptor
21+
* and original remote URL let integrations decide which repositories need it.
22+
*/
23+
private function enforce_repository_git_identity( string $repo_path ): ?\WP_Error {
24+
$identity = $this->repository_git_identity_policy($repo_path);
25+
if ( is_wp_error($identity) ) {
26+
return $identity;
27+
}
28+
if ( null === $identity ) {
29+
return null;
30+
}
31+
32+
$name_result = $this->run_git($repo_path, 'config --get user.name');
33+
$email_result = $this->run_git($repo_path, 'config --get user.email');
34+
$name = $this->git_config_value($name_result);
35+
$email = $this->git_config_value($email_result);
36+
if ( $identity['name'] !== $name || $identity['email'] !== $email ) {
37+
return new \WP_Error(
38+
'repository_git_identity_mismatch',
39+
'Refusing to commit because the effective Git user.name and user.email do not satisfy this repository host identity policy.',
40+
array( 'status' => 409 )
41+
);
42+
}
43+
44+
return null;
45+
}
46+
47+
/**
48+
* Configure a managed worktree when its remote-specific policy provides identity.
49+
*/
50+
private function configure_repository_git_identity( string $repo_path ): ?\WP_Error {
51+
$identity = $this->repository_git_identity_policy($repo_path);
52+
if ( is_wp_error($identity) ) {
53+
return $identity;
54+
}
55+
if ( null === $identity ) {
56+
return null;
57+
}
58+
59+
// Worktree config prevents an identity selected for one branch from changing its primary checkout.
60+
foreach ( array(
61+
'config extensions.worktreeConfig true',
62+
'config --worktree user.name ' . escapeshellarg($identity['name']),
63+
'config --worktree user.email ' . escapeshellarg($identity['email']),
64+
) as $command ) {
65+
$result = $this->run_git($repo_path, $command);
66+
if ( is_wp_error($result) ) {
67+
return $result;
68+
}
69+
}
70+
71+
return null;
72+
}
73+
74+
/**
75+
* Resolve a configured identity policy for the repository's origin host.
76+
*
77+
* @return array{name:string,email:string}|null|\WP_Error
78+
*/
79+
private function repository_git_identity_policy( string $repo_path ): array|null|\WP_Error {
80+
$remote_result = $this->run_git($repo_path, 'remote get-url origin');
81+
if ( is_wp_error($remote_result) ) {
82+
return null;
83+
}
84+
85+
$remote = trim((string) ($remote_result['output'] ?? ''));
86+
$descriptor = GitHubRemote::descriptor($remote);
87+
if ( null === $descriptor || ! function_exists('apply_filters') ) {
88+
return null;
89+
}
90+
91+
$identity = apply_filters('datamachine_code_git_identity_policy', null, $descriptor, $remote);
92+
if ( null === $identity ) {
93+
return null;
94+
}
95+
if ( ! is_array($identity) ) {
96+
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 ));
97+
}
98+
99+
$name = trim((string) ($identity['name'] ?? ''));
100+
$email = trim((string) ($identity['email'] ?? ''));
101+
if ( '' === $name || '' === $email ) {
102+
return new \WP_Error('invalid_git_identity_policy', 'Git identity policy must provide non-empty name and email values.', array( 'status' => 500 ));
103+
}
104+
105+
return array( 'name' => $name, 'email' => $email );
106+
}
107+
108+
/**
109+
* Extract the effective Git config value.
110+
*/
111+
private function git_config_value( array|\WP_Error $result ): string {
112+
if ( is_wp_error($result) ) {
113+
return '';
114+
}
115+
116+
return trim((string) ($result['output'] ?? ''));
117+
}
118+
}

inc/Workspace/WorkspaceGitOperations.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,10 @@ public function git_commit( string $handle, string $message, bool $allow_primary
518518
if ( is_wp_error($attestation) ) {
519519
return $attestation;
520520
}
521+
$identity_check = $this->enforce_repository_git_identity($repo_path);
522+
if ( null !== $identity_check ) {
523+
return $identity_check;
524+
}
521525

522526
$commit = $this->run_git($repo_path, 'commit -m ' . escapeshellarg($message));
523527
if ( is_wp_error($commit) ) {
@@ -953,6 +957,10 @@ public function pr_rebase( string $handle, string|int|null $pr = null, bool $squ
953957
if ( '' !== $body ) {
954958
$commit_cmd .= ' -m ' . escapeshellarg($body);
955959
}
960+
$identity_check = $this->enforce_repository_git_identity($repo_path);
961+
if ( null !== $identity_check ) {
962+
return $identity_check;
963+
}
956964
$commit = $this->run_git($repo_path, $commit_cmd);
957965
if ( is_wp_error($commit) ) {
958966
return $commit;

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,12 @@ private function worktree_add_locked(
313313
return $result;
314314
}
315315

316+
$identity_configuration = $this->configure_repository_git_identity($wt_path);
317+
if ( null !== $identity_configuration ) {
318+
$this->rollback_rejected_worktree($primary_path, $wt_path, $branch, $created_branch);
319+
return $identity_configuration;
320+
}
321+
316322
$response = array(
317323
'success' => true,
318324
'handle' => $wt_handle,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
if ( ! defined('ABSPATH') ) {
6+
define('ABSPATH', __DIR__ . '/fixtures/');
7+
}
8+
9+
final class WP_Error {
10+
public function __construct( private string $code, private string $message = '', private array $data = array() ) {}
11+
public function get_error_code(): string { return $this->code; }
12+
}
13+
14+
function is_wp_error( mixed $value ): bool {
15+
return $value instanceof WP_Error;
16+
}
17+
18+
$GLOBALS['workspace_git_identity_filter'] = null;
19+
function apply_filters( string $hook, mixed $value, mixed ...$args ): mixed {
20+
$filter = $GLOBALS['workspace_git_identity_filter'];
21+
return 'datamachine_code_git_identity_policy' === $hook && is_callable($filter) ? $filter($value, ...$args) : $value;
22+
}
23+
24+
require_once dirname(__DIR__) . '/inc/Support/GitHubRemote.php';
25+
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceGitIdentityPolicy.php';
26+
27+
use DataMachineCode\Workspace\WorkspaceGitIdentityPolicy;
28+
29+
function identity_policy_assert( bool $condition, string $message ): void {
30+
if ( ! $condition ) {
31+
throw new RuntimeException($message);
32+
}
33+
}
34+
35+
function identity_policy_run( string $path, string $command ): array|WP_Error {
36+
$lines = array();
37+
$status = 0;
38+
exec('GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null git -C ' . escapeshellarg($path) . ' ' . $command . ' 2>&1', $lines, $status);
39+
return 0 === $status ? array( 'output' => implode("\n", $lines) ) : new WP_Error('git_failed', implode("\n", $lines));
40+
}
41+
42+
function identity_policy_output( array|WP_Error $result ): string {
43+
return is_wp_error($result) ? '' : (string) ($result['output'] ?? '');
44+
}
45+
46+
$harness = new class {
47+
use WorkspaceGitIdentityPolicy;
48+
public function enforce( string $path ): ?WP_Error { return $this->enforce_repository_git_identity($path); }
49+
public function configure( string $path ): ?WP_Error { return $this->configure_repository_git_identity($path); }
50+
private function run_git( string $path, string $command ): array|WP_Error { return identity_policy_run($path, $command); }
51+
};
52+
53+
$root = sys_get_temp_dir() . '/dmc-git-identity-' . getmypid() . '-' . bin2hex(random_bytes(4));
54+
$primary = $root . '/primary';
55+
$worktree = $root . '/primary@identity';
56+
57+
try {
58+
mkdir($primary, 0777, true);
59+
identity_policy_run($primary, 'init -b main');
60+
identity_policy_run($primary, '-c user.name=Fixture -c user.email=fixture@example.test commit --allow-empty -m initial');
61+
identity_policy_run($primary, 'remote add origin git@github.com:Example-Owner/example-repo.git');
62+
identity_policy_run($primary, 'worktree add -b feature/identity ' . escapeshellarg($worktree));
63+
64+
$GLOBALS['workspace_git_identity_filter'] = static function ( mixed $identity, array $descriptor, string $remote ): ?array {
65+
identity_policy_assert('Example-Owner/example-repo' === $descriptor['slug'], 'Policy receives the parsed GitHub remote descriptor.');
66+
identity_policy_assert('git@github.com:Example-Owner/example-repo.git' === $remote, 'Policy receives the original remote URL.');
67+
return array( 'name' => 'Managed Identity', 'email' => 'managed@example.test' );
68+
};
69+
identity_policy_assert(null === $harness->configure($worktree), 'Policy-backed managed worktree configures a valid identity.');
70+
identity_policy_assert('Managed Identity' === trim(identity_policy_output(identity_policy_run($worktree, 'config --get user.name'))), 'Managed worktree uses the policy name.');
71+
identity_policy_assert('managed@example.test' === trim(identity_policy_output(identity_policy_run($worktree, 'config --get user.email'))), 'Managed worktree uses the policy email.');
72+
identity_policy_assert('' === trim(identity_policy_output(identity_policy_run($primary, 'config --get user.name'))), 'Worktree policy identity does not alter the primary checkout.');
73+
identity_policy_assert(null === $harness->enforce($worktree), 'Configured host accepts its configured effective identity.');
74+
75+
identity_policy_run($worktree, 'config --worktree user.name ' . escapeshellarg('Wrong Identity'));
76+
identity_policy_assert('repository_git_identity_mismatch' === $harness->enforce($worktree)?->get_error_code(), 'Configured host rejects a mismatched effective identity.');
77+
identity_policy_run($worktree, 'config --worktree --unset user.name');
78+
identity_policy_assert('repository_git_identity_mismatch' === $harness->enforce($worktree)?->get_error_code(), 'Configured host rejects a missing effective identity.');
79+
80+
$GLOBALS['workspace_git_identity_filter'] = null;
81+
$bare = $root . '/bare';
82+
mkdir($bare, 0777, true);
83+
identity_policy_run($bare, 'init -b main');
84+
identity_policy_run($bare, 'remote add origin git@github.com:Example-Owner/unconfigured-repo.git');
85+
identity_policy_assert(null === $harness->enforce($bare), 'Unconfigured host preserves ambient Git identity behavior.');
86+
87+
echo "workspace-git-identity-policy: ok\n";
88+
} finally {
89+
if ( is_dir($root) ) {
90+
exec('rm -rf ' . escapeshellarg($root));
91+
}
92+
}

0 commit comments

Comments
 (0)