diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 37171046..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. -**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). +**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,7 +328,11 @@ - **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` - **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` @@ -359,7 +363,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/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 @@ + 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]); + } + + // --- 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 + { + 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'); + } + + // --- 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"); + } + + /** A tag name GitHub will accept in a URL path and that carries no traversal/option trickery. */ + private function assertTag(string $tag): void + { + 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'); + } + } + + /** 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 ''; + } + + /** 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 [ + '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 + { + 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 = $this->authHeaders($tree); + $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. 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'] ?? ''), + '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'); + } + $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 => 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); + $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, 'headers' => $respHeaders]; + } +} 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/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()); + } + } +} 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'); + } +} 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); + } +}