agent = (string)($opt['agent'] ?? 'LittleBeast'); $this->unpackLimit = (int)($opt['unpackLimit'] ?? $repo->config->getInt('receive.unpacklimit', $repo->config->getInt('transfer.unpacklimit', 100))); } /** * 参照の広告 * * @param resource $out */ public function advertise($out, bool $http): void { $o = new Output($out, $http); $s = $http ? PktLine::encode("# service=git-receive-pack\n").PktLine::flush() : ''; $caps = "report-status delete-refs side-band-64k quiet atomic ofs-delta no-thin object-format={$this->repo->hashAlgo} agent={$this->agent}"; $first = true; foreach ($this->repo->refs->all() as $name => $id) { $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(); } /** * プッシュを処理する * * @param resource $in * @param resource $out * @return array 更新した参照 [ref, old, new] */ public function serve($in, $out, bool $statelessRpc): array { $this->out = new Output($out, $statelessRpc); if (!$statelessRpc) $this->advertise($out, false); $pkt = new PktLine($in); $commands = []; $caps = []; $zero = $this->repo->zeroId(); while (true) { $l = $pkt->read(); if ($l === null || $l === PktLine::FLUSH) break; if (!is_string($l)) continue; if (str_starts_with($l, 'shallow ')) continue; $nul = strpos($l, "\0"); if ($nul !== false) { $caps = explode(' ', trim(substr($l, $nul + 1))); $l = substr($l, 0, $nul); } if (!preg_match('/^([0-9a-f]+) ([0-9a-f]+) (.+)$/', $l, $m) || !$this->repo->isId($m[1]) || !$this->repo->isId($m[2])) { throw new GitException("不正なコマンド:{$l}"); } $commands[] = ['ref' => $m[3], 'old' => $m[1], 'new' => $m[2], 'error' => null]; } if (empty($commands)) return []; foreach ($caps as $c) { if (str_starts_with($c, 'object-format=') && substr($c, 14) !== $this->repo->hashAlgo) throw new GitException('オブジェクトの形式が違います'); } $this->sideband = in_array('side-band-64k', $caps, true); $quiet = in_array('quiet', $caps, true); $atomic = in_array('atomic', $caps, true); $report = in_array('report-status', $caps, true); // パック(削除だけなら無い) $unpack = 'ok'; $received = ['objects' => [], 'links' => []]; $needPack = false; foreach ($commands as $c) if ($c['new'] !== $zero) $needPack = true; if ($needPack) { try { $progress = $quiet || !$this->sideband ? null : function(string $what, int $done, int $total): void { $this->out->write(PktLine::band(2, "{$what}:{$done}/{$total}".($done === $total ? "\n" : "\r"))); $this->out->flush(); }; $received = (new PackIndexer($this->repo))->receive($in, $this->unpackLimit, $progress); // 足りないオブジェクト foreach (array_keys($received['links']) as $id) { if (!$this->repo->objects->exists($id)) throw new GitException("必要なオブジェクトが有りません:{$id}"); } } catch (GitException $e) { $unpack = $e->getMessage(); } } if ($unpack !== 'ok') { foreach ($commands as $i => $c) $commands[$i]['error'] = 'unpacker error'; } else { $commands = $this->check($commands, $received['objects']); if ($atomic && count(array_filter($commands, fn($c) => $c['error'] !== null)) > 0) { foreach ($commands as $i => $c) $commands[$i]['error'] ??= 'atomic push failed'; } $commands = $this->apply($commands, $atomic); // 参照の処理(プルリクエスト等)とクライアントへのメッセージ if (isset($this->opt['process'])) { $ok = array_values(array_map(fn($c) => [$c['ref'], $c['old'], $c['new']], array_filter($commands, fn($c) => $c['error'] === null))); $lines = empty($ok) ? [] : ($this->opt['process'])($ok); if ($this->sideband && !empty($lines)) { foreach ($lines as $line) $this->out->write(PktLine::band(2, rtrim((string)$line, "\n")."\n")); $this->out->flush(); } } } if ($report) { $s = PktLine::encode('unpack '.($unpack === 'ok' ? 'ok' : str_replace("\n", ' ', $unpack))."\n"); foreach ($commands as $c) $s .= PktLine::encode($c['error'] === null ? "ok {$c['ref']}\n" : "ng {$c['ref']} ".str_replace("\n", ' ', $c['error'])."\n"); $s .= PktLine::flush(); $this->out->write($this->sideband ? PktLine::band(1, $s) : $s); } if ($this->sideband) $this->out->write(PktLine::flush()); $this->out->flush(); $done = []; foreach ($commands as $c) if ($c['error'] === null) $done[] = [$c['ref'], $c['old'], $c['new']]; if (!empty($done) && isset($this->opt['after'])) ($this->opt['after'])($done); return $done; } /** 更新出来るか確かめる */ private function check(array $commands, array $received): array { $zero = $this->repo->zeroId(); $denyFf = (bool)($this->opt['denyNonFastForwards'] ?? $this->repo->config->getBool('receive.denynonfastforwards')); $denyDel = (bool)($this->opt['denyDeletes'] ?? $this->repo->config->getBool('receive.denydeletes')); if (isset($this->opt['check'])) { $err = ($this->opt['check'])(array_map(fn($c) => [$c['ref'], $c['old'], $c['new']], $commands)); if ($err !== null) { foreach ($commands as $i => $c) $commands[$i]['error'] = $err; return $commands; } } $walk = new Walk($this->repo); foreach ($commands as $i => $c) { $err = null; $ref = $c['ref']; $type = $c['new'] === $zero ? null : ($received[$c['new']] ?? $this->repo->objects->info($c['new'])[0] ?? null); if (!Refs::validName($ref) || !str_starts_with($ref, 'refs/') || $ref === 'HEAD') $err = 'funny refname'; else if ($c['new'] !== $zero && $type === null) $err = 'missing necessary objects'; else if (str_starts_with($ref, 'refs/heads/') && $c['new'] !== $zero && $type !== 'commit') $err = 'branch must point to a commit'; else if ($c['new'] === $zero && $denyDel) $err = 'deletion prohibited'; else if ($c['new'] === $zero && $c['old'] === $zero) $err = 'nothing to delete'; else if ($denyFf && $c['old'] !== $zero && $c['new'] !== $zero && $this->repo->objects->exists($c['old']) && !$walk->isAncestor($c['old'], $c['new'])) $err = 'non-fast-forward'; if ($err === null && isset($this->opt['update'])) $err = ($this->opt['update'])($ref, $c['old'], $c['new']); $commands[$i]['error'] = $err; } return $commands; } /** 参照を更新する(古い値が変わっていれば失敗) */ private function apply(array $commands, bool $atomic): array { $zero = $this->repo->zeroId(); // interceptの参照は書かない $skip = fn(array $c) => isset($this->opt['intercept']) && ($this->opt['intercept'])($c['ref']); $ok = array_filter($commands, fn($c) => $c['error'] === null && !$skip($c)); if (empty($ok)) return $commands; $tx = fn(array $c) => [$c['ref'], $c['new'] === $zero ? null : $c['new'], $c['old']]; if ($atomic) { try { $this->repo->refs->transaction(array_map($tx, $ok)); } catch (GitException $e) { foreach ($commands as $i => $c) $commands[$i]['error'] ??= 'failed to update ref'; } return $commands; } foreach ($commands as $i => $c) { if ($c['error'] !== null || $skip($c)) continue; try { $this->repo->refs->transaction([$tx($c)]); } catch (GitException $e) { $commands[$i]['error'] = str_contains($e->getMessage(), '変わって') ? 'fetch first' : 'failed to update ref'; } } return $commands; } }