| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, and/or sell copies of the Software, subject to the following conditions: |
| 10 | |
| 11 | 1. **Origin Attribution**: |
| 12 | - You must not misrepresent the origin of the Software; you must not claim |
| 13 | you created the original Software. |
| 14 | - If the Software is used in a product, you must either: |
| 15 | a. Provide clear attribution in the product's documentation, user interface, |
| 16 | or other visible areas, **OR** |
| 17 | b. Pay the original developers a fee they specify in writing. |
| 18 | 2. **Usage Restriction**: |
| 19 | - The Software, or any derivative works, dependencies, or libraries |
| 20 | incorporating it, must not be used for censorship or to suppress freedom of |
| 21 | speech, expression, or creativity. Prohibited uses include, but are not |
| 22 | limited to: |
| 23 | - Censorship of so-called "hate speech", visuals, non-mainstream opinions, |
| 24 | ideas, or objective reality. |
| 25 | - Tools or systems designed to restrict access to information or |
| 26 | artistic works. |
| 27 | 3. **Notice Preservation**: |
| 28 | - This license and the above copyright notice must remain intact in all copies |
| 29 | of the source code. |
| 30 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib\Gitler; |
| 39 | |
| 40 | use Std\Lib\Fedi\Fedi; |
| 41 | use Std\Lib\Git\Commit; |
| 42 | use Std\Lib\Git\GitException; |
| 43 | use Std\Lib\Git\Maintenance; |
| 44 | use Std\Lib\Git\Person; |
| 45 | use Std\Lib\Git\Repository; |
| 46 | use Std\Lib\Git\Tree; |
| 47 | use Result; |
| 48 | use Roles; |
| 49 | |
| 50 | /** |
| 51 | * Gitler(Gitのホスティング):リポジトリと権限 |
| 52 | * |
| 53 | * リポジトリは{root}/{所有者}/{名前}.git(GITINFOのroot)。説明・非公開・共同作業者はリポジトリの中のgitler.json。 |
| 54 | * 権限:NONE(見えない)、READ(読める)、WRITE(プッシュ出来る)、ADMIN(設定出来る)。 |
| 55 | * アーカイブしたリポジトリは読むだけ(プッシュ、イシュー等の変更は出来ない)。 |
| 56 | */ |
| 57 | final class Gitler { |
| 58 | public const NONE = 0; |
| 59 | public const READ = 1; |
| 60 | public const WRITE = 2; |
| 61 | public const ADMIN = 3; |
| 62 | |
| 63 | /** リポジトリのディレクトリ(テスト用) */ |
| 64 | public static ?string $root = null; |
| 65 | /** ユーザーのデータ(テスト用、既定はdata/user/) */ |
| 66 | public static ?string $userDir = null; |
| 67 | /** |
| 68 | * SSHのプッシュ(gitユーザー)ではdata/に書けないので、通知とFediverseへの配信をリポジトリのgitler/spool/に溜める。 |
| 69 | * cronのnotifyが送る |
| 70 | */ |
| 71 | public static bool $deferred = false; |
| 72 | |
| 73 | public static function enabled(): bool { |
| 74 | return defined('GITLER_ENABLED') && GITLER_ENABLED && AUTH_ENABLED; |
| 75 | } |
| 76 | |
| 77 | /** GITINFOと既定値 */ |
| 78 | public static function config(): array { |
| 79 | return (defined('GITINFO') ? GITINFO : []) + ['root' => '../git', 'ssh' => '', 'sshPort' => 22, 'php' => '/usr/local/bin/php-8.5', 'objectFormat' => 'sha1', 'branch' => 'master', 'maxRepos' => 100]; |
| 80 | } |
| 81 | |
| 82 | /** リポジトリのディレクトリ(相対パスはROOTから) */ |
| 83 | public static function root(): string { |
| 84 | if (self::$root !== null) return rtrim(self::$root, '/\\'); |
| 85 | return self::resolvePath((string)self::config()['root']); |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * パスを絶対パスにする(相対パスはROOTから) |
| 90 | * |
| 91 | * 「..」は文字列として解決する(chrootの中と外で同じ設定を使える様に、realpathは使わない)。 |
| 92 | */ |
| 93 | public static function resolvePath(string $path): string { |
| 94 | $r = str_replace('\\', '/', $path); |
| 95 | if (!preg_match('#^(/|[A-Za-z]:/)#', $r)) $r = str_replace('\\', '/', ROOT).'/'.$r; |
| 96 | $parts = []; |
| 97 | foreach (explode('/', $r) as $i => $p) { |
| 98 | if ($p === '..') { |
| 99 | if (count($parts) > 1) array_pop($parts); |
| 100 | } else if ($p !== '.' && ($p !== '' || $i === 0)) { |
| 101 | $parts[] = $p; |
| 102 | } |
| 103 | } |
| 104 | return implode('/', $parts) ?: '/'; |
| 105 | } |
| 106 | |
| 107 | /** リポジトリの名前(英数字・「.」「_」「-」、「.git」で終わらない) */ |
| 108 | public static function validName(string $name): bool { |
| 109 | return (bool)preg_match('/^[A-Za-z0-9_][A-Za-z0-9._-]{0,99}$/', $name) && !str_ends_with(strtolower($name), '.git') && !str_contains($name, '..'); |
| 110 | } |
| 111 | |
| 112 | public static function validOwner(string $owner): bool { |
| 113 | return (bool)preg_match('/^[A-Za-z0-9]+$/', $owner); |
| 114 | } |
| 115 | |
| 116 | public static function path(string $owner, string $name): string { |
| 117 | if (!self::validOwner($owner) || !self::validName($name)) throw new GitException("不正なリポジトリ:{$owner}/{$name}"); |
| 118 | return self::root()."/{$owner}/{$name}.git"; |
| 119 | } |
| 120 | |
| 121 | public static function exists(string $owner, string $name): bool { |
| 122 | return self::validOwner($owner) && self::validName($name) && Repository::isRepository(self::path($owner, $name)); |
| 123 | } |
| 124 | |
| 125 | public static function open(string $owner, string $name): Repository { |
| 126 | return Repository::open(self::path($owner, $name)); |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * リポジトリの情報 |
| 131 | * |
| 132 | * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch、 |
| 133 | * issues(イシューを使う)、pulls(プルリクエストを使う)、federate(公開ならFediverseに出す)、archived(アーカイブ)、 |
| 134 | * forkOf(フォーク元の「所有者/名前」又は空) |
| 135 | */ |
| 136 | public static function meta(string $owner, string $name): array { |
| 137 | $j = json_decode((string)@file_get_contents(self::path($owner, $name).'/gitler.json'), true); |
| 138 | $j = is_array($j) ? $j : []; |
| 139 | $j += ['description' => '', 'private' => false, 'created' => 0, 'collaborators' => [], 'issues' => true, 'pulls' => true, 'federate' => true, 'archived' => false, 'forkOf' => '']; |
| 140 | foreach (['private', 'issues', 'pulls', 'federate', 'archived'] as $k) $j[$k] = (bool)$j[$k]; |
| 141 | $j['forkOf'] = (string)$j['forkOf']; |
| 142 | $j['collaborators'] = is_array($j['collaborators']) ? $j['collaborators'] : []; |
| 143 | return $j; |
| 144 | } |
| 145 | |
| 146 | public static function saveMeta(string $owner, string $name, array $meta): void { |
| 147 | $repo = self::open($owner, $name); |
| 148 | $repo->writeFile($repo->path.'/gitler.json', (string)json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
| 149 | $repo->writeFile($repo->path.'/description', ($meta['description'] ?? '') !== '' ? $meta['description']."\n" : ''); |
| 150 | } |
| 151 | |
| 152 | //////// ユーザーと権限 |
| 153 | |
| 154 | /** ユーザー(パスワード等を除く)。無ければnull */ |
| 155 | public static function user(string $name): ?\stdClass { |
| 156 | if (!self::validOwner($name)) return null; |
| 157 | $dir = rtrim(self::$userDir ?? Fedi::$userDir ?? ROOT.'/data/user', '/').'/'; |
| 158 | foreach (glob($dir.'*.'.$name.'.json') ?: [] as $f) { |
| 159 | if (!preg_match('/^\d+\.'.$name.'\.json$/', basename($f))) continue; |
| 160 | $u = json_decode((string)file_get_contents($f)); |
| 161 | if (!$u instanceof \stdClass || ($u->username ?? '') !== $name) continue; |
| 162 | unset($u->password, $u->tokens, $u->verifyToken, $u->twoFactor, $u->twoFactorPending, $u->twoFactorTicket); |
| 163 | return $u; |
| 164 | } |
| 165 | return null; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * ユーザーがGitlerの機能を使えるか |
| 170 | * |
| 171 | * @param string $what repos(自分のリポジトリ、既定は無し)、issues(イシュー、既定は全員)、pulls(プルリクエスト、既定は無し) |
| 172 | */ |
| 173 | public static function userCan(?\stdClass $user, string $what): bool { |
| 174 | if ($user === null || !isset($user->username)) return false; |
| 175 | $role = (int)($user->role ?? 0); |
| 176 | if ($role === Roles::BANNED || ($user->verified ?? true) === false) return false; |
| 177 | if ($role & Roles::ADMIN) return true; |
| 178 | return match ($what) { |
| 179 | 'repos' => (bool)($role & Roles::GIT_REPOS), |
| 180 | 'pulls' => (bool)($role & Roles::GIT_PULLS), |
| 181 | // 管理者がロールを変えていなければ全員(Fediverseと同じ) |
| 182 | 'issues' => (bool)($role & Roles::GIT_ISSUES) || !isset($user->fediDefault), |
| 183 | default => false, |
| 184 | }; |
| 185 | } |
| 186 | |
| 187 | /** リポジトリの権限(NONE・READ・WRITE・ADMIN) */ |
| 188 | public static function access(?\stdClass $user, string $owner, string $name): int { |
| 189 | if (!self::exists($owner, $name)) return self::NONE; |
| 190 | $meta = self::meta($owner, $name); |
| 191 | $public = $meta['private'] ? self::NONE : self::READ; |
| 192 | if ($user === null || !isset($user->username)) return $public; |
| 193 | $role = (int)($user->role ?? 0); |
| 194 | if ($role === Roles::BANNED || ($user->verified ?? true) === false) return $public; |
| 195 | if ($role & Roles::ADMIN) return self::ADMIN; |
| 196 | if ($user->username === $owner) return self::userCan($user, 'repos') ? self::ADMIN : self::READ; |
| 197 | $c = $meta['collaborators'][$user->username] ?? null; |
| 198 | if ($c === 'write') return self::WRITE; |
| 199 | if ($c === 'read') return self::READ; |
| 200 | return $public; |
| 201 | } |
| 202 | |
| 203 | //////// リポジトリ |
| 204 | |
| 205 | /** |
| 206 | * リポジトリを作る |
| 207 | * |
| 208 | * @param array $opt description、private、readme(最初のコミットにREADME.mdを作る)、objectFormat、branch |
| 209 | */ |
| 210 | public static function create(\stdClass $user, string $name, array $opt = []): Result { |
| 211 | if (!self::userCan($user, 'repos')) return Result::Error('リポジトリを作る権限が有りません。'); |
| 212 | if (!self::validName($name)) return Result::Error('リポジトリの名前は英数字・「.」「_」「-」の100文字までです(「.git」で終わらない)。'); |
| 213 | $owner = (string)$user->username; |
| 214 | if (self::exists($owner, $name) || is_dir(self::path($owner, $name))) return Result::Error("リポジトリ「{$owner}/{$name}」は既に有ります。"); |
| 215 | $max = (int)self::config()['maxRepos']; |
| 216 | if ($max > 0 && count(self::list($owner)) >= $max && !((int)($user->role ?? 0) & Roles::ADMIN)) return Result::Error("リポジトリは{$max}個までです。"); |
| 217 | $format = (string)($opt['objectFormat'] ?? self::config()['objectFormat']); |
| 218 | $branch = trim((string)($opt['branch'] ?? '')) ?: (string)self::config()['branch']; |
| 219 | try { |
| 220 | $repo = Repository::init(self::path($owner, $name), in_array($format, ['sha1', 'sha256'], true) ? $format : 'sha1', $branch, 'group', (string)($opt['description'] ?? '')); |
| 221 | } catch (GitException $e) { |
| 222 | return Result::Error('リポジトリを作れません:'.$e->getMessage()); |
| 223 | } |
| 224 | self::saveMeta($owner, $name, ['description' => trim((string)($opt['description'] ?? '')), 'private' => !empty($opt['private']), 'created' => time(), 'collaborators' => []]); |
| 225 | if (!empty($opt['readme'])) { |
| 226 | $who = Person::now(($user->displayname ?? '') !== '' ? (string)$user->displayname : $owner, (string)($user->email ?? "{$owner}@localhost")); |
| 227 | $text = "# {$name}\n".(trim((string)($opt['description'] ?? '')) !== '' ? "\n".trim((string)$opt['description'])."\n" : ''); |
| 228 | $tree = $repo->updateTree(null, ['README.md' => [Tree::FILE, $text]]); |
| 229 | $c = $repo->writeCommit(Commit::create($tree, [], $who, $who, "最初のコミット\n")); |
| 230 | $repo->refs->update('refs/heads/'.$branch, $c, $repo->zeroId()); |
| 231 | } |
| 232 | return Result::Success("リポジトリ「{$owner}/{$name}」を作りました。", ['owner' => $owner, 'name' => $name]); |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * フォークする(自分のアカウントに同じ名前で。有れば「名前-2」等) |
| 237 | * パックはハードリンク(出来なければコピー)、ブランチとタグだけ持って来る。非公開のリポジトリのフォークは非公開 |
| 238 | * |
| 239 | * @return Result data:owner、name |
| 240 | */ |
| 241 | public static function fork(\stdClass $user, string $owner, string $name): Result { |
| 242 | if (!self::userCan($user, 'repos')) return Result::Error('リポジトリを作る権限が有りません(管理者に「Gitのリポジトリ」のロールを付けて貰って下さい)。'); |
| 243 | if (self::access($user, $owner, $name) < self::READ) return Result::Error('リポジトリが有りません。'); |
| 244 | $me = (string)$user->username; |
| 245 | if ($me === $owner) return Result::Error('自分のリポジトリはフォーク出来ません。'); |
| 246 | foreach (self::list($me) as $r) { |
| 247 | if ($r['meta']['forkOf'] === "{$owner}/{$name}") return Result::Success('既にフォークしています。', ['owner' => $me, 'name' => $r['name']]); |
| 248 | } |
| 249 | $max = (int)self::config()['maxRepos']; |
| 250 | if ($max > 0 && count(self::list($me)) >= $max && !((int)($user->role ?? 0) & Roles::ADMIN)) return Result::Error("リポジトリは{$max}個までです。"); |
| 251 | $new = $name; |
| 252 | for ($i = 2; self::exists($me, $new) || is_dir(self::path($me, $new)); $i++) $new = "{$name}-{$i}"; |
| 253 | if (!self::validName($new)) return Result::Error('フォークの名前を決められません。'); |
| 254 | $src = self::open($owner, $name); |
| 255 | $meta = self::meta($owner, $name); |
| 256 | $head = $src->headRef(); |
| 257 | $branch = $head !== null && str_starts_with($head, 'refs/heads/') ? substr($head, 11) : (string)self::config()['branch']; |
| 258 | try { |
| 259 | $dst = Repository::init(self::path($me, $new), $src->hashAlgo, $branch, 'group', $meta['description']); |
| 260 | $from = $src->objects->dir(); |
| 261 | $to = $dst->objects->dir(); |
| 262 | foreach (glob($from.'/pack/pack-*') ?: [] as $f) { |
| 263 | $t = $to.'/pack/'.basename($f); |
| 264 | $dst->mkdir(dirname($t)); |
| 265 | if (!@link($f, $t) && !@copy($f, $t)) throw new GitException("コピー出来ません:{$f}"); |
| 266 | } |
| 267 | foreach (glob($from.'/[0-9a-f][0-9a-f]/*') ?: [] as $f) { |
| 268 | $t = $to.'/'.basename(dirname($f)).'/'.basename($f); |
| 269 | $dst->mkdir(dirname($t)); |
| 270 | if (!@copy($f, $t)) throw new GitException("コピー出来ません:{$f}"); |
| 271 | $dst->chmodFile($t, true); |
| 272 | } |
| 273 | $refs = []; |
| 274 | foreach (['refs/heads/', 'refs/tags/'] as $prefix) foreach ($src->refs->all($prefix) as $ref => $id) $refs[] = [$ref, $id, null]; |
| 275 | if (!empty($refs)) $dst->refs->transaction($refs); |
| 276 | } catch (GitException $e) { |
| 277 | if (Repository::isRepository(self::path($me, $new))) self::delete($me, $new); |
| 278 | return Result::Error('フォーク出来ません:'.$e->getMessage()); |
| 279 | } |
| 280 | self::saveMeta($me, $new, ['description' => $meta['description'], 'private' => $meta['private'], 'created' => time(), 'collaborators' => [], 'forkOf' => "{$owner}/{$name}"]); |
| 281 | return Result::Success("{$owner}/{$name}を{$me}/{$new}にフォークしました。", ['owner' => $me, 'name' => $new]); |
| 282 | } |
| 283 | |
| 284 | /** フォーク([所有者, 名前]の配列) */ |
| 285 | public static function forks(string $owner, string $name): array { |
| 286 | $out = []; |
| 287 | foreach (self::list() as $r) if ($r['meta']['forkOf'] === "{$owner}/{$name}") $out[] = [$r['owner'], $r['name']]; |
| 288 | return $out; |
| 289 | } |
| 290 | |
| 291 | /** 名前を変える */ |
| 292 | public static function rename(string $owner, string $name, string $new): Result { |
| 293 | if (!self::validName($new)) return Result::Error('不正なリポジトリの名前です。'); |
| 294 | if ($new === $name) return Result::Success(); |
| 295 | if (self::exists($owner, $new) || is_dir(self::path($owner, $new))) return Result::Error("リポジトリ「{$owner}/{$new}」は既に有ります。"); |
| 296 | if (!@rename(self::path($owner, $name), self::path($owner, $new))) return Result::Error('名前を変えられません。'); |
| 297 | return Result::Success("名前を「{$new}」に変えました。"); |
| 298 | } |
| 299 | |
| 300 | /** リポジトリを消す(元に戻せない) */ |
| 301 | public static function delete(string $owner, string $name): bool { |
| 302 | $dir = self::path($owner, $name); |
| 303 | if (!Repository::isRepository($dir)) return false; |
| 304 | $rm = function(string $d) use (&$rm): void { |
| 305 | foreach (@scandir($d) ?: [] as $f) { |
| 306 | if ($f === '.' || $f === '..') continue; |
| 307 | $p = "{$d}/{$f}"; |
| 308 | if (is_dir($p) && !is_link($p)) $rm($p); |
| 309 | // 読むだけのファイル(Windows)だけ権限を変える(フォークのパックはハードリンクで元と共有) |
| 310 | else if (!@unlink($p)) { |
| 311 | @chmod($p, 0666); |
| 312 | @unlink($p); |
| 313 | } |
| 314 | } |
| 315 | @rmdir($d); |
| 316 | }; |
| 317 | $rm($dir); |
| 318 | @rmdir(dirname($dir)); |
| 319 | return !is_dir($dir); |
| 320 | } |
| 321 | |
| 322 | /** |
| 323 | * リポジトリの一覧(更新の新しい順) |
| 324 | * |
| 325 | * @param string|null $owner 所有者(nullなら全部) |
| 326 | * @return array owner、name、meta、updated(最後の更新の時刻) |
| 327 | */ |
| 328 | public static function list(?string $owner = null): array { |
| 329 | $out = []; |
| 330 | $owners = $owner !== null ? [$owner] : array_map('basename', glob(self::root().'/*', GLOB_ONLYDIR) ?: []); |
| 331 | foreach ($owners as $o) { |
| 332 | if (!self::validOwner($o)) continue; |
| 333 | foreach (glob(self::root()."/{$o}/*.git", GLOB_ONLYDIR) ?: [] as $dir) { |
| 334 | $name = substr(basename($dir), 0, -4); |
| 335 | if (!self::validName($name) || !Repository::isRepository($dir)) continue; |
| 336 | $updated = max((int)@filemtime($dir.'/refs/heads'), (int)@filemtime($dir.'/packed-refs'), (int)@filemtime($dir.'/gitler.json')); |
| 337 | foreach (glob($dir.'/refs/heads/*') ?: [] as $f) $updated = max($updated, (int)@filemtime($f)); |
| 338 | $out[] = ['owner' => $o, 'name' => $name, 'meta' => self::meta($o, $name), 'updated' => $updated]; |
| 339 | } |
| 340 | } |
| 341 | usort($out, fn($a, $b) => $b['updated'] <=> $a['updated'] ?: strcmp($a['owner'].'/'.$a['name'], $b['owner'].'/'.$b['name'])); |
| 342 | return $out; |
| 343 | } |
| 344 | |
| 345 | //////// 後で処理する物 |
| 346 | |
| 347 | /** SSHのプッシュの通知・配信をリポジトリに溜める(kind:notify、federate) */ |
| 348 | public static function defer(string $owner, string $name, array $job): void { |
| 349 | $repo = self::open($owner, $name); |
| 350 | $dir = $repo->path.'/gitler/spool'; |
| 351 | $repo->mkdir($dir); |
| 352 | $repo->writeFile($dir.'/'.Fedi::newId().'.json', (string)json_encode($job, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); |
| 353 | } |
| 354 | |
| 355 | /** |
| 356 | * 溜めた通知と配信を処理する(cronのnotify) |
| 357 | * |
| 358 | * @return int 処理した数 |
| 359 | */ |
| 360 | public static function flushDeferred(): int { |
| 361 | $n = 0; |
| 362 | foreach (self::list() as $r) { |
| 363 | foreach (glob(self::path($r['owner'], $r['name']).'/gitler/spool/*.json') ?: [] as $f) { |
| 364 | // 同時に動いた別のcronと重ならない様に、名前を変えて取る |
| 365 | if (!@rename($f, $f.'.work')) continue; |
| 366 | $job = json_decode((string)@file_get_contents($f.'.work'), true); |
| 367 | @unlink($f.'.work'); |
| 368 | if (!is_array($job)) continue; |
| 369 | if (($job['kind'] ?? 'federate') === 'notify') { |
| 370 | Notify::deliver((string)($job['user'] ?? ''), (array)($job['record'] ?? []), $job['mail'] ?? null); |
| 371 | } else if (is_array($job['activity'] ?? null) && Federation::enabled($r['meta'])) { |
| 372 | Federation::deliver($r['owner'], $r['name'], $job['activity'], (array)($job['extra'] ?? [])); |
| 373 | } |
| 374 | $n++; |
| 375 | } |
| 376 | } |
| 377 | return $n; |
| 378 | } |
| 379 | |
| 380 | /** 全部のリポジトリを整理する(cronのgitlergc) */ |
| 381 | public static function gcAll(): Result { |
| 382 | $n = 0; |
| 383 | $errors = []; |
| 384 | foreach (self::list() as $r) { |
| 385 | try { |
| 386 | Maintenance::gc(self::open($r['owner'], $r['name'])); |
| 387 | $n++; |
| 388 | } catch (\Throwable $e) { |
| 389 | $errors[] = "{$r['owner']}/{$r['name']}:".$e->getMessage(); |
| 390 | } |
| 391 | } |
| 392 | return empty($errors) ? Result::Success("{$n}個のリポジトリを整理しました。") : Result::Error(implode("\n", $errors)); |
| 393 | } |
| 394 | } |