From bc5e4188ead3ad7ccdb6b315e27c96d674990543 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 13:39:47 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(git):=20ApiTransport=20commit=20spike?= =?UTF-8?q?=20=E2=80=94=20create=20commits=20via=20the=20GitHub=20API,=20n?= =?UTF-8?q?o=20.git=20(TIGER-364)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 leads with the module's biggest risk: committing with no local git repo. Tiger_Git_Transport_Api implements the commit path through the GitHub Git Data API: 1. read the branch head (ref) + its base tree 2. POST a blob per changed file (base64); a null-sha entry per deleted file 3. POST a new tree (on base_tree) then a new commit (parent = the old head) 4. fast-forward the branch ref (force:false) — a branch that moved under us is reported NON_FAST_FORWARD, never clobbered With the API transport there is no local index, so commit() writes to the remote in one flow and push() is a verified no-op (committing IS pushing). Also implemented: branches(), createBranch() (refuses the default branch). status/diff/switch/pull/tag/listReleases/fetchArchive throw NOT_IMPLEMENTED — the rest of TIGER-364. The HTTP layer is a seam (setHttp), the token is resolved per request via Tiger_Git_Credential (never held), and every call is pinned to api.github.com. Errors map to stable, secret-free codes (401->CREDENTIAL_INVALID, 403 rate->RATE_LIMITED, 404->INVALID_REF, 422 'fast forward'->NON_FAST_FORWARD). Tests: 10 unit (29 across the Git suite) — the full four-step sequence + payloads asserted against canned GitHub responses (no network), plus NON_FAST_FORWARD, missing-credential (no call made), nothing-to-commit, empty-message, createBranch, branches, push no-op, and the NOT_IMPLEMENTED guards. CAPABILITIES.md regenerated. Built on the Phase-1 substrate (PR #356); rebase onto main once that merges. A live-GitHub integration test (real throwaway repo + token) is the Testing-column gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 5 +- library/Tiger/Git/Exception.php | 2 + library/Tiger/Git/Transport/Api.php | 320 ++++++++++++++++++++++ tests/Unit/Git/ApiTransportCommitTest.php | 228 +++++++++++++++ 4 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 library/Tiger/Git/Transport/Api.php create mode 100644 tests/Unit/Git/ApiTransportCommitTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 37171046..4b4917ae 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**240 classes** across **33 capabilities** · **26 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**241 classes** across **33 capabilities** · **26 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -328,6 +328,7 @@ - **Tiger_Git_Credential** `@api` — the write-only credential manager (TIGER-334 / TIGER-365). · `library/Tiger/Git/Credential.php` - **Tiger_Git_Environment** `@api` — detect which transport this instance can use (TIGER-334). · `library/Tiger/Git/Environment.php` - **Tiger_Git_Exception** `@api` — a structured, agent-readable, SECRET-FREE git failure (TIGER-334). · `library/Tiger/Git/Exception.php` +- **Tiger_Git_Transport_Api** `@api` — git operations through the GitHub API, with NO `.git` directory (TIGER-364). · `library/Tiger/Git/Transport/Api.php` - **Tiger_Git_Transport_Interface** `@api` — HOW a git operation is carried out (TIGER-334). · `library/Tiger/Git/Transport/Interface.php` - **Tiger_Git_WorkingTree** `@api` — one module's git working tree (TIGER-334). · `library/Tiger/Git/WorkingTree.php` - **Tiger_Message** `@api` — the platform's way of telling people things (TIGER-114). · `library/Tiger/Message.php` @@ -359,7 +360,7 @@ - **CMS** (`cms`, app) · services: Menu, Page, Paths, Settings · `modules/cms` - **Code** (`code`, developer) · services: Code · `modules/code` - **Comments** (`comment`, app) · services: Comment, Render, Settings, Subjects · `modules/comment` -- **Git** (`git`, app) · `modules/git` +- **TigerGit** (`git`, app) · `modules/git` - **Identity** (`identity`, plugin) · services: Identity · `modules/identity` - **Matomo** (`matomo`, app) · services: Matomo · `modules/matomo` - **Mcp** (`mcp`, module) · services: Settings · `modules/mcp` diff --git a/library/Tiger/Git/Exception.php b/library/Tiger/Git/Exception.php index e0c2e580..4d8dcf08 100644 --- a/library/Tiger/Git/Exception.php +++ b/library/Tiger/Git/Exception.php @@ -30,6 +30,8 @@ class Tiger_Git_Exception extends RuntimeException const INVALID_REF = 'INVALID_REF'; const API_ERROR = 'API_ERROR'; const SHELL_ERROR = 'SHELL_ERROR'; + const NOT_IMPLEMENTED = 'NOT_IMPLEMENTED'; // a transport method not yet built (phased delivery) + const NOTHING_TO_COMMIT = 'NOTHING_TO_COMMIT'; /** @var string the stable error code */ protected $gitCode; diff --git a/library/Tiger/Git/Transport/Api.php b/library/Tiger/Git/Transport/Api.php new file mode 100644 index 00000000..65cc71dd --- /dev/null +++ b/library/Tiger/Git/Transport/Api.php @@ -0,0 +1,320 @@ + memoized token per credential ref (one resolve per request) */ + private array $tokenCache = []; + + public function __construct(Tiger_Git_Credential $credentials, ?callable $http = null) + { + $this->credentials = $credentials; + $this->http = $http; + } + + /** Swap the HTTP transport (tests inject canned GitHub responses). Null restores real cURL. */ + public function setHttp(?callable $http): void + { + $this->http = $http; + } + + public function name(): string + { + return Tiger_Git::TRANSPORT_API; + } + + // --- the spike: create a commit with no local repo ------------------------------------------- + + public function commit(Tiger_Git_WorkingTree $tree, string $message, array $paths = []): string + { + if (trim($message) === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'a commit message is required'); + } + if ($paths === []) { + // No local index to infer "all changes" from — the caller (the agent/Forge) names the files. + throw new Tiger_Git_Exception( + Tiger_Git_Exception::NOTHING_TO_COMMIT, + 'the API transport commits explicit paths (it has no local index to diff against)' + ); + } + $branch = $tree->currentBranch(); + $repo = $this->repoPath($tree); + + // 1. the branch head + its tree. + $ref = $this->api($tree, 'GET', $repo . '/git/ref/heads/' . $branch); + $headSha = (string) ($ref['object']['sha'] ?? ''); + if ($headSha === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'could not resolve the branch head'); + } + $headCommit = $this->api($tree, 'GET', $repo . '/git/commits/' . $headSha); + $baseTree = (string) ($headCommit['tree']['sha'] ?? ''); + + // 2. one tree entry per path — a blob for an add/modify, a null sha for a delete. + $entries = []; + foreach ($paths as $rel) { + $rel = ltrim(str_replace('\\', '/', (string) $rel), '/'); + if ($rel === '') { continue; } + $abs = $tree->resolve($rel); // refuses a path that escapes the module dir + if (is_file($abs)) { + $blob = $this->api($tree, 'POST', $repo . '/git/blobs', [ + 'content' => base64_encode((string) file_get_contents($abs)), + 'encoding' => 'base64', + ]); + $entries[] = [ + 'path' => $rel, + 'mode' => is_executable($abs) ? '100755' : '100644', + 'type' => 'blob', + 'sha' => (string) ($blob['sha'] ?? ''), + ]; + } else { + // Not on disk → stage a deletion (GitHub removes it when base_tree is set). + $entries[] = ['path' => $rel, 'mode' => '100644', 'type' => 'blob', 'sha' => null]; + } + } + if ($entries === []) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::NOTHING_TO_COMMIT, 'no files resolved to commit'); + } + + // 3. new tree → new commit (parent = the old head). + $newTree = $this->api($tree, 'POST', $repo . '/git/trees', ['base_tree' => $baseTree, 'tree' => $entries]); + $commit = $this->api($tree, 'POST', $repo . '/git/commits', [ + 'message' => $message, + 'tree' => (string) ($newTree['sha'] ?? ''), + 'parents' => [$headSha], + ]); + $newSha = (string) ($commit['sha'] ?? ''); + if ($newSha === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'commit creation returned no sha'); + } + + // 4. fast-forward the branch to the new commit (force:false → NON_FAST_FORWARD if it moved). + $this->api($tree, 'PATCH', $repo . '/git/refs/heads/' . $branch, ['sha' => $newSha, 'force' => false]); + + return $newSha; + } + + /** + * With the API transport, `commit()` already wrote to the remote branch — there is no separate local + * repo to push from. So push is a verified no-op: it confirms the branch head and reports it. + */ + public function push(Tiger_Git_WorkingTree $tree, string $branch): array + { + $ref = $this->api($tree, 'GET', $this->repoPath($tree) . '/git/ref/heads/' . $branch); + return ['ref' => (string) ($ref['object']['sha'] ?? ''), 'pushed' => true]; + } + + public function branches(Tiger_Git_WorkingTree $tree): array + { + $list = $this->api($tree, 'GET', $this->repoPath($tree) . '/branches?per_page=100'); + return array_values(array_map(static fn ($b) => (string) ($b['name'] ?? ''), is_array($list) ? $list : [])); + } + + public function createBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + if ($name === $tree->defaultBranch()) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::PROTECTED_BRANCH, 'refusing to recreate the default branch'); + } + $repo = $this->repoPath($tree); + $ref = $this->api($tree, 'GET', $repo . '/git/ref/heads/' . $tree->currentBranch()); + $sha = (string) ($ref['object']['sha'] ?? ''); + if ($sha === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'could not resolve the source branch head'); + } + $this->api($tree, 'POST', $repo . '/git/refs', ['ref' => 'refs/heads/' . $name, 'sha' => $sha]); + } + + // --- deferred to the rest of TIGER-364 (status/diff/switch/pull/tag/releases/archive) -------- + + public function status(Tiger_Git_WorkingTree $tree): array + { + throw $this->notImplemented('status'); + } + + public function diff(Tiger_Git_WorkingTree $tree, array $opts = []): array + { + throw $this->notImplemented('diff'); + } + + public function switchBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + throw $this->notImplemented('switchBranch'); + } + + public function pull(Tiger_Git_WorkingTree $tree, string $branch): array + { + throw $this->notImplemented('pull'); + } + + public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = null): array + { + throw $this->notImplemented('tag'); + } + + public function listReleases(Tiger_Git_WorkingTree $tree): array + { + throw $this->notImplemented('listReleases'); + } + + public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string + { + throw $this->notImplemented('fetchArchive'); + } + + // --- internals ------------------------------------------------------------------------------- + + private function notImplemented(string $op): Tiger_Git_Exception + { + return new Tiger_Git_Exception(Tiger_Git_Exception::NOT_IMPLEMENTED, "ApiTransport::$op lands in the rest of TIGER-364"); + } + + private function repoPath(Tiger_Git_WorkingTree $tree): string + { + return '/repos/' . $tree->owner() . '/' . $tree->repo(); + } + + /** The token for a tree's credential, resolved once per request. Throws when a write has none. */ + private function token(Tiger_Git_WorkingTree $tree): string + { + $refKey = $tree->credentialRef(); + if (!array_key_exists($refKey, $this->tokenCache)) { + $this->tokenCache[$refKey] = $this->credentials->resolve($refKey); + } + $tok = $this->tokenCache[$refKey]; + if ($tok === null || $tok === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_MISSING, 'no usable credential for this connection'); + } + return $tok; + } + + /** + * One authenticated GitHub API call. Returns the decoded JSON body (array), or throws a structured + * Tiger_Git_Exception mapping the status to a stable code. Pinned to api.github.com. + * + * @param array|null $json a request body to JSON-encode, or null for none + * @return array + */ + private function api(Tiger_Git_WorkingTree $tree, string $method, string $path, ?array $json = null): array + { + if (!Tiger_Git::isAllowedHost(self::HOST)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::HOST_NOT_ALLOWED, 'api.github.com is not allowed'); + } + $headers = [ + 'Authorization' => 'Bearer ' . $this->token($tree), + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + 'User-Agent' => 'Tiger-Git', + ]; + $body = $json !== null ? json_encode($json) : null; + if ($body !== null) { $headers['Content-Type'] = 'application/json'; } + + $res = $this->send($method, self::API . $path, $headers, $body); + $status = (int) ($res['status'] ?? 0); + $decoded = json_decode((string) ($res['body'] ?? ''), true); + $decoded = is_array($decoded) ? $decoded : []; + + if ($status >= 200 && $status < 300) { + return $decoded; + } + throw $this->mapError($status, $decoded, (string) ($res['body'] ?? '')); + } + + /** Translate a non-2xx GitHub response to a stable, secret-free error code. */ + private function mapError(int $status, array $decoded, string $raw): Tiger_Git_Exception + { + $msg = (string) ($decoded['message'] ?? ''); + $detail = ['status' => $status]; + + if ($status === 401) { + return new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_INVALID, 'the credential was rejected by GitHub', $detail); + } + if ($status === 403) { + if (stripos($msg, 'rate limit') !== false || stripos($raw, 'rate limit') !== false) { + return new Tiger_Git_Exception(Tiger_Git_Exception::RATE_LIMITED, 'GitHub rate limit reached', $detail); + } + return new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_INVALID, 'the credential lacks the required scope', $detail); + } + if ($status === 404) { + return new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'repository or ref not found', $detail); + } + if ($status === 409 || $status === 422) { + if (stripos($msg, 'fast forward') !== false || stripos($msg, 'fast-forward') !== false) { + return new Tiger_Git_Exception(Tiger_Git_Exception::NON_FAST_FORWARD, 'the branch moved — update is not a fast-forward', $detail); + } + return new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'GitHub rejected the request', $detail); + } + return new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'GitHub API error', $detail); + } + + /** + * The raw HTTP call — the seam. Tests inject canned responses; production uses cURL. Returns + * `{status:int, body:string}`. + * + * @param array $headers + */ + private function send(string $method, string $url, array $headers, ?string $body): array + { + if ($this->http !== null) { + $res = ($this->http)($method, $url, $headers, $body); + return ['status' => (int) ($res['status'] ?? 0), 'body' => (string) ($res['body'] ?? '')]; + } + if (!function_exists('curl_init')) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::TRANSPORT_UNAVAILABLE, 'cURL is not available for the API transport'); + } + $ch = curl_init($url); + $hdr = []; + foreach ($headers as $k => $v) { $hdr[] = $k . ': ' . $v; } + $opts = [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $hdr, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_FOLLOWLOCATION => false, // never follow a redirect off the allowlisted host + ]; + if ($body !== null) { $opts[CURLOPT_POSTFIELDS] = $body; } + curl_setopt_array($ch, $opts); + $out = curl_exec($ch); + $errno = curl_errno($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($errno !== 0) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'the GitHub request failed', ['curl' => $errno]); + } + return ['status' => $code, 'body' => (string) $out]; + } +} diff --git a/tests/Unit/Git/ApiTransportCommitTest.php b/tests/Unit/Git/ApiTransportCommitTest.php new file mode 100644 index 00000000..8480434c --- /dev/null +++ b/tests/Unit/Git/ApiTransportCommitTest.php @@ -0,0 +1,228 @@ +dir = sys_get_temp_dir() . '/tiger-git-api-' . bin2hex(random_bytes(4)); + mkdir($this->dir . '/src', 0700, true); + file_put_contents($this->dir . '/src/a.php', 'hello world'); + } + + protected function tearDown(): void + { + @unlink($this->dir . '/src/a.php'); + @rmdir($this->dir . '/src'); + @rmdir($this->dir); + parent::tearDown(); + } + + private function tree(): Tiger_Git_WorkingTree + { + return new Tiger_Git_WorkingTree($this->dir, 'acme', 'mod', 'main', 'main', 'api', 'cred-1', true); + } + + /** A credential manager that resolves to a fixed token (or null) — no DB. */ + private function creds(?string $token): Tiger_Git_Credential + { + return new class($token) extends Tiger_Git_Credential { + public function __construct(private ?string $token) {} + public function resolve(string $ref): ?string { return $this->token; } + }; + } + + /** + * A GitHub API fake. Records every call into $calls (by ref) and returns canned happy-path responses; + * $override lets a test replace the response for a matched (method, url-substring). + * + * @param array $calls + */ + private function http(array &$calls, array $override = []): callable + { + return function (string $method, string $url, array $headers, ?string $body) use (&$calls, $override): array { + $calls[] = ['method' => $method, 'url' => $url, 'headers' => $headers, 'json' => $body !== null ? json_decode($body, true) : null]; + foreach ($override as [$m, $needle, $resp]) { + if ($method === $m && str_contains($url, $needle)) { return $resp; } + } + if ($method === 'GET' && str_contains($url, '/git/ref/heads/')) { return ['status' => 200, 'body' => json_encode(['object' => ['sha' => 'HEAD1']])]; } + if ($method === 'GET' && str_contains($url, '/git/commits/')) { return ['status' => 200, 'body' => json_encode(['tree' => ['sha' => 'TREE0']])]; } + if ($method === 'POST' && str_contains($url, '/git/blobs')) { return ['status' => 201, 'body' => json_encode(['sha' => 'BLOB1'])]; } + if ($method === 'POST' && str_contains($url, '/git/trees')) { return ['status' => 201, 'body' => json_encode(['sha' => 'TREE1'])]; } + if ($method === 'POST' && str_contains($url, '/git/commits')) { return ['status' => 201, 'body' => json_encode(['sha' => 'NEW1'])]; } + if ($method === 'POST' && str_contains($url, '/git/refs')) { return ['status' => 201, 'body' => json_encode(['ref' => 'refs/heads/new'])]; } + if ($method === 'PATCH' && str_contains($url, '/git/refs/heads/')) { return ['status' => 200, 'body' => json_encode(['object' => ['sha' => 'NEW1']])]; } + return ['status' => 404, 'body' => json_encode(['message' => 'not found'])]; + }; + } + + #[Test] + public function commit_runs_the_blob_tree_commit_ref_dance_and_returns_the_new_sha(): void + { + $calls = []; + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($calls)); + + // Commit one modified file (on disk) and one deleted file (not on disk). + $sha = $api->commit($this->tree(), 'Add feature', ['src/a.php', 'old.php']); + + $this->assertSame('NEW1', $sha); + + // The four-step sequence, in order. + $seq = array_map(fn ($c) => $c['method'] . ' ' . parse_url($c['url'], PHP_URL_PATH), $calls); + $this->assertSame([ + 'GET /repos/acme/mod/git/ref/heads/main', + 'GET /repos/acme/mod/git/commits/HEAD1', + 'POST /repos/acme/mod/git/blobs', + 'POST /repos/acme/mod/git/trees', + 'POST /repos/acme/mod/git/commits', + 'PATCH /repos/acme/mod/git/refs/heads/main', + ], $seq); + + // The blob is the file content, base64. + $blob = $calls[2]['json']; + $this->assertSame(base64_encode('hello world'), $blob['content']); + $this->assertSame('base64', $blob['encoding']); + + // The tree builds on the base tree; the modified file is a blob entry, the missing one a deletion. + $treeBody = $calls[3]['json']; + $this->assertSame('TREE0', $treeBody['base_tree']); + $byPath = []; + foreach ($treeBody['tree'] as $e) { $byPath[$e['path']] = $e; } + $this->assertSame('BLOB1', $byPath['src/a.php']['sha']); + $this->assertSame('100644', $byPath['src/a.php']['mode']); + $this->assertNull($byPath['old.php']['sha'], 'a path absent on disk is staged as a deletion'); + + // The commit parents the old head; the ref update is a non-forced fast-forward. + $this->assertSame(['HEAD1'], $calls[4]['json']['parents']); + $this->assertSame('TREE1', $calls[4]['json']['tree']); + $this->assertSame('NEW1', $calls[5]['json']['sha']); + $this->assertFalse($calls[5]['json']['force']); + + // Every call is authenticated. + $this->assertSame('Bearer ghp_token', $calls[0]['headers']['Authorization']); + } + + #[Test] + public function a_non_fast_forward_ref_update_is_reported_not_forced(): void + { + $calls = []; + $http = $this->http($calls, [ + ['PATCH', '/git/refs/heads/', ['status' => 422, 'body' => json_encode(['message' => 'Update is not a fast forward'])]], + ]); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + + try { + $api->commit($this->tree(), 'msg', ['src/a.php']); + $this->fail('expected a NON_FAST_FORWARD exception'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::NON_FAST_FORWARD, $e->code()); + } + } + + #[Test] + public function an_empty_path_list_is_nothing_to_commit(): void + { + $t = []; $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($t)); + $this->expectException(Tiger_Git_Exception::class); + $this->expectExceptionMessageMatches('/explicit paths/'); + $api->commit($this->tree(), 'msg', []); + } + + #[Test] + public function an_empty_message_is_refused(): void + { + $t = []; $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($t)); + $this->expectException(Tiger_Git_Exception::class); + $api->commit($this->tree(), ' ', ['src/a.php']); + } + + #[Test] + public function a_missing_credential_fails_before_any_write(): void + { + $calls = []; + $api = new Tiger_Git_Transport_Api($this->creds(null), $this->http($calls)); + try { + $api->commit($this->tree(), 'msg', ['src/a.php']); + $this->fail('expected CREDENTIAL_MISSING'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::CREDENTIAL_MISSING, $e->code()); + } + $this->assertSame([], $calls, 'no API call is made without a credential'); + } + + #[Test] + public function create_branch_posts_a_new_ref_off_the_current_head(): void + { + $calls = []; + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($calls)); + $api->createBranch($this->tree(), 'feature/x'); + + $post = $calls[1]; + $this->assertSame('POST', $post['method']); + $this->assertStringEndsWith('/git/refs', parse_url($post['url'], PHP_URL_PATH)); + $this->assertSame('refs/heads/feature/x', $post['json']['ref']); + $this->assertSame('HEAD1', $post['json']['sha']); + } + + #[Test] + public function create_branch_refuses_the_default_branch(): void + { + $t = []; $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($t)); + $this->expectException(Tiger_Git_Exception::class); + $api->createBranch($this->tree(), 'main'); + } + + #[Test] + public function branches_lists_names(): void + { + $calls = []; + $http = $this->http($calls, [ + ['GET', '/branches', ['status' => 200, 'body' => json_encode([['name' => 'main'], ['name' => 'dev']])]], + ]); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + $this->assertSame(['main', 'dev'], $api->branches($this->tree())); + } + + #[Test] + public function push_is_a_verified_no_op_on_the_api_transport(): void + { + $calls = []; + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($calls)); + $res = $api->push($this->tree(), 'main'); + $this->assertTrue($res['pushed']); + $this->assertSame('HEAD1', $res['ref']); + } + + #[Test] + public function deferred_methods_report_not_implemented(): void + { + $t = []; $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $this->http($t)); + try { + $api->status($this->tree()); + $this->fail('expected NOT_IMPLEMENTED'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::NOT_IMPLEMENTED, $e->code()); + } + } +} From c408874c7bc2b11227148d7ab42ae8984f235b15 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 13:59:18 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(git):=20ShellTransport=20=E2=80=94=20t?= =?UTF-8?q?he=20git-binary=20transport=20(TIGER-364)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counterpart to ApiTransport, behind the same interface: git operations driven by the `git` binary over a real .git working tree, for a host that has git and can spawn a subprocess. Host-agnostic (works against any git remote, not just GitHub). Implemented: status (branch + porcelain changes + ahead/behind vs upstream), diff (numstat, size-capped), commit (stage -> -c author commit -> rev-parse; NOTHING_TO_COMMIT when empty), branches, createBranch (off HEAD, refuses the default), switchBranch (refuses a dirty tree -> DIRTY_TREE), push (origin, NO --force ever -> NON_FAST_FORWARD on rejection), pull (--ff-only), tag (annotated + pushed to origin). listReleases/fetchArchive are a provider-API concern (GitHub adapter routes them to ApiTransport) -> NOT_IMPLEMENTED. Safety, structural: git is invoked via proc_open with an ARGUMENT ARRAY (no shell string, no metacharacter interpretation); only a fixed subcommand set runs; every path is resolved under the module root first; ref/branch/tag names are validated (no leading '-', no '..'); a token is injected through an inline credential.helper that reads GIT_TOKEN from the ENV, so the secret is never in argv (ps-visible) nor written to .git/config. A command-runner seam (setRunner) allows stubbing. Tests: 12, proven END TO END against a real local repo + a bare file:// "remote", with a second clone used to force a genuine NON_FAST_FORWARD (push refused, never forced) and to verify push/tag actually reach the remote. Skipped where git is absent. Full Git suite 41 green; full unit suite 1172 green. CAPABILITIES.md regenerated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 3 +- library/Tiger/Git/Transport/Shell.php | 320 ++++++++++++++++++++++++++ tests/Unit/Git/ShellTransportTest.php | 227 ++++++++++++++++++ 3 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 library/Tiger/Git/Transport/Shell.php create mode 100644 tests/Unit/Git/ShellTransportTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 4b4917ae..16b1cbc1 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**241 classes** across **33 capabilities** · **26 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**242 classes** across **33 capabilities** · **26 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -330,6 +330,7 @@ - **Tiger_Git_Exception** `@api` — a structured, agent-readable, SECRET-FREE git failure (TIGER-334). · `library/Tiger/Git/Exception.php` - **Tiger_Git_Transport_Api** `@api` — git operations through the GitHub API, with NO `.git` directory (TIGER-364). · `library/Tiger/Git/Transport/Api.php` - **Tiger_Git_Transport_Interface** `@api` — HOW a git operation is carried out (TIGER-334). · `library/Tiger/Git/Transport/Interface.php` +- **Tiger_Git_Transport_Shell** `@api` — git operations driven by the `git` binary (TIGER-364). · `library/Tiger/Git/Transport/Shell.php` - **Tiger_Git_WorkingTree** `@api` — one module's git working tree (TIGER-334). · `library/Tiger/Git/WorkingTree.php` - **Tiger_Message** `@api` — the platform's way of telling people things (TIGER-114). · `library/Tiger/Message.php` - **Tiger_Secret** `@api` — provider-neutral resolution of secrets and sensitive config values. · `library/Tiger/Secret.php` diff --git a/library/Tiger/Git/Transport/Shell.php b/library/Tiger/Git/Transport/Shell.php new file mode 100644 index 00000000..907fd87f --- /dev/null +++ b/library/Tiger/Git/Transport/Shell.php @@ -0,0 +1,320 @@ +dir()`. + * + * Safety is structural: git is invoked through `proc_open` with an ARGUMENT ARRAY (never a shell + * string), so nothing a caller passes can be interpreted as a shell metacharacter; only a fixed set of + * subcommands is ever run; every file path is resolved under the module root first (refusing an escape); + * pushes are fast-forward only (no `--force`, ever); and a credential is injected through an inline + * `credential.helper` that reads the token from the process ENVIRONMENT (`GIT_TOKEN`), so the secret is + * never in argv (visible to `ps`) nor written to `.git/config`. + * + * GitHub *releases* and release *archives* are a provider-API concept, not a git one — the GitHub + * adapter routes those to Tiger_Git_Transport_Api regardless of the working transport — so + * `listReleases()`/`fetchArchive()` here report NOT_IMPLEMENTED by design. + * + * @api + * @since 1.38.0 + */ +class Tiger_Git_Transport_Shell implements Tiger_Git_Transport_Interface +{ + /** @var Tiger_Git_Credential resolves a connection's credential ref to a token (never returned out) */ + private Tiger_Git_Credential $credentials; + + /** @var null|callable(array,string,array):array the command-runner seam; null = real proc_open */ + private $runner; + + public function __construct(Tiger_Git_Credential $credentials, ?callable $runner = null) + { + $this->credentials = $credentials; + $this->runner = $runner; + } + + /** Swap the command runner (tests stub git output). Null restores real proc_open. */ + public function setRunner(?callable $runner): void + { + $this->runner = $runner; + } + + public function name(): string + { + return Tiger_Git::TRANSPORT_SHELL; + } + + // --- status / diff --------------------------------------------------------------------------- + + public function status(Tiger_Git_WorkingTree $tree): array + { + $branch = trim($this->ok($tree, ['rev-parse', '--abbrev-ref', 'HEAD'])['out']); + + $porcelain = $this->ok($tree, ['status', '--porcelain'])['out']; + $changed = []; + foreach (preg_split('/\R/', $porcelain) as $line) { + if ($line === '') { continue; } + $state = trim(substr($line, 0, 2)); + $path = substr($line, 3); + if (str_contains($path, ' -> ')) { $path = substr($path, strpos($path, ' -> ') + 4); } // rename + $changed[] = ['path' => $path, 'state' => $state]; + } + + [$ahead, $behind] = $this->aheadBehind($tree); + + return [ + 'branch' => $branch, + 'ahead' => $ahead, + 'behind' => $behind, + 'changed' => $changed, + 'clean' => $changed === [], + ]; + } + + public function diff(Tiger_Git_WorkingTree $tree, array $opts = []): array + { + $args = ['diff', '--numstat']; + if (!empty($opts['paths']) && is_array($opts['paths'])) { + $args[] = '--'; + foreach ($opts['paths'] as $p) { $tree->resolve((string) $p); $args[] = (string) $p; } + } + $out = $this->ok($tree, $args)['out']; + $cap = (int) ($opts['max_bytes'] ?? 0); + $files = []; + $truncated = false; + foreach (preg_split('/\R/', $out) as $line) { + if ($line === '') { continue; } + $parts = preg_split('/\t/', $line); + if (count($parts) < 3) { continue; } + [$add, $rem, $path] = $parts; + $binary = ($add === '-' && $rem === '-'); + $files[] = ['path' => $path, 'added' => $binary ? 0 : (int) $add, 'removed' => $binary ? 0 : (int) $rem, 'binary' => $binary]; + if ($cap > 0 && count($files) >= $cap) { $truncated = true; break; } + } + return ['files' => $files, 'truncated' => $truncated]; + } + + // --- commit ---------------------------------------------------------------------------------- + + public function commit(Tiger_Git_WorkingTree $tree, string $message, array $paths = []): string + { + if (trim($message) === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'a commit message is required'); + } + + if ($paths === []) { + $this->ok($tree, ['add', '-A']); + } else { + $add = ['add', '--']; + foreach ($paths as $p) { $tree->resolve((string) $p); $add[] = (string) $p; } + $this->ok($tree, $add); + } + + [$name, $email] = $this->authorIdentity(); + $res = $this->run($tree, ['-c', 'user.name=' . $name, '-c', 'user.email=' . $email, 'commit', '-m', $message]); + if ($res['code'] !== 0) { + if (stripos($res['out'] . $res['err'], 'nothing to commit') !== false) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::NOTHING_TO_COMMIT, 'there is nothing to commit'); + } + throw $this->shellError($res, 'commit failed'); + } + return trim($this->ok($tree, ['rev-parse', 'HEAD'])['out']); + } + + // --- branches -------------------------------------------------------------------------------- + + public function branches(Tiger_Git_WorkingTree $tree): array + { + $out = $this->ok($tree, ['branch', '--format=%(refname:short)'])['out']; + return array_values(array_filter(array_map('trim', preg_split('/\R/', $out)), static fn ($b) => $b !== '')); + } + + public function createBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + $this->assertRef($name); + if ($name === $tree->defaultBranch()) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::PROTECTED_BRANCH, 'refusing to recreate the default branch'); + } + $this->ok($tree, ['branch', $name]); // off the current HEAD, no checkout + } + + public function switchBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + $this->assertRef($name); + if (!$this->status($tree)['clean']) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::DIRTY_TREE, 'refusing to switch with uncommitted changes'); + } + $res = $this->run($tree, ['switch', $name]); + if ($res['code'] !== 0) { throw $this->shellError($res, 'branch switch failed'); } + } + + // --- push / pull (network) ------------------------------------------------------------------- + + public function push(Tiger_Git_WorkingTree $tree, string $branch): array + { + $this->assertRef($branch); + $res = $this->run($tree, ['push', 'origin', $branch], $this->authEnv($tree)); // NO --force, ever + if ($res['code'] !== 0) { throw $this->pushPullError($res); } + return ['ref' => trim($this->ok($tree, ['rev-parse', $branch])['out']), 'pushed' => true]; + } + + public function pull(Tiger_Git_WorkingTree $tree, string $branch): array + { + $this->assertRef($branch); + $res = $this->run($tree, ['pull', '--ff-only', 'origin', $branch], $this->authEnv($tree)); + if ($res['code'] !== 0) { throw $this->pushPullError($res); } + return ['ref' => trim($this->ok($tree, ['rev-parse', 'HEAD'])['out']), 'updated' => true, 'conflicts' => []]; + } + + // --- tag ------------------------------------------------------------------------------------- + + public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = null): array + { + $this->assertRef($tag); + $args = ['tag', '-a', $tag, '-m', $tag]; + if ($ref !== null && $ref !== '') { $this->assertRef($ref); $args[] = $ref; } + $this->ok($tree, $args); + + // A release tag is only useful on the remote — push it (auth, never forced). + $push = $this->run($tree, ['push', 'origin', 'refs/tags/' . $tag], $this->authEnv($tree)); + if ($push['code'] !== 0) { throw $this->pushPullError($push); } + + return ['tag' => $tag, 'sha' => trim($this->ok($tree, ['rev-parse', $tag . '^{commit}'])['out'])]; + } + + // --- provider-API concerns (not git) --------------------------------------------------------- + + public function listReleases(Tiger_Git_WorkingTree $tree): array + { + throw new Tiger_Git_Exception(Tiger_Git_Exception::NOT_IMPLEMENTED, 'GitHub releases are a provider-API concern — use the GitHub adapter'); + } + + public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string + { + throw new Tiger_Git_Exception(Tiger_Git_Exception::NOT_IMPLEMENTED, 'a release archive is a provider-API concern — use the GitHub adapter'); + } + + // --- internals ------------------------------------------------------------------------------- + + /** The ahead/behind counts vs the upstream, or [0,0] when there is no upstream. */ + private function aheadBehind(Tiger_Git_WorkingTree $tree): array + { + $up = $this->run($tree, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']); + if ($up['code'] !== 0) { return [0, 0]; } + $counts = $this->run($tree, ['rev-list', '--left-right', '--count', '@{upstream}...HEAD']); + if ($counts['code'] !== 0) { return [0, 0]; } + $parts = preg_split('/\s+/', trim($counts['out'])); + return [(int) ($parts[1] ?? 0), (int) ($parts[0] ?? 0)]; // [ahead (right), behind (left)] + } + + /** The commit author identity (config-overridable), with safe defaults so commit never fails unconfigured. */ + private function authorIdentity(): array + { + $name = 'Tiger'; + $email = 'tiger@localhost'; + try { + if (Zend_Registry::isRegistered('Zend_Config')) { + $cfg = Zend_Registry::get('Zend_Config'); + $g = $cfg->tiger->git->author ?? null; + if ($g) { + $name = (string) ($g->name ?: $name); + $email = (string) ($g->email ?: $email); + } + } + } catch (\Throwable $e) { /* defaults */ } + return [$name, $email]; + } + + /** + * The environment for a network op: the resolved token in `GIT_TOKEN` + an inline credential helper + * that reads it (so the secret is never in argv or `.git/config`). Empty when there's no token (a + * public / file / ssh-agent remote), which is fine — git just proceeds unauthenticated. + * + * @return array + */ + private function authEnv(Tiger_Git_WorkingTree $tree): array + { + $token = $this->credentials->resolve($tree->credentialRef()); + if ($token === null || $token === '') { return []; } + return ['GIT_TOKEN' => $token, 'GIT_TERMINAL_PROMPT' => '0']; + } + + /** Extra `-c` args that wire the inline credential helper (used only when a token is present). */ + private function authArgs(Tiger_Git_WorkingTree $tree): array + { + $token = $this->credentials->resolve($tree->credentialRef()); + if ($token === null || $token === '') { return []; } + // The helper references $GIT_TOKEN from the env — no secret in argv. + return ['-c', 'credential.helper=', '-c', 'credential.helper=!f() { echo username=x-access-token; echo "password=$GIT_TOKEN"; }; f']; + } + + /** A ref/branch/tag name git will accept and that carries no shell/option trickery. */ + private function assertRef(string $name): void + { + if ($name === '' || $name[0] === '-' || !preg_match('~^[A-Za-z0-9._/-]+$~', $name) || str_contains($name, '..')) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'invalid ref name'); + } + } + + /** Run git and throw SHELL_ERROR on any non-zero exit (for commands expected to succeed). */ + private function ok(Tiger_Git_WorkingTree $tree, array $args, array $env = []): array + { + $res = $this->run($tree, $args, $env); + if ($res['code'] !== 0) { throw $this->shellError($res, 'git ' . ($args[0] ?? '') . ' failed'); } + return $res; + } + + private function shellError(array $res, string $what): Tiger_Git_Exception + { + return new Tiger_Git_Exception(Tiger_Git_Exception::SHELL_ERROR, $what, ['code' => $res['code']]); + } + + /** Map a push/pull failure to NON_FAST_FORWARD / CREDENTIAL_INVALID / SHELL_ERROR. */ + private function pushPullError(array $res): Tiger_Git_Exception + { + $blob = strtolower($res['out'] . ' ' . $res['err']); + if (str_contains($blob, 'non-fast-forward') || str_contains($blob, 'fetch first') || str_contains($blob, 'not possible to fast-forward')) { + return new Tiger_Git_Exception(Tiger_Git_Exception::NON_FAST_FORWARD, 'the branch moved — update is not a fast-forward'); + } + if (str_contains($blob, 'authentication failed') || str_contains($blob, 'could not read username') || str_contains($blob, 'terminal prompts disabled') || str_contains($blob, '403')) { + return new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_INVALID, 'the credential was rejected by the remote'); + } + return new Tiger_Git_Exception(Tiger_Git_Exception::SHELL_ERROR, 'the git remote operation failed', ['code' => $res['code']]); + } + + /** + * Run `git ` in the module dir. A network op's $env carries the token + its inline helper args + * are prepended. Returns `{code:int, out:string, err:string}`. The command is an ARG ARRAY — no shell. + * + * @param array $args + * @param array $env + * @return array{code:int,out:string,err:string} + */ + private function run(Tiger_Git_WorkingTree $tree, array $args, array $env = []): array + { + // A token in the env means this is an authenticated op — wire the helper args in front. + if (isset($env['GIT_TOKEN'])) { $args = array_merge($this->authArgs($tree), $args); } + + if ($this->runner !== null) { + $res = ($this->runner)($args, $tree->dir(), $env); + return ['code' => (int) ($res['code'] ?? 0), 'out' => (string) ($res['out'] ?? ''), 'err' => (string) ($res['err'] ?? '')]; + } + if (!Tiger_Git_Environment::execUsable()) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::TRANSPORT_UNAVAILABLE, 'proc_open is unavailable for the shell transport'); + } + $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $procEnv = $env === [] ? null : array_merge(getenv() ?: [], $env); // null = inherit; else merge over it + $proc = proc_open(array_merge(['git'], $args), $desc, $pipes, $tree->dir(), $procEnv); + if (!is_resource($proc)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::SHELL_ERROR, 'could not start git'); + } + $out = stream_get_contents($pipes[1]) ?: ''; + $err = stream_get_contents($pipes[2]) ?: ''; + foreach ($pipes as $p) { if (is_resource($p)) { fclose($p); } } + $code = proc_close($proc); + return ['code' => (int) $code, 'out' => (string) $out, 'err' => (string) $err]; + } +} diff --git a/tests/Unit/Git/ShellTransportTest.php b/tests/Unit/Git/ShellTransportTest.php new file mode 100644 index 00000000..e19f504f --- /dev/null +++ b/tests/Unit/Git/ShellTransportTest.php @@ -0,0 +1,227 @@ +markTestSkipped('git binary not available'); + } + $this->base = sys_get_temp_dir() . '/tiger-git-sh-' . bin2hex(random_bytes(4)); + $this->work = $this->base . '/work'; + $this->remote = $this->base . '/remote.git'; + mkdir($this->base, 0700, true); + + $this->raw($this->base, ['init', '--bare', '-b', 'main', 'remote.git']); + $this->raw($this->base, ['init', '-b', 'main', 'work']); + $this->raw($this->work, ['config', 'user.email', 'dev@test']); + $this->raw($this->work, ['config', 'user.name', 'Dev']); + file_put_contents($this->work . '/README.md', "init\n"); + $this->raw($this->work, ['add', '-A']); + $this->raw($this->work, ['commit', '-m', 'initial']); + $this->raw($this->work, ['remote', 'add', 'origin', $this->remote]); + $this->raw($this->work, ['push', '-u', 'origin', 'main']); + } + + protected function tearDown(): void + { + if (isset($this->base) && is_dir($this->base)) { $this->rrmdir($this->base); } + parent::tearDown(); + } + + private function tree(): Tiger_Git_WorkingTree + { + return new Tiger_Git_WorkingTree($this->work, 'acme', 'mod', 'main', 'main', 'shell', '', false); + } + + private function transport(): Tiger_Git_Transport_Shell + { + // No token — a file:// remote needs none. + $creds = new class extends Tiger_Git_Credential { + public function resolve(string $ref): ?string { return null; } + }; + return new Tiger_Git_Transport_Shell($creds); + } + + #[Test] + public function name_is_shell(): void + { + $this->assertSame('shell', $this->transport()->name()); + } + + #[Test] + public function status_is_clean_then_sees_a_change(): void + { + $t = $this->transport(); + $clean = $t->status($this->tree()); + $this->assertSame('main', $clean['branch']); + $this->assertTrue($clean['clean']); + + file_put_contents($this->work . '/new.php', "status($this->tree()); + $this->assertFalse($dirty['clean']); + $this->assertContains('new.php', array_column($dirty['changed'], 'path')); + } + + #[Test] + public function commit_stages_and_returns_a_sha(): void + { + file_put_contents($this->work . '/feature.php', "transport()->commit($this->tree(), 'Add feature', ['feature.php']); + $this->assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $sha); + $this->assertTrue($this->transport()->status($this->tree())['clean'], 'tree is clean after commit'); + } + + #[Test] + public function commit_with_nothing_staged_is_nothing_to_commit(): void + { + $this->expectException(Tiger_Git_Exception::class); + $this->expectExceptionMessageMatches('/nothing to commit/i'); + $this->transport()->commit($this->tree(), 'empty'); // no changes on disk + } + + #[Test] + public function create_and_list_branches(): void + { + $t = $this->transport(); + $t->createBranch($this->tree(), 'feature/x'); + $this->assertContains('feature/x', $t->branches($this->tree())); + } + + #[Test] + public function create_branch_refuses_the_default(): void + { + $this->expectException(Tiger_Git_Exception::class); + $this->transport()->createBranch($this->tree(), 'main'); + } + + #[Test] + public function switch_branch_refuses_a_dirty_tree(): void + { + $t = $this->transport(); + $t->createBranch($this->tree(), 'feature/x'); + file_put_contents($this->work . '/dirty.php', "switchBranch($this->tree(), 'feature/x'); + $this->fail('expected DIRTY_TREE'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::DIRTY_TREE, $e->code()); + } + } + + #[Test] + public function push_advances_the_remote(): void + { + file_put_contents($this->work . '/p.php', "transport()->commit($this->tree(), 'to push', ['p.php']); + $res = $this->transport()->push($this->tree(), 'main'); + $this->assertTrue($res['pushed']); + + $localHead = trim($this->raw($this->work, ['rev-parse', 'HEAD'])['out']); + $remoteHead = trim($this->raw($this->remote, ['rev-parse', 'main'])['out']); + $this->assertSame($localHead, $remoteHead, 'the bare remote advanced to the pushed commit'); + } + + #[Test] + public function push_refuses_a_non_fast_forward(): void + { + // A second clone advances the remote behind our back. + $this->raw($this->base, ['clone', $this->remote, 'other']); + $other = $this->base . '/other'; + $this->raw($other, ['config', 'user.email', 'o@test']); + $this->raw($other, ['config', 'user.name', 'Other']); + file_put_contents($other . '/remote-change.php', "raw($other, ['add', '-A']); + $this->raw($other, ['commit', '-m', 'remote moves']); + $this->raw($other, ['push', 'origin', 'main']); + + // Now our local commits on a now-stale main and pushes → must be refused, never forced. + file_put_contents($this->work . '/local-change.php', "transport()->commit($this->tree(), 'local moves', ['local-change.php']); + try { + $this->transport()->push($this->tree(), 'main'); + $this->fail('expected NON_FAST_FORWARD'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::NON_FAST_FORWARD, $e->code()); + } + } + + #[Test] + public function tag_creates_annotated_and_pushes_it(): void + { + $out = $this->transport()->tag($this->tree(), 'v1.0.0'); + $this->assertSame('v1.0.0', $out['tag']); + $this->assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $out['sha']); + $remoteTags = $this->raw($this->remote, ['tag'])['out']; + $this->assertStringContainsString('v1.0.0', $remoteTags, 'the tag reached the remote'); + } + + #[Test] + public function diff_reports_numstat(): void + { + file_put_contents($this->work . '/README.md', "init\nmore\nlines\n"); + $diff = $this->transport()->diff($this->tree()); + $paths = array_column($diff['files'], 'path'); + $this->assertContains('README.md', $paths); + } + + #[Test] + public function release_ops_are_a_provider_concern(): void + { + try { + $this->transport()->listReleases($this->tree()); + $this->fail('expected NOT_IMPLEMENTED'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::NOT_IMPLEMENTED, $e->code()); + } + } + + // --- helpers --------------------------------------------------------------------------------- + + /** Run git directly for setup/verification (asserts success). */ + private function raw(string $cwd, array $args): array + { + $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = proc_open(array_merge(['git'], $args), $desc, $pipes, $cwd); + $out = stream_get_contents($pipes[1]) ?: ''; + $err = stream_get_contents($pipes[2]) ?: ''; + foreach ($pipes as $p) { if (is_resource($p)) { fclose($p); } } + $code = proc_close($proc); + $this->assertSame(0, $code, "setup `git " . implode(' ', $args) . "` failed: $err"); + return ['out' => $out, 'err' => $err]; + } + + private function rrmdir(string $dir): void + { + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $f) { $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname()); } + @rmdir($dir); + } +} From 8a172ba7d1023d961b28c08d2fddc35970aef647 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 21 Oct 2026 15:18:49 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(git):=20GitHubAdapter=20=E2=80=94=20pi?= =?UTF-8?q?ck=20transport,=20route=20release=20ops=20to=20the=20API=20(TIG?= =?UTF-8?q?ER-364)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiger_Git_Provider_GitHub composes the two transports into one object a service uses without caring: it picks the WORKING transport for a connection (the git binary where the host allows it, the API otherwise) for the git ops, and ALWAYS routes the provider-API ops (listReleases/fetchArchive) to the API — because a GitHub release is not a git concept, so even a shell connection reaches it over the API. New Tiger_Git_Provider_Interface is the N-provider seam (GitLab/Bitbucket land later without touching the transports). Implements the two provider-API verbs on ApiTransport (their reason to exist): - listReleases() — newest first, drafts excluded. - fetchArchive() — GitHub's tarball 302 captured; the redirect host is confirmed against the allowlist before the CDN fetch, and our token is never forwarded to the signed codeload URL. send() now captures response headers for the capture. Remaining ApiTransport verbs (status/diff/switch/pull/tag over the API) stay NOT_IMPLEMENTED — the rest of TIGER-364. 10 new unit tests (routing proven with both seams; releases/archive incl. the 302 capture, host-allowlist refusal, bad-tag refusal). 68 Git, 1181 unit green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 4 +- library/Tiger/Git/Provider/GitHub.php | 148 +++++++++++++++++++ library/Tiger/Git/Provider/Interface.php | 25 ++++ library/Tiger/Git/Transport/Api.php | 145 ++++++++++++++++--- tests/Unit/Git/ApiTransportReleaseTest.php | 156 +++++++++++++++++++++ tests/Unit/Git/GitHubProviderTest.php | 131 +++++++++++++++++ 6 files changed, 588 insertions(+), 21 deletions(-) create mode 100644 library/Tiger/Git/Provider/GitHub.php create mode 100644 library/Tiger/Git/Provider/Interface.php create mode 100644 tests/Unit/Git/ApiTransportReleaseTest.php create mode 100644 tests/Unit/Git/GitHubProviderTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 16b1cbc1..28dadcde 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**242 classes** across **33 capabilities** · **26 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**244 classes** across **33 capabilities** · **26 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -328,6 +328,8 @@ - **Tiger_Git_Credential** `@api` — the write-only credential manager (TIGER-334 / TIGER-365). · `library/Tiger/Git/Credential.php` - **Tiger_Git_Environment** `@api` — detect which transport this instance can use (TIGER-334). · `library/Tiger/Git/Environment.php` - **Tiger_Git_Exception** `@api` — a structured, agent-readable, SECRET-FREE git failure (TIGER-334). · `library/Tiger/Git/Exception.php` +- **Tiger_Git_Provider_GitHub** `@api` — the GitHub adapter: WHICH host, composing the two transports (TIGER-334). · `library/Tiger/Git/Provider/GitHub.php` +- **Tiger_Git_Provider_Interface** `@api` — WHICH host a connection talks to (TIGER-334). · `library/Tiger/Git/Provider/Interface.php` - **Tiger_Git_Transport_Api** `@api` — git operations through the GitHub API, with NO `.git` directory (TIGER-364). · `library/Tiger/Git/Transport/Api.php` - **Tiger_Git_Transport_Interface** `@api` — HOW a git operation is carried out (TIGER-334). · `library/Tiger/Git/Transport/Interface.php` - **Tiger_Git_Transport_Shell** `@api` — git operations driven by the `git` binary (TIGER-364). · `library/Tiger/Git/Transport/Shell.php` diff --git a/library/Tiger/Git/Provider/GitHub.php b/library/Tiger/Git/Provider/GitHub.php new file mode 100644 index 00000000..a5259722 --- /dev/null +++ b/library/Tiger/Git/Provider/GitHub.php @@ -0,0 +1,148 @@ +api = new Tiger_Git_Transport_Api($credentials, $http); + $this->shell = new Tiger_Git_Transport_Shell($credentials, $runner); + } + + /** The provider/host key. */ + public function provider(): string + { + return self::PROVIDER; + } + + /** + * The working transport recommended for THIS host (detected once, memoized) — not a specific + * connection. A connection stores its own chosen transport (set at connect, re-checkable); this is + * the host-capability answer, used for display and as the fallback when a tree names no transport. + */ + public function name(): string + { + if ($this->hostTransport === null) { + $env = Tiger_Git_Environment::detect(); + $this->hostTransport = $env['transport'] !== '' ? $env['transport'] : Tiger_Git::TRANSPORT_API; + } + return $this->hostTransport; + } + + // --- git operations → the connection's working transport ------------------------------------- + + public function status(Tiger_Git_WorkingTree $tree): array + { + return $this->working($tree)->status($tree); + } + + public function diff(Tiger_Git_WorkingTree $tree, array $opts = []): array + { + return $this->working($tree)->diff($tree, $opts); + } + + public function commit(Tiger_Git_WorkingTree $tree, string $message, array $paths = []): string + { + return $this->working($tree)->commit($tree, $message, $paths); + } + + public function branches(Tiger_Git_WorkingTree $tree): array + { + return $this->working($tree)->branches($tree); + } + + public function createBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + $this->working($tree)->createBranch($tree, $name); + } + + public function switchBranch(Tiger_Git_WorkingTree $tree, string $name): void + { + $this->working($tree)->switchBranch($tree, $name); + } + + public function push(Tiger_Git_WorkingTree $tree, string $branch): array + { + return $this->working($tree)->push($tree, $branch); + } + + public function pull(Tiger_Git_WorkingTree $tree, string $branch): array + { + return $this->working($tree)->pull($tree, $branch); + } + + public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = null): array + { + return $this->working($tree)->tag($tree, $tag, $ref); + } + + // --- provider-API operations → ALWAYS the API transport -------------------------------------- + + public function listReleases(Tiger_Git_WorkingTree $tree): array + { + return $this->api->listReleases($tree); + } + + public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string + { + return $this->api->fetchArchive($tree, $tag, $destFile); + } + + // --- internals ------------------------------------------------------------------------------- + + /** + * The working transport for a connection: the git binary when the connection chose shell AND the + * host can still run a subprocess, the API otherwise. A tree that names no transport falls back to + * host detection; a stale "shell" choice on a host that lost `exec` degrades to the API rather than + * failing every operation. + */ + private function working(Tiger_Git_WorkingTree $tree): Tiger_Git_Transport_Interface + { + $want = $tree->transport(); + if ($want === '') { $want = $this->name(); } + if ($want === Tiger_Git::TRANSPORT_SHELL && Tiger_Git_Environment::execUsable()) { + return $this->shell; + } + return $this->api; + } +} diff --git a/library/Tiger/Git/Provider/Interface.php b/library/Tiger/Git/Provider/Interface.php new file mode 100644 index 00000000..bc4efeb8 --- /dev/null +++ b/library/Tiger/Git/Provider/Interface.php @@ -0,0 +1,25 @@ +api($tree, 'POST', $repo . '/git/refs', ['ref' => 'refs/heads/' . $name, 'sha' => $sha]); } - // --- deferred to the rest of TIGER-364 (status/diff/switch/pull/tag/releases/archive) -------- + // --- provider-API operations (releases + archives — the GitHub adapter routes these here) ---- + + /** + * The repository's releases, newest first (drafts excluded — a draft has no installable tag). The + * `sha` is the release's `target_commitish` (a branch name or a commit sha — what the releases + * endpoint gives without an extra call per release). + */ + public function listReleases(Tiger_Git_WorkingTree $tree): array + { + $list = $this->api($tree, 'GET', $this->repoPath($tree) . '/releases?per_page=100'); + $out = []; + foreach (is_array($list) ? $list : [] as $r) { + if (!is_array($r) || !empty($r['draft'])) { continue; } + $tag = (string) ($r['tag_name'] ?? ''); + $name = (string) ($r['name'] ?? ''); + $out[] = [ + 'tag' => $tag, + 'name' => $name !== '' ? $name : $tag, + 'sha' => (string) ($r['target_commitish'] ?? ''), + 'created_at' => (string) ($r['created_at'] ?? ''), + 'prerelease' => (bool) ($r['prerelease'] ?? false), + ]; + } + return $out; + } + + /** + * Download a tag's source archive (`.tar.gz`) to $destFile. GitHub answers the tarball endpoint with + * a 302 to codeload.github.com; we capture the redirect, confirm it stays on the host allowlist, and + * stream the bytes from the CDN WITHOUT re-sending our credential (the signed URL carries its own). + * This is the one artifact an update ever installs — a tag's tree, never an arbitrary branch. + */ + public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string + { + $this->assertTag($tag); + if (!Tiger_Git::isAllowedHost(self::HOST)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::HOST_NOT_ALLOWED, 'api.github.com is not allowed'); + } + + // Ask GitHub for the tarball; it answers a 302 to the download host (codeload, on the allowlist). + $res = $this->send('GET', self::API . $this->repoPath($tree) . '/tarball/' . $tag, $this->authHeaders($tree), null); + $status = (int) ($res['status'] ?? 0); + + if (in_array($status, [301, 302, 303, 307, 308], true)) { + $loc = $this->header($res, 'location'); + if ($loc === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'the archive redirect carried no location'); + } + $host = strtolower((string) parse_url($loc, PHP_URL_HOST)); + if (!Tiger_Git::isAllowedHost($host)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::HOST_NOT_ALLOWED, 'the archive redirect left the allowed hosts'); + } + // The signed CDN URL authenticates itself — never forward our token to it. + $dl = $this->send('GET', $loc, ['User-Agent' => 'Tiger-Git'], null); + $dstatus = (int) ($dl['status'] ?? 0); + if ($dstatus < 200 || $dstatus >= 300) { + throw $this->mapError($dstatus, [], (string) ($dl['body'] ?? '')); + } + $bytes = (string) ($dl['body'] ?? ''); + } elseif ($status >= 200 && $status < 300) { + $bytes = (string) ($res['body'] ?? ''); // some setups return the bytes directly + } else { + $decoded = json_decode((string) ($res['body'] ?? ''), true); + throw $this->mapError($status, is_array($decoded) ? $decoded : [], (string) ($res['body'] ?? '')); + } + + if (@file_put_contents($destFile, $bytes) === false) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'could not write the release archive'); + } + return $destFile; + } + + // --- deferred to the rest of TIGER-364 (status/diff/switch/pull/tag over the API) ------------- public function status(Tiger_Git_WorkingTree $tree): array { @@ -185,21 +262,42 @@ public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = nul throw $this->notImplemented('tag'); } - public function listReleases(Tiger_Git_WorkingTree $tree): array + // --- internals ------------------------------------------------------------------------------- + + private function notImplemented(string $op): Tiger_Git_Exception { - throw $this->notImplemented('listReleases'); + return new Tiger_Git_Exception(Tiger_Git_Exception::NOT_IMPLEMENTED, "ApiTransport::$op lands in the rest of TIGER-364"); } - public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string + /** A tag name GitHub will accept in a URL path and that carries no traversal/option trickery. */ + private function assertTag(string $tag): void { - throw $this->notImplemented('fetchArchive'); + if ($tag === '' || $tag[0] === '-' || !preg_match('~^[A-Za-z0-9._/-]+$~', $tag) || str_contains($tag, '..')) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::INVALID_REF, 'invalid tag name'); + } } - // --- internals ------------------------------------------------------------------------------- + /** Case-insensitive lookup of a response header from a send() result. */ + private function header(array $res, string $name): string + { + $name = strtolower($name); + foreach (($res['headers'] ?? []) as $k => $v) { + if (strtolower((string) $k) === $name) { + return is_array($v) ? (string) ($v[0] ?? '') : (string) $v; + } + } + return ''; + } - private function notImplemented(string $op): Tiger_Git_Exception + /** The base authenticated GitHub headers (no Content-Type — api() adds it when there's a body). */ + private function authHeaders(Tiger_Git_WorkingTree $tree): array { - return new Tiger_Git_Exception(Tiger_Git_Exception::NOT_IMPLEMENTED, "ApiTransport::$op lands in the rest of TIGER-364"); + return [ + 'Authorization' => 'Bearer ' . $this->token($tree), + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + 'User-Agent' => 'Tiger-Git', + ]; } private function repoPath(Tiger_Git_WorkingTree $tree): string @@ -233,12 +331,7 @@ private function api(Tiger_Git_WorkingTree $tree, string $method, string $path, if (!Tiger_Git::isAllowedHost(self::HOST)) { throw new Tiger_Git_Exception(Tiger_Git_Exception::HOST_NOT_ALLOWED, 'api.github.com is not allowed'); } - $headers = [ - 'Authorization' => 'Bearer ' . $this->token($tree), - 'Accept' => 'application/vnd.github+json', - 'X-GitHub-Api-Version' => '2022-11-28', - 'User-Agent' => 'Tiger-Git', - ]; + $headers = $this->authHeaders($tree); $body = $json !== null ? json_encode($json) : null; if ($body !== null) { $headers['Content-Type'] = 'application/json'; } @@ -281,16 +374,22 @@ private function mapError(int $status, array $decoded, string $raw): Tiger_Git_E } /** - * The raw HTTP call — the seam. Tests inject canned responses; production uses cURL. Returns - * `{status:int, body:string}`. + * The raw HTTP call — the seam. Tests inject canned responses; production uses cURL. Redirects are + * never auto-followed (the caller inspects the Location so it can confirm the host allowlist before + * fetching). Returns `{status:int, body:string, headers:array}`. * * @param array $headers + * @return array{status:int,body:string,headers:array} */ private function send(string $method, string $url, array $headers, ?string $body): array { if ($this->http !== null) { $res = ($this->http)($method, $url, $headers, $body); - return ['status' => (int) ($res['status'] ?? 0), 'body' => (string) ($res['body'] ?? '')]; + return [ + 'status' => (int) ($res['status'] ?? 0), + 'body' => (string) ($res['body'] ?? ''), + 'headers' => is_array($res['headers'] ?? null) ? $res['headers'] : [], + ]; } if (!function_exists('curl_init')) { throw new Tiger_Git_Exception(Tiger_Git_Exception::TRANSPORT_UNAVAILABLE, 'cURL is not available for the API transport'); @@ -298,14 +397,20 @@ private function send(string $method, string $url, array $headers, ?string $body $ch = curl_init($url); $hdr = []; foreach ($headers as $k => $v) { $hdr[] = $k . ': ' . $v; } + $respHeaders = []; $opts = [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $hdr, CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, + CURLOPT_TIMEOUT => 60, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_FOLLOWLOCATION => false, // never follow a redirect off the allowlisted host + CURLOPT_HEADERFUNCTION => static function ($ch, string $line) use (&$respHeaders): int { + $p = strpos($line, ':'); + if ($p !== false) { $respHeaders[strtolower(trim(substr($line, 0, $p)))] = trim(substr($line, $p + 1)); } + return strlen($line); + }, ]; if ($body !== null) { $opts[CURLOPT_POSTFIELDS] = $body; } curl_setopt_array($ch, $opts); @@ -315,6 +420,6 @@ private function send(string $method, string $url, array $headers, ?string $body if ($errno !== 0) { throw new Tiger_Git_Exception(Tiger_Git_Exception::API_ERROR, 'the GitHub request failed', ['curl' => $errno]); } - return ['status' => $code, 'body' => (string) $out]; + return ['status' => $code, 'body' => (string) $out, 'headers' => $respHeaders]; } } diff --git a/tests/Unit/Git/ApiTransportReleaseTest.php b/tests/Unit/Git/ApiTransportReleaseTest.php new file mode 100644 index 00000000..98aac15f --- /dev/null +++ b/tests/Unit/Git/ApiTransportReleaseTest.php @@ -0,0 +1,156 @@ +dir = sys_get_temp_dir() . '/tiger-git-rel-' . bin2hex(random_bytes(4)); + mkdir($this->dir, 0700, true); + } + + protected function tearDown(): void + { + @unlink($this->dir . '/archive.tar.gz'); + @rmdir($this->dir); + parent::tearDown(); + } + + private function tree(): Tiger_Git_WorkingTree + { + return new Tiger_Git_WorkingTree($this->dir, 'acme', 'mod', 'main', 'main', 'api', 'cred-1', true); + } + + private function creds(?string $token): Tiger_Git_Credential + { + return new class($token) extends Tiger_Git_Credential { + public function __construct(private ?string $token) {} + public function resolve(string $ref): ?string { return $this->token; } + }; + } + + /** @param array $calls */ + private function http(array &$calls, callable $responder): callable + { + return function (string $method, string $url, array $headers, ?string $body) use (&$calls, $responder): array { + $calls[] = ['method' => $method, 'url' => $url, 'headers' => $headers, 'body' => $body]; + return $responder($method, $url); + }; + } + + #[Test] + public function list_releases_maps_newest_first_and_skips_drafts(): void + { + $calls = []; + $http = $this->http($calls, function (string $m, string $url): array { + if (str_contains($url, '/releases')) { + return ['status' => 200, 'body' => json_encode([ + ['tag_name' => 'v1.1.0', 'name' => 'Spring release', 'target_commitish' => 'abc123', 'created_at' => '2026-03-01T00:00:00Z', 'prerelease' => false, 'draft' => false], + ['tag_name' => 'v1.1.0-rc1', 'name' => '', 'target_commitish' => 'main', 'created_at' => '2026-02-01T00:00:00Z', 'prerelease' => true, 'draft' => false], + ['tag_name' => 'wip', 'name' => 'Draft', 'target_commitish' => 'main', 'created_at' => '2026-02-15T00:00:00Z', 'prerelease' => false, 'draft' => true], + ])]; + } + return ['status' => 404, 'body' => '[]']; + }); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + + $releases = $api->listReleases($this->tree()); + + $this->assertCount(2, $releases, 'the draft is excluded'); + $this->assertSame('v1.1.0', $releases[0]['tag']); + $this->assertSame('Spring release', $releases[0]['name']); + $this->assertSame('abc123', $releases[0]['sha']); + $this->assertFalse($releases[0]['prerelease']); + + // Empty name falls back to the tag; the prerelease flag is carried. + $this->assertSame('v1.1.0-rc1', $releases[1]['name']); + $this->assertTrue($releases[1]['prerelease']); + + $this->assertStringContainsString('/repos/acme/mod/releases', $calls[0]['url']); + $this->assertSame('Bearer ghp_token', $calls[0]['headers']['Authorization']); + } + + #[Test] + public function fetch_archive_captures_the_302_and_streams_from_codeload(): void + { + $calls = []; + $http = $this->http($calls, function (string $m, string $url): array { + if (str_contains($url, 'api.github.com') && str_contains($url, '/tarball/')) { + return ['status' => 302, 'headers' => ['Location' => 'https://codeload.github.com/acme/mod/tar.gz/v1.0.0'], 'body' => '']; + } + if (str_contains($url, 'codeload.github.com')) { + return ['status' => 200, 'body' => 'TARBALL-BYTES']; + } + return ['status' => 404, 'body' => 'nope']; + }); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + + $dest = $this->dir . '/archive.tar.gz'; + $path = $api->fetchArchive($this->tree(), 'v1.0.0', $dest); + + $this->assertSame($dest, $path); + $this->assertSame('TARBALL-BYTES', file_get_contents($dest)); + + // Two hops: the api.github.com tarball request (authenticated), then the CDN (NOT authenticated). + $this->assertStringContainsString('api.github.com', $calls[0]['url']); + $this->assertSame('Bearer ghp_token', $calls[0]['headers']['Authorization']); + $this->assertStringContainsString('codeload.github.com', $calls[1]['url']); + $this->assertArrayNotHasKey('Authorization', $calls[1]['headers'], 'the signed CDN URL is not sent our token'); + } + + #[Test] + public function fetch_archive_refuses_a_redirect_off_the_allowlist(): void + { + $calls = []; + $http = $this->http($calls, fn (string $m, string $url): array => + ['status' => 302, 'headers' => ['Location' => 'https://evil.example.com/acme/mod.tar.gz'], 'body' => ''] + ); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + + try { + $api->fetchArchive($this->tree(), 'v1.0.0', $this->dir . '/archive.tar.gz'); + $this->fail('expected HOST_NOT_ALLOWED'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::HOST_NOT_ALLOWED, $e->code()); + } + $this->assertCount(1, $calls, 'the off-allowlist redirect is never followed'); + $this->assertFileDoesNotExist($this->dir . '/archive.tar.gz'); + } + + #[Test] + public function fetch_archive_refuses_a_bad_tag_before_any_call(): void + { + $calls = []; + $http = $this->http($calls, fn () => ['status' => 200, 'body' => '']); + $api = new Tiger_Git_Transport_Api($this->creds('ghp_token'), $http); + + try { + $api->fetchArchive($this->tree(), '../../etc/passwd', $this->dir . '/x.tar.gz'); + $this->fail('expected INVALID_REF'); + } catch (Tiger_Git_Exception $e) { + $this->assertSame(Tiger_Git_Exception::INVALID_REF, $e->code()); + } + $this->assertSame([], $calls, 'nothing is fetched for a rejected tag'); + } +} diff --git a/tests/Unit/Git/GitHubProviderTest.php b/tests/Unit/Git/GitHubProviderTest.php new file mode 100644 index 00000000..f0bf1901 --- /dev/null +++ b/tests/Unit/Git/GitHubProviderTest.php @@ -0,0 +1,131 @@ + git commands the runner saw */ + private array $gitCalls = []; + /** @var array HTTP requests the API seam saw */ + private array $httpCalls = []; + + protected function setUp(): void + { + parent::setUp(); + $this->dir = sys_get_temp_dir() . '/tiger-git-prov-' . bin2hex(random_bytes(4)); + mkdir($this->dir, 0700, true); + Tiger_Git_Environment::setProbe(fn () => true); // keep detect() off the network + } + + protected function tearDown(): void + { + Tiger_Git_Environment::setProbe(null); + @rmdir($this->dir); + parent::tearDown(); + } + + private function tree(string $transport): Tiger_Git_WorkingTree + { + return new Tiger_Git_WorkingTree($this->dir, 'acme', 'mod', 'main', 'main', $transport, 'cred-1', true); + } + + private function provider(): Tiger_Git_Provider_GitHub + { + $creds = new class extends Tiger_Git_Credential { + public function resolve(string $ref): ?string { return 'ghp_token'; } + }; + + // The shell runner answers just enough git for status(); it records every call. + $runner = function (array $args, string $dir, array $env): array { + $this->gitCalls[] = $args; + if ($args === ['rev-parse', '--abbrev-ref', 'HEAD']) { return ['code' => 0, 'out' => "main\n", 'err' => '']; } + if ($args === ['status', '--porcelain']) { return ['code' => 0, 'out' => '', 'err' => '']; } + if (in_array('@{upstream}', $args, true)) { return ['code' => 1, 'out' => '', 'err' => 'no upstream']; } + return ['code' => 0, 'out' => '', 'err' => '']; + }; + + // The API seam records every call and answers the few endpoints these tests reach. + $http = function (string $method, string $url, array $headers, ?string $body): array { + $this->httpCalls[] = ['method' => $method, 'url' => $url]; + if (str_contains($url, '/branches')) { + return ['status' => 200, 'body' => json_encode([['name' => 'main'], ['name' => 'dev']])]; + } + if (str_contains($url, '/releases')) { + return ['status' => 200, 'body' => json_encode([['tag_name' => 'v1.0.0', 'target_commitish' => 'abc', 'created_at' => 'now', 'prerelease' => false]])]; + } + return ['status' => 404, 'body' => '[]']; + }; + + return new Tiger_Git_Provider_GitHub($creds, $http, $runner); + } + + #[Test] + public function provider_key_is_github(): void + { + $this->assertSame('github', $this->provider()->provider()); + } + + #[Test] + public function name_reports_the_detected_host_transport(): void + { + $this->assertSame(Tiger_Git_Environment::detect()['transport'], $this->provider()->name()); + } + + #[Test] + public function a_git_op_on_a_shell_connection_uses_the_git_binary(): void + { + if (!Tiger_Git_Environment::execUsable()) { + $this->markTestSkipped('proc_open unavailable — shell routing cannot be exercised'); + } + $status = $this->provider()->status($this->tree(Tiger_Git::TRANSPORT_SHELL)); + + $this->assertSame('main', $status['branch']); + $this->assertTrue($status['clean']); + $this->assertNotEmpty($this->gitCalls, 'the git binary was driven'); + $this->assertSame([], $this->httpCalls, 'no API call for a shell git op'); + } + + #[Test] + public function a_git_op_on_an_api_connection_uses_the_api(): void + { + $branches = $this->provider()->branches($this->tree(Tiger_Git::TRANSPORT_API)); + + $this->assertSame(['main', 'dev'], $branches); + $this->assertNotEmpty($this->httpCalls, 'the API was called'); + $this->assertSame([], $this->gitCalls, 'the git binary was not touched for an API connection'); + } + + #[Test] + public function releases_go_to_the_api_even_on_a_shell_connection(): void + { + // The whole reason the provider exists: a GitHub release is not a git concept, so a shell + // connection still reaches it over the API — never the git binary. + $releases = $this->provider()->listReleases($this->tree(Tiger_Git::TRANSPORT_SHELL)); + + $this->assertSame('v1.0.0', $releases[0]['tag']); + $this->assertNotEmpty($this->httpCalls, 'releases were fetched over the API'); + $this->assertStringContainsString('/releases', $this->httpCalls[0]['url']); + $this->assertSame([], $this->gitCalls, 'the git binary is never used for releases'); + } +}