パック */ private array $packs = []; private int $scanned = 0; /** 差分の元のキャッシュ(キー => [種類, データ]) */ private array $cache = []; private int $cacheBytes = 0; private const CACHE_LIMIT = 33554432; public function __construct(private Repository $repo) { $this->dirs = [$repo->path.'/objects']; $alt = $repo->path.'/objects/info/alternates'; if (is_file($alt)) { foreach (preg_split('/\r?\n/', (string)file_get_contents($alt)) as $line) { $line = trim($line); if ($line === '' || $line[0] === '#') continue; $dir = preg_match('#^(/|[A-Za-z]:[/\\\\])#', $line) ? $line : $repo->path.'/objects/'.$line; if (is_dir($dir)) $this->dirs[] = rtrim($dir, '/\\'); } } } /** @return Pack[] */ public function packs(bool $rescan = false): array { if ($rescan || $this->scanned === 0) { foreach ($this->dirs as $dir) { foreach (glob($dir.'/pack/pack-*.idx') ?: [] as $idx) { $pack = substr($idx, 0, -4).'.pack'; if (!isset($this->packs[$pack]) && is_file($pack)) $this->packs[$pack] = new Pack($pack, $this->repo->hashLen); } } $this->scanned++; } return $this->packs; } /** * オブジェクトの場所 * * @return array|null ['loose', パス] 又は ['pack', Pack, オフセット] */ public function locate(string $id): ?array { $id = strtolower($id); if (!$this->repo->isId($id)) return null; foreach ($this->dirs as $dir) { $p = $dir.'/'.substr($id, 0, 2).'/'.substr($id, 2); if (is_file($p)) return ['loose', $p]; } $bin = hex2bin($id); foreach ([false, true] as $rescan) { foreach ($this->packs($rescan) as $pack) { $off = $pack->find($bin); if ($off !== null) return ['pack', $pack, $off]; } } return null; } public function exists(string $id): bool { return $this->locate($id) !== null; } /** * オブジェクトを読む * * @return array|null [種類, データ] */ public function read(string $id): ?array { $loc = $this->locate($id); if ($loc === null) return null; if ($loc[0] === 'loose') return $this->readLoose($loc[1], $id); return $this->readPacked($loc[1], $loc[2]); } /** 必ず有るオブジェクト */ public function get(string $id, ?string $type = null): string { $o = $this->read($id); if ($o === null) throw new GitException("オブジェクトが有りません:{$id}"); if ($type !== null && $o[0] !== $type) throw new GitException("{$id}は{$type}ではなく{$o[0]}です"); return $o[1]; } /** * 種類と大きさだけ(データを全部は読まない) * * @return array|null [種類, 大きさ] */ public function info(string $id): ?array { $loc = $this->locate($id); if ($loc === null) return null; if ($loc[0] === 'loose') { $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); $fh = fopen($loc[1], 'rb'); $head = ''; while (!str_contains($head, "\0") && !feof($fh)) $head .= (string)@inflate_add($ctx, (string)fread($fh, 256), ZLIB_SYNC_FLUSH); fclose($fh); if (!preg_match('/^(commit|tree|blob|tag) (\d+)\0/', $head, $m)) throw new GitException("壊れたオブジェクト:{$id}"); return [$m[1], (int)$m[2]]; } [$type, $size, $dataAt, $baseOff, $baseId] = $loc[1]->header($loc[2]); if (isset(Pack::TYPES[$type])) return [Pack::TYPES[$type], $size]; // 差分:大きさは差分の頭、種類は元を辿る $head = $loc[1]->inflate($dataAt, $size); $target = Delta::targetSize($head); $pack = $loc[1]; $off = $loc[2]; for ($depth = 0; $depth < 10000; $depth++) { [$t, , , $bo, $bi] = $pack->header($off); if (isset(Pack::TYPES[$t])) return [Pack::TYPES[$t], $target]; if ($bo !== null) { $off = $bo; continue; } $l = $this->locate(bin2hex($bi)); if ($l === null) throw new GitException('差分の元が有りません:'.bin2hex($bi)); if ($l[0] === 'loose') return [$this->info(bin2hex($bi))[0], $target]; [$pack, $off] = [$l[1], $l[2]]; } throw new GitException("差分が深過ぎます:{$id}"); } private function readLoose(string $path, string $id): array { $raw = @file_get_contents($path); $data = $raw === false ? false : @zlib_decode($raw); if ($data === false) throw new GitException("壊れたオブジェクト:{$id}"); $nul = strpos($data, "\0"); if ($nul === false || !preg_match('/^(commit|tree|blob|tag) (\d+)$/', substr($data, 0, $nul), $m) || (int)$m[2] !== strlen($data) - $nul - 1) { throw new GitException("壊れたオブジェクト:{$id}"); } return [$m[1], substr($data, $nul + 1)]; } /** * パックの中のオブジェクト(差分は元を辿って当てる) * * @return array [種類, データ] */ public function readPacked(Pack $pack, int $offset): array { $key = $pack->path.':'.$offset; if (isset($this->cache[$key])) return $this->cache[$key]; // 差分の連鎖を集める $chain = []; $p = $pack; $off = $offset; while (true) { $k = $p->path.':'.$off; if (isset($this->cache[$k])) { $base = $this->cache[$k]; break; } [$type, $size, $dataAt, $baseOff, $baseId] = $p->header($off); if (isset(Pack::TYPES[$type])) { $base = [Pack::TYPES[$type], $p->inflate($dataAt, $size)]; $this->remember($k, $base); break; } $chain[] = [$p, $off, $dataAt, $size]; if (count($chain) > 10000) throw new GitException('差分が深過ぎます'); if ($baseOff !== null) { $off = $baseOff; continue; } $hex = bin2hex($baseId); $o = $p->find($baseId); if ($o !== null) { $off = $o; continue; } $l = $this->locate($hex); if ($l === null) throw new GitException("差分の元が有りません:{$hex}"); if ($l[0] === 'loose') { $base = $this->readLoose($l[1], $hex); break; } [$p, $off] = [$l[1], $l[2]]; } for ($i = count($chain) - 1; $i >= 0; $i--) { [$cp, $coff, $dataAt, $size] = $chain[$i]; $base = [$base[0], Delta::apply($base[1], $cp->inflate($dataAt, $size))]; $this->remember($cp->path.':'.$coff, $base); } return $base; } private function remember(string $key, array $obj): void { $n = strlen($obj[1]); if ($n > self::CACHE_LIMIT / 4) return; while ($this->cacheBytes + $n > self::CACHE_LIMIT && !empty($this->cache)) { $k = array_key_first($this->cache); $this->cacheBytes -= strlen($this->cache[$k][1]); unset($this->cache[$k]); } $this->cache[$key] = $obj; $this->cacheBytes += $n; } /** * ルーズオブジェクトとして書く(既に有れば何もしない) * * @return string ID */ public function write(string $type, string $data): string { if (!in_array($type, ['commit', 'tree', 'blob', 'tag'], true)) throw new GitException("不正な種類:{$type}"); $id = $this->repo->hashObject($type, $data); if ($this->exists($id)) return $id; $dir = $this->dirs[0].'/'.substr($id, 0, 2); $this->repo->mkdir($dir); $level = $this->repo->config->getInt('core.loosecompression', $this->repo->config->getInt('core.compression', -1)); $this->repo->writeFile($dir.'/'.substr($id, 2), (string)gzcompress($type.' '.strlen($data)."\0".$data, max(-1, min(9, $level))), true); return $id; } /** * 短いIDを解決する * * @return string|null 一意に決まれば完全なID */ public function resolvePrefix(string $prefix): ?string { $prefix = strtolower($prefix); if (!preg_match('/^[0-9a-f]{4,}$/', $prefix) || strlen($prefix) > $this->repo->hexLen) return null; if (strlen($prefix) === $this->repo->hexLen) return $this->exists($prefix) ? $prefix : null; $found = []; foreach ($this->dirs as $dir) { foreach (glob($dir.'/'.substr($prefix, 0, 2).'/'.substr($prefix, 2).'*') ?: [] as $f) $found[substr($prefix, 0, 2).basename($f)] = true; } foreach ($this->packs(true) as $pack) foreach ($pack->findPrefix($prefix) as $id) $found[$id] = true; return count($found) === 1 ? (string)array_key_first($found) : null; } /** 全部のID(重複あり得る) */ public function ids(): \Generator { foreach ($this->dirs as $dir) { foreach (glob($dir.'/[0-9a-f][0-9a-f]/*') ?: [] as $f) { $id = basename(dirname($f)).basename($f); if ($this->repo->isId($id)) yield $id; } } foreach ($this->packs(true) as $pack) yield from $pack->ids(); } public function dir(): string { return $this->dirs[0]; } }