agent = (string)($opt['agent'] ?? 'LittleBeast'); } /** * 参照の広告(最初の応答) * * @param resource $out * @param bool $http 「# service=git-upload-pack」を付ける(v0・v1) */ public function advertise($out, bool $http, int $version = 0): void { $o = new Output($out, $http); if ($version === 2) { $o->write(PktLine::encode("version 2\n").PktLine::encode("agent={$this->agent}\n").PktLine::encode("ls-refs=unborn\n") .PktLine::encode("fetch=shallow wait-for-done\n").PktLine::encode("server-option\n").PktLine::encode("object-format={$this->repo->hashAlgo}\n").PktLine::flush()); $o->flush(); return; } $s = $http ? PktLine::encode("# service=git-upload-pack\n").PktLine::flush() : ''; if ($version === 1) $s .= PktLine::encode("version 1\n"); $caps = 'multi_ack multi_ack_detailed side-band side-band-64k ofs-delta shallow deepen-relative no-progress include-tag thin-pack'; $head = $this->repo->headRef(); if ($head !== null && $this->repo->refs->resolve('HEAD') !== null) $caps .= " symref=HEAD:{$head}"; $caps .= " object-format={$this->repo->hashAlgo} agent={$this->agent}"; $first = true; foreach ($this->refLines() as [$id, $name]) { $s .= PktLine::encode($id.' '.$name.($first ? "\0".$caps : '')."\n"); $first = false; } if ($first) $s .= PktLine::encode($this->repo->zeroId()." capabilities^{}\0{$caps}\n"); $o->write($s.PktLine::flush()); $o->flush(); } /** HEADと参照(注釈付きタグは剥いた物も) */ private function refLines(): array { $out = []; $head = $this->repo->refs->resolve('HEAD'); if ($head !== null) $out[] = [$head, 'HEAD']; foreach ($this->repo->refs->all() as $name => $id) { $out[] = [$id, $name]; if (str_starts_with($name, 'refs/tags/')) { $peeled = $this->peeled($name, $id); if ($peeled !== null && $peeled !== $id) $out[] = [$peeled, $name.'^{}']; } } return $out; } private function peeled(string $name, string $id): ?string { return $this->repo->refs->peeledFromPack($name) ?? $this->repo->peel($id); } /** * リクエストを処理する * * @param resource $in * @param resource $out * @param bool $statelessRpc HTTP(1回のリクエストと応答) */ public function serve($in, $out, bool $statelessRpc, int $version = 0): void { $this->out = new Output($out, $statelessRpc); $pkt = new PktLine($in); try { if ($version === 2) { if (!$statelessRpc) $this->advertise($out, false, 2); do { $more = $this->command($pkt); } while ($more && !$statelessRpc); return; } if (!$statelessRpc) $this->advertise($out, false, $version); $this->fetchV0($pkt, $statelessRpc); } catch (GitException $e) { if ($this->sideband) $this->out->write(PktLine::band(3, $e->getMessage()."\n", $this->bandMax).PktLine::flush()); else $this->out->write(PktLine::encode('ERR '.$e->getMessage()."\n")); $this->out->flush(); } } //////// v0・v1 private function fetchV0(PktLine $pkt, bool $stateless): void { $wants = []; $caps = []; $clientShallow = []; $depth = 0; $relative = false; while (true) { $l = $pkt->read(); if ($l === null || $l === PktLine::FLUSH) break; if (!is_string($l)) continue; if (preg_match('/^want ([0-9a-f]+)(?: (.*))?$/', $l, $m)) { if (empty($wants) && isset($m[2])) $caps = explode(' ', trim($m[2])); $this->checkWant($m[1]); $wants[$m[1]] = true; } else if (preg_match('/^shallow ([0-9a-f]+)$/', $l, $m)) { $clientShallow[] = $m[1]; } else if (preg_match('/^deepen (\d+)$/', $l, $m)) { $depth = (int)$m[1]; } else if ($l === 'deepen-relative') { $relative = true; } else if (preg_match('/^(deepen-since|deepen-not|filter) /', $l, $m)) { throw new GitException("未対応:{$m[1]}"); } } if (empty($wants)) return; // v0では能力として送られる if (in_array('deepen-relative', $caps, true)) $relative = true; $this->sideband = in_array('side-band-64k', $caps, true) || in_array('side-band', $caps, true); $this->bandMax = in_array('side-band-64k', $caps, true) ? PktLine::MAX : 1000; $this->progress = !in_array('no-progress', $caps, true); $multiAck = in_array('multi_ack_detailed', $caps, true) ? 2 : (in_array('multi_ack', $caps, true) ? 1 : 0); // 深さ $commits = null; $boundary = null; if ($depth > 0) { [$commits, $shallow] = $this->shallow(array_keys($wants), $depth, $clientShallow, $relative); $s = ''; foreach ($shallow['shallow'] as $id) $s .= PktLine::encode("shallow {$id}\n"); foreach ($shallow['unshallow'] as $id) $s .= PktLine::encode("unshallow {$id}\n"); $this->out->write($s.PktLine::flush()); $this->out->flush(); } // ネゴシエーション $common = []; $last = null; $done = false; while (true) { $l = $pkt->read(); if ($l === null) return; if ($l === PktLine::FLUSH) { if (empty($common) || $multiAck) $this->out->write(PktLine::encode("NAK\n")); $this->out->flush(); if ($stateless) return; continue; } if ($l === 'done') { $done = true; break; } if (!is_string($l) || !preg_match('/^have ([0-9a-f]+)$/', $l, $m)) continue; $id = $m[1]; if (!$this->repo->isId($id) || !$this->repo->objects->exists($id)) continue; $isNew = !isset($common[$id]); $common[$id] = true; $last = $id; if ($multiAck === 2) $this->out->write(PktLine::encode("ACK {$id} common\n")); else if ($multiAck === 1) $this->out->write(PktLine::encode("ACK {$id} continue\n")); else if ($isNew && count($common) === 1) $this->out->write(PktLine::encode("ACK {$id}\n")); } if (!empty($common)) { if ($multiAck) $this->out->write(PktLine::encode("ACK {$last}\n")); } else { $this->out->write(PktLine::encode("NAK\n")); } $this->sendPack(array_keys($wants), array_keys($common), in_array('include-tag', $caps, true), $commits, $clientShallow, $depth > 0); } private function checkWant(string $id): void { if (!$this->repo->isId($id) || !$this->repo->objects->exists($id)) throw new GitException("オブジェクトが有りません:{$id}"); } /** * 深さの制限 * * @return array [送るコミット, ['shallow' => [...], 'unshallow' => [...]]] */ private function shallow(array $wants, int $depth, array $clientShallow, bool $relative = false): array { $walk = new Walk($this->repo); $wantCommits = []; foreach ($wants as $w) { $c = $this->repo->peel($w, 'commit'); if ($c !== null) $wantCommits[] = $c; } if ($relative && !empty($clientShallow)) { // 今のshallowのコミットから更に深く(git fetch --deepen) [$deeper, $shallow] = $walk->deepen($clientShallow, $depth + 1); $grafted = new Walk($this->repo); $grafted->setGrafts($clientShallow); $commits = array_values(array_unique(array_merge($grafted->ancestors($wantCommits), $deeper))); $sh = array_fill_keys($shallow, true); return [$commits, ['shallow' => $shallow, 'unshallow' => array_values(array_filter($clientShallow, fn($id) => !isset($sh[$id])))]]; } [$commits, $shallow] = $walk->deepen($wantCommits, $depth); $in = array_fill_keys($commits, true); $sh = array_fill_keys($shallow, true); $unshallow = []; foreach ($clientShallow as $id) if (isset($in[$id]) && !isset($sh[$id])) $unshallow[] = $id; return [$commits, ['shallow' => $shallow, 'unshallow' => $unshallow]]; } /** パックを送る(サイドバンドが有れば進捗も) */ private function sendPack(array $wants, array $haves, bool $includeTag, ?array $commits, array $clientShallow, bool $deepen): void { $walk = new Walk($this->repo); $walk->setGrafts($clientShallow); $haveCommits = []; foreach ($haves as $h) { $c = $this->repo->peel($h, 'commit'); if ($c !== null) $haveCommits[] = $c; } $boundary = null; if ($commits !== null) { // 深さで決めたコミットから、相手が持っている物を除く $have = array_fill_keys($walk->ancestors($haveCommits), true); $commits = array_values(array_filter($commits, fn($c) => !isset($have[$c]))); $boundary = []; foreach ($commits as $c) foreach ($this->repo->commit($c)->parents as $p) if (isset($have[$p])) $boundary[$p] = true; $boundary = array_keys($boundary); } $this->progress('オブジェクトを数えています…'); $objects = $walk->objects($wants, $haveCommits, $commits, $boundary); if ($includeTag) { foreach ($this->repo->refs->all('refs/tags/') as $name => $id) { if (isset($objects[$id]) || ($this->repo->objects->info($id)[0] ?? null) !== 'tag') continue; $target = $this->peeled($name, $id); if ($target !== null && isset($objects[$target])) $objects[$id] = 'tag'; } } $this->progress('オブジェクト:'.count($objects)."件\n"); $writer = new PackWriter($this->repo); $writer->write($objects, function(string $bytes): void { $this->out->write($this->sideband ? PktLine::band(1, $bytes, $this->bandMax) : $bytes); $this->out->flush(); }); if ($this->sideband) $this->out->write(PktLine::flush()); $this->out->flush(); } private function progress(string $msg): void { if ($this->sideband && $this->progress) { $this->out->write(PktLine::band(2, str_ends_with($msg, "\n") ? $msg : $msg."\r", $this->bandMax)); $this->out->flush(); } } //////// v2 /** コマンドを1つ処理する(続けるならtrue) */ private function command(PktLine $pkt): bool { $command = null; $caps = []; while (true) { $l = $pkt->read(); if ($l === null) return false; if ($l === PktLine::FLUSH) { if ($command === null) return true; $args = []; break; } if ($l === PktLine::DELIM) { $args = $pkt->readUntilFlush(); break; } if (is_string($l) && str_starts_with($l, 'command=')) $command = substr($l, 8); else if (is_string($l)) $caps[] = $l; } foreach ($caps as $c) { if (str_starts_with($c, 'object-format=') && substr($c, 14) !== $this->repo->hashAlgo) throw new GitException('オブジェクトの形式が違います('.substr($c, 14).')'); } match ($command) { 'ls-refs' => $this->lsRefs($args), 'fetch' => $this->fetchV2($args), default => throw new GitException("未対応のコマンド:{$command}"), }; return true; } private function lsRefs(array $args): void { $symrefs = in_array('symrefs', $args, true); $peel = in_array('peel', $args, true); $unborn = in_array('unborn', $args, true); $prefixes = []; foreach ($args as $a) if (str_starts_with($a, 'ref-prefix ')) $prefixes[] = substr($a, 11); $match = function(string $name) use ($prefixes): bool { if (empty($prefixes)) return true; foreach ($prefixes as $p) if (str_starts_with($name, $p)) return true; return false; }; $s = ''; if ($match('HEAD')) { $head = $this->repo->refs->resolve('HEAD'); $target = $this->repo->headRef(); $sym = $symrefs && $target !== null ? " symref-target:{$target}" : ''; if ($head !== null) $s .= PktLine::encode("{$head} HEAD{$sym}\n"); else if ($unborn && $target !== null) $s .= PktLine::encode("unborn HEAD{$sym}\n"); } foreach ($this->repo->refs->all() as $name => $id) { if (!$match($name)) continue; $line = "{$id} {$name}"; if ($peel && str_starts_with($name, 'refs/tags/')) { $p = $this->peeled($name, $id); if ($p !== null && $p !== $id) $line .= " peeled:{$p}"; } $s .= PktLine::encode($line."\n"); } $this->out->write($s.PktLine::flush()); $this->out->flush(); } private function fetchV2(array $args): void { $wants = []; $haves = []; $clientShallow = []; $depth = 0; $relative = false; $done = false; $includeTag = false; foreach ($args as $a) { if (preg_match('/^want ([0-9a-f]+)$/', $a, $m)) { $this->checkWant($m[1]); $wants[$m[1]] = true; } else if (preg_match('/^have ([0-9a-f]+)$/', $a, $m)) { if ($this->repo->isId($m[1]) && $this->repo->objects->exists($m[1])) $haves[$m[1]] = true; } else if (preg_match('/^shallow ([0-9a-f]+)$/', $a, $m)) { $clientShallow[] = $m[1]; } else if (preg_match('/^deepen (\d+)$/', $a, $m)) { $depth = (int)$m[1]; } else if ($a === 'deepen-relative') { $relative = true; } else if ($a === 'done') { $done = true; } else if ($a === 'include-tag') { $includeTag = true; } else if ($a === 'no-progress') { $this->progress = false; } else if (preg_match('/^(deepen-since|deepen-not|filter|want-ref|packfile-uris) /', $a, $m)) { throw new GitException("未対応:{$m[1]}"); } } if (!$done) { // 共通のコミットを知らせる(readyは送らないので、相手がdoneを送る) $s = PktLine::encode("acknowledgments\n"); if (empty($haves)) $s .= PktLine::encode("NAK\n"); foreach (array_keys($haves) as $h) $s .= PktLine::encode("ACK {$h}\n"); $this->out->write($s.PktLine::flush()); $this->out->flush(); return; } $commits = null; if ($depth > 0) { [$commits, $shallow] = $this->shallow(array_keys($wants), $depth, $clientShallow, $relative); $s = PktLine::encode("shallow-info\n"); foreach ($shallow['shallow'] as $id) $s .= PktLine::encode("shallow {$id}\n"); foreach ($shallow['unshallow'] as $id) $s .= PktLine::encode("unshallow {$id}\n"); $this->out->write($s.PktLine::delim()); } $this->out->write(PktLine::encode("packfile\n")); $this->sideband = true; $this->bandMax = PktLine::MAX; $this->sendPack(array_keys($wants), array_keys($haves), $includeTag, $commits, $clientShallow, $depth > 0); } }