packed !== null) return $this->packed; $this->packed = []; $this->peeled = []; $file = $this->repo->path.'/packed-refs'; if (!is_file($file)) return $this->packed; $last = null; foreach (preg_split('/\r?\n/', (string)file_get_contents($file)) as $line) { if ($line === '' || $line[0] === '#') continue; if ($line[0] === '^') { if ($last !== null) $this->peeled[$last] = substr($line, 1); continue; } $sp = strpos($line, ' '); if ($sp === false) continue; $last = substr($line, $sp + 1); $this->packed[$last] = substr($line, 0, $sp); } return $this->packed; } /** * 参照の中身(解決しない) * * @return string|null ID又は「ref: 名前」 */ public function readRaw(string $name): ?string { if (!self::validName($name)) return null; $file = $this->repo->path.'/'.$name; if (is_file($file)) { $v = trim((string)file_get_contents($file)); return $v === '' ? null : $v; } return $this->packed()[$name] ?? null; } /** シンボリック参照の先(HEAD → refs/heads/main)、シンボリックでなければnull */ public function symbolic(string $name): ?string { $v = $this->readRaw($name); return $v !== null && str_starts_with($v, 'ref: ') ? trim(substr($v, 5)) : null; } /** * 参照をIDに解決する(シンボリック参照を辿る) */ public function resolve(string $name): ?string { for ($i = 0; $i < 10; $i++) { $v = $this->readRaw($name); if ($v === null) return null; if (!str_starts_with($v, 'ref: ')) return $this->repo->isId($v) ? strtolower($v) : null; $name = trim(substr($v, 5)); } return null; } /** * 短い名前(main、v1.0、origin/main)を完全な名前にする */ public function dwim(string $short): ?string { foreach ([$short, "refs/{$short}", "refs/tags/{$short}", "refs/heads/{$short}", "refs/remotes/{$short}", "refs/remotes/{$short}/HEAD"] as $n) { if ($this->readRaw($n) !== null) return $n; } return null; } /** * 全部の参照(HEADを除く、名前の順) * * @param string $prefix 名前の始まり(例:refs/heads/) * @return array 名前 => ID */ public function all(string $prefix = 'refs/'): array { $out = []; foreach ($this->packed() as $name => $id) if (str_starts_with($name, $prefix)) $out[$name] = $id; $base = $this->repo->path.'/'; $dir = $base.rtrim(str_contains($prefix, '/') ? substr($prefix, 0, strrpos($prefix, '/')) : $prefix, '/'); if (is_dir($dir)) { $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); foreach ($it as $f) { if (!$f->isFile()) continue; $name = str_replace('\\', '/', substr($f->getPathname(), strlen($base))); if (!str_starts_with($name, $prefix) || !self::validName($name)) continue; $v = trim((string)file_get_contents($f->getPathname())); if (str_starts_with($v, 'ref: ')) { $r = $this->resolve($name); if ($r !== null) $out[$name] = $r; } else if ($this->repo->isId($v)) { $out[$name] = strtolower($v); } } } ksort($out, SORT_STRING); return $out; } /** packed-refsの剥いた値(注釈付きタグの先)。無ければnull */ public function peeledFromPack(string $name): ?string { $this->packed(); return $this->peeled[$name] ?? null; } /** * 参照を更新する * * @param string $name 参照の名前 * @param string|null $new 新しいID(nullなら削除) * @param string|null $old 今のID(確かめる。0ばかりのIDは「無い事」、nullなら確かめない) * @throws GitException 競合した場合等 */ public function update(string $name, ?string $new, ?string $old = null): void { $this->transaction([[$name, $new, $old]]); } /** * 複数の参照を全部更新するか、全部しない * * @param array $updates [名前, 新しいID又はnull, 今のID又はnull] */ public function transaction(array $updates): void { $locks = []; $zero = $this->repo->zeroId(); try { // HEAD等のシンボリック参照は先を更新する foreach ($updates as $i => [$name, $new, $old]) { $target = $name; for ($d = 0; $d < 10 && ($s = $this->symbolic($target)) !== null; $d++) $target = $s; if (!self::validName($target) || $target === 'HEAD') throw new GitException("不正な参照の名前:{$name}"); if ($new !== null && !$this->repo->isId($new)) throw new GitException("不正なID:{$new}"); $updates[$i][0] = $target; $lock = $this->repo->path.'/'.$target.'.lock'; $this->repo->mkdir(dirname($lock)); // ディレクトリと同じ名前の参照(refs/heads/aとrefs/heads/a/b)は作れない if (is_dir($this->repo->path.'/'.$target)) throw new GitException("参照の名前が他の参照と重なります:{$target}"); $fh = @fopen($lock, 'x'); if ($fh === false) throw new GitException("参照がロックされています:{$target}"); $locks[$target] = [$fh, $lock]; } $this->packed = null; foreach ($updates as [$name, $new, $old]) { if ($old === null) continue; $cur = $this->resolve($name); if ($old === $zero ? $cur !== null : $cur !== strtolower($old)) throw new GitException("参照が変わっています:{$name}"); } $deletes = []; foreach ($updates as [$name, $new]) { [$fh, $lock] = $locks[$name]; if ($new === null) { $deletes[] = $name; continue; } fwrite($fh, strtolower($new)."\n"); } if (!empty($deletes)) $this->removePacked($deletes); foreach ($updates as [$name, $new]) { [$fh, $lock] = $locks[$name]; fclose($fh); $file = $this->repo->path.'/'.$name; if ($new === null) { @unlink($lock); @unlink($file); $this->pruneDirs(dirname($file)); } else { if (!@rename($lock, $file)) throw new GitException("参照を書けません:{$name}"); $this->repo->chmodFile($file); } unset($locks[$name]); } } finally { foreach ($locks as [$fh, $lock]) { if (is_resource($fh)) fclose($fh); @unlink($lock); } $this->packed = null; } } /** packed-refsから消す */ private function removePacked(array $names): void { $file = $this->repo->path.'/packed-refs'; if (!is_file($file)) return; $lock = $file.'.lock'; $fh = @fopen($lock, 'x'); if ($fh === false) throw new GitException('packed-refsがロックされています'); $out = []; $skip = false; foreach (preg_split('/\r?\n/', (string)file_get_contents($file)) as $line) { if ($line === '') continue; if ($line[0] === '^') { if (!$skip) $out[] = $line; continue; } $sp = strpos($line, ' '); $skip = $sp !== false && $line[0] !== '#' && in_array(substr($line, $sp + 1), $names, true); if (!$skip) $out[] = $line; } fwrite($fh, implode("\n", $out)."\n"); fclose($fh); if (!@rename($lock, $file)) { @unlink($lock); throw new GitException('packed-refsを書けません'); } } /** 空になった参照のディレクトリを消す(refs/heads等は残す) */ private function pruneDirs(string $dir): void { $base = $this->repo->path.'/refs'; while (strlen($dir) > strlen($base) && !in_array(basename($dir), ['heads', 'tags'], true) && dirname($dir) !== $base) { if (!@rmdir($dir)) break; $dir = dirname($dir); } } /** シンボリック参照を設定する(例:HEADをrefs/heads/mainに) */ public function setSymbolic(string $name, string $target): void { if (!self::validName($name) || !self::validName($target)) throw new GitException("不正な参照の名前:{$target}"); $this->repo->writeFile($this->repo->path.'/'.$name, "ref: {$target}\n"); } }