writeBlob("こんにちは\n"); * $tree = $repo->writeTree([[Tree::FILE, 'README.md', $blob]]); * $me = Person::now('諏訪子', 'suwako@example.com'); * $commit = $repo->writeCommit(Commit::create($tree, [], $me, $me, "最初のコミット\n")); * $repo->refs->update('refs/heads/main', $commit, $repo->zeroId()); * foreach ($repo->log('main') as $c) echo $c->subject(), "\n"; */ final class Repository { public readonly string $path; /** sha1又はsha256 */ public readonly string $hashAlgo; /** IDのバイト数(20又は32) */ public readonly int $hashLen; /** 16進数のIDの長さ(40又は64) */ public readonly int $hexLen; public readonly Config $config; public readonly ObjectDb $objects; public readonly Refs $refs; private ?int $fileMode = null; private ?int $dirMode = null; private function __construct(string $path) { $this->path = rtrim(str_replace('\\', '/', $path), '/'); $this->config = new Config($this->path.'/config'); $format = strtolower((string)$this->config->get('extensions.objectformat', 'sha1')); if (!in_array($format, ['sha1', 'sha256'], true)) throw new GitException("未対応のオブジェクトの形式:{$format}"); $version = $this->config->getInt('core.repositoryformatversion', 0); if ($version > 1) throw new GitException("未対応のリポジトリの形式:{$version}"); $this->hashAlgo = $format; $this->hashLen = $format === 'sha1' ? 20 : 32; $this->hexLen = $this->hashLen * 2; // core.sharedRepository(group:グループも書ける、all:皆が読める、0xxx) $shared = strtolower((string)$this->config->get('core.sharedrepository', '')); if (in_array($shared, ['group', 'true', '1'], true)) [$this->fileMode, $this->dirMode] = [0664, 02775]; else if (in_array($shared, ['all', 'world', 'everybody', '2'], true)) [$this->fileMode, $this->dirMode] = [0664, 02775]; else if (preg_match('/^0[0-7]{3}$/', $shared)) [$this->fileMode, $this->dirMode] = [octdec($shared), octdec($shared) | 0111 | 02000]; $this->objects = new ObjectDb($this); $this->refs = new Refs($this); } /** * リポジトリを開く * * @param string $path ベアのリポジトリ、又は.gitの有るディレクトリ */ public static function open(string $path): self { if (is_dir($path.'/.git')) $path .= '/.git'; if (!is_dir($path.'/objects') || !is_file($path.'/HEAD')) throw new GitException("Gitのリポジトリではありません:{$path}"); return new self($path); } public static function isRepository(string $path): bool { return is_dir($path.'/objects') && is_file($path.'/HEAD') && is_dir($path.'/refs'); } /** * ベアのリポジトリを作る * * @param string $path ディレクトリ(無ければ作る、空でなければならない) * @param string $objectFormat sha1又はsha256 * @param string $branch 最初のブランチ(HEADの先) * @param string|null $shared core.sharedRepository(例:group。SSHのgitユーザーとphp-fpmで共有する場合) */ public static function init(string $path, string $objectFormat = 'sha1', string $branch = 'main', ?string $shared = null, string $description = ''): self { if (!in_array($objectFormat, ['sha1', 'sha256'], true)) throw new GitException("未対応のオブジェクトの形式:{$objectFormat}"); if (!Refs::validName('refs/heads/'.$branch)) throw new GitException("不正なブランチの名前:{$branch}"); if (is_dir($path) && count(array_diff(scandir($path) ?: [], ['.', '..'])) > 0) throw new GitException("ディレクトリが空ではありません:{$path}"); $dirMode = $shared !== null ? 02775 : 0777; if (!is_dir($path) && !@mkdir($path, $dirMode, true) && !is_dir($path)) throw new GitException("ディレクトリを作れません:{$path}"); if ($shared !== null) @chmod($path, 02775); $c = Config::fromString(''); $c->set('core.repositoryformatversion', $objectFormat === 'sha256' ? '1' : '0'); $c->set('core.filemode', 'true'); $c->set('core.bare', 'true'); if ($shared !== null) $c->set('core.sharedrepository', $shared); if ($objectFormat === 'sha256') $c->set('extensions.objectformat', 'sha256'); // receive-packでの確認 $c->set('receive.fsckobjects', 'true'); if (@file_put_contents($path.'/config', $c->toString()) === false) throw new GitException("リポジトリを作れません:{$path}"); $repo = new self($path); foreach (['objects/info', 'objects/pack', 'refs/heads', 'refs/tags', 'info', 'hooks'] as $d) $repo->mkdir($repo->path.'/'.$d); $repo->writeFile($repo->path.'/HEAD', "ref: refs/heads/{$branch}\n"); $repo->writeFile($repo->path.'/description', $description !== '' ? $description."\n" : "Unnamed repository; edit this file 'description' to name the repository.\n"); $repo->chmodFile($repo->path.'/config'); return $repo; } //////// ID /** 0ばかりのID(「無い」) */ public function zeroId(): string { return str_repeat('0', $this->hexLen); } public function isId(string $s): bool { return strlen($s) === $this->hexLen && ctype_xdigit($s); } /** オブジェクトのID(16進数) */ public function hashObject(string $type, string $data): string { return hash($this->hashAlgo, $type.' '.strlen($data)."\0".$data); } //////// 読む public function commit(string $id): Commit { return Commit::parse($this->objects->get($id, 'commit'), $id); } public function tree(string $id): Tree { return Tree::parse($this->objects->get($id, 'tree'), $this->hashLen, $id); } public function tag(string $id): Tag { return Tag::parse($this->objects->get($id, 'tag'), $id); } public function blob(string $id): string { return $this->objects->get($id, 'blob'); } /** 注釈付きタグを剥く(タグの先のオブジェクト) */ public function peel(string $id, ?string $want = null): ?string { for ($i = 0; $i < 100; $i++) { $o = $this->objects->info($id); if ($o === null) return null; if ($o[0] !== 'tag' || $want === 'tag') return $want === null || $o[0] === $want ? $id : null; $id = $this->tag($id)->object; } return null; } /** コミットのツリー(ツリーならそのまま、IDでなければリビジョンとして解決) */ public function treeOf(string $id): ?string { if (!$this->isId($id)) $id = $this->resolve($id) ?? ''; $id = $id === '' ? null : $this->peel($id); if ($id === null) return null; $t = $this->objects->info($id)[0] ?? null; if ($t === 'tree') return $id; return $t === 'commit' ? $this->commit($id)->tree : null; } /** * ツリーの中のパスのエントリー * * @param string $treeish コミット又はツリー(IDかリビジョン) * @param string $path 「src/a.php」(空ならルートのツリー) * @return array|null [mode, name, id] */ public function entry(string $treeish, string $path): ?array { $tree = $this->treeOf($treeish); if ($tree === null) return null; $path = trim($path, '/'); if ($path === '') return [Tree::DIR, '', $tree]; $parts = explode('/', $path); $entry = null; foreach ($parts as $i => $part) { $entry = $this->tree($tree)->find($part); if ($entry === null) return null; if ($i < count($parts) - 1) { if ($entry[0] !== Tree::DIR) return null; $tree = $entry[2]; } } return $entry; } /** * リビジョンを解決する(git rev-parseの基本) * * 対応:完全・短いID、参照の名前(HEAD、main、v1.0、refs/...)、rev^、rev^2、rev~3、rev^{tree}、rev^{commit}、rev:パス * * @return string|null ID */ public function resolve(string $rev): ?string { $rev = trim($rev); if ($rev === '') return null; // rev:パス if (preg_match('/^([^:]*):(.*)$/s', $rev, $m) && !preg_match('#^[A-Za-z]:[\\\\/]#', $rev)) { $base = $this->resolve($m[1] === '' ? 'HEAD' : $m[1]); if ($base === null) return null; return $this->entry($base, $m[2])[2] ?? null; } if (!preg_match('/^(.*?)((?:\^\{[a-z]*\}|\^\d*|~\d*)*)$/s', $rev, $m)) return null; $id = $this->resolveName($m[1]); if ($id === null) return null; preg_match_all('/\^\{([a-z]*)\}|\^(\d*)|~(\d*)/', $m[2], $ops, PREG_SET_ORDER); foreach ($ops as $op) { if (isset($op[1]) && $op[1] !== '' || str_starts_with($op[0], '^{')) { $id = $op[1] === '' ? $this->peel($id) : ($op[1] === 'object' ? $id : $this->peelTo($id, $op[1])); } else if (str_starts_with($op[0], '^')) { $n = $op[2] === '' ? 1 : (int)$op[2]; $c = $this->peel($id, 'commit'); if ($c === null) return null; $id = $n === 0 ? $c : ($this->commit($c)->parents[$n - 1] ?? null); } else { $n = ($op[3] ?? '') === '' ? 1 : (int)$op[3]; for ($i = 0; $i < $n && $id !== null; $i++) { $c = $this->peel($id, 'commit'); $id = $c === null ? null : ($this->commit($c)->parents[0] ?? null); } } if ($id === null) return null; } return $id; } private function peelTo(string $id, string $type): ?string { $id = $type === 'tag' ? $id : $this->peel($id); if ($id === null) return null; if ($type === 'tree') return $this->treeOf($id); return ($this->objects->info($id)[0] ?? null) === $type ? $id : null; } private function resolveName(string $name): ?string { if ($name === '') $name = 'HEAD'; if ($name === '@') $name = 'HEAD'; $full = $this->refs->dwim($name); if ($full !== null) return $this->refs->resolve($full); if (preg_match('/^[0-9a-fA-F]{4,}$/', $name)) return $this->objects->resolvePrefix($name); return null; } /** HEADの先のブランチ(例:refs/heads/main) */ public function headRef(): ?string { return $this->refs->symbolic('HEAD'); } /** 空のリポジトリか(コミットが1つも無い) */ public function isEmpty(): bool { return empty($this->refs->all('refs/')); } //////// 書く public function writeBlob(string $data): string { return $this->objects->write('blob', $data); } /** @param array $entries [mode, name, id] */ public function writeTree(array $entries): string { return $this->objects->write('tree', Tree::create($entries)->toString()); } public function writeCommit(Commit $c): string { if (!$this->isId($c->tree)) throw new GitException('不正なツリーのID'); return $c->id = $this->objects->write('commit', $c->toString()); } public function writeTag(Tag $t): string { return $t->id = $this->objects->write('tag', $t->toString()); } /** * ツリーのファイルを変えた新しいツリー(Webでの編集用) * * @param string|null $tree 元のツリー(nullなら空) * @param array $changes パス => [mode, 内容(文字列)] 又はnull(削除) * @return string 新しいツリーのID */ public function updateTree(?string $tree, array $changes): string { return $this->changeTree($tree, $changes) ?? $this->writeTree([]); } /** @return string|null 空になったらnull */ private function changeTree(?string $tree, array $changes): ?string { $byDir = []; foreach ($changes as $path => $change) { $path = trim(str_replace('\\', '/', (string)$path), '/'); if ($path === '') throw new GitException('不正なパス'); $parts = explode('/', $path); $name = array_shift($parts); if (empty($parts)) $byDir[''][$name] = $change; else $byDir[$name][implode('/', $parts)] = $change; } $entries = []; foreach ($tree !== null ? $this->tree($tree)->entries : [] as $e) $entries[$e[1]] = $e; foreach ($byDir[''] ?? [] as $name => $change) { if ($change === null) unset($entries[$name]); else $entries[$name] = [$change[0], $name, $this->writeBlob((string)$change[1])]; } unset($byDir['']); foreach ($byDir as $dir => $sub) { $old = isset($entries[$dir]) && $entries[$dir][0] === Tree::DIR ? $entries[$dir][2] : null; $new = $this->changeTree($old, $sub); if ($new === null) unset($entries[$dir]); else $entries[$dir] = [Tree::DIR, $dir, $new]; } return empty($entries) ? null : $this->writeTree(array_values($entries)); } //////// 履歴 /** * コミットの履歴(新しい順) * * @param string|array $from リビジョン(複数可) * @param array $opt path(そのパスを変えたコミットだけ)、limit、skip、not(除くリビジョン)、firstParent * @return \Generator Commit */ public function log(string|array $from, array $opt = []): \Generator { yield from (new Walk($this))->log((array)$from, $opt); } //////// ファイル public function mkdir(string $dir): void { if (is_dir($dir)) return; $this->mkdir(dirname($dir)); if (!@mkdir($dir, $this->dirMode ?? 0777) && !is_dir($dir)) throw new GitException("ディレクトリを作れません:{$dir}"); if ($this->dirMode !== null) @chmod($dir, $this->dirMode); } /** 一時ファイルに書いてから置き換える */ public function writeFile(string $file, string $data, bool $readOnly = false): void { $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp'; if (@file_put_contents($tmp, $data) === false) throw new GitException("ファイルに書けません:{$file}"); $this->chmodFile($tmp, $readOnly); if (!@rename($tmp, $file)) { // Windowsでは有るファイルに名前を変えられない場合が有る if (!$readOnly || !is_file($file)) { @unlink($file); if (!@rename($tmp, $file)) { @unlink($tmp); throw new GitException("ファイルに書けません:{$file}"); } } else { @unlink($tmp); } } } public function chmodFile(string $file, bool $readOnly = false): void { if ($this->fileMode !== null) @chmod($file, $readOnly ? $this->fileMode & 0444 : $this->fileMode); else if ($readOnly) @chmod($file, 0444 & ~umask()); } }