'application/zip', 'tar.gz' => 'application/gzip', 'tar' => 'application/x-tar']; /** * @param string $treeish コミット(時刻に使う)又はツリー * @param string $format zip、tar、tar.gz * @param string $prefix 例:「repo-main/」 * @param callable $out fn(string $bytes) */ public static function write(Repository $repo, string $treeish, string $format, string $prefix, callable $out): void { if (!isset(self::FORMATS[$format])) throw new GitException("未対応の形式:{$format}"); $tree = $repo->treeOf($treeish) ?? throw new GitException("リビジョンが有りません:{$treeish}"); $commit = $repo->peel($repo->isId($treeish) ? $treeish : ($repo->resolve($treeish) ?? ''), 'commit'); $mtime = $commit !== null ? (int)$repo->commit($commit)->committer?->time : time(); $entries = []; self::collect($repo, $tree, $prefix, $entries); if ($format === 'zip') { self::zip($entries, $mtime, $out, $repo); return; } if ($format === 'tar.gz') { $ctx = deflate_init(ZLIB_ENCODING_GZIP, ['level' => 6]); $raw = $out; $out = function(string $b) use ($ctx, $raw): void { $z = deflate_add($ctx, $b, ZLIB_NO_FLUSH); if ($z !== '') $raw($z); }; self::tar($entries, $mtime, $out, $repo); $raw(deflate_add($ctx, '', ZLIB_FINISH)); return; } self::tar($entries, $mtime, $out, $repo); } /** [mode, パス, ID](ディレクトリは最後が「/」) */ private static function collect(Repository $repo, string $tree, string $prefix, array &$entries): void { if ($prefix !== '') $entries[] = [Tree::DIR, $prefix, $tree]; foreach ($repo->tree($tree)->entries as [$mode, $name, $id]) { if ($mode === Tree::DIR) self::collect($repo, $id, $prefix.$name.'/', $entries); else if ($mode !== Tree::SUBMODULE) $entries[] = [$mode, $prefix.$name, $id]; else $entries[] = [Tree::DIR, $prefix.$name.'/', $id]; } } private static function tar(array $entries, int $mtime, callable $out, Repository $repo): void { foreach ($entries as [$mode, $path, $id]) { $isDir = $mode === Tree::DIR; $isLink = $mode === Tree::LINK; $data = $isDir ? '' : $repo->blob($id); $perm = $isDir || $mode === Tree::EXEC ? 0775 : ($isLink ? 0777 : 0664); $link = $isLink ? $data : ''; $size = $isDir || $isLink ? 0 : strlen($data); // 長い名前はpaxの拡張ヘッダー [$name, $pre] = self::splitName($path); if ($name === null || strlen($link) > 100) { $pax = self::paxRecord('path', $path).($isLink && strlen($link) > 100 ? self::paxRecord('linkpath', $link) : ''); $out(self::tarHeader('././@PaxHeader', '', 0644, strlen($pax), $mtime, 'x', '').$pax.str_repeat("\0", (512 - strlen($pax) % 512) % 512)); [$name, $pre] = [substr(basename($path), 0, 100), '']; } $out(self::tarHeader($name, $pre, $perm, $size, $mtime, $isDir ? '5' : ($isLink ? '2' : '0'), substr($link, 0, 100))); if ($size > 0) $out($data.str_repeat("\0", (512 - $size % 512) % 512)); } $out(str_repeat("\0", 1024)); } /** ustarの名前(100)と前(155)に分ける。入らなければ[null, null] */ private static function splitName(string $path): array { if (strlen($path) <= 100) return [$path, '']; for ($i = strlen($path) - 1; $i > 0; $i--) { if ($path[$i] !== '/' || $i > 155) continue; if (strlen($path) - $i - 1 <= 100 && strlen($path) - $i - 1 > 0) return [substr($path, $i + 1), substr($path, 0, $i)]; } return [null, null]; } private static function paxRecord(string $key, string $value): string { $body = " {$key}={$value}\n"; $len = strlen($body); $n = $len + strlen((string)$len); if (strlen((string)$n) !== strlen((string)$len)) $n++; return $n.$body; } private static function tarHeader(string $name, string $prefix, int $mode, int $size, int $mtime, string $type, string $link): string { $h = str_pad($name, 100, "\0").sprintf('%07o', $mode)."\0".sprintf('%07o', 0)."\0".sprintf('%07o', 0)."\0" .sprintf('%011o', $size)."\0".sprintf('%011o', $mtime)."\0".' '.$type.str_pad($link, 100, "\0") ."ustar\0".'00'.str_pad('git', 32, "\0").str_pad('git', 32, "\0").sprintf('%07o', 0)."\0".sprintf('%07o', 0)."\0" .str_pad($prefix, 155, "\0"); $h = str_pad($h, 512, "\0"); $sum = array_sum(array_map('ord', str_split($h))); return substr($h, 0, 148).sprintf('%06o', $sum)."\0 ".substr($h, 156); } private static function zip(array $entries, int $mtime, callable $out, Repository $repo): void { $d = getdate($mtime); $dosTime = ($d['hours'] << 11) | ($d['minutes'] << 5) | intdiv($d['seconds'], 2); $dosDate = ((max(1980, $d['year']) - 1980) << 9) | ($d['mon'] << 5) | $d['mday']; $central = ''; $offset = 0; $count = 0; foreach ($entries as [$mode, $path, $id]) { $isDir = $mode === Tree::DIR; $data = $isDir ? '' : $repo->blob($id); $crc = $isDir ? 0 : crc32($data); $method = $isDir || strlen($data) < 64 ? 0 : 8; $body = $method === 8 ? (string)gzdeflate($data, 6) : $data; if ($method === 8 && strlen($body) >= strlen($data)) { $method = 0; $body = $data; } if ($offset > 0xffffffff || strlen($data) > 0xffffffff) throw new GitException('アーカイブが大き過ぎます(4GB)'); $unix = $isDir ? 040755 : ($mode === Tree::LINK ? 0120777 : ($mode === Tree::EXEC ? 0100755 : 0100644)); // UTF-8の名前(ビット11) $local = pack('VvvvvvVVVvv', 0x04034b50, 20, 0x0800, $method, $dosTime, $dosDate, $crc, strlen($body), strlen($data), strlen($path), 0).$path; $central .= pack('VvvvvvvVVVvvvvvVV', 0x02014b50, (3 << 8) | 20, 20, 0x0800, $method, $dosTime, $dosDate, $crc, strlen($body), strlen($data), strlen($path), 0, 0, 0, 0, ($unix << 16) | ($isDir ? 0x10 : 0), $offset).$path; $out($local.$body); $offset += strlen($local) + strlen($body); $count++; } if ($count > 0xffff) throw new GitException('ファイルが多過ぎます(65535)'); $out($central.pack('VvvvvVVv', 0x06054b50, 0, 0, $count, $count, strlen($central), $offset, 0)); } }