Gitライブラリ
守矢諏訪子 <suwako@076.moe>、2026-11-13 11:32
コミット d68bcbb560d90ec114b9db137d789e54aa0d66b9
・親 1b438ae
・ファイルを見る
29個のファイル、+5564 -1
変更
README-JP.md
| @@ -250,6 +250,35 @@ | ||
| 250 | 250 | * JSON のデータ:ユーザー、コメント、通知、Cron のジョブ、フォロワー、ActivityPub の投稿は名前が `lb_` で始まるテーブルになり、他のファイル(`cache/`、`ratelimit/`、`tmp/`、ロック・一時ファイル、SQLite のデータベースを除く)は `lb_files` に入るので、戻すと同じファイルになります。他のテーブルは `data/db/{テーブル}.json` になります。`data/` が空・無い場合は、テーブルが無いだけです。`JsonTarget` は、2番目の引数(上書き)が `true` か `drop` が無ければ、空でないディレクトリには書きません。 |
| 251 | 251 | * サイトが使っているデータベースを読む・書く場合は、先に書き込みを止めて下さい(又は新しいデータベースに変換)。変換中に元をロックしません。 |
| 252 | 252 | |
| 253 | +### Git | |
| 254 | +`Std\Lib\Git` は Git のリポジトリを PHP だけで読み書きします(PHP の zlib が必要、`git` のコマンドは不要)。SHA-1 と SHA-256 のリポジトリに対応しています。サイトでリポジトリをホストする為の基礎です。 | |
| 255 | +```php | |
| 256 | +use Std\Lib\Git\{Repository, Tree, Commit, Person, Diff, Blame}; | |
| 257 | + | |
| 258 | +$repo = Repository::init('/var/www/git/suwako/test.git', 'sha1', 'main', 'group'); // 又はRepository::open(...) | |
| 259 | +$me = Person::now('諏訪子', 'suwako@example.com'); | |
| 260 | +$tree = $repo->updateTree(null, ['README.md' => [Tree::FILE, "# テスト\n"]]); | |
| 261 | +$id = $repo->writeCommit(Commit::create($tree, [], $me, $me, "最初のコミット\n")); | |
| 262 | +$repo->refs->update('refs/heads/main', $id, $repo->zeroId()); // 途中でブランチが変わっていれば失敗 | |
| 263 | + | |
| 264 | +foreach ($repo->log('main', ['path' => 'README.md', 'limit' => 20]) as $c) echo $c->id, ' ', $c->subject(), "\n"; | |
| 265 | +echo $repo->blob($repo->resolve('main~1:README.md')); | |
| 266 | +echo Diff::unified($repo, 'main~1', 'main'); | |
| 267 | +$lines = Blame::file($repo, 'main', 'README.md'); | |
| 268 | +``` | |
| 269 | +* 保存:ルーズオブジェクト、パック(`.idx` v1・v2 と `.rev`、差分も解決)、参照と `packed-refs`、`objects/info/alternates`、`core.sharedRepository`。`main~2`、`v1.0^{}`、`HEAD^2`、`main:パス/ファイル` 等のリビジョンが使えます。`Diff` は `git diff` と同じ出力(Myers)、`Blame` は同じ行が有る最初の親を辿り、`Maintenance::gc()` は到達出来るオブジェクトを1つのパックにまとめます(Cron で実行)。 | |
| 270 | +* ネットワーク:`Http::handle()` は Smart HTTP(`git clone https://…`、`git fetch`、`git push`)、`Ssh::handle()` は SSH の `git clone git@host:…` と `git push` を処理します。プロトコル v0・v1・v2、浅いクローン(`--depth`、`--deepen`)、`include-tag`、サイドバンドの進捗、atomic なプッシュ、受け取ったオブジェクトの確認に対応しています。送るパックはディスクの差分を再利用します。アクセスの確認は呼ぶ側で行い、`ReceivePack` には `check`・`update`・`after` のコールバックを渡せます(保護されたブランチ等)。 | |
| 271 | +```php | |
| 272 | +// GET …/repo.git/info/refs、POST …/repo.git/git-upload-pack と git-receive-pack | |
| 273 | +Http::handle(Repository::open($dir), $action, ['push' => $userMayPush]); | |
| 274 | + | |
| 275 | +// SSH:gitユーザーのauthorized_keys | |
| 276 | +// command="/usr/local/bin/php-8.5 /var/www/htdocs/gitssh.php suwako",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA… | |
| 277 | +exit(Ssh::handle((string)getenv('SSH_ORIGINAL_COMMAND'), fn(string $path, bool $write) => /* アクセスの確認 */ Repository::open('/var/www/git/'.$path))); | |
| 278 | +``` | |
| 279 | +* OpenBSD では php-fpm は `/var/www` に chroot されるので、リポジトリはその中に置き(例:`git` ユーザーのホームを `/var/www/git` に)、`$shared = 'group'` で作り、`www` を `git` グループに入れると、サイトと SSH の両方から書けます。SSH でプロトコル v2 を使うには、`sshd_config` に `AcceptEnv GIT_PROTOCOL` を追加して下さい。 | |
| 280 | +* 未対応:新しい差分の作成(ルーズで受け取ったオブジェクトは差分無しでパックにする)、シンパック、部分クローン(`--filter`)、`--shallow-since`・`--shallow-exclude`、`git archive --remote`、LFS、commit-graph・multi-pack-index の書き込み。 | |
| 281 | + | |
| 253 | 282 | ### TLS 1.3・1.2(php_openssl は不要) |
| 254 | 283 | HTTPS のリクエスト(`Curl`、ActivityPub)、SMTP の STARTTLS(`Mailer`)、MySQL・PostgreSQL の TLS は PHP だけで書かれた TLS 1.3・1.2 のクライアント `Std\Lib\Tls`(`php_gmp` が必要)を使うので、`php_openssl` は不要です。RSA の鍵と署名(ActivityPub)は `Std\Lib\Crypto\Rsa` を使い、今までの PEM の鍵もそのまま使えます。 |
| 255 | 284 | * TLS 1.3 を優先し、TLS 1.2 にしか対応していないサーバーとは安全な暗号スイートだけで TLS 1.2 を使います(ECDHE と AES-GCM 又は ChaCha20-Poly1305、サーバーが対応していれば Extended Master Secret、再ネゴシエーションはしない)。CBC、RSA の鍵交換、DHE、TLS 1.1 以前は未対応です。TLS 1.3 に対応しているサーバーとの通信を攻撃者が TLS 1.2 に下げた場合は分かります。TLS 1.2 を拒否するには、オプションの `'min_version' => '1.3'` を使って下さい。 |
| @@ -327,4 +356,5 @@ | ||
| 327 | 356 | ## 必要な PHP モジュール |
| 328 | 357 | * php_fileinfo(OpenBSDでデフォルトで有効している) |
| 329 | 358 | * php_gmp |
| 330 | -* php_mbstring(OpenBSDでデフォルトで有効している) | |
| 359 | +* php_mbstring(OpenBSDでデフォルトで有効している) | |
| 360 | +* php_zlib(Git、普通はPHPに組み込まれている) |
変更
README.md
| @@ -248,6 +248,35 @@ | ||
| 248 | 248 | * JSON data: users, comments, notifications, Cron jobs, followers and ActivityPub notes become tables whose names start with `lb_`, and every other file (except `cache/`, `ratelimit/`, `tmp/`, lock and temporary files and SQLite databases) goes into `lb_files`, so converting back gives the same files. Other tables become `data/db/{table}.json`. An empty or missing `data/` directory simply has no tables. `JsonTarget` refuses to write into a directory that is not empty unless its second argument (overwrite) is `true` or `drop` is set. |
| 249 | 249 | * When the source or target is the database the site is using, stop writes to it first (or convert into a new database), as the conversion does not lock the source. |
| 250 | 250 | |
| 251 | +### Git | |
| 252 | +`Std\Lib\Git` reads and writes Git repositories in pure PHP (it needs PHP's zlib, but no `git` command), for both SHA-1 and SHA-256 repositories. It is the base for hosting repositories on the site. | |
| 253 | +```php | |
| 254 | +use Std\Lib\Git\{Repository, Tree, Commit, Person, Diff, Blame}; | |
| 255 | + | |
| 256 | +$repo = Repository::init('/var/www/git/suwako/test.git', 'sha1', 'main', 'group'); // or Repository::open(...) | |
| 257 | +$me = Person::now('諏訪子', 'suwako@example.com'); | |
| 258 | +$tree = $repo->updateTree(null, ['README.md' => [Tree::FILE, "# Test\n"]]); | |
| 259 | +$id = $repo->writeCommit(Commit::create($tree, [], $me, $me, "First commit\n")); | |
| 260 | +$repo->refs->update('refs/heads/main', $id, $repo->zeroId()); // fails if the branch changed in the meantime | |
| 261 | + | |
| 262 | +foreach ($repo->log('main', ['path' => 'README.md', 'limit' => 20]) as $c) echo $c->id, ' ', $c->subject(), "\n"; | |
| 263 | +echo $repo->blob($repo->resolve('main~1:README.md')); | |
| 264 | +echo Diff::unified($repo, 'main~1', 'main'); | |
| 265 | +$lines = Blame::file($repo, 'main', 'README.md'); | |
| 266 | +``` | |
| 267 | +* Storage: loose objects, packfiles (`.idx` v1/v2 and `.rev`; deltas are resolved), refs and `packed-refs`, `objects/info/alternates`, `core.sharedRepository`. Revisions such as `main~2`, `v1.0^{}`, `HEAD^2` and `main:path/file` are understood. `Diff` gives the same output as `git diff` (Myers), `Blame` follows the first parent that has the same line, and `Maintenance::gc()` packs everything reachable into one pack (run it from Cron). | |
| 268 | +* Network: `Http::handle()` serves Smart HTTP (`git clone https://…`, `git fetch`, `git push`) and `Ssh::handle()` serves `git clone git@host:…` and `git push` over SSH, with protocol v0, v1 and v2, shallow clones (`--depth`, `--deepen`), `include-tag`, side-band progress, atomic pushes and checks of received objects. Packs that are sent reuse the deltas already on disk. Access checks are done by the caller; `ReceivePack` takes `check`, `update` and `after` callbacks (for example for protected branches). | |
| 269 | +```php | |
| 270 | +// GET …/repo.git/info/refs, POST …/repo.git/git-upload-pack and git-receive-pack | |
| 271 | +Http::handle(Repository::open($dir), $action, ['push' => $userMayPush]); | |
| 272 | + | |
| 273 | +// SSH: authorized_keys of the git user | |
| 274 | +// command="/usr/local/bin/php-8.5 /var/www/htdocs/gitssh.php suwako",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA… | |
| 275 | +exit(Ssh::handle((string)getenv('SSH_ORIGINAL_COMMAND'), fn(string $path, bool $write) => /* check access */ Repository::open('/var/www/git/'.$path))); | |
| 276 | +``` | |
| 277 | +* On OpenBSD, php-fpm runs chrooted in `/var/www`, so keep the repositories inside it (for example `/var/www/git` as the home of the `git` user) and create them with `$shared = 'group'`, with `www` in the `git` group, so that both the website and SSH can write. For protocol v2 over SSH, add `AcceptEnv GIT_PROTOCOL` to `sshd_config`. | |
| 278 | +* Not supported: creating new deltas (objects pushed as loose files are packed without them), thin packs, partial clone (`--filter`), `--shallow-since`/`--shallow-exclude`, `git archive --remote`, LFS, and writing commit-graph or multi-pack-index files. | |
| 279 | + | |
| 251 | 280 | ### TLS 1.3 and 1.2 (no php_openssl) |
| 252 | 281 | HTTPS requests (`Curl`, ActivityPub), SMTP STARTTLS (`Mailer`) and MySQL and PostgreSQL over TLS use `Std\Lib\Tls`, a TLS 1.3 and 1.2 client written in pure PHP (it needs `php_gmp`), so `php_openssl` is not needed. RSA keys and signatures (ActivityPub) use `Std\Lib\Crypto\Rsa`, and existing PEM keys keep working. |
| 253 | 282 | * TLS 1.3 is preferred. Servers that only speak TLS 1.2 get TLS 1.2 with secure cipher suites only (ECDHE with AES-GCM or ChaCha20-Poly1305, Extended Master Secret when the server supports it, no renegotiation); CBC, RSA key exchange and DHE are not supported, and neither is TLS 1.1 or older. A TLS 1.3 server that is pushed down to TLS 1.2 by an attacker is detected. Use the option `'min_version' => '1.3'` to refuse TLS 1.2. |
| @@ -316,3 +345,4 @@ | ||
| 316 | 345 | |
| 317 | 346 | ## Required PHP module |
| 318 | 347 | * php_gmp |
| 348 | +* php_zlib (for Git; usually built into PHP) |
| @@ -0,0 +1,118 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * 行毎の最後に変更したコミット(git blame) | |
| 42 | + * | |
| 43 | + * 名前の変更は辿らない。マージは、同じ行が有る最初の親から辿る。 | |
| 44 | + */ | |
| 45 | +final class Blame { | |
| 46 | + /** | |
| 47 | + * @param string $rev リビジョン | |
| 48 | + * @param string $path ファイルのパス | |
| 49 | + * @param int $maxCommits 調べるコミットの最大(超えたら残りの行はそのコミット) | |
| 50 | + * @return array 行毎に ['commit' => ID, 'line' => そのコミットでの行の番号(1から), 'text' => 行] | |
| 51 | + */ | |
| 52 | + public static function file(Repository $repo, string $rev, string $path, int $maxCommits = 10000): array { | |
| 53 | + $start = $repo->resolve($rev); | |
| 54 | + $start = $start === null ? null : $repo->peel($start, 'commit'); | |
| 55 | + if ($start === null) throw new GitException("リビジョンが有りません:{$rev}"); | |
| 56 | + $path = trim($path, '/'); | |
| 57 | + $entry = $repo->entry($start, $path); | |
| 58 | + if ($entry === null || $entry[0] === Tree::DIR) throw new GitException("ファイルが有りません:{$path}"); | |
| 59 | + $lines = Diff::split($repo->blob($entry[2])); | |
| 60 | + $result = array_fill(0, count($lines), null); | |
| 61 | + // コミット => [ブロブのID, [そのコミットでの行の番号 => 最後の行の番号]] | |
| 62 | + $pending = [$start => [$entry[2], array_combine(array_keys($lines), array_keys($lines))]]; | |
| 63 | + $queue = new \SplPriorityQueue(); | |
| 64 | + $seq = PHP_INT_MAX; | |
| 65 | + $queue->insert($start, [$repo->commit($start)->committer?->time ?? 0, $seq--]); | |
| 66 | + $blobs = []; | |
| 67 | + $split = function(string $id) use ($repo, &$blobs): array { | |
| 68 | + return $blobs[$id] ??= Diff::split($repo->blob($id)); | |
| 69 | + }; | |
| 70 | + $done = 0; | |
| 71 | + while (!$queue->isEmpty()) { | |
| 72 | + $id = $queue->extract(); | |
| 73 | + if (!isset($pending[$id])) continue; | |
| 74 | + [$blob, $map] = $pending[$id]; | |
| 75 | + unset($pending[$id]); | |
| 76 | + $c = $repo->commit($id); | |
| 77 | + if (++$done > $maxCommits) { | |
| 78 | + foreach ($map as $cur => $final) $result[$final] = [$id, $cur]; | |
| 79 | + continue; | |
| 80 | + } | |
| 81 | + foreach ($c->parents as $p) { | |
| 82 | + if (empty($map)) break; | |
| 83 | + if (!$repo->objects->exists($p)) continue; | |
| 84 | + $pe = $repo->entry($p, $path); | |
| 85 | + if ($pe === null || $pe[0] === Tree::DIR) continue; | |
| 86 | + if ($pe[2] === $blob) { | |
| 87 | + // 同じ内容:全部を親に | |
| 88 | + self::pass($repo, $pending, $queue, $seq, $p, $pe[2], $map); | |
| 89 | + $map = []; | |
| 90 | + break; | |
| 91 | + } | |
| 92 | + $mine = $split($blob); | |
| 93 | + $theirs = $split($pe[2]); | |
| 94 | + $passed = []; | |
| 95 | + foreach (Diff::match($theirs, $mine) as [$pi, $ci]) { | |
| 96 | + if (isset($map[$ci])) { | |
| 97 | + $passed[$pi] = $map[$ci]; | |
| 98 | + unset($map[$ci]); | |
| 99 | + } | |
| 100 | + } | |
| 101 | + if (!empty($passed)) self::pass($repo, $pending, $queue, $seq, $p, $pe[2], $passed); | |
| 102 | + } | |
| 103 | + foreach ($map as $cur => $final) $result[$final] = [$id, $cur]; | |
| 104 | + } | |
| 105 | + $out = []; | |
| 106 | + foreach ($lines as $i => $text) $out[] = ['commit' => $result[$i][0], 'line' => $result[$i][1] + 1, 'text' => $text]; | |
| 107 | + return $out; | |
| 108 | + } | |
| 109 | + | |
| 110 | + private static function pass(Repository $repo, array &$pending, \SplPriorityQueue $queue, int &$seq, string $p, string $blob, array $map): void { | |
| 111 | + if (isset($pending[$p])) { | |
| 112 | + $pending[$p][1] += $map; | |
| 113 | + return; | |
| 114 | + } | |
| 115 | + $pending[$p] = [$blob, $map]; | |
| 116 | + $queue->insert($p, [$repo->commit($p)->committer?->time ?? 0, $seq--]); | |
| 117 | + } | |
| 118 | +} |
| @@ -0,0 +1,105 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * コミット | |
| 42 | + * | |
| 43 | + * headersは元の順番の[名前, 値](gpgsig・mergetag等の複数行の値も)。toString()は同じバイト列に戻る。 | |
| 44 | + */ | |
| 45 | +final class Commit { | |
| 46 | + public string $tree = ''; | |
| 47 | + /** @var string[] */ | |
| 48 | + public array $parents = []; | |
| 49 | + public ?Person $author = null; | |
| 50 | + public ?Person $committer = null; | |
| 51 | + public string $message = ''; | |
| 52 | + /** @var array 全部のヘッダー [名前, 値] */ | |
| 53 | + public array $headers = []; | |
| 54 | + public ?string $id = null; | |
| 55 | + | |
| 56 | + public static function parse(string $data, ?string $id = null): self { | |
| 57 | + $c = new self(); | |
| 58 | + $c->id = $id; | |
| 59 | + $c->headers = Tag::parseHeaders($data, $c->message); | |
| 60 | + foreach ($c->headers as [$k, $v]) { | |
| 61 | + match ($k) { | |
| 62 | + 'tree' => $c->tree = $v, | |
| 63 | + 'parent' => $c->parents[] = $v, | |
| 64 | + 'author' => $c->author = Person::parse($v), | |
| 65 | + 'committer' => $c->committer = Person::parse($v), | |
| 66 | + default => null, | |
| 67 | + }; | |
| 68 | + } | |
| 69 | + return $c; | |
| 70 | + } | |
| 71 | + | |
| 72 | + /** | |
| 73 | + * 新しいコミット | |
| 74 | + * | |
| 75 | + * @param string[] $parents | |
| 76 | + */ | |
| 77 | + public static function create(string $tree, array $parents, Person $author, Person $committer, string $message): self { | |
| 78 | + $c = new self(); | |
| 79 | + $c->tree = $tree; | |
| 80 | + $c->parents = array_values($parents); | |
| 81 | + $c->author = $author; | |
| 82 | + $c->committer = $committer; | |
| 83 | + $c->message = $message; | |
| 84 | + $c->headers[] = ['tree', $tree]; | |
| 85 | + foreach ($c->parents as $p) $c->headers[] = ['parent', $p]; | |
| 86 | + $c->headers[] = ['author', (string)$author]; | |
| 87 | + $c->headers[] = ['committer', (string)$committer]; | |
| 88 | + return $c; | |
| 89 | + } | |
| 90 | + | |
| 91 | + public function toString(): string { | |
| 92 | + return Tag::buildHeaders($this->headers).$this->message; | |
| 93 | + } | |
| 94 | + | |
| 95 | + /** メッセージの最初の行 */ | |
| 96 | + public function subject(): string { | |
| 97 | + return trim(strtok($this->message, "\n") ?: ''); | |
| 98 | + } | |
| 99 | + | |
| 100 | + /** ヘッダーの値(無ければnull) */ | |
| 101 | + public function header(string $name): ?string { | |
| 102 | + foreach ($this->headers as [$k, $v]) if ($k === $name) return $v; | |
| 103 | + return null; | |
| 104 | + } | |
| 105 | +} |
| @@ -0,0 +1,187 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * Gitの設定ファイル(config) | |
| 42 | + * | |
| 43 | + * 「[section]」「[section "subsection"]」と「key = value」。名前はsection.subsection.key | |
| 44 | + * (sectionとkeyは小文字、subsectionは大文字・小文字を区別する)。書く時はコメントを残さない。 | |
| 45 | + */ | |
| 46 | +final class Config { | |
| 47 | + /** @var array 名前 => 値の配列(同じ名前が複数有り得る) */ | |
| 48 | + private array $values = []; | |
| 49 | + | |
| 50 | + public function __construct(private ?string $file = null) { | |
| 51 | + if ($file !== null && is_file($file)) $this->parse((string)file_get_contents($file)); | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static function fromString(string $text): self { | |
| 55 | + $c = new self(); | |
| 56 | + $c->parse($text); | |
| 57 | + return $c; | |
| 58 | + } | |
| 59 | + | |
| 60 | + private function parse(string $text): void { | |
| 61 | + $section = ''; | |
| 62 | + $lines = preg_split('/\r?\n/', $text); | |
| 63 | + for ($i = 0; $i < count($lines); $i++) { | |
| 64 | + $line = $lines[$i]; | |
| 65 | + $t = ltrim($line); | |
| 66 | + if ($t === '' || $t[0] === '#' || $t[0] === ';') continue; | |
| 67 | + if ($t[0] === '[') { | |
| 68 | + if (!preg_match('/^\[\s*([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\\\]|\\\\.)*)")?\s*\](.*)$/', $t, $m)) throw new GitException("不正な設定の行:{$line}"); | |
| 69 | + $section = strtolower($m[1]); | |
| 70 | + if (isset($m[2]) && $m[2] !== '') $section .= '.'.preg_replace('/\\\\(.)/', '$1', $m[2]); | |
| 71 | + $t = ltrim($m[3]); | |
| 72 | + if ($t === '' || $t[0] === '#' || $t[0] === ';') continue; | |
| 73 | + } | |
| 74 | + if (!preg_match('/^([A-Za-z][A-Za-z0-9-]*)\s*(=\s*(.*))?$/s', $t, $m)) throw new GitException("不正な設定の行:{$line}"); | |
| 75 | + $key = $section.'.'.strtolower($m[1]); | |
| 76 | + if (!isset($m[2]) || $m[2] === '') { | |
| 77 | + $this->values[$key][] = 'true'; | |
| 78 | + continue; | |
| 79 | + } | |
| 80 | + $raw = $m[3]; | |
| 81 | + // 行の最後の「\」で次の行に続く | |
| 82 | + while (preg_match('/(?<!\\\\)(\\\\\\\\)*\\\\$/', $raw) && $i + 1 < count($lines)) $raw = substr($raw, 0, -1).$lines[++$i]; | |
| 83 | + $this->values[$key][] = self::unquote($raw); | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + private static function unquote(string $raw): string { | |
| 88 | + $out = ''; | |
| 89 | + $quoted = false; | |
| 90 | + $len = strlen($raw); | |
| 91 | + $pendingSpace = ''; | |
| 92 | + for ($i = 0; $i < $len; $i++) { | |
| 93 | + $c = $raw[$i]; | |
| 94 | + if (!$quoted && ($c === '#' || $c === ';')) break; | |
| 95 | + if ($c === '"') { | |
| 96 | + $quoted = !$quoted; | |
| 97 | + continue; | |
| 98 | + } | |
| 99 | + if ($c === '\\' && $i + 1 < $len) { | |
| 100 | + $n = $raw[++$i]; | |
| 101 | + $out .= $pendingSpace.match ($n) { 'n' => "\n", 't' => "\t", 'b' => "\x08", default => $n }; | |
| 102 | + $pendingSpace = ''; | |
| 103 | + continue; | |
| 104 | + } | |
| 105 | + if (!$quoted && ($c === ' ' || $c === "\t")) { | |
| 106 | + // 引用符の外の空白は1つにまとめ、最後の空白は消す | |
| 107 | + if ($out !== '' || $pendingSpace !== '') $pendingSpace = ' '; | |
| 108 | + continue; | |
| 109 | + } | |
| 110 | + $out .= $pendingSpace.$c; | |
| 111 | + $pendingSpace = ''; | |
| 112 | + } | |
| 113 | + return $out; | |
| 114 | + } | |
| 115 | + | |
| 116 | + /** 最後の値 */ | |
| 117 | + public function get(string $name, ?string $default = null): ?string { | |
| 118 | + $k = $this->key($name); | |
| 119 | + return isset($this->values[$k]) ? end($this->values[$k]) : $default; | |
| 120 | + } | |
| 121 | + | |
| 122 | + /** 全部の値 */ | |
| 123 | + public function getAll(string $name): array { | |
| 124 | + return $this->values[$this->key($name)] ?? []; | |
| 125 | + } | |
| 126 | + | |
| 127 | + public function getBool(string $name, bool $default = false): bool { | |
| 128 | + $v = $this->get($name); | |
| 129 | + if ($v === null) return $default; | |
| 130 | + return in_array(strtolower($v), ['true', 'yes', 'on', '1'], true); | |
| 131 | + } | |
| 132 | + | |
| 133 | + public function getInt(string $name, int $default = 0): int { | |
| 134 | + $v = $this->get($name); | |
| 135 | + if ($v === null || !preg_match('/^(-?\d+)([kmg]?)$/i', trim($v), $m)) return $default; | |
| 136 | + return (int)$m[1] * match (strtolower($m[2])) { 'k' => 1024, 'm' => 1048576, 'g' => 1073741824, default => 1 }; | |
| 137 | + } | |
| 138 | + | |
| 139 | + public function set(string $name, ?string $value): void { | |
| 140 | + $k = $this->key($name); | |
| 141 | + if ($value === null) unset($this->values[$k]); | |
| 142 | + else $this->values[$k] = [$value]; | |
| 143 | + } | |
| 144 | + | |
| 145 | + /** 全部(名前 => 最後の値) */ | |
| 146 | + public function all(): array { | |
| 147 | + return array_map(fn($v) => end($v), $this->values); | |
| 148 | + } | |
| 149 | + | |
| 150 | + public function toString(): string { | |
| 151 | + $sections = []; | |
| 152 | + foreach ($this->values as $k => $vals) { | |
| 153 | + $p = strrpos($k, '.'); | |
| 154 | + $sections[substr($k, 0, $p)][substr($k, $p + 1)] = $vals; | |
| 155 | + } | |
| 156 | + $out = ''; | |
| 157 | + foreach ($sections as $sec => $keys) { | |
| 158 | + $dot = strpos($sec, '.'); | |
| 159 | + $out .= $dot === false ? "[{$sec}]\n" : '['.substr($sec, 0, $dot).' "'.addcslashes(substr($sec, $dot + 1), '"\\')."\"]\n"; | |
| 160 | + foreach ($keys as $key => $vals) { | |
| 161 | + foreach ($vals as $v) { | |
| 162 | + $q = preg_match('/^\s|\s$|[#;"\\\\\n\t]/', $v) ? '"'.str_replace(['\\', '"', "\n", "\t"], ['\\\\', '\\"', '\\n', '\\t'], $v).'"' : $v; | |
| 163 | + $out .= "\t{$key} = {$q}\n"; | |
| 164 | + } | |
| 165 | + } | |
| 166 | + } | |
| 167 | + return $out; | |
| 168 | + } | |
| 169 | + | |
| 170 | + public function save(?string $file = null): void { | |
| 171 | + $file ??= $this->file; | |
| 172 | + if ($file === null) throw new GitException('設定ファイルが有りません。'); | |
| 173 | + $tmp = $file.'.lock'; | |
| 174 | + if (@file_put_contents($tmp, $this->toString()) === false || !@rename($tmp, $file)) { | |
| 175 | + @unlink($tmp); | |
| 176 | + throw new GitException("設定ファイルに書けません:{$file}"); | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + private function key(string $name): string { | |
| 181 | + $first = strpos($name, '.'); | |
| 182 | + $last = strrpos($name, '.'); | |
| 183 | + if ($first === false) throw new GitException("不正な設定の名前:{$name}"); | |
| 184 | + if ($first === $last) return strtolower($name); | |
| 185 | + return strtolower(substr($name, 0, $first)).substr($name, $first, $last - $first).'.'.strtolower(substr($name, $last + 1)); | |
| 186 | + } | |
| 187 | +} |
| @@ -0,0 +1,96 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * パックの差分(デルタ) | |
| 42 | + */ | |
| 43 | +final class Delta { | |
| 44 | + /** 可変長の大きさ(7ビットずつ、小さい方から) */ | |
| 45 | + public static function size(string $delta, int &$pos): int { | |
| 46 | + $n = 0; | |
| 47 | + $shift = 0; | |
| 48 | + $len = strlen($delta); | |
| 49 | + do { | |
| 50 | + if ($pos >= $len) throw new GitException('壊れた差分'); | |
| 51 | + $b = ord($delta[$pos++]); | |
| 52 | + $n |= ($b & 0x7f) << $shift; | |
| 53 | + $shift += 7; | |
| 54 | + } while ($b & 0x80); | |
| 55 | + return $n; | |
| 56 | + } | |
| 57 | + | |
| 58 | + /** 差分を当てる */ | |
| 59 | + public static function apply(string $base, string $delta): string { | |
| 60 | + $pos = 0; | |
| 61 | + $srcSize = self::size($delta, $pos); | |
| 62 | + $dstSize = self::size($delta, $pos); | |
| 63 | + if ($srcSize !== strlen($base)) throw new GitException('差分の元の大きさが違います'); | |
| 64 | + $out = ''; | |
| 65 | + $len = strlen($delta); | |
| 66 | + while ($pos < $len) { | |
| 67 | + $op = ord($delta[$pos++]); | |
| 68 | + if ($op & 0x80) { | |
| 69 | + // 元からコピー | |
| 70 | + $off = 0; | |
| 71 | + $size = 0; | |
| 72 | + for ($i = 0; $i < 4; $i++) if ($op & (1 << $i)) $off |= ord($delta[$pos++]) << (8 * $i); | |
| 73 | + for ($i = 0; $i < 3; $i++) if ($op & (0x10 << $i)) $size |= ord($delta[$pos++]) << (8 * $i); | |
| 74 | + if ($size === 0) $size = 0x10000; | |
| 75 | + if ($off + $size > $srcSize) throw new GitException('壊れた差分(範囲外)'); | |
| 76 | + $out .= substr($base, $off, $size); | |
| 77 | + } else if ($op > 0) { | |
| 78 | + // そのまま挿入 | |
| 79 | + if ($pos + $op > $len) throw new GitException('壊れた差分(短い)'); | |
| 80 | + $out .= substr($delta, $pos, $op); | |
| 81 | + $pos += $op; | |
| 82 | + } else { | |
| 83 | + throw new GitException('壊れた差分(不明な命令)'); | |
| 84 | + } | |
| 85 | + } | |
| 86 | + if (strlen($out) !== $dstSize) throw new GitException('差分の結果の大きさが違います'); | |
| 87 | + return $out; | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** 差分の結果の大きさ */ | |
| 91 | + public static function targetSize(string $deltaHead): int { | |
| 92 | + $pos = 0; | |
| 93 | + self::size($deltaHead, $pos); | |
| 94 | + return self::size($deltaHead, $pos); | |
| 95 | + } | |
| 96 | +} |
| @@ -0,0 +1,364 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * 差分(ツリーの変更、行の差分、git diffの形式) | |
| 42 | + */ | |
| 43 | +final class Diff { | |
| 44 | + /** これより大きいファイルは行の差分を出さない */ | |
| 45 | + public const MAX_BYTES = 2097152; | |
| 46 | + /** 編集距離の上限(超えたら全部の置き換えとする) */ | |
| 47 | + private const MAX_D = 3000; | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * 2つのツリーの違い | |
| 51 | + * | |
| 52 | + * @param string|null $a 元のツリー(nullなら空) | |
| 53 | + * @param string|null $b 新しいツリー(nullなら空) | |
| 54 | + * @param bool $renames 同じ内容の削除と追加を名前の変更(R)にする | |
| 55 | + * @return array ['status' => A・D・M・T・R, 'old' => [mode, path, id]|null, 'new' => [mode, path, id]|null](パスの順) | |
| 56 | + */ | |
| 57 | + public static function trees(Repository $repo, ?string $a, ?string $b, bool $renames = true): array { | |
| 58 | + $out = []; | |
| 59 | + self::walk($repo, $a, $b, '', $out); | |
| 60 | + if ($renames) { | |
| 61 | + $added = []; | |
| 62 | + foreach ($out as $i => $c) if ($c['status'] === 'A') $added[$c['new'][2]][] = $i; | |
| 63 | + foreach ($out as $i => $c) { | |
| 64 | + if ($c['status'] !== 'D' || empty($added[$c['old'][2]])) continue; | |
| 65 | + $j = array_shift($added[$c['old'][2]]); | |
| 66 | + $out[$j] = ['status' => 'R', 'old' => $c['old'], 'new' => $out[$j]['new']]; | |
| 67 | + unset($out[$i]); | |
| 68 | + } | |
| 69 | + } | |
| 70 | + $out = array_values($out); | |
| 71 | + usort($out, fn($x, $y) => strcmp(($x['new'] ?? $x['old'])[1], ($y['new'] ?? $y['old'])[1])); | |
| 72 | + return $out; | |
| 73 | + } | |
| 74 | + | |
| 75 | + private static function walk(Repository $repo, ?string $a, ?string $b, string $prefix, array &$out): void { | |
| 76 | + if ($a === $b) return; | |
| 77 | + $ea = []; | |
| 78 | + $eb = []; | |
| 79 | + foreach ($a !== null ? $repo->tree($a)->entries : [] as $e) $ea[$e[1]] = $e; | |
| 80 | + foreach ($b !== null ? $repo->tree($b)->entries : [] as $e) $eb[$e[1]] = $e; | |
| 81 | + $names = array_unique(array_merge(array_keys($ea), array_keys($eb))); | |
| 82 | + sort($names, SORT_STRING); | |
| 83 | + foreach ($names as $name) { | |
| 84 | + $x = $ea[$name] ?? null; | |
| 85 | + $y = $eb[$name] ?? null; | |
| 86 | + $path = $prefix.$name; | |
| 87 | + $xd = $x !== null && $x[0] === Tree::DIR; | |
| 88 | + $yd = $y !== null && $y[0] === Tree::DIR; | |
| 89 | + if ($xd || $yd) { | |
| 90 | + self::walk($repo, $xd ? $x[2] : null, $yd ? $y[2] : null, $path.'/', $out); | |
| 91 | + if ($x !== null && !$xd) $out[] = ['status' => 'D', 'old' => [$x[0], $path, $x[2]], 'new' => null]; | |
| 92 | + if ($y !== null && !$yd) $out[] = ['status' => 'A', 'old' => null, 'new' => [$y[0], $path, $y[2]]]; | |
| 93 | + continue; | |
| 94 | + } | |
| 95 | + if ($x === null) $out[] = ['status' => 'A', 'old' => null, 'new' => [$y[0], $path, $y[2]]]; | |
| 96 | + else if ($y === null) $out[] = ['status' => 'D', 'old' => [$x[0], $path, $x[2]], 'new' => null]; | |
| 97 | + else if ($x[2] !== $y[2] || $x[0] !== $y[0]) { | |
| 98 | + $type = fn($m) => $m === Tree::LINK ? 'l' : ($m === Tree::SUBMODULE ? 's' : 'f'); | |
| 99 | + $out[] = ['status' => $type($x[0]) === $type($y[0]) ? 'M' : 'T', 'old' => [$x[0], $path, $x[2]], 'new' => [$y[0], $path, $y[2]]]; | |
| 100 | + } | |
| 101 | + } | |
| 102 | + } | |
| 103 | + | |
| 104 | + /** バイナリか(最初の8000バイトにNULが有る) */ | |
| 105 | + public static function isBinary(string $s): bool { | |
| 106 | + return str_contains(substr($s, 0, 8000), "\0"); | |
| 107 | + } | |
| 108 | + | |
| 109 | + /** 行に分ける(改行は含まない) */ | |
| 110 | + public static function split(string $s): array { | |
| 111 | + if ($s === '') return []; | |
| 112 | + $lines = explode("\n", $s); | |
| 113 | + if (end($lines) === '') array_pop($lines); | |
| 114 | + return $lines; | |
| 115 | + } | |
| 116 | + | |
| 117 | + /** | |
| 118 | + * 行の対応(Myersのアルゴリズム) | |
| 119 | + * | |
| 120 | + * @return array [元の行の番号, 新しい行の番号]の配列(同じ行、0から) | |
| 121 | + */ | |
| 122 | + public static function match(array $a, array $b): array { | |
| 123 | + $n = count($a); | |
| 124 | + $m = count($b); | |
| 125 | + // 前と後ろの同じ部分 | |
| 126 | + $pre = 0; | |
| 127 | + while ($pre < $n && $pre < $m && $a[$pre] === $b[$pre]) $pre++; | |
| 128 | + $suf = 0; | |
| 129 | + while ($suf < $n - $pre && $suf < $m - $pre && $a[$n - 1 - $suf] === $b[$m - 1 - $suf]) $suf++; | |
| 130 | + $pairs = []; | |
| 131 | + for ($i = 0; $i < $pre; $i++) $pairs[] = [$i, $i]; | |
| 132 | + $A = array_slice($a, $pre, $n - $pre - $suf); | |
| 133 | + $B = array_slice($b, $pre, $m - $pre - $suf); | |
| 134 | + foreach (self::myers($A, $B) as [$i, $j]) $pairs[] = [$i + $pre, $j + $pre]; | |
| 135 | + for ($k = $suf; $k > 0; $k--) $pairs[] = [$n - $k, $m - $k]; | |
| 136 | + return $pairs; | |
| 137 | + } | |
| 138 | + | |
| 139 | + private static function myers(array $a, array $b): array { | |
| 140 | + if ($a === [] || $b === []) return []; | |
| 141 | + // 相手に無い行は必ず変更なので除く(xdiffと同じ)。行は番号にする | |
| 142 | + $ids = []; | |
| 143 | + $inB = []; | |
| 144 | + foreach ($b as $l) $inB[$l] = true; | |
| 145 | + $inA = []; | |
| 146 | + foreach ($a as $l) $inA[$l] = true; | |
| 147 | + $A = []; | |
| 148 | + $mapA = []; | |
| 149 | + foreach ($a as $i => $l) if (isset($inB[$l])) { | |
| 150 | + $A[] = $ids[$l] ??= count($ids); | |
| 151 | + $mapA[] = $i; | |
| 152 | + } | |
| 153 | + $B = []; | |
| 154 | + $mapB = []; | |
| 155 | + foreach ($b as $j => $l) if (isset($inA[$l])) { | |
| 156 | + $B[] = $ids[$l] ??= count($ids); | |
| 157 | + $mapB[] = $j; | |
| 158 | + } | |
| 159 | + $n = count($A); | |
| 160 | + $m = count($B); | |
| 161 | + if ($n === 0 || $m === 0) return []; | |
| 162 | + // 各ステップのVは詰めた文字列で持つ(メモリを節約) | |
| 163 | + $v = [1 => 0]; | |
| 164 | + $trace = []; | |
| 165 | + $found = false; | |
| 166 | + $limit = min($n + $m, self::MAX_D); | |
| 167 | + for ($d = 0; $d <= $limit; $d++) { | |
| 168 | + $trace[] = pack('N*', ...array_map(fn($k) => ($v[$k] ?? 0) + 1, range(-$d - 1, $d + 1))); | |
| 169 | + for ($k = -$d; $k <= $d; $k += 2) { | |
| 170 | + if ($k === -$d || ($k !== $d && ($v[$k - 1] ?? -1) < ($v[$k + 1] ?? -1))) $x = $v[$k + 1] ?? 0; | |
| 171 | + else $x = ($v[$k - 1] ?? 0) + 1; | |
| 172 | + $y = $x - $k; | |
| 173 | + while ($x < $n && $y < $m && $A[$x] === $B[$y]) { | |
| 174 | + $x++; | |
| 175 | + $y++; | |
| 176 | + } | |
| 177 | + $v[$k] = $x; | |
| 178 | + if ($x >= $n && $y >= $m) { | |
| 179 | + $found = true; | |
| 180 | + break 2; | |
| 181 | + } | |
| 182 | + } | |
| 183 | + } | |
| 184 | + if (!$found) return []; | |
| 185 | + $get = function(int $d, int $k) use (&$trace): int { | |
| 186 | + // k = -d-1 … d+1(0は「無い」) | |
| 187 | + $i = $k + $d + 1; | |
| 188 | + if ($i < 0 || $i > 2 * $d + 2) return -1; | |
| 189 | + return unpack('N', $trace[$d], $i * 4)[1] - 1; | |
| 190 | + }; | |
| 191 | + $pairs = []; | |
| 192 | + $x = $n; | |
| 193 | + $y = $m; | |
| 194 | + for ($d = count($trace) - 1; $d >= 0; $d--) { | |
| 195 | + $k = $x - $y; | |
| 196 | + $down = $k === -$d || ($k !== $d && $get($d, $k - 1) < $get($d, $k + 1)); | |
| 197 | + $pk = $down ? $k + 1 : $k - 1; | |
| 198 | + $px = max(0, $get($d, $pk)); | |
| 199 | + $py = $px - $pk; | |
| 200 | + while ($x > $px && $y > $py) { | |
| 201 | + $x--; | |
| 202 | + $y--; | |
| 203 | + $pairs[] = [$mapA[$x], $mapB[$y]]; | |
| 204 | + } | |
| 205 | + if ($d > 0) { | |
| 206 | + $x = $px; | |
| 207 | + $y = $py; | |
| 208 | + } | |
| 209 | + } | |
| 210 | + return array_reverse($pairs); | |
| 211 | + } | |
| 212 | + | |
| 213 | + /** | |
| 214 | + * 行の差分のハンク | |
| 215 | + * | |
| 216 | + * @return array ['oldStart', 'oldLines', 'newStart', 'newLines', 'lines' => [[' '|'-'|'+', 行]]](番号は1から) | |
| 217 | + */ | |
| 218 | + public static function hunks(array $a, array $b, int $context = 3): array { | |
| 219 | + // 編集の列(' '、'-'、'+') | |
| 220 | + $ops = []; | |
| 221 | + $i = 0; | |
| 222 | + $j = 0; | |
| 223 | + foreach (array_merge(self::match($a, $b), [[count($a), count($b)]]) as [$x, $y]) { | |
| 224 | + while ($i < $x) $ops[] = ['-', $i++, null]; | |
| 225 | + while ($j < $y) $ops[] = ['+', null, $j++]; | |
| 226 | + if ($x < count($a)) $ops[] = [' ', $i++, $j++]; | |
| 227 | + } | |
| 228 | + $hunks = []; | |
| 229 | + $n = count($ops); | |
| 230 | + $k = 0; | |
| 231 | + while ($k < $n) { | |
| 232 | + while ($k < $n && $ops[$k][0] === ' ') $k++; | |
| 233 | + if ($k >= $n) break; | |
| 234 | + $start = max(0, $k - $context); | |
| 235 | + // 変更の間の同じ行が2*context以下なら同じハンク | |
| 236 | + $end = $k; | |
| 237 | + while (true) { | |
| 238 | + while ($end < $n && $ops[$end][0] !== ' ') $end++; | |
| 239 | + $same = $end; | |
| 240 | + while ($same < $n && $ops[$same][0] === ' ') $same++; | |
| 241 | + if ($same < $n && $same - $end <= 2 * $context) { | |
| 242 | + $end = $same; | |
| 243 | + continue; | |
| 244 | + } | |
| 245 | + $end = min($n, $end + $context); | |
| 246 | + break; | |
| 247 | + } | |
| 248 | + $h = ['oldStart' => 0, 'oldLines' => 0, 'newStart' => 0, 'newLines' => 0, 'lines' => []]; | |
| 249 | + $oldStart = null; | |
| 250 | + $newStart = null; | |
| 251 | + for ($p = $start; $p < $end; $p++) { | |
| 252 | + [$op, $x, $y] = $ops[$p]; | |
| 253 | + if ($x !== null) { | |
| 254 | + $oldStart ??= $x; | |
| 255 | + $h['oldLines']++; | |
| 256 | + } | |
| 257 | + if ($y !== null) { | |
| 258 | + $newStart ??= $y; | |
| 259 | + $h['newLines']++; | |
| 260 | + } | |
| 261 | + $h['lines'][] = [$op, $op === '+' ? $b[$y] : $a[$x]]; | |
| 262 | + } | |
| 263 | + // 行が無い側の番号はgitと同じ(直前の行) | |
| 264 | + $h['oldStart'] = $oldStart !== null ? $oldStart + 1 : self::before($ops, $start, 1); | |
| 265 | + $h['newStart'] = $newStart !== null ? $newStart + 1 : self::before($ops, $start, 2); | |
| 266 | + $hunks[] = $h; | |
| 267 | + $k = $end; | |
| 268 | + } | |
| 269 | + return $hunks; | |
| 270 | + } | |
| 271 | + | |
| 272 | + private static function before(array $ops, int $pos, int $side): int { | |
| 273 | + for ($p = $pos - 1; $p >= 0; $p--) if ($ops[$p][$side] !== null) return $ops[$p][$side] + 1; | |
| 274 | + return 0; | |
| 275 | + } | |
| 276 | + | |
| 277 | + /** | |
| 278 | + * git diffの形式 | |
| 279 | + * | |
| 280 | + * @param string|null $a 元のコミット・ツリー(nullなら空) | |
| 281 | + * @param string|null $b 新しいコミット・ツリー | |
| 282 | + */ | |
| 283 | + public static function unified(Repository $repo, ?string $a, ?string $b, int $context = 3, bool $quotePath = true): string { | |
| 284 | + $q = fn(string $prefix, string $path) => self::quote($prefix.$path, $quotePath); | |
| 285 | + $ta = $a === null ? null : $repo->treeOf($a); | |
| 286 | + $tb = $b === null ? null : $repo->treeOf($b); | |
| 287 | + $out = ''; | |
| 288 | + $short = fn(?string $id) => substr($id ?? $repo->zeroId(), 0, 7); | |
| 289 | + foreach (self::trees($repo, $ta, $tb) as $c) { | |
| 290 | + [$om, $op, $oid] = $c['old'] ?? [null, null, null]; | |
| 291 | + [$nm, $np, $nid] = $c['new'] ?? [null, null, null]; | |
| 292 | + $pa = $op ?? $np; | |
| 293 | + $pb = $np ?? $op; | |
| 294 | + $out .= 'diff --git '.$q('a/', $pa).' '.$q('b/', $pb)."\n"; | |
| 295 | + if ($c['status'] === 'A') $out .= "new file mode {$nm}\n"; | |
| 296 | + if ($c['status'] === 'D') $out .= "deleted file mode {$om}\n"; | |
| 297 | + if ($c['status'] === 'T' || ($c['status'] === 'M' && $om !== $nm)) $out .= "old mode {$om}\nnew mode {$nm}\n"; | |
| 298 | + if ($c['status'] === 'R') $out .= "similarity index 100%\nrename from ".self::quote($op, $quotePath)."\nrename to ".self::quote($np, $quotePath)."\n"; | |
| 299 | + if ($c['status'] === 'R' || $oid === $nid) continue; | |
| 300 | + $out .= 'index '.$short($oid).'..'.$short($nid).($c['status'] === 'M' && $om === $nm ? " {$nm}" : '')."\n"; | |
| 301 | + $old = $oid !== null && $om !== Tree::SUBMODULE ? $repo->blob($oid) : ($om === Tree::SUBMODULE ? "Subproject commit {$oid}\n" : ''); | |
| 302 | + $new = $nid !== null && $nm !== Tree::SUBMODULE ? $repo->blob($nid) : ($nm === Tree::SUBMODULE ? "Subproject commit {$nid}\n" : ''); | |
| 303 | + if (self::isBinary($old) || self::isBinary($new)) { | |
| 304 | + $out .= 'Binary files '.($oid !== null ? $q('a/', $pa) : '/dev/null').' and '.($nid !== null ? $q('b/', $pb) : '/dev/null')." differ\n"; | |
| 305 | + continue; | |
| 306 | + } | |
| 307 | + $out .= '--- '.($oid !== null ? $q('a/', $pa) : '/dev/null')."\n".'+++ '.($nid !== null ? $q('b/', $pb) : '/dev/null')."\n"; | |
| 308 | + $out .= self::format($old, $new, $context); | |
| 309 | + } | |
| 310 | + return $out; | |
| 311 | + } | |
| 312 | + | |
| 313 | + /** | |
| 314 | + * パスの引用(gitのcore.quotePath。制御文字・「"」・「\」と、$nonAsciiならASCII以外を8進数に) | |
| 315 | + */ | |
| 316 | + public static function quote(string $path, bool $nonAscii = true): string { | |
| 317 | + if (!preg_match($nonAscii ? '/[\x00-\x1f"\\\\\x7f-\xff]/' : '/[\x00-\x1f"\\\\\x7f]/', $path)) return $path; | |
| 318 | + $out = '"'; | |
| 319 | + $map = ["\x07" => '\a', "\x08" => '\b', "\t" => '\t', "\n" => '\n', "\x0b" => '\v', "\x0c" => '\f', "\r" => '\r', '"' => '\"', '\\' => '\\\\']; | |
| 320 | + for ($i = 0, $n = strlen($path); $i < $n; $i++) { | |
| 321 | + $c = $path[$i]; | |
| 322 | + $o = ord($c); | |
| 323 | + if (isset($map[$c])) $out .= $map[$c]; | |
| 324 | + else if ($o < 0x20 || $o === 0x7f || ($nonAscii && $o >= 0x80)) $out .= sprintf('\\%03o', $o); | |
| 325 | + else $out .= $c; | |
| 326 | + } | |
| 327 | + return $out.'"'; | |
| 328 | + } | |
| 329 | + | |
| 330 | + /** 2つの文字列の差分(「@@」から) */ | |
| 331 | + public static function format(string $old, string $new, int $context = 3): string { | |
| 332 | + $a = self::split($old); | |
| 333 | + $b = self::split($new); | |
| 334 | + $noNlA = $old !== '' && !str_ends_with($old, "\n"); | |
| 335 | + $noNlB = $new !== '' && !str_ends_with($new, "\n"); | |
| 336 | + // 最後の行の改行の有無が違えば別の行 | |
| 337 | + if ($noNlA && $noNlB === false && $a !== []) $a[count($a) - 1] .= "\0"; | |
| 338 | + if ($noNlB && $noNlA === false && $b !== []) $b[count($b) - 1] .= "\0"; | |
| 339 | + $out = ''; | |
| 340 | + foreach (self::hunks($a, $b, $context) as $h) { | |
| 341 | + $range = fn($s, $l) => $l === 1 ? "{$s}" : "{$s},{$l}"; | |
| 342 | + // 関数の名前(gitの既定:英字・「_」・「$」で始まる前の行) | |
| 343 | + $func = ''; | |
| 344 | + for ($p = $h['oldStart'] - 2; $p >= 0; $p--) { | |
| 345 | + if (preg_match('/^[A-Za-z_$]/', $a[$p])) { | |
| 346 | + $func = ' '.rtrim(substr(rtrim($a[$p], "\0"), 0, 80)); | |
| 347 | + break; | |
| 348 | + } | |
| 349 | + } | |
| 350 | + $out .= '@@ -'.$range($h['oldStart'], $h['oldLines']).' +'.$range($h['newStart'], $h['newLines'])." @@{$func}\n"; | |
| 351 | + $ia = 0; | |
| 352 | + $ib = 0; | |
| 353 | + foreach ($h['lines'] as [$op, $line]) { | |
| 354 | + $out .= $op.rtrim($line, "\0")."\n"; | |
| 355 | + $lastA = $op !== '+' && $h['oldStart'] - 1 + $ia++ === count($a) - 1; | |
| 356 | + $lastB = $op !== '-' && $h['newStart'] - 1 + $ib++ === count($b) - 1; | |
| 357 | + if (($op === '-' && $lastA && $noNlA) || ($op === '+' && $lastB && $noNlB) || ($op === ' ' && (($lastA && $noNlA) || ($lastB && $noNlB)))) { | |
| 358 | + $out .= "\\ No newline at end of file\n"; | |
| 359 | + } | |
| 360 | + } | |
| 361 | + } | |
| 362 | + return $out; | |
| 363 | + } | |
| 364 | +} |
| @@ -0,0 +1,44 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * Gitのエラー(壊れたオブジェクト、無いリポジトリ、プロトコルのエラー等) | |
| 42 | + */ | |
| 43 | +class GitException extends \RuntimeException { | |
| 44 | +} |
| @@ -0,0 +1,104 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * Smart HTTP(git clone https://…、git push) | |
| 42 | + * | |
| 43 | + * ルートから呼ぶ(アクセスの確認は先に済ませる): | |
| 44 | + * GET …/repo.git/info/refs?service=git-upload-pack|git-receive-pack | |
| 45 | + * POST …/repo.git/git-upload-pack | |
| 46 | + * POST …/repo.git/git-receive-pack | |
| 47 | + * 認証が必要な場合は、呼ぶ側が401とWWW-Authenticate: Basicを返す。 | |
| 48 | + */ | |
| 49 | +final class Http { | |
| 50 | + /** | |
| 51 | + * @param string $action info/refs、git-upload-pack、git-receive-pack | |
| 52 | + * @param array $opt push(プッシュを許すか)、receive(ReceivePackのオプション)、agent | |
| 53 | + * @param resource|null $in リクエストの本文(既定はphp://input) | |
| 54 | + * @param resource|null $out 応答(既定はphp://output) | |
| 55 | + * @return int HTTPのステータス | |
| 56 | + */ | |
| 57 | + public static function handle(Repository $repo, string $action, array $opt = [], $in = null, $out = null): int { | |
| 58 | + $push = !empty($opt['push']); | |
| 59 | + $version = str_contains((string)($_SERVER['HTTP_GIT_PROTOCOL'] ?? ''), 'version=2') ? 2 : 0; | |
| 60 | + $agent = ['agent' => (string)($opt['agent'] ?? 'LittleBeast')]; | |
| 61 | + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; | |
| 62 | + if ($action === 'info/refs' && $method === 'GET') { | |
| 63 | + $service = (string)($_GET['service'] ?? ''); | |
| 64 | + if ($service !== 'git-upload-pack' && ($service !== 'git-receive-pack' || !$push)) return self::error(403, 'このサービスは使えません'); | |
| 65 | + self::headers("application/x-{$service}-advertisement"); | |
| 66 | + $out ??= fopen('php://output', 'wb'); | |
| 67 | + if ($service === 'git-upload-pack') (new UploadPack($repo, $agent))->advertise($out, $version !== 2, $version); | |
| 68 | + else (new ReceivePack($repo, $agent + ($opt['receive'] ?? [])))->advertise($out, true); | |
| 69 | + return 200; | |
| 70 | + } | |
| 71 | + if (($action === 'git-upload-pack' || $action === 'git-receive-pack') && $method === 'POST') { | |
| 72 | + if ($action === 'git-receive-pack' && !$push) return self::error(403, 'プッシュは出来ません'); | |
| 73 | + if (($_SERVER['CONTENT_TYPE'] ?? '') !== "application/x-{$action}-request") return self::error(415, '不正なContent-Type'); | |
| 74 | + $in ??= fopen('php://input', 'rb'); | |
| 75 | + $enc = strtolower((string)($_SERVER['HTTP_CONTENT_ENCODING'] ?? '')); | |
| 76 | + if ($enc === 'gzip' || $enc === 'x-gzip') stream_filter_append($in, 'zlib.inflate', STREAM_FILTER_READ, ['window' => 31]); | |
| 77 | + @set_time_limit(0); | |
| 78 | + self::headers("application/x-{$action}-result"); | |
| 79 | + $out ??= fopen('php://output', 'wb'); | |
| 80 | + while (ob_get_level() > 0) ob_end_flush(); | |
| 81 | + if ($action === 'git-upload-pack') (new UploadPack($repo, $agent))->serve($in, $out, true, $version); | |
| 82 | + else (new ReceivePack($repo, $agent + ($opt['receive'] ?? [])))->serve($in, $out, true); | |
| 83 | + return 200; | |
| 84 | + } | |
| 85 | + return self::error(404, '有りません'); | |
| 86 | + } | |
| 87 | + | |
| 88 | + private static function headers(string $type): void { | |
| 89 | + if (headers_sent()) return; | |
| 90 | + header('Content-Type: '.$type); | |
| 91 | + header('Cache-Control: no-cache, max-age=0, must-revalidate'); | |
| 92 | + header('Pragma: no-cache'); | |
| 93 | + header('Expires: Fri, 01 Jan 1980 00:00:00 GMT'); | |
| 94 | + } | |
| 95 | + | |
| 96 | + private static function error(int $code, string $msg): int { | |
| 97 | + if (!headers_sent()) { | |
| 98 | + http_response_code($code); | |
| 99 | + header('Content-Type: text/plain; charset=UTF-8'); | |
| 100 | + } | |
| 101 | + echo $msg, "\n"; | |
| 102 | + return $code; | |
| 103 | + } | |
| 104 | +} |
| @@ -0,0 +1,110 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * リポジトリの整理(git gc の基本):到達出来るオブジェクトを1つのパックにまとめ、古い不要なファイルを消す | |
| 42 | + * | |
| 43 | + * プッシュで増えたルーズオブジェクトと小さいパックをまとめる。cronで定期的に実行すると良い。 | |
| 44 | + * 新しい差分は作らない(既存のパックの差分は再利用する)。 | |
| 45 | + */ | |
| 46 | +final class Maintenance { | |
| 47 | + /** | |
| 48 | + * @param int $pruneAge 到達出来ないオブジェクト・パックを消すまでの秒数(既定は2週間、受信中の物を消さない様に) | |
| 49 | + * @return array objects(パックにしたオブジェクトの数)、removedLoose、removedPacks | |
| 50 | + */ | |
| 51 | + public static function gc(Repository $repo, int $pruneAge = 1209600): array { | |
| 52 | + $lock = $repo->path.'/gc.pid'; | |
| 53 | + $fh = @fopen($lock, 'c'); | |
| 54 | + if ($fh === false || !flock($fh, LOCK_EX | LOCK_NB)) throw new GitException('別のgcが実行中です'); | |
| 55 | + try { | |
| 56 | + $tips = array_values($repo->refs->all()); | |
| 57 | + $head = $repo->refs->resolve('HEAD'); | |
| 58 | + if ($head !== null) $tips[] = $head; | |
| 59 | + $tips = array_values(array_unique($tips)); | |
| 60 | + $objects = empty($tips) ? [] : (new Walk($repo))->objects($tips, []); | |
| 61 | + $old = $repo->objects->packs(true); | |
| 62 | + $newPack = null; | |
| 63 | + if (!empty($objects)) { | |
| 64 | + $tmp = $repo->objects->dir().'/pack/tmp_gc_'.bin2hex(random_bytes(6)); | |
| 65 | + $out = fopen($tmp, 'w+b'); | |
| 66 | + try { | |
| 67 | + (new PackWriter($repo))->write($objects, fn(string $b) => fwrite($out, $b)); | |
| 68 | + rewind($out); | |
| 69 | + $res = (new PackIndexer($repo))->receive($out, 0); | |
| 70 | + $newPack = $res['pack']; | |
| 71 | + } finally { | |
| 72 | + fclose($out); | |
| 73 | + @unlink($tmp); | |
| 74 | + } | |
| 75 | + } | |
| 76 | + $cutoff = time() - $pruneAge; | |
| 77 | + $removedPacks = 0; | |
| 78 | + foreach ($old as $path => $pack) { | |
| 79 | + if ($path === $newPack || !str_starts_with($path, $repo->objects->dir().'/')) continue; | |
| 80 | + // 全部が新しいパックに有るか、古い(到達出来ない物だけ)なら消す | |
| 81 | + $all = true; | |
| 82 | + foreach ($pack->ids() as $id) if (!isset($objects[$id])) { | |
| 83 | + $all = false; | |
| 84 | + break; | |
| 85 | + } | |
| 86 | + if (!$all && filemtime($path) >= $cutoff) continue; | |
| 87 | + if (is_file(substr($path, 0, -5).'.keep')) continue; | |
| 88 | + unset($pack); | |
| 89 | + foreach (['.idx', '.rev', '.pack'] as $ext) @unlink(substr($path, 0, -5).$ext); | |
| 90 | + $removedPacks++; | |
| 91 | + } | |
| 92 | + $removedLoose = 0; | |
| 93 | + foreach (glob($repo->objects->dir().'/[0-9a-f][0-9a-f]/*') ?: [] as $f) { | |
| 94 | + $id = basename(dirname($f)).basename($f); | |
| 95 | + if (!$repo->isId($id)) continue; | |
| 96 | + if (isset($objects[$id]) || filemtime($f) < $cutoff) { | |
| 97 | + if (@unlink($f)) $removedLoose++; | |
| 98 | + } | |
| 99 | + } | |
| 100 | + foreach (glob($repo->objects->dir().'/[0-9a-f][0-9a-f]', GLOB_ONLYDIR) ?: [] as $d) @rmdir($d); | |
| 101 | + // 残った一時ファイル | |
| 102 | + foreach (glob($repo->objects->dir().'/pack/tmp_*') ?: [] as $f) if (filemtime($f) < $cutoff) @unlink($f); | |
| 103 | + return ['objects' => count($objects), 'removedLoose' => $removedLoose, 'removedPacks' => $removedPacks]; | |
| 104 | + } finally { | |
| 105 | + flock($fh, LOCK_UN); | |
| 106 | + fclose($fh); | |
| 107 | + @unlink($lock); | |
| 108 | + } | |
| 109 | + } | |
| 110 | +} |
| @@ -0,0 +1,290 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * オブジェクトの保存(objects/):ルーズオブジェクトとパック、objects/info/alternates | |
| 42 | + * | |
| 43 | + * IDは16進数の文字列。読むと[種類, データ](種類はcommit、tree、blob、tag)。 | |
| 44 | + */ | |
| 45 | +final class ObjectDb { | |
| 46 | + /** @var string[] オブジェクトのディレクトリ(自分とalternates) */ | |
| 47 | + private array $dirs; | |
| 48 | + /** @var Pack[] パス => パック */ | |
| 49 | + private array $packs = []; | |
| 50 | + private int $scanned = 0; | |
| 51 | + /** 差分の元のキャッシュ(キー => [種類, データ]) */ | |
| 52 | + private array $cache = []; | |
| 53 | + private int $cacheBytes = 0; | |
| 54 | + private const CACHE_LIMIT = 33554432; | |
| 55 | + | |
| 56 | + public function __construct(private Repository $repo) { | |
| 57 | + $this->dirs = [$repo->path.'/objects']; | |
| 58 | + $alt = $repo->path.'/objects/info/alternates'; | |
| 59 | + if (is_file($alt)) { | |
| 60 | + foreach (preg_split('/\r?\n/', (string)file_get_contents($alt)) as $line) { | |
| 61 | + $line = trim($line); | |
| 62 | + if ($line === '' || $line[0] === '#') continue; | |
| 63 | + $dir = preg_match('#^(/|[A-Za-z]:[/\\\\])#', $line) ? $line : $repo->path.'/objects/'.$line; | |
| 64 | + if (is_dir($dir)) $this->dirs[] = rtrim($dir, '/\\'); | |
| 65 | + } | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + /** @return Pack[] */ | |
| 70 | + public function packs(bool $rescan = false): array { | |
| 71 | + if ($rescan || $this->scanned === 0) { | |
| 72 | + foreach ($this->dirs as $dir) { | |
| 73 | + foreach (glob($dir.'/pack/pack-*.idx') ?: [] as $idx) { | |
| 74 | + $pack = substr($idx, 0, -4).'.pack'; | |
| 75 | + if (!isset($this->packs[$pack]) && is_file($pack)) $this->packs[$pack] = new Pack($pack, $this->repo->hashLen); | |
| 76 | + } | |
| 77 | + } | |
| 78 | + $this->scanned++; | |
| 79 | + } | |
| 80 | + return $this->packs; | |
| 81 | + } | |
| 82 | + | |
| 83 | + /** | |
| 84 | + * オブジェクトの場所 | |
| 85 | + * | |
| 86 | + * @return array|null ['loose', パス] 又は ['pack', Pack, オフセット] | |
| 87 | + */ | |
| 88 | + public function locate(string $id): ?array { | |
| 89 | + $id = strtolower($id); | |
| 90 | + if (!$this->repo->isId($id)) return null; | |
| 91 | + foreach ($this->dirs as $dir) { | |
| 92 | + $p = $dir.'/'.substr($id, 0, 2).'/'.substr($id, 2); | |
| 93 | + if (is_file($p)) return ['loose', $p]; | |
| 94 | + } | |
| 95 | + $bin = hex2bin($id); | |
| 96 | + foreach ([false, true] as $rescan) { | |
| 97 | + foreach ($this->packs($rescan) as $pack) { | |
| 98 | + $off = $pack->find($bin); | |
| 99 | + if ($off !== null) return ['pack', $pack, $off]; | |
| 100 | + } | |
| 101 | + } | |
| 102 | + return null; | |
| 103 | + } | |
| 104 | + | |
| 105 | + public function exists(string $id): bool { | |
| 106 | + return $this->locate($id) !== null; | |
| 107 | + } | |
| 108 | + | |
| 109 | + /** | |
| 110 | + * オブジェクトを読む | |
| 111 | + * | |
| 112 | + * @return array|null [種類, データ] | |
| 113 | + */ | |
| 114 | + public function read(string $id): ?array { | |
| 115 | + $loc = $this->locate($id); | |
| 116 | + if ($loc === null) return null; | |
| 117 | + if ($loc[0] === 'loose') return $this->readLoose($loc[1], $id); | |
| 118 | + return $this->readPacked($loc[1], $loc[2]); | |
| 119 | + } | |
| 120 | + | |
| 121 | + /** 必ず有るオブジェクト */ | |
| 122 | + public function get(string $id, ?string $type = null): string { | |
| 123 | + $o = $this->read($id); | |
| 124 | + if ($o === null) throw new GitException("オブジェクトが有りません:{$id}"); | |
| 125 | + if ($type !== null && $o[0] !== $type) throw new GitException("{$id}は{$type}ではなく{$o[0]}です"); | |
| 126 | + return $o[1]; | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** | |
| 130 | + * 種類と大きさだけ(データを全部は読まない) | |
| 131 | + * | |
| 132 | + * @return array|null [種類, 大きさ] | |
| 133 | + */ | |
| 134 | + public function info(string $id): ?array { | |
| 135 | + $loc = $this->locate($id); | |
| 136 | + if ($loc === null) return null; | |
| 137 | + if ($loc[0] === 'loose') { | |
| 138 | + $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); | |
| 139 | + $fh = fopen($loc[1], 'rb'); | |
| 140 | + $head = ''; | |
| 141 | + while (!str_contains($head, "\0") && !feof($fh)) $head .= (string)@inflate_add($ctx, (string)fread($fh, 256), ZLIB_SYNC_FLUSH); | |
| 142 | + fclose($fh); | |
| 143 | + if (!preg_match('/^(commit|tree|blob|tag) (\d+)\0/', $head, $m)) throw new GitException("壊れたオブジェクト:{$id}"); | |
| 144 | + return [$m[1], (int)$m[2]]; | |
| 145 | + } | |
| 146 | + [$type, $size, $dataAt, $baseOff, $baseId] = $loc[1]->header($loc[2]); | |
| 147 | + if (isset(Pack::TYPES[$type])) return [Pack::TYPES[$type], $size]; | |
| 148 | + // 差分:大きさは差分の頭、種類は元を辿る | |
| 149 | + $head = $loc[1]->inflate($dataAt, $size); | |
| 150 | + $target = Delta::targetSize($head); | |
| 151 | + $pack = $loc[1]; | |
| 152 | + $off = $loc[2]; | |
| 153 | + for ($depth = 0; $depth < 10000; $depth++) { | |
| 154 | + [$t, , , $bo, $bi] = $pack->header($off); | |
| 155 | + if (isset(Pack::TYPES[$t])) return [Pack::TYPES[$t], $target]; | |
| 156 | + if ($bo !== null) { | |
| 157 | + $off = $bo; | |
| 158 | + continue; | |
| 159 | + } | |
| 160 | + $l = $this->locate(bin2hex($bi)); | |
| 161 | + if ($l === null) throw new GitException('差分の元が有りません:'.bin2hex($bi)); | |
| 162 | + if ($l[0] === 'loose') return [$this->info(bin2hex($bi))[0], $target]; | |
| 163 | + [$pack, $off] = [$l[1], $l[2]]; | |
| 164 | + } | |
| 165 | + throw new GitException("差分が深過ぎます:{$id}"); | |
| 166 | + } | |
| 167 | + | |
| 168 | + private function readLoose(string $path, string $id): array { | |
| 169 | + $raw = @file_get_contents($path); | |
| 170 | + $data = $raw === false ? false : @zlib_decode($raw); | |
| 171 | + if ($data === false) throw new GitException("壊れたオブジェクト:{$id}"); | |
| 172 | + $nul = strpos($data, "\0"); | |
| 173 | + if ($nul === false || !preg_match('/^(commit|tree|blob|tag) (\d+)$/', substr($data, 0, $nul), $m) || (int)$m[2] !== strlen($data) - $nul - 1) { | |
| 174 | + throw new GitException("壊れたオブジェクト:{$id}"); | |
| 175 | + } | |
| 176 | + return [$m[1], substr($data, $nul + 1)]; | |
| 177 | + } | |
| 178 | + | |
| 179 | + /** | |
| 180 | + * パックの中のオブジェクト(差分は元を辿って当てる) | |
| 181 | + * | |
| 182 | + * @return array [種類, データ] | |
| 183 | + */ | |
| 184 | + public function readPacked(Pack $pack, int $offset): array { | |
| 185 | + $key = $pack->path.':'.$offset; | |
| 186 | + if (isset($this->cache[$key])) return $this->cache[$key]; | |
| 187 | + // 差分の連鎖を集める | |
| 188 | + $chain = []; | |
| 189 | + $p = $pack; | |
| 190 | + $off = $offset; | |
| 191 | + while (true) { | |
| 192 | + $k = $p->path.':'.$off; | |
| 193 | + if (isset($this->cache[$k])) { | |
| 194 | + $base = $this->cache[$k]; | |
| 195 | + break; | |
| 196 | + } | |
| 197 | + [$type, $size, $dataAt, $baseOff, $baseId] = $p->header($off); | |
| 198 | + if (isset(Pack::TYPES[$type])) { | |
| 199 | + $base = [Pack::TYPES[$type], $p->inflate($dataAt, $size)]; | |
| 200 | + $this->remember($k, $base); | |
| 201 | + break; | |
| 202 | + } | |
| 203 | + $chain[] = [$p, $off, $dataAt, $size]; | |
| 204 | + if (count($chain) > 10000) throw new GitException('差分が深過ぎます'); | |
| 205 | + if ($baseOff !== null) { | |
| 206 | + $off = $baseOff; | |
| 207 | + continue; | |
| 208 | + } | |
| 209 | + $hex = bin2hex($baseId); | |
| 210 | + $o = $p->find($baseId); | |
| 211 | + if ($o !== null) { | |
| 212 | + $off = $o; | |
| 213 | + continue; | |
| 214 | + } | |
| 215 | + $l = $this->locate($hex); | |
| 216 | + if ($l === null) throw new GitException("差分の元が有りません:{$hex}"); | |
| 217 | + if ($l[0] === 'loose') { | |
| 218 | + $base = $this->readLoose($l[1], $hex); | |
| 219 | + break; | |
| 220 | + } | |
| 221 | + [$p, $off] = [$l[1], $l[2]]; | |
| 222 | + } | |
| 223 | + for ($i = count($chain) - 1; $i >= 0; $i--) { | |
| 224 | + [$cp, $coff, $dataAt, $size] = $chain[$i]; | |
| 225 | + $base = [$base[0], Delta::apply($base[1], $cp->inflate($dataAt, $size))]; | |
| 226 | + $this->remember($cp->path.':'.$coff, $base); | |
| 227 | + } | |
| 228 | + return $base; | |
| 229 | + } | |
| 230 | + | |
| 231 | + private function remember(string $key, array $obj): void { | |
| 232 | + $n = strlen($obj[1]); | |
| 233 | + if ($n > self::CACHE_LIMIT / 4) return; | |
| 234 | + while ($this->cacheBytes + $n > self::CACHE_LIMIT && !empty($this->cache)) { | |
| 235 | + $k = array_key_first($this->cache); | |
| 236 | + $this->cacheBytes -= strlen($this->cache[$k][1]); | |
| 237 | + unset($this->cache[$k]); | |
| 238 | + } | |
| 239 | + $this->cache[$key] = $obj; | |
| 240 | + $this->cacheBytes += $n; | |
| 241 | + } | |
| 242 | + | |
| 243 | + /** | |
| 244 | + * ルーズオブジェクトとして書く(既に有れば何もしない) | |
| 245 | + * | |
| 246 | + * @return string ID | |
| 247 | + */ | |
| 248 | + public function write(string $type, string $data): string { | |
| 249 | + if (!in_array($type, ['commit', 'tree', 'blob', 'tag'], true)) throw new GitException("不正な種類:{$type}"); | |
| 250 | + $id = $this->repo->hashObject($type, $data); | |
| 251 | + if ($this->exists($id)) return $id; | |
| 252 | + $dir = $this->dirs[0].'/'.substr($id, 0, 2); | |
| 253 | + $this->repo->mkdir($dir); | |
| 254 | + $level = $this->repo->config->getInt('core.loosecompression', $this->repo->config->getInt('core.compression', -1)); | |
| 255 | + $this->repo->writeFile($dir.'/'.substr($id, 2), (string)gzcompress($type.' '.strlen($data)."\0".$data, max(-1, min(9, $level))), true); | |
| 256 | + return $id; | |
| 257 | + } | |
| 258 | + | |
| 259 | + /** | |
| 260 | + * 短いIDを解決する | |
| 261 | + * | |
| 262 | + * @return string|null 一意に決まれば完全なID | |
| 263 | + */ | |
| 264 | + public function resolvePrefix(string $prefix): ?string { | |
| 265 | + $prefix = strtolower($prefix); | |
| 266 | + if (!preg_match('/^[0-9a-f]{4,}$/', $prefix) || strlen($prefix) > $this->repo->hexLen) return null; | |
| 267 | + if (strlen($prefix) === $this->repo->hexLen) return $this->exists($prefix) ? $prefix : null; | |
| 268 | + $found = []; | |
| 269 | + foreach ($this->dirs as $dir) { | |
| 270 | + foreach (glob($dir.'/'.substr($prefix, 0, 2).'/'.substr($prefix, 2).'*') ?: [] as $f) $found[substr($prefix, 0, 2).basename($f)] = true; | |
| 271 | + } | |
| 272 | + foreach ($this->packs(true) as $pack) foreach ($pack->findPrefix($prefix) as $id) $found[$id] = true; | |
| 273 | + return count($found) === 1 ? (string)array_key_first($found) : null; | |
| 274 | + } | |
| 275 | + | |
| 276 | + /** 全部のID(重複あり得る) */ | |
| 277 | + public function ids(): \Generator { | |
| 278 | + foreach ($this->dirs as $dir) { | |
| 279 | + foreach (glob($dir.'/[0-9a-f][0-9a-f]/*') ?: [] as $f) { | |
| 280 | + $id = basename(dirname($f)).basename($f); | |
| 281 | + if ($this->repo->isId($id)) yield $id; | |
| 282 | + } | |
| 283 | + } | |
| 284 | + foreach ($this->packs(true) as $pack) yield from $pack->ids(); | |
| 285 | + } | |
| 286 | + | |
| 287 | + public function dir(): string { | |
| 288 | + return $this->dirs[0]; | |
| 289 | + } | |
| 290 | +} |
| @@ -0,0 +1,61 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * プロトコルの出力(直ぐに送る) | |
| 42 | + */ | |
| 43 | +final class Output { | |
| 44 | + /** @param resource $out */ | |
| 45 | + public function __construct(private $out, private bool $http = false) {} | |
| 46 | + | |
| 47 | + public function write(string $s): void { | |
| 48 | + if ($s === '') return; | |
| 49 | + for ($i = 0, $n = strlen($s); $i < $n;) { | |
| 50 | + $w = @fwrite($this->out, substr($s, $i, 1048576)); | |
| 51 | + if ($w === false || $w === 0) throw new GitException('接続が切れました'); | |
| 52 | + $i += $w; | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + /** バッファーを送る */ | |
| 57 | + public function flush(): void { | |
| 58 | + @fflush($this->out); | |
| 59 | + if ($this->http) @flush(); | |
| 60 | + } | |
| 61 | +} |
| @@ -0,0 +1,297 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * パックファイル(objects/pack/pack-*.pack)とインデックス(.idx v2、v1) | |
| 42 | + * | |
| 43 | + * オブジェクトの種類:1 commit、2 tree、3 blob、4 tag、6 OFS_DELTA、7 REF_DELTA。 | |
| 44 | + */ | |
| 45 | +final class Pack { | |
| 46 | + public const COMMIT = 1; | |
| 47 | + public const TREE = 2; | |
| 48 | + public const BLOB = 3; | |
| 49 | + public const TAG = 4; | |
| 50 | + public const OFS_DELTA = 6; | |
| 51 | + public const REF_DELTA = 7; | |
| 52 | + public const TYPES = [1 => 'commit', 2 => 'tree', 3 => 'blob', 4 => 'tag']; | |
| 53 | + | |
| 54 | + /** @var resource|null */ | |
| 55 | + private $fh = null; | |
| 56 | + private string $idx = ''; | |
| 57 | + private int $version = 2; | |
| 58 | + private int $count = 0; | |
| 59 | + private int $namesAt = 0; | |
| 60 | + private int $crcAt = 0; | |
| 61 | + private int $offsetsAt = 0; | |
| 62 | + private int $largeAt = 0; | |
| 63 | + private ?string $rev = null; | |
| 64 | + public readonly int $size; | |
| 65 | + | |
| 66 | + /** | |
| 67 | + * @param string $path .packのパス | |
| 68 | + * @param int $hashLen 20(SHA-1)又は32(SHA-256) | |
| 69 | + * @param bool $withIndex .idxを読む(受け取り中のパックはfalse) | |
| 70 | + */ | |
| 71 | + public function __construct(public readonly string $path, private int $hashLen, bool $withIndex = true) { | |
| 72 | + $fh = @fopen($path, 'rb'); | |
| 73 | + if ($fh === false) throw new GitException("パックを開けません:{$path}"); | |
| 74 | + $this->fh = $fh; | |
| 75 | + $this->size = (int)fstat($fh)['size']; | |
| 76 | + $head = (string)fread($fh, 12); | |
| 77 | + if (strlen($head) < 12 || substr($head, 0, 4) !== 'PACK' || !in_array(unpack('N', $head, 4)[1], [2, 3], true)) throw new GitException("パックではありません:{$path}"); | |
| 78 | + if ($withIndex) $this->loadIndex(substr($path, 0, -5).'.idx'); | |
| 79 | + } | |
| 80 | + | |
| 81 | + public function __destruct() { | |
| 82 | + if ($this->fh) fclose($this->fh); | |
| 83 | + } | |
| 84 | + | |
| 85 | + private function loadIndex(string $file): void { | |
| 86 | + $idx = @file_get_contents($file); | |
| 87 | + if ($idx === false) throw new GitException("インデックスを読めません:{$file}"); | |
| 88 | + $this->idx = $idx; | |
| 89 | + $h = $this->hashLen; | |
| 90 | + if (substr($idx, 0, 4) === "\xfftOc") { | |
| 91 | + $this->version = unpack('N', $idx, 4)[1]; | |
| 92 | + if ($this->version !== 2) throw new GitException("未対応のインデックスのバージョン:{$this->version}"); | |
| 93 | + $this->count = unpack('N', $idx, 8 + 255 * 4)[1]; | |
| 94 | + $this->namesAt = 8 + 256 * 4; | |
| 95 | + $this->crcAt = $this->namesAt + $this->count * $h; | |
| 96 | + $this->offsetsAt = $this->crcAt + $this->count * 4; | |
| 97 | + $this->largeAt = $this->offsetsAt + $this->count * 4; | |
| 98 | + } else { | |
| 99 | + // v1:fanout、[オフセット(4) 名前]の配列 | |
| 100 | + $this->version = 1; | |
| 101 | + $this->count = unpack('N', $idx, 255 * 4)[1]; | |
| 102 | + $this->namesAt = 256 * 4; | |
| 103 | + } | |
| 104 | + if (strlen($idx) < $this->namesAt + $this->count * $h) throw new GitException("壊れたインデックス:{$file}"); | |
| 105 | + } | |
| 106 | + | |
| 107 | + public function count(): int { | |
| 108 | + return $this->count; | |
| 109 | + } | |
| 110 | + | |
| 111 | + /** n番目(名前の順)のID(バイナリ) */ | |
| 112 | + public function idAt(int $n): string { | |
| 113 | + return $this->version === 2 ? substr($this->idx, $this->namesAt + $n * $this->hashLen, $this->hashLen) | |
| 114 | + : substr($this->idx, $this->namesAt + $n * ($this->hashLen + 4) + 4, $this->hashLen); | |
| 115 | + } | |
| 116 | + | |
| 117 | + /** n番目のオフセット */ | |
| 118 | + public function offsetAt(int $n): int { | |
| 119 | + if ($this->version === 1) return unpack('N', $this->idx, $this->namesAt + $n * ($this->hashLen + 4))[1]; | |
| 120 | + $o = unpack('N', $this->idx, $this->offsetsAt + $n * 4)[1]; | |
| 121 | + if ($o & 0x80000000) $o = unpack('J', $this->idx, $this->largeAt + ($o & 0x7fffffff) * 8)[1]; | |
| 122 | + return $o; | |
| 123 | + } | |
| 124 | + | |
| 125 | + public function crcAt(int $n): ?int { | |
| 126 | + return $this->version === 2 ? unpack('N', $this->idx, $this->crcAt + $n * 4)[1] : null; | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** | |
| 130 | + * IDの位置(名前の順の番号) | |
| 131 | + * | |
| 132 | + * @param string $id バイナリのID | |
| 133 | + */ | |
| 134 | + public function position(string $id): ?int { | |
| 135 | + $first = ord($id[0]); | |
| 136 | + $fan = $this->version === 2 ? 8 : 0; | |
| 137 | + $lo = $first === 0 ? 0 : unpack('N', $this->idx, $fan + ($first - 1) * 4)[1]; | |
| 138 | + $hi = unpack('N', $this->idx, $fan + $first * 4)[1] - 1; | |
| 139 | + while ($lo <= $hi) { | |
| 140 | + $mid = ($lo + $hi) >> 1; | |
| 141 | + $c = strcmp($this->idAt($mid), $id); | |
| 142 | + if ($c === 0) return $mid; | |
| 143 | + if ($c < 0) $lo = $mid + 1; | |
| 144 | + else $hi = $mid - 1; | |
| 145 | + } | |
| 146 | + return null; | |
| 147 | + } | |
| 148 | + | |
| 149 | + /** IDのオフセット(無ければnull) */ | |
| 150 | + public function find(string $id): ?int { | |
| 151 | + $n = $this->position($id); | |
| 152 | + return $n === null ? null : $this->offsetAt($n); | |
| 153 | + } | |
| 154 | + | |
| 155 | + /** | |
| 156 | + * 始まりが一致するID(短いIDの解決用) | |
| 157 | + * | |
| 158 | + * @param string $prefix 16進数(偶数でなくても良い) | |
| 159 | + * @return string[] 16進数(最大2件) | |
| 160 | + */ | |
| 161 | + public function findPrefix(string $prefix): array { | |
| 162 | + $out = []; | |
| 163 | + $first = hexdec(substr($prefix . '0', 0, 2)); | |
| 164 | + $fan = $this->version === 2 ? 8 : 0; | |
| 165 | + $lo = $first === 0 ? 0 : unpack('N', $this->idx, $fan + ($first - 1) * 4)[1]; | |
| 166 | + $hi = unpack('N', $this->idx, $fan + (strlen($prefix) >= 2 ? $first : min(255, $first + 15)) * 4)[1]; | |
| 167 | + for ($n = $lo; $n < $hi && count($out) < 2; $n++) { | |
| 168 | + $hex = bin2hex($this->idAt($n)); | |
| 169 | + if (str_starts_with($hex, $prefix)) $out[] = $hex; | |
| 170 | + } | |
| 171 | + return $out; | |
| 172 | + } | |
| 173 | + | |
| 174 | + /** 全部のID(16進数) */ | |
| 175 | + public function ids(): \Generator { | |
| 176 | + for ($n = 0; $n < $this->count; $n++) yield bin2hex($this->idAt($n)); | |
| 177 | + } | |
| 178 | + | |
| 179 | + /** | |
| 180 | + * エントリーのヘッダー | |
| 181 | + * | |
| 182 | + * @return array [種類, 大きさ, データの位置, 元のオフセット(OFS), 元のID(REF、バイナリ)] | |
| 183 | + */ | |
| 184 | + public function header(int $offset): array { | |
| 185 | + fseek($this->fh, $offset); | |
| 186 | + $buf = (string)fread($this->fh, 32 + $this->hashLen); | |
| 187 | + $p = 0; | |
| 188 | + $b = ord($buf[$p++]); | |
| 189 | + $type = ($b >> 4) & 7; | |
| 190 | + $size = $b & 15; | |
| 191 | + $shift = 4; | |
| 192 | + while ($b & 0x80) { | |
| 193 | + $b = ord($buf[$p++]); | |
| 194 | + $size |= ($b & 0x7f) << $shift; | |
| 195 | + $shift += 7; | |
| 196 | + } | |
| 197 | + $baseOffset = null; | |
| 198 | + $baseId = null; | |
| 199 | + if ($type === self::OFS_DELTA) { | |
| 200 | + $b = ord($buf[$p++]); | |
| 201 | + $n = $b & 0x7f; | |
| 202 | + while ($b & 0x80) { | |
| 203 | + $b = ord($buf[$p++]); | |
| 204 | + $n = (($n + 1) << 7) | ($b & 0x7f); | |
| 205 | + } | |
| 206 | + $baseOffset = $offset - $n; | |
| 207 | + if ($baseOffset < 12 || $baseOffset >= $offset) throw new GitException("壊れたパック(OFS_DELTA):{$this->path}"); | |
| 208 | + } else if ($type === self::REF_DELTA) { | |
| 209 | + $baseId = substr($buf, $p, $this->hashLen); | |
| 210 | + $p += $this->hashLen; | |
| 211 | + } else if (!isset(self::TYPES[$type])) { | |
| 212 | + throw new GitException("壊れたパック(種類{$type}、{$offset}):{$this->path}"); | |
| 213 | + } | |
| 214 | + return [$type, $size, $offset + $p, $baseOffset, $baseId]; | |
| 215 | + } | |
| 216 | + | |
| 217 | + /** | |
| 218 | + * zlibのデータを展開する | |
| 219 | + * | |
| 220 | + * @param int $pos データの位置 | |
| 221 | + * @param int $size 展開後の大きさ(分かっていれば読む量の目安) | |
| 222 | + * @param int|null $consumed 圧縮されたデータの長さ | |
| 223 | + */ | |
| 224 | + public function inflate(int $pos, int $size, ?int &$consumed = null): string { | |
| 225 | + fseek($this->fh, $pos); | |
| 226 | + $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); | |
| 227 | + $out = ''; | |
| 228 | + $chunk = min(max(256, $size + 64), 1048576); | |
| 229 | + while (true) { | |
| 230 | + $in = fread($this->fh, $chunk); | |
| 231 | + if ($in === false || $in === '') throw new GitException("壊れたパック(途中で終わる):{$this->path}"); | |
| 232 | + $res = @inflate_add($ctx, $in, ZLIB_SYNC_FLUSH); | |
| 233 | + if ($res === false) throw new GitException("壊れたパック(zlib):{$this->path}"); | |
| 234 | + $out .= $res; | |
| 235 | + if (inflate_get_status($ctx) === ZLIB_STREAM_END) break; | |
| 236 | + $chunk = 65536; | |
| 237 | + } | |
| 238 | + $consumed = inflate_get_read_len($ctx); | |
| 239 | + if (strlen($out) !== $size) throw new GitException("壊れたパック(大きさ):{$this->path}"); | |
| 240 | + return $out; | |
| 241 | + } | |
| 242 | + | |
| 243 | + /** 位置のオフセットに対する次のエントリーの始まり(.revか、無ければ作る) */ | |
| 244 | + public function entryEnd(int $offset): int { | |
| 245 | + $this->loadRev(); | |
| 246 | + // オフセットの順のn番目 | |
| 247 | + $lo = 0; | |
| 248 | + $hi = $this->count - 1; | |
| 249 | + while ($lo <= $hi) { | |
| 250 | + $mid = ($lo + $hi) >> 1; | |
| 251 | + $o = $this->offsetAt(unpack('N', $this->rev, $mid * 4)[1]); | |
| 252 | + if ($o === $offset) return $mid + 1 < $this->count ? $this->offsetAt(unpack('N', $this->rev, ($mid + 1) * 4)[1]) : $this->size - $this->hashLen; | |
| 253 | + if ($o < $offset) $lo = $mid + 1; | |
| 254 | + else $hi = $mid - 1; | |
| 255 | + } | |
| 256 | + throw new GitException("パックにエントリーが有りません({$offset}):{$this->path}"); | |
| 257 | + } | |
| 258 | + | |
| 259 | + /** オフセットのID(バイナリ) */ | |
| 260 | + public function idAtOffset(int $offset): ?string { | |
| 261 | + $this->loadRev(); | |
| 262 | + $lo = 0; | |
| 263 | + $hi = $this->count - 1; | |
| 264 | + while ($lo <= $hi) { | |
| 265 | + $mid = ($lo + $hi) >> 1; | |
| 266 | + $n = unpack('N', $this->rev, $mid * 4)[1]; | |
| 267 | + $o = $this->offsetAt($n); | |
| 268 | + if ($o === $offset) return $this->idAt($n); | |
| 269 | + if ($o < $offset) $lo = $mid + 1; | |
| 270 | + else $hi = $mid - 1; | |
| 271 | + } | |
| 272 | + return null; | |
| 273 | + } | |
| 274 | + | |
| 275 | + /** 逆インデックス(オフセットの順の名前の番号)。.revが有れば使う */ | |
| 276 | + private function loadRev(): void { | |
| 277 | + if ($this->rev !== null) return; | |
| 278 | + $file = substr($this->path, 0, -5).'.rev'; | |
| 279 | + $r = is_file($file) ? (string)file_get_contents($file) : ''; | |
| 280 | + if (strlen($r) >= 12 + $this->count * 4 && substr($r, 0, 4) === 'RIDX' && unpack('N', $r, 4)[1] === 1) { | |
| 281 | + $this->rev = substr($r, 12, $this->count * 4); | |
| 282 | + return; | |
| 283 | + } | |
| 284 | + $offs = []; | |
| 285 | + for ($n = 0; $n < $this->count; $n++) $offs[$n] = $this->offsetAt($n); | |
| 286 | + asort($offs); | |
| 287 | + $this->rev = pack('N*', ...array_keys($offs)); | |
| 288 | + } | |
| 289 | + | |
| 290 | + /** パックの中身の生のバイト列(ヘッダーと圧縮されたデータ) */ | |
| 291 | + public function raw(int $start, int $end): string { | |
| 292 | + fseek($this->fh, $start); | |
| 293 | + $s = (string)fread($this->fh, $end - $start); | |
| 294 | + if (strlen($s) !== $end - $start) throw new GitException("パックを読めません:{$this->path}"); | |
| 295 | + return $s; | |
| 296 | + } | |
| 297 | +} |
| @@ -0,0 +1,363 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * 受け取ったパックを読んで保存する(git index-pack、git unpack-objects) | |
| 42 | + * | |
| 43 | + * ストリームから読みながら一時ファイルに書き、差分を解決してIDを計算し、.idx(v2)と.revを作る。 | |
| 44 | + * オブジェクトが少なければルーズオブジェクトにする。 | |
| 45 | + */ | |
| 46 | +final class PackIndexer { | |
| 47 | + /** @var resource */ | |
| 48 | + private $in; | |
| 49 | + /** @var resource */ | |
| 50 | + private $tmp; | |
| 51 | + private string $buf = ''; | |
| 52 | + private $hash; | |
| 53 | + private int $pos = 0; | |
| 54 | + private string $tmpPath = ''; | |
| 55 | + /** @var array オフセット => [種類, 大きさ, データの位置, 元のオフセット, 元のID(bin), crc] */ | |
| 56 | + private array $entries = []; | |
| 57 | + /** @var array オフセット => ID(bin) */ | |
| 58 | + private array $ids = []; | |
| 59 | + /** @var array ID(hex) => オフセット */ | |
| 60 | + private array $byId = []; | |
| 61 | + /** @var array オフセット => 種類 */ | |
| 62 | + private array $types = []; | |
| 63 | + private array $cache = []; | |
| 64 | + private int $cacheBytes = 0; | |
| 65 | + private ?Pack $pack = null; | |
| 66 | + /** 参照しているID(接続の確認用) */ | |
| 67 | + private array $links = []; | |
| 68 | + private bool $thin = false; | |
| 69 | + | |
| 70 | + public function __construct(private Repository $repo) {} | |
| 71 | + | |
| 72 | + /** | |
| 73 | + * パックを受け取る | |
| 74 | + * | |
| 75 | + * @param resource $in 入力(パックの始まりから) | |
| 76 | + * @param int $unpackLimit これより少なければルーズオブジェクトにする | |
| 77 | + * @param callable|null $progress fn(string $what, int $done, int $total) | |
| 78 | + * @return array objects(ID => 種類)、links(参照しているID => true)、pack(パックのパス、ルーズならnull) | |
| 79 | + */ | |
| 80 | + public function receive($in, int $unpackLimit = 100, ?callable $progress = null): array { | |
| 81 | + $this->in = $in; | |
| 82 | + $this->hash = hash_init($this->repo->hashAlgo); | |
| 83 | + $dir = $this->repo->objects->dir().'/pack'; | |
| 84 | + $this->repo->mkdir($dir); | |
| 85 | + $this->tmpPath = $dir.'/tmp_pack_'.bin2hex(random_bytes(6)); | |
| 86 | + $tmp = @fopen($this->tmpPath, 'w+b'); | |
| 87 | + if ($tmp === false) throw new GitException("一時ファイルを作れません:{$this->tmpPath}"); | |
| 88 | + $this->tmp = $tmp; | |
| 89 | + try { | |
| 90 | + $head = $this->read(12); | |
| 91 | + if (substr($head, 0, 4) !== 'PACK' || !in_array(unpack('N', $head, 4)[1], [2, 3], true)) throw new GitException('パックではありません'); | |
| 92 | + $count = unpack('N', $head, 8)[1]; | |
| 93 | + for ($i = 0; $i < $count; $i++) { | |
| 94 | + $this->readEntry(); | |
| 95 | + if ($progress && (($i + 1) % 1000 === 0 || $i + 1 === $count)) $progress('受信', $i + 1, $count); | |
| 96 | + } | |
| 97 | + $sum = hash_final($this->hash, true); | |
| 98 | + $trailer = $this->read($this->repo->hashLen, false); | |
| 99 | + if ($trailer !== $sum) throw new GitException('パックのチェックサムが違います'); | |
| 100 | + fwrite($this->tmp, $trailer); | |
| 101 | + fflush($this->tmp); | |
| 102 | + $this->resolveAll($progress); | |
| 103 | + $objects = []; | |
| 104 | + foreach ($this->ids as $off => $bin) $objects[bin2hex($bin)] = $this->types[$off]; | |
| 105 | + // 中に無い物を参照しているか(相手が持っている物はOK、無い物は接続の確認で) | |
| 106 | + foreach (array_keys($this->links) as $l) if (isset($objects[$l])) unset($this->links[$l]); | |
| 107 | + if ($count < $unpackLimit || $this->thin) { | |
| 108 | + foreach (array_keys($this->entries) as $off) { | |
| 109 | + [$type, $data] = $this->resolve($off); | |
| 110 | + $this->repo->objects->write($type, $data); | |
| 111 | + } | |
| 112 | + $path = null; | |
| 113 | + } else { | |
| 114 | + $path = $this->install($sum); | |
| 115 | + } | |
| 116 | + return ['objects' => $objects, 'links' => $this->links, 'pack' => $path]; | |
| 117 | + } finally { | |
| 118 | + $this->pack = null; | |
| 119 | + if (is_resource($this->tmp)) fclose($this->tmp); | |
| 120 | + @unlink($this->tmpPath); | |
| 121 | + } | |
| 122 | + } | |
| 123 | + | |
| 124 | + /** 入力からnバイト(ハッシュと一時ファイルにも) */ | |
| 125 | + private function read(int $n, bool $hash = true): string { | |
| 126 | + while (strlen($this->buf) < $n) { | |
| 127 | + $s = fread($this->in, max(65536, $n - strlen($this->buf))); | |
| 128 | + if ($s === false || $s === '') { | |
| 129 | + if (feof($this->in)) throw new GitException('パックが途中で終わりました'); | |
| 130 | + continue; | |
| 131 | + } | |
| 132 | + $this->buf .= $s; | |
| 133 | + } | |
| 134 | + $out = substr($this->buf, 0, $n); | |
| 135 | + $this->buf = (string)substr($this->buf, $n); | |
| 136 | + if ($hash) { | |
| 137 | + hash_update($this->hash, $out); | |
| 138 | + fwrite($this->tmp, $out); | |
| 139 | + $this->pos += $n; | |
| 140 | + } | |
| 141 | + return $out; | |
| 142 | + } | |
| 143 | + | |
| 144 | + private function readEntry(): void { | |
| 145 | + $offset = $this->pos; | |
| 146 | + $crc = hash_init('crc32b'); | |
| 147 | + $b = ord($h = $this->read(1)); | |
| 148 | + $raw = $h; | |
| 149 | + $type = ($b >> 4) & 7; | |
| 150 | + $size = $b & 15; | |
| 151 | + $shift = 4; | |
| 152 | + while ($b & 0x80) { | |
| 153 | + $b = ord($h = $this->read(1)); | |
| 154 | + $raw .= $h; | |
| 155 | + $size |= ($b & 0x7f) << $shift; | |
| 156 | + $shift += 7; | |
| 157 | + } | |
| 158 | + $baseOff = null; | |
| 159 | + $baseId = null; | |
| 160 | + if ($type === Pack::OFS_DELTA) { | |
| 161 | + $b = ord($h = $this->read(1)); | |
| 162 | + $raw .= $h; | |
| 163 | + $n = $b & 0x7f; | |
| 164 | + while ($b & 0x80) { | |
| 165 | + $b = ord($h = $this->read(1)); | |
| 166 | + $raw .= $h; | |
| 167 | + $n = (($n + 1) << 7) | ($b & 0x7f); | |
| 168 | + } | |
| 169 | + $baseOff = $offset - $n; | |
| 170 | + if (!isset($this->entries[$baseOff])) throw new GitException('壊れたパック(OFS_DELTAの元)'); | |
| 171 | + } else if ($type === Pack::REF_DELTA) { | |
| 172 | + $baseId = $this->read($this->repo->hashLen); | |
| 173 | + $raw .= $baseId; | |
| 174 | + } else if (!isset(Pack::TYPES[$type])) { | |
| 175 | + throw new GitException("壊れたパック(種類{$type})"); | |
| 176 | + } | |
| 177 | + hash_update($crc, $raw); | |
| 178 | + $dataAt = $this->pos; | |
| 179 | + // zlibのデータ(長さは展開しないと分からない) | |
| 180 | + $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); | |
| 181 | + $out = ''; | |
| 182 | + $readBefore = 0; | |
| 183 | + while (true) { | |
| 184 | + if ($this->buf === '') { | |
| 185 | + $s = fread($this->in, 65536); | |
| 186 | + if ($s === false || $s === '') { | |
| 187 | + if (feof($this->in)) throw new GitException('パックが途中で終わりました'); | |
| 188 | + continue; | |
| 189 | + } | |
| 190 | + $this->buf = $s; | |
| 191 | + } | |
| 192 | + $res = @inflate_add($ctx, $this->buf, ZLIB_SYNC_FLUSH); | |
| 193 | + if ($res === false) throw new GitException('壊れたパック(zlib)'); | |
| 194 | + $out .= $res; | |
| 195 | + if (strlen($out) > $size) throw new GitException('壊れたパック(大きさ)'); | |
| 196 | + $total = inflate_get_read_len($ctx); | |
| 197 | + $used = $total - $readBefore; | |
| 198 | + $readBefore = $total; | |
| 199 | + $chunk = substr($this->buf, 0, $used); | |
| 200 | + $this->buf = (string)substr($this->buf, $used); | |
| 201 | + hash_update($this->hash, $chunk); | |
| 202 | + hash_update($crc, $chunk); | |
| 203 | + fwrite($this->tmp, $chunk); | |
| 204 | + $this->pos += $used; | |
| 205 | + if (inflate_get_status($ctx) === ZLIB_STREAM_END) break; | |
| 206 | + } | |
| 207 | + if (strlen($out) !== $size) throw new GitException('壊れたパック(大きさ)'); | |
| 208 | + $this->entries[$offset] = [$type, $size, $dataAt, $baseOff, $baseId, unpack('N', hash_final($crc, true))[1]]; | |
| 209 | + if (isset(Pack::TYPES[$type])) $this->found($offset, Pack::TYPES[$type], $out); | |
| 210 | + } | |
| 211 | + | |
| 212 | + /** IDが分かったオブジェクト */ | |
| 213 | + private function found(int $off, string $type, string $data): void { | |
| 214 | + $id = $this->repo->hashObject($type, $data); | |
| 215 | + $this->ids[$off] = hex2bin($id); | |
| 216 | + $this->byId[$id] = $off; | |
| 217 | + $this->types[$off] = $type; | |
| 218 | + $this->check($id, $type, $data); | |
| 219 | + } | |
| 220 | + | |
| 221 | + /** 中身の確認と、参照しているID */ | |
| 222 | + private function check(string $id, string $type, string $data): void { | |
| 223 | + $repo = $this->repo; | |
| 224 | + try { | |
| 225 | + if ($type === 'commit') { | |
| 226 | + $c = Commit::parse($data); | |
| 227 | + if (!$repo->isId($c->tree) || $c->author === null || $c->committer === null) throw new GitException("壊れたコミット:{$id}"); | |
| 228 | + $this->links[$c->tree] = true; | |
| 229 | + foreach ($c->parents as $p) { | |
| 230 | + if (!$repo->isId($p)) throw new GitException("壊れたコミット:{$id}"); | |
| 231 | + $this->links[$p] = true; | |
| 232 | + } | |
| 233 | + } else if ($type === 'tree') { | |
| 234 | + foreach (Tree::parse($data, $repo->hashLen)->entries as [$mode, $name, $eid]) { | |
| 235 | + if (!in_array($mode, [Tree::FILE, Tree::EXEC, Tree::LINK, Tree::DIR, Tree::SUBMODULE, '100664'], true) || $name === '' || $name === '.' || $name === '..' || str_contains($name, '/') | |
| 236 | + || strcasecmp($name, '.git') === 0) { | |
| 237 | + throw new GitException("不正なツリー:{$id}({$name})"); | |
| 238 | + } | |
| 239 | + if ($mode !== Tree::SUBMODULE) $this->links[$eid] = true; | |
| 240 | + } | |
| 241 | + } else if ($type === 'tag') { | |
| 242 | + $t = Tag::parse($data); | |
| 243 | + if (!$repo->isId($t->object)) throw new GitException("壊れたタグ:{$id}"); | |
| 244 | + $this->links[$t->object] = true; | |
| 245 | + } | |
| 246 | + } catch (GitException $e) { | |
| 247 | + throw $e; | |
| 248 | + } | |
| 249 | + } | |
| 250 | + | |
| 251 | + /** 差分を全部解決する */ | |
| 252 | + private function resolveAll(?callable $progress): void { | |
| 253 | + $this->pack = new Pack($this->tmpPath, $this->repo->hashLen, false); | |
| 254 | + $pending = array_keys(array_filter($this->entries, fn($e) => !isset(Pack::TYPES[$e[0]]))); | |
| 255 | + $total = count($pending); | |
| 256 | + while (!empty($pending)) { | |
| 257 | + $left = []; | |
| 258 | + foreach ($pending as $off) { | |
| 259 | + if (isset($this->ids[$off])) continue; | |
| 260 | + if ($this->resolve($off, true) === null) $left[] = $off; | |
| 261 | + } | |
| 262 | + if (count($left) === count($pending)) { | |
| 263 | + // 元がパックに無い(シンパック):リポジトリの物を使う | |
| 264 | + foreach ($left as $off) { | |
| 265 | + $bid = $this->entries[$off][4]; | |
| 266 | + if ($bid === null || !$this->repo->objects->exists(bin2hex($bid))) throw new GitException('差分の元が有りません:'.($bid === null ? '?' : bin2hex($bid))); | |
| 267 | + $this->thin = true; | |
| 268 | + } | |
| 269 | + foreach ($left as $off) $this->resolve($off); | |
| 270 | + break; | |
| 271 | + } | |
| 272 | + $pending = $left; | |
| 273 | + if ($progress) $progress('差分の解決', $total - count($pending), $total); | |
| 274 | + } | |
| 275 | + } | |
| 276 | + | |
| 277 | + /** | |
| 278 | + * オフセットのオブジェクト | |
| 279 | + * | |
| 280 | + * @param bool $inPackOnly 元が(まだ)分からなければnull | |
| 281 | + * @return array|null [種類, データ] | |
| 282 | + */ | |
| 283 | + private function resolve(int $off, bool $inPackOnly = false): ?array { | |
| 284 | + if (isset($this->cache[$off])) return $this->cache[$off]; | |
| 285 | + [$type, $size, $dataAt, $baseOff, $baseId] = $this->entries[$off]; | |
| 286 | + if (isset(Pack::TYPES[$type])) { | |
| 287 | + $obj = [Pack::TYPES[$type], $this->pack->inflate($dataAt, $size)]; | |
| 288 | + } else { | |
| 289 | + if ($baseOff !== null) { | |
| 290 | + $base = $this->resolve($baseOff, $inPackOnly); | |
| 291 | + } else { | |
| 292 | + $hex = bin2hex($baseId); | |
| 293 | + if (isset($this->byId[$hex])) $base = $this->resolve($this->byId[$hex], $inPackOnly); | |
| 294 | + else if ($inPackOnly) return null; | |
| 295 | + else $base = $this->repo->objects->read($hex); | |
| 296 | + } | |
| 297 | + if ($base === null) return null; | |
| 298 | + $obj = [$base[0], Delta::apply($base[1], $this->pack->inflate($dataAt, $size))]; | |
| 299 | + if (!isset($this->ids[$off])) $this->found($off, $obj[0], $obj[1]); | |
| 300 | + } | |
| 301 | + $n = strlen($obj[1]); | |
| 302 | + if ($n < 8388608) { | |
| 303 | + while ($this->cacheBytes + $n > 67108864 && !empty($this->cache)) { | |
| 304 | + $k = array_key_first($this->cache); | |
| 305 | + $this->cacheBytes -= strlen($this->cache[$k][1]); | |
| 306 | + unset($this->cache[$k]); | |
| 307 | + } | |
| 308 | + $this->cache[$off] = $obj; | |
| 309 | + $this->cacheBytes += $n; | |
| 310 | + } | |
| 311 | + return $obj; | |
| 312 | + } | |
| 313 | + | |
| 314 | + /** パックと.idx・.revを置く */ | |
| 315 | + private function install(string $sum): string { | |
| 316 | + $h = $this->repo->hashLen; | |
| 317 | + $sorted = $this->ids; | |
| 318 | + asort($sorted, SORT_STRING); | |
| 319 | + $fan = array_fill(0, 256, 0); | |
| 320 | + foreach ($sorted as $bin) $fan[ord($bin[0])]++; | |
| 321 | + $idx = "\xfftOc".pack('N', 2); | |
| 322 | + $acc = 0; | |
| 323 | + for ($i = 0; $i < 256; $i++) { | |
| 324 | + $acc += $fan[$i]; | |
| 325 | + $idx .= pack('N', $acc); | |
| 326 | + } | |
| 327 | + $names = ''; | |
| 328 | + $crcs = ''; | |
| 329 | + $offs = ''; | |
| 330 | + $large = ''; | |
| 331 | + $nLarge = 0; | |
| 332 | + $position = []; | |
| 333 | + $n = 0; | |
| 334 | + foreach ($sorted as $off => $bin) { | |
| 335 | + $names .= $bin; | |
| 336 | + $crcs .= pack('N', $this->entries[$off][5]); | |
| 337 | + if ($off < 0x80000000) { | |
| 338 | + $offs .= pack('N', $off); | |
| 339 | + } else { | |
| 340 | + $offs .= pack('N', 0x80000000 | $nLarge++); | |
| 341 | + $large .= pack('J', $off); | |
| 342 | + } | |
| 343 | + $position[$off] = $n++; | |
| 344 | + } | |
| 345 | + $idx .= $names.$crcs.$offs.$large.$sum; | |
| 346 | + $idx .= hash($this->repo->hashAlgo, $idx, true); | |
| 347 | + ksort($position); | |
| 348 | + $rev = 'RIDX'.pack('NN', 1, $this->repo->hashAlgo === 'sha1' ? 1 : 2).pack('N*', ...array_values($position)).$sum; | |
| 349 | + $rev .= hash($this->repo->hashAlgo, $rev, true); | |
| 350 | + | |
| 351 | + $base = $this->repo->objects->dir().'/pack/pack-'.bin2hex($sum); | |
| 352 | + $this->pack = null; | |
| 353 | + fclose($this->tmp); | |
| 354 | + if (!is_file($base.'.pack')) { | |
| 355 | + if (!@rename($this->tmpPath, $base.'.pack')) throw new GitException('パックを保存出来ません'); | |
| 356 | + $this->repo->chmodFile($base.'.pack', true); | |
| 357 | + } | |
| 358 | + $this->repo->writeFile($base.'.rev', $rev, true); | |
| 359 | + $this->repo->writeFile($base.'.idx', $idx, true); | |
| 360 | + $this->repo->objects->packs(true); | |
| 361 | + return $base.'.pack'; | |
| 362 | + } | |
| 363 | +} |
| @@ -0,0 +1,116 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * パックを作る(クローン・フェッチで送る) | |
| 42 | + * | |
| 43 | + * 既存のパックの中のオブジェクトは圧縮されたまま再利用する(差分の元も送る場合は差分のまま)。 | |
| 44 | + */ | |
| 45 | +final class PackWriter { | |
| 46 | + private const CHUNK = 65536; | |
| 47 | + | |
| 48 | + public function __construct(private Repository $repo) {} | |
| 49 | + | |
| 50 | + /** パックのエントリーのヘッダー */ | |
| 51 | + public static function entryHeader(int $type, int $size): string { | |
| 52 | + $b = ($type << 4) | ($size & 15); | |
| 53 | + $size >>= 4; | |
| 54 | + $out = ''; | |
| 55 | + while ($size > 0) { | |
| 56 | + $out .= chr($b | 0x80); | |
| 57 | + $b = $size & 0x7f; | |
| 58 | + $size >>= 7; | |
| 59 | + } | |
| 60 | + return $out.chr($b); | |
| 61 | + } | |
| 62 | + | |
| 63 | + /** | |
| 64 | + * パックを書く | |
| 65 | + * | |
| 66 | + * @param array $objects ID => 種類 | |
| 67 | + * @param callable $out fn(string $bytes) | |
| 68 | + * @param callable|null $progress fn(int $done, int $total) | |
| 69 | + * @return string パックのチェックサム(16進数) | |
| 70 | + */ | |
| 71 | + public function write(array $objects, callable $out, ?callable $progress = null): string { | |
| 72 | + $ctx = hash_init($this->repo->hashAlgo); | |
| 73 | + $buf = 'PACK'.pack('NN', 2, count($objects)); | |
| 74 | + $flush = function(bool $force = false) use (&$buf, $ctx, $out): void { | |
| 75 | + if ($buf === '' || (!$force && strlen($buf) < self::CHUNK)) return; | |
| 76 | + hash_update($ctx, $buf); | |
| 77 | + $out($buf); | |
| 78 | + $buf = ''; | |
| 79 | + }; | |
| 80 | + $codes = array_flip(Pack::TYPES); | |
| 81 | + $done = 0; | |
| 82 | + $total = count($objects); | |
| 83 | + foreach ($objects as $id => $type) { | |
| 84 | + $loc = $this->repo->objects->locate($id); | |
| 85 | + if ($loc === null) throw new GitException("オブジェクトが有りません:{$id}"); | |
| 86 | + $written = false; | |
| 87 | + if ($loc[0] === 'pack') { | |
| 88 | + [$pack, $off] = [$loc[1], $loc[2]]; | |
| 89 | + [$t, $size, $dataAt, $baseOff, $baseId] = $pack->header($off); | |
| 90 | + if (isset(Pack::TYPES[$t])) { | |
| 91 | + // そのままコピー | |
| 92 | + $buf .= $pack->raw($off, $pack->entryEnd($off)); | |
| 93 | + $written = true; | |
| 94 | + } else { | |
| 95 | + $base = $baseOff !== null ? $pack->idAtOffset($baseOff) : $baseId; | |
| 96 | + if ($base !== null && isset($objects[bin2hex($base)])) { | |
| 97 | + // 元も送るので差分のまま(REF_DELTA) | |
| 98 | + $buf .= self::entryHeader(Pack::REF_DELTA, $size).$base.$pack->raw($dataAt, $pack->entryEnd($off)); | |
| 99 | + $written = true; | |
| 100 | + } | |
| 101 | + } | |
| 102 | + } | |
| 103 | + if (!$written) { | |
| 104 | + [$t, $data] = $this->repo->objects->read($id) ?? throw new GitException("オブジェクトが有りません:{$id}"); | |
| 105 | + $buf .= self::entryHeader($codes[$t], strlen($data)).gzcompress($data, 6); | |
| 106 | + } | |
| 107 | + $flush(); | |
| 108 | + $done++; | |
| 109 | + if ($progress && ($done % 1000 === 0 || $done === $total)) $progress($done, $total); | |
| 110 | + } | |
| 111 | + $flush(true); | |
| 112 | + $sum = hash_final($ctx, true); | |
| 113 | + $out($sum); | |
| 114 | + return bin2hex($sum); | |
| 115 | + } | |
| 116 | +} |
| @@ -0,0 +1,72 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * 作者・コミッター(「名前 <メール> 時刻 +0900」) | |
| 42 | + */ | |
| 43 | +final class Person { | |
| 44 | + /** | |
| 45 | + * @param string $name 名前 | |
| 46 | + * @param string $email メールアドレス | |
| 47 | + * @param int $time UNIX時刻 | |
| 48 | + * @param string $tz 時差(「+0900」) | |
| 49 | + */ | |
| 50 | + public function __construct(public string $name, public string $email, public int $time, public string $tz = '+0000') {} | |
| 51 | + | |
| 52 | + public static function parse(string $s): self { | |
| 53 | + if (preg_match('/^(.*?) ?<([^>]*)> (-?\d+) ([+-]\d{4})$/s', $s, $m)) return new self($m[1], $m[2], (int)$m[3], $m[4]); | |
| 54 | + if (preg_match('/^(.*?) ?<([^>]*)>/s', $s, $m)) return new self($m[1], $m[2], 0, '+0000'); | |
| 55 | + return new self($s, '', 0, '+0000'); | |
| 56 | + } | |
| 57 | + | |
| 58 | + /** 今の時刻で(時差はPHPの時間帯) */ | |
| 59 | + public static function now(string $name, string $email): self { | |
| 60 | + $t = new \DateTimeImmutable(); | |
| 61 | + return new self($name, $email, $t->getTimestamp(), $t->format('O')); | |
| 62 | + } | |
| 63 | + | |
| 64 | + public function __toString(): string { | |
| 65 | + return "{$this->name} <{$this->email}> {$this->time} {$this->tz}"; | |
| 66 | + } | |
| 67 | + | |
| 68 | + /** 時差を付けた日時 */ | |
| 69 | + public function date(): \DateTimeImmutable { | |
| 70 | + return (new \DateTimeImmutable('@'.$this->time))->setTimezone(new \DateTimeZone($this->tz)); | |
| 71 | + } | |
| 72 | +} |
| @@ -0,0 +1,116 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * Gitのプロトコルのpkt-line(4桁の16進数の長さ+データ、0000はflush) | |
| 42 | + */ | |
| 43 | +final class PktLine { | |
| 44 | + public const FLUSH = 0; | |
| 45 | + public const DELIM = 1; | |
| 46 | + public const END = 2; | |
| 47 | + /** pkt-lineの最大の長さ */ | |
| 48 | + public const MAX = 65520; | |
| 49 | + | |
| 50 | + /** @param resource $in */ | |
| 51 | + public function __construct(private $in) {} | |
| 52 | + | |
| 53 | + /** 1行(文字列、又はFLUSH・DELIM・END、終わりならnull)。行の最後の改行は消す */ | |
| 54 | + public function read(bool $keepNewline = false): string|int|null { | |
| 55 | + $head = $this->exact(4, true); | |
| 56 | + if ($head === null) return null; | |
| 57 | + if (!ctype_xdigit($head)) throw new GitException('不正なpkt-line:'.bin2hex($head)); | |
| 58 | + $len = hexdec($head); | |
| 59 | + if ($len < 4) return $len; | |
| 60 | + if ($len > self::MAX) throw new GitException('pkt-lineが長過ぎます'); | |
| 61 | + $data = $this->exact($len - 4, false); | |
| 62 | + return $keepNewline ? $data : (str_ends_with($data, "\n") ? substr($data, 0, -1) : $data); | |
| 63 | + } | |
| 64 | + | |
| 65 | + /** 次のflushまでの行 */ | |
| 66 | + public function readUntilFlush(): array { | |
| 67 | + $out = []; | |
| 68 | + while (true) { | |
| 69 | + $l = $this->read(); | |
| 70 | + if ($l === null || $l === self::FLUSH) return $out; | |
| 71 | + $out[] = $l; | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + private function exact(int $n, bool $eofOk): ?string { | |
| 76 | + $s = ''; | |
| 77 | + while (strlen($s) < $n) { | |
| 78 | + $chunk = fread($this->in, $n - strlen($s)); | |
| 79 | + if ($chunk === false || $chunk === '') { | |
| 80 | + if (feof($this->in)) { | |
| 81 | + if ($s === '' && $eofOk) return null; | |
| 82 | + throw new GitException('接続が途中で切れました'); | |
| 83 | + } | |
| 84 | + continue; | |
| 85 | + } | |
| 86 | + $s .= $chunk; | |
| 87 | + } | |
| 88 | + return $s; | |
| 89 | + } | |
| 90 | + | |
| 91 | + /** pkt-lineにする */ | |
| 92 | + public static function encode(string $data): string { | |
| 93 | + if (strlen($data) + 4 > self::MAX) throw new GitException('pkt-lineが長過ぎます'); | |
| 94 | + return sprintf('%04x', strlen($data) + 4).$data; | |
| 95 | + } | |
| 96 | + | |
| 97 | + public static function flush(): string { | |
| 98 | + return '0000'; | |
| 99 | + } | |
| 100 | + | |
| 101 | + public static function delim(): string { | |
| 102 | + return '0001'; | |
| 103 | + } | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * サイドバンドのpkt-line(1:データ、2:進捗、3:エラー) | |
| 107 | + * | |
| 108 | + * @param int $max 1つの最大(side-band-64kは65520、side-bandは1000) | |
| 109 | + */ | |
| 110 | + public static function band(int $band, string $data, int $max = self::MAX): string { | |
| 111 | + $out = ''; | |
| 112 | + $step = $max - 5; | |
| 113 | + for ($i = 0, $n = strlen($data); $i < $n; $i += $step) $out .= self::encode(chr($band).substr($data, $i, $step)); | |
| 114 | + return $out; | |
| 115 | + } | |
| 116 | +} |
| @@ -0,0 +1,220 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * git-receive-pack(プッシュ)。HTTP(stateless)とSSH | |
| 42 | + * | |
| 43 | + * 対応:report-status、delete-refs、side-band-64k、quiet、atomic、ofs-delta、no-thin(相手はシンパックを送らない)。 | |
| 44 | + * 受け取ったオブジェクトは確認し(壊れたオブジェクト、足りないオブジェクト)、参照はロックして更新する。 | |
| 45 | + */ | |
| 46 | +final class ReceivePack { | |
| 47 | + private string $agent; | |
| 48 | + private int $unpackLimit; | |
| 49 | + private Output $out; | |
| 50 | + private bool $sideband = false; | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * @param array $opt agent、unpackLimit(これより少ないオブジェクトはルーズにする、既定100)、 | |
| 54 | + * check(fn(array $commands): ?string、全部の更新の前、エラーのメッセージで全部拒否)、 | |
| 55 | + * update(fn(string $ref, string $old, string $new): ?string、参照毎、エラーならその参照を拒否)、 | |
| 56 | + * after(fn(array $commands)、更新した参照 [ref, old, new])、 | |
| 57 | + * denyNonFastForwards、denyDeletes(既定はconfigのreceive.*) | |
| 58 | + */ | |
| 59 | + public function __construct(private Repository $repo, private array $opt = []) { | |
| 60 | + $this->agent = (string)($opt['agent'] ?? 'LittleBeast'); | |
| 61 | + $this->unpackLimit = (int)($opt['unpackLimit'] ?? $repo->config->getInt('receive.unpacklimit', $repo->config->getInt('transfer.unpacklimit', 100))); | |
| 62 | + } | |
| 63 | + | |
| 64 | + /** | |
| 65 | + * 参照の広告 | |
| 66 | + * | |
| 67 | + * @param resource $out | |
| 68 | + */ | |
| 69 | + public function advertise($out, bool $http): void { | |
| 70 | + $o = new Output($out, $http); | |
| 71 | + $s = $http ? PktLine::encode("# service=git-receive-pack\n").PktLine::flush() : ''; | |
| 72 | + $caps = "report-status delete-refs side-band-64k quiet atomic ofs-delta no-thin object-format={$this->repo->hashAlgo} agent={$this->agent}"; | |
| 73 | + $first = true; | |
| 74 | + foreach ($this->repo->refs->all() as $name => $id) { | |
| 75 | + $s .= PktLine::encode("{$id} {$name}".($first ? "\0".$caps : '')."\n"); | |
| 76 | + $first = false; | |
| 77 | + } | |
| 78 | + if ($first) $s .= PktLine::encode($this->repo->zeroId()." capabilities^{}\0{$caps}\n"); | |
| 79 | + $o->write($s.PktLine::flush()); | |
| 80 | + $o->flush(); | |
| 81 | + } | |
| 82 | + | |
| 83 | + /** | |
| 84 | + * プッシュを処理する | |
| 85 | + * | |
| 86 | + * @param resource $in | |
| 87 | + * @param resource $out | |
| 88 | + * @return array 更新した参照 [ref, old, new] | |
| 89 | + */ | |
| 90 | + public function serve($in, $out, bool $statelessRpc): array { | |
| 91 | + $this->out = new Output($out, $statelessRpc); | |
| 92 | + if (!$statelessRpc) $this->advertise($out, false); | |
| 93 | + $pkt = new PktLine($in); | |
| 94 | + $commands = []; | |
| 95 | + $caps = []; | |
| 96 | + $zero = $this->repo->zeroId(); | |
| 97 | + while (true) { | |
| 98 | + $l = $pkt->read(); | |
| 99 | + if ($l === null || $l === PktLine::FLUSH) break; | |
| 100 | + if (!is_string($l)) continue; | |
| 101 | + if (str_starts_with($l, 'shallow ')) continue; | |
| 102 | + $nul = strpos($l, "\0"); | |
| 103 | + if ($nul !== false) { | |
| 104 | + $caps = explode(' ', trim(substr($l, $nul + 1))); | |
| 105 | + $l = substr($l, 0, $nul); | |
| 106 | + } | |
| 107 | + if (!preg_match('/^([0-9a-f]+) ([0-9a-f]+) (.+)$/', $l, $m) || !$this->repo->isId($m[1]) || !$this->repo->isId($m[2])) { | |
| 108 | + throw new GitException("不正なコマンド:{$l}"); | |
| 109 | + } | |
| 110 | + $commands[] = ['ref' => $m[3], 'old' => $m[1], 'new' => $m[2], 'error' => null]; | |
| 111 | + } | |
| 112 | + if (empty($commands)) return []; | |
| 113 | + foreach ($caps as $c) { | |
| 114 | + if (str_starts_with($c, 'object-format=') && substr($c, 14) !== $this->repo->hashAlgo) throw new GitException('オブジェクトの形式が違います'); | |
| 115 | + } | |
| 116 | + $this->sideband = in_array('side-band-64k', $caps, true); | |
| 117 | + $quiet = in_array('quiet', $caps, true); | |
| 118 | + $atomic = in_array('atomic', $caps, true); | |
| 119 | + $report = in_array('report-status', $caps, true); | |
| 120 | + | |
| 121 | + // パック(削除だけなら無い) | |
| 122 | + $unpack = 'ok'; | |
| 123 | + $received = ['objects' => [], 'links' => []]; | |
| 124 | + $needPack = false; | |
| 125 | + foreach ($commands as $c) if ($c['new'] !== $zero) $needPack = true; | |
| 126 | + if ($needPack) { | |
| 127 | + try { | |
| 128 | + $progress = $quiet || !$this->sideband ? null : function(string $what, int $done, int $total): void { | |
| 129 | + $this->out->write(PktLine::band(2, "{$what}:{$done}/{$total}".($done === $total ? "\n" : "\r"))); | |
| 130 | + $this->out->flush(); | |
| 131 | + }; | |
| 132 | + $received = (new PackIndexer($this->repo))->receive($in, $this->unpackLimit, $progress); | |
| 133 | + // 足りないオブジェクト | |
| 134 | + foreach (array_keys($received['links']) as $id) { | |
| 135 | + if (!$this->repo->objects->exists($id)) throw new GitException("必要なオブジェクトが有りません:{$id}"); | |
| 136 | + } | |
| 137 | + } catch (GitException $e) { | |
| 138 | + $unpack = $e->getMessage(); | |
| 139 | + } | |
| 140 | + } | |
| 141 | + if ($unpack !== 'ok') { | |
| 142 | + foreach ($commands as $i => $c) $commands[$i]['error'] = 'unpacker error'; | |
| 143 | + } else { | |
| 144 | + $commands = $this->check($commands, $received['objects']); | |
| 145 | + if ($atomic && count(array_filter($commands, fn($c) => $c['error'] !== null)) > 0) { | |
| 146 | + foreach ($commands as $i => $c) $commands[$i]['error'] ??= 'atomic push failed'; | |
| 147 | + } | |
| 148 | + $commands = $this->apply($commands, $atomic); | |
| 149 | + } | |
| 150 | + | |
| 151 | + if ($report) { | |
| 152 | + $s = PktLine::encode('unpack '.($unpack === 'ok' ? 'ok' : str_replace("\n", ' ', $unpack))."\n"); | |
| 153 | + foreach ($commands as $c) $s .= PktLine::encode($c['error'] === null ? "ok {$c['ref']}\n" : "ng {$c['ref']} ".str_replace("\n", ' ', $c['error'])."\n"); | |
| 154 | + $s .= PktLine::flush(); | |
| 155 | + $this->out->write($this->sideband ? PktLine::band(1, $s) : $s); | |
| 156 | + } | |
| 157 | + if ($this->sideband) $this->out->write(PktLine::flush()); | |
| 158 | + $this->out->flush(); | |
| 159 | + | |
| 160 | + $done = []; | |
| 161 | + foreach ($commands as $c) if ($c['error'] === null) $done[] = [$c['ref'], $c['old'], $c['new']]; | |
| 162 | + if (!empty($done) && isset($this->opt['after'])) ($this->opt['after'])($done); | |
| 163 | + return $done; | |
| 164 | + } | |
| 165 | + | |
| 166 | + /** 更新出来るか確かめる */ | |
| 167 | + private function check(array $commands, array $received): array { | |
| 168 | + $zero = $this->repo->zeroId(); | |
| 169 | + $denyFf = (bool)($this->opt['denyNonFastForwards'] ?? $this->repo->config->getBool('receive.denynonfastforwards')); | |
| 170 | + $denyDel = (bool)($this->opt['denyDeletes'] ?? $this->repo->config->getBool('receive.denydeletes')); | |
| 171 | + if (isset($this->opt['check'])) { | |
| 172 | + $err = ($this->opt['check'])(array_map(fn($c) => [$c['ref'], $c['old'], $c['new']], $commands)); | |
| 173 | + if ($err !== null) { | |
| 174 | + foreach ($commands as $i => $c) $commands[$i]['error'] = $err; | |
| 175 | + return $commands; | |
| 176 | + } | |
| 177 | + } | |
| 178 | + $walk = new Walk($this->repo); | |
| 179 | + foreach ($commands as $i => $c) { | |
| 180 | + $err = null; | |
| 181 | + $ref = $c['ref']; | |
| 182 | + $type = $c['new'] === $zero ? null : ($received[$c['new']] ?? $this->repo->objects->info($c['new'])[0] ?? null); | |
| 183 | + if (!Refs::validName($ref) || !str_starts_with($ref, 'refs/') || $ref === 'HEAD') $err = 'funny refname'; | |
| 184 | + else if ($c['new'] !== $zero && $type === null) $err = 'missing necessary objects'; | |
| 185 | + else if (str_starts_with($ref, 'refs/heads/') && $c['new'] !== $zero && $type !== 'commit') $err = 'branch must point to a commit'; | |
| 186 | + else if ($c['new'] === $zero && $denyDel) $err = 'deletion prohibited'; | |
| 187 | + else if ($c['new'] === $zero && $c['old'] === $zero) $err = 'nothing to delete'; | |
| 188 | + else if ($denyFf && $c['old'] !== $zero && $c['new'] !== $zero && $this->repo->objects->exists($c['old']) | |
| 189 | + && !$walk->isAncestor($c['old'], $c['new'])) $err = 'non-fast-forward'; | |
| 190 | + if ($err === null && isset($this->opt['update'])) $err = ($this->opt['update'])($ref, $c['old'], $c['new']); | |
| 191 | + $commands[$i]['error'] = $err; | |
| 192 | + } | |
| 193 | + return $commands; | |
| 194 | + } | |
| 195 | + | |
| 196 | + /** 参照を更新する(古い値が変わっていれば失敗) */ | |
| 197 | + private function apply(array $commands, bool $atomic): array { | |
| 198 | + $zero = $this->repo->zeroId(); | |
| 199 | + $ok = array_filter($commands, fn($c) => $c['error'] === null); | |
| 200 | + if (empty($ok)) return $commands; | |
| 201 | + $tx = fn(array $c) => [$c['ref'], $c['new'] === $zero ? null : $c['new'], $c['old']]; | |
| 202 | + if ($atomic) { | |
| 203 | + try { | |
| 204 | + $this->repo->refs->transaction(array_map($tx, $ok)); | |
| 205 | + } catch (GitException $e) { | |
| 206 | + foreach ($commands as $i => $c) $commands[$i]['error'] ??= 'failed to update ref'; | |
| 207 | + } | |
| 208 | + return $commands; | |
| 209 | + } | |
| 210 | + foreach ($commands as $i => $c) { | |
| 211 | + if ($c['error'] !== null) continue; | |
| 212 | + try { | |
| 213 | + $this->repo->refs->transaction([$tx($c)]); | |
| 214 | + } catch (GitException $e) { | |
| 215 | + $commands[$i]['error'] = str_contains($e->getMessage(), '変わって') ? 'fetch first' : 'failed to update ref'; | |
| 216 | + } | |
| 217 | + } | |
| 218 | + return $commands; | |
| 219 | + } | |
| 220 | +} |
| @@ -0,0 +1,277 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * 参照(HEAD、refs/heads/、refs/tags/等):ルーズとpacked-refs | |
| 42 | + * | |
| 43 | + * 変更はロックファイル(.lock)で行い、古い値を確かめられる(他の更新と競合しない様に)。 | |
| 44 | + */ | |
| 45 | +final class Refs { | |
| 46 | + private ?array $packed = null; | |
| 47 | + private ?array $peeled = null; | |
| 48 | + | |
| 49 | + public function __construct(private Repository $repo) {} | |
| 50 | + | |
| 51 | + /** 参照の名前が正しいか(git check-ref-format) */ | |
| 52 | + public static function validName(string $name): bool { | |
| 53 | + if ($name === 'HEAD') return true; | |
| 54 | + if (!str_starts_with($name, 'refs/') || str_ends_with($name, '/') || str_ends_with($name, '.') || str_ends_with($name, '.lock')) return false; | |
| 55 | + if (preg_match('#[\x00-\x20\x7f~^:?*\[\\\\]|\.\.|//|@\{|/\.|^\.#', $name)) return false; | |
| 56 | + foreach (explode('/', $name) as $part) if ($part === '' || $part[0] === '.') return false; | |
| 57 | + return $name !== '@'; | |
| 58 | + } | |
| 59 | + | |
| 60 | + private function packed(): array { | |
| 61 | + if ($this->packed !== null) return $this->packed; | |
| 62 | + $this->packed = []; | |
| 63 | + $this->peeled = []; | |
| 64 | + $file = $this->repo->path.'/packed-refs'; | |
| 65 | + if (!is_file($file)) return $this->packed; | |
| 66 | + $last = null; | |
| 67 | + foreach (preg_split('/\r?\n/', (string)file_get_contents($file)) as $line) { | |
| 68 | + if ($line === '' || $line[0] === '#') continue; | |
| 69 | + if ($line[0] === '^') { | |
| 70 | + if ($last !== null) $this->peeled[$last] = substr($line, 1); | |
| 71 | + continue; | |
| 72 | + } | |
| 73 | + $sp = strpos($line, ' '); | |
| 74 | + if ($sp === false) continue; | |
| 75 | + $last = substr($line, $sp + 1); | |
| 76 | + $this->packed[$last] = substr($line, 0, $sp); | |
| 77 | + } | |
| 78 | + return $this->packed; | |
| 79 | + } | |
| 80 | + | |
| 81 | + /** | |
| 82 | + * 参照の中身(解決しない) | |
| 83 | + * | |
| 84 | + * @return string|null ID又は「ref: 名前」 | |
| 85 | + */ | |
| 86 | + public function readRaw(string $name): ?string { | |
| 87 | + if (!self::validName($name)) return null; | |
| 88 | + $file = $this->repo->path.'/'.$name; | |
| 89 | + if (is_file($file)) { | |
| 90 | + $v = trim((string)file_get_contents($file)); | |
| 91 | + return $v === '' ? null : $v; | |
| 92 | + } | |
| 93 | + return $this->packed()[$name] ?? null; | |
| 94 | + } | |
| 95 | + | |
| 96 | + /** シンボリック参照の先(HEAD → refs/heads/main)、シンボリックでなければnull */ | |
| 97 | + public function symbolic(string $name): ?string { | |
| 98 | + $v = $this->readRaw($name); | |
| 99 | + return $v !== null && str_starts_with($v, 'ref: ') ? trim(substr($v, 5)) : null; | |
| 100 | + } | |
| 101 | + | |
| 102 | + /** | |
| 103 | + * 参照をIDに解決する(シンボリック参照を辿る) | |
| 104 | + */ | |
| 105 | + public function resolve(string $name): ?string { | |
| 106 | + for ($i = 0; $i < 10; $i++) { | |
| 107 | + $v = $this->readRaw($name); | |
| 108 | + if ($v === null) return null; | |
| 109 | + if (!str_starts_with($v, 'ref: ')) return $this->repo->isId($v) ? strtolower($v) : null; | |
| 110 | + $name = trim(substr($v, 5)); | |
| 111 | + } | |
| 112 | + return null; | |
| 113 | + } | |
| 114 | + | |
| 115 | + /** | |
| 116 | + * 短い名前(main、v1.0、origin/main)を完全な名前にする | |
| 117 | + */ | |
| 118 | + public function dwim(string $short): ?string { | |
| 119 | + foreach ([$short, "refs/{$short}", "refs/tags/{$short}", "refs/heads/{$short}", "refs/remotes/{$short}", "refs/remotes/{$short}/HEAD"] as $n) { | |
| 120 | + if ($this->readRaw($n) !== null) return $n; | |
| 121 | + } | |
| 122 | + return null; | |
| 123 | + } | |
| 124 | + | |
| 125 | + /** | |
| 126 | + * 全部の参照(HEADを除く、名前の順) | |
| 127 | + * | |
| 128 | + * @param string $prefix 名前の始まり(例:refs/heads/) | |
| 129 | + * @return array 名前 => ID | |
| 130 | + */ | |
| 131 | + public function all(string $prefix = 'refs/'): array { | |
| 132 | + $out = []; | |
| 133 | + foreach ($this->packed() as $name => $id) if (str_starts_with($name, $prefix)) $out[$name] = $id; | |
| 134 | + $base = $this->repo->path.'/'; | |
| 135 | + $dir = $base.rtrim(str_contains($prefix, '/') ? substr($prefix, 0, strrpos($prefix, '/')) : $prefix, '/'); | |
| 136 | + if (is_dir($dir)) { | |
| 137 | + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); | |
| 138 | + foreach ($it as $f) { | |
| 139 | + if (!$f->isFile()) continue; | |
| 140 | + $name = str_replace('\\', '/', substr($f->getPathname(), strlen($base))); | |
| 141 | + if (!str_starts_with($name, $prefix) || !self::validName($name)) continue; | |
| 142 | + $v = trim((string)file_get_contents($f->getPathname())); | |
| 143 | + if (str_starts_with($v, 'ref: ')) { | |
| 144 | + $r = $this->resolve($name); | |
| 145 | + if ($r !== null) $out[$name] = $r; | |
| 146 | + } else if ($this->repo->isId($v)) { | |
| 147 | + $out[$name] = strtolower($v); | |
| 148 | + } | |
| 149 | + } | |
| 150 | + } | |
| 151 | + ksort($out, SORT_STRING); | |
| 152 | + return $out; | |
| 153 | + } | |
| 154 | + | |
| 155 | + /** packed-refsの剥いた値(注釈付きタグの先)。無ければnull */ | |
| 156 | + public function peeledFromPack(string $name): ?string { | |
| 157 | + $this->packed(); | |
| 158 | + return $this->peeled[$name] ?? null; | |
| 159 | + } | |
| 160 | + | |
| 161 | + /** | |
| 162 | + * 参照を更新する | |
| 163 | + * | |
| 164 | + * @param string $name 参照の名前 | |
| 165 | + * @param string|null $new 新しいID(nullなら削除) | |
| 166 | + * @param string|null $old 今のID(確かめる。0ばかりのIDは「無い事」、nullなら確かめない) | |
| 167 | + * @throws GitException 競合した場合等 | |
| 168 | + */ | |
| 169 | + public function update(string $name, ?string $new, ?string $old = null): void { | |
| 170 | + $this->transaction([[$name, $new, $old]]); | |
| 171 | + } | |
| 172 | + | |
| 173 | + /** | |
| 174 | + * 複数の参照を全部更新するか、全部しない | |
| 175 | + * | |
| 176 | + * @param array $updates [名前, 新しいID又はnull, 今のID又はnull] | |
| 177 | + */ | |
| 178 | + public function transaction(array $updates): void { | |
| 179 | + $locks = []; | |
| 180 | + $zero = $this->repo->zeroId(); | |
| 181 | + try { | |
| 182 | + // HEAD等のシンボリック参照は先を更新する | |
| 183 | + foreach ($updates as $i => [$name, $new, $old]) { | |
| 184 | + $target = $name; | |
| 185 | + for ($d = 0; $d < 10 && ($s = $this->symbolic($target)) !== null; $d++) $target = $s; | |
| 186 | + if (!self::validName($target) || $target === 'HEAD') throw new GitException("不正な参照の名前:{$name}"); | |
| 187 | + if ($new !== null && !$this->repo->isId($new)) throw new GitException("不正なID:{$new}"); | |
| 188 | + $updates[$i][0] = $target; | |
| 189 | + $lock = $this->repo->path.'/'.$target.'.lock'; | |
| 190 | + $this->repo->mkdir(dirname($lock)); | |
| 191 | + // ディレクトリと同じ名前の参照(refs/heads/aとrefs/heads/a/b)は作れない | |
| 192 | + if (is_dir($this->repo->path.'/'.$target)) throw new GitException("参照の名前が他の参照と重なります:{$target}"); | |
| 193 | + $fh = @fopen($lock, 'x'); | |
| 194 | + if ($fh === false) throw new GitException("参照がロックされています:{$target}"); | |
| 195 | + $locks[$target] = [$fh, $lock]; | |
| 196 | + } | |
| 197 | + $this->packed = null; | |
| 198 | + foreach ($updates as [$name, $new, $old]) { | |
| 199 | + if ($old === null) continue; | |
| 200 | + $cur = $this->resolve($name); | |
| 201 | + if ($old === $zero ? $cur !== null : $cur !== strtolower($old)) throw new GitException("参照が変わっています:{$name}"); | |
| 202 | + } | |
| 203 | + $deletes = []; | |
| 204 | + foreach ($updates as [$name, $new]) { | |
| 205 | + [$fh, $lock] = $locks[$name]; | |
| 206 | + if ($new === null) { | |
| 207 | + $deletes[] = $name; | |
| 208 | + continue; | |
| 209 | + } | |
| 210 | + fwrite($fh, strtolower($new)."\n"); | |
| 211 | + } | |
| 212 | + if (!empty($deletes)) $this->removePacked($deletes); | |
| 213 | + foreach ($updates as [$name, $new]) { | |
| 214 | + [$fh, $lock] = $locks[$name]; | |
| 215 | + fclose($fh); | |
| 216 | + $file = $this->repo->path.'/'.$name; | |
| 217 | + if ($new === null) { | |
| 218 | + @unlink($lock); | |
| 219 | + @unlink($file); | |
| 220 | + $this->pruneDirs(dirname($file)); | |
| 221 | + } else { | |
| 222 | + if (!@rename($lock, $file)) throw new GitException("参照を書けません:{$name}"); | |
| 223 | + $this->repo->chmodFile($file); | |
| 224 | + } | |
| 225 | + unset($locks[$name]); | |
| 226 | + } | |
| 227 | + } finally { | |
| 228 | + foreach ($locks as [$fh, $lock]) { | |
| 229 | + if (is_resource($fh)) fclose($fh); | |
| 230 | + @unlink($lock); | |
| 231 | + } | |
| 232 | + $this->packed = null; | |
| 233 | + } | |
| 234 | + } | |
| 235 | + | |
| 236 | + /** packed-refsから消す */ | |
| 237 | + private function removePacked(array $names): void { | |
| 238 | + $file = $this->repo->path.'/packed-refs'; | |
| 239 | + if (!is_file($file)) return; | |
| 240 | + $lock = $file.'.lock'; | |
| 241 | + $fh = @fopen($lock, 'x'); | |
| 242 | + if ($fh === false) throw new GitException('packed-refsがロックされています'); | |
| 243 | + $out = []; | |
| 244 | + $skip = false; | |
| 245 | + foreach (preg_split('/\r?\n/', (string)file_get_contents($file)) as $line) { | |
| 246 | + if ($line === '') continue; | |
| 247 | + if ($line[0] === '^') { | |
| 248 | + if (!$skip) $out[] = $line; | |
| 249 | + continue; | |
| 250 | + } | |
| 251 | + $sp = strpos($line, ' '); | |
| 252 | + $skip = $sp !== false && $line[0] !== '#' && in_array(substr($line, $sp + 1), $names, true); | |
| 253 | + if (!$skip) $out[] = $line; | |
| 254 | + } | |
| 255 | + fwrite($fh, implode("\n", $out)."\n"); | |
| 256 | + fclose($fh); | |
| 257 | + if (!@rename($lock, $file)) { | |
| 258 | + @unlink($lock); | |
| 259 | + throw new GitException('packed-refsを書けません'); | |
| 260 | + } | |
| 261 | + } | |
| 262 | + | |
| 263 | + /** 空になった参照のディレクトリを消す(refs/heads等は残す) */ | |
| 264 | + private function pruneDirs(string $dir): void { | |
| 265 | + $base = $this->repo->path.'/refs'; | |
| 266 | + while (strlen($dir) > strlen($base) && !in_array(basename($dir), ['heads', 'tags'], true) && dirname($dir) !== $base) { | |
| 267 | + if (!@rmdir($dir)) break; | |
| 268 | + $dir = dirname($dir); | |
| 269 | + } | |
| 270 | + } | |
| 271 | + | |
| 272 | + /** シンボリック参照を設定する(例:HEADをrefs/heads/mainに) */ | |
| 273 | + public function setSymbolic(string $name, string $target): void { | |
| 274 | + if (!self::validName($name) || !self::validName($target)) throw new GitException("不正な参照の名前:{$target}"); | |
| 275 | + $this->repo->writeFile($this->repo->path.'/'.$name, "ref: {$target}\n"); | |
| 276 | + } | |
| 277 | +} |
| @@ -0,0 +1,382 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * Gitのリポジトリ(純粋なPHP、zlibが必要) | |
| 42 | + * | |
| 43 | + * ベア(例:/var/www/git/user/repo.git)又は作業ディレクトリの.git。SHA-1とSHA-256のリポジトリに対応。 | |
| 44 | + * | |
| 45 | + * $repo = Repository::init(ROOT.'/data/test.git'); | |
| 46 | + * $blob = $repo->writeBlob("こんにちは\n"); | |
| 47 | + * $tree = $repo->writeTree([[Tree::FILE, 'README.md', $blob]]); | |
| 48 | + * $me = Person::now('諏訪子', 'suwako@example.com'); | |
| 49 | + * $commit = $repo->writeCommit(Commit::create($tree, [], $me, $me, "最初のコミット\n")); | |
| 50 | + * $repo->refs->update('refs/heads/main', $commit, $repo->zeroId()); | |
| 51 | + * foreach ($repo->log('main') as $c) echo $c->subject(), "\n"; | |
| 52 | + */ | |
| 53 | +final class Repository { | |
| 54 | + public readonly string $path; | |
| 55 | + /** sha1又はsha256 */ | |
| 56 | + public readonly string $hashAlgo; | |
| 57 | + /** IDのバイト数(20又は32) */ | |
| 58 | + public readonly int $hashLen; | |
| 59 | + /** 16進数のIDの長さ(40又は64) */ | |
| 60 | + public readonly int $hexLen; | |
| 61 | + public readonly Config $config; | |
| 62 | + public readonly ObjectDb $objects; | |
| 63 | + public readonly Refs $refs; | |
| 64 | + private ?int $fileMode = null; | |
| 65 | + private ?int $dirMode = null; | |
| 66 | + | |
| 67 | + private function __construct(string $path) { | |
| 68 | + $this->path = rtrim(str_replace('\\', '/', $path), '/'); | |
| 69 | + $this->config = new Config($this->path.'/config'); | |
| 70 | + $format = strtolower((string)$this->config->get('extensions.objectformat', 'sha1')); | |
| 71 | + if (!in_array($format, ['sha1', 'sha256'], true)) throw new GitException("未対応のオブジェクトの形式:{$format}"); | |
| 72 | + $version = $this->config->getInt('core.repositoryformatversion', 0); | |
| 73 | + if ($version > 1) throw new GitException("未対応のリポジトリの形式:{$version}"); | |
| 74 | + $this->hashAlgo = $format; | |
| 75 | + $this->hashLen = $format === 'sha1' ? 20 : 32; | |
| 76 | + $this->hexLen = $this->hashLen * 2; | |
| 77 | + // core.sharedRepository(group:グループも書ける、all:皆が読める、0xxx) | |
| 78 | + $shared = strtolower((string)$this->config->get('core.sharedrepository', '')); | |
| 79 | + if (in_array($shared, ['group', 'true', '1'], true)) [$this->fileMode, $this->dirMode] = [0664, 02775]; | |
| 80 | + else if (in_array($shared, ['all', 'world', 'everybody', '2'], true)) [$this->fileMode, $this->dirMode] = [0664, 02775]; | |
| 81 | + else if (preg_match('/^0[0-7]{3}$/', $shared)) [$this->fileMode, $this->dirMode] = [octdec($shared), octdec($shared) | 0111 | 02000]; | |
| 82 | + $this->objects = new ObjectDb($this); | |
| 83 | + $this->refs = new Refs($this); | |
| 84 | + } | |
| 85 | + | |
| 86 | + /** | |
| 87 | + * リポジトリを開く | |
| 88 | + * | |
| 89 | + * @param string $path ベアのリポジトリ、又は.gitの有るディレクトリ | |
| 90 | + */ | |
| 91 | + public static function open(string $path): self { | |
| 92 | + if (is_dir($path.'/.git')) $path .= '/.git'; | |
| 93 | + if (!is_dir($path.'/objects') || !is_file($path.'/HEAD')) throw new GitException("Gitのリポジトリではありません:{$path}"); | |
| 94 | + return new self($path); | |
| 95 | + } | |
| 96 | + | |
| 97 | + public static function isRepository(string $path): bool { | |
| 98 | + return is_dir($path.'/objects') && is_file($path.'/HEAD') && is_dir($path.'/refs'); | |
| 99 | + } | |
| 100 | + | |
| 101 | + /** | |
| 102 | + * ベアのリポジトリを作る | |
| 103 | + * | |
| 104 | + * @param string $path ディレクトリ(無ければ作る、空でなければならない) | |
| 105 | + * @param string $objectFormat sha1又はsha256 | |
| 106 | + * @param string $branch 最初のブランチ(HEADの先) | |
| 107 | + * @param string|null $shared core.sharedRepository(例:group。SSHのgitユーザーとphp-fpmで共有する場合) | |
| 108 | + */ | |
| 109 | + public static function init(string $path, string $objectFormat = 'sha1', string $branch = 'main', ?string $shared = null, string $description = ''): self { | |
| 110 | + if (!in_array($objectFormat, ['sha1', 'sha256'], true)) throw new GitException("未対応のオブジェクトの形式:{$objectFormat}"); | |
| 111 | + if (!Refs::validName('refs/heads/'.$branch)) throw new GitException("不正なブランチの名前:{$branch}"); | |
| 112 | + if (is_dir($path) && count(array_diff(scandir($path) ?: [], ['.', '..'])) > 0) throw new GitException("ディレクトリが空ではありません:{$path}"); | |
| 113 | + $dirMode = $shared !== null ? 02775 : 0777; | |
| 114 | + if (!is_dir($path) && !@mkdir($path, $dirMode, true) && !is_dir($path)) throw new GitException("ディレクトリを作れません:{$path}"); | |
| 115 | + if ($shared !== null) @chmod($path, 02775); | |
| 116 | + $c = Config::fromString(''); | |
| 117 | + $c->set('core.repositoryformatversion', $objectFormat === 'sha256' ? '1' : '0'); | |
| 118 | + $c->set('core.filemode', 'true'); | |
| 119 | + $c->set('core.bare', 'true'); | |
| 120 | + if ($shared !== null) $c->set('core.sharedrepository', $shared); | |
| 121 | + if ($objectFormat === 'sha256') $c->set('extensions.objectformat', 'sha256'); | |
| 122 | + // receive-packでの確認 | |
| 123 | + $c->set('receive.fsckobjects', 'true'); | |
| 124 | + if (@file_put_contents($path.'/config', $c->toString()) === false) throw new GitException("リポジトリを作れません:{$path}"); | |
| 125 | + $repo = new self($path); | |
| 126 | + foreach (['objects/info', 'objects/pack', 'refs/heads', 'refs/tags', 'info', 'hooks'] as $d) $repo->mkdir($repo->path.'/'.$d); | |
| 127 | + $repo->writeFile($repo->path.'/HEAD', "ref: refs/heads/{$branch}\n"); | |
| 128 | + $repo->writeFile($repo->path.'/description', $description !== '' ? $description."\n" : "Unnamed repository; edit this file 'description' to name the repository.\n"); | |
| 129 | + $repo->chmodFile($repo->path.'/config'); | |
| 130 | + return $repo; | |
| 131 | + } | |
| 132 | + | |
| 133 | + //////// ID | |
| 134 | + | |
| 135 | + /** 0ばかりのID(「無い」) */ | |
| 136 | + public function zeroId(): string { | |
| 137 | + return str_repeat('0', $this->hexLen); | |
| 138 | + } | |
| 139 | + | |
| 140 | + public function isId(string $s): bool { | |
| 141 | + return strlen($s) === $this->hexLen && ctype_xdigit($s); | |
| 142 | + } | |
| 143 | + | |
| 144 | + /** オブジェクトのID(16進数) */ | |
| 145 | + public function hashObject(string $type, string $data): string { | |
| 146 | + return hash($this->hashAlgo, $type.' '.strlen($data)."\0".$data); | |
| 147 | + } | |
| 148 | + | |
| 149 | + //////// 読む | |
| 150 | + | |
| 151 | + public function commit(string $id): Commit { | |
| 152 | + return Commit::parse($this->objects->get($id, 'commit'), $id); | |
| 153 | + } | |
| 154 | + | |
| 155 | + public function tree(string $id): Tree { | |
| 156 | + return Tree::parse($this->objects->get($id, 'tree'), $this->hashLen, $id); | |
| 157 | + } | |
| 158 | + | |
| 159 | + public function tag(string $id): Tag { | |
| 160 | + return Tag::parse($this->objects->get($id, 'tag'), $id); | |
| 161 | + } | |
| 162 | + | |
| 163 | + public function blob(string $id): string { | |
| 164 | + return $this->objects->get($id, 'blob'); | |
| 165 | + } | |
| 166 | + | |
| 167 | + /** 注釈付きタグを剥く(タグの先のオブジェクト) */ | |
| 168 | + public function peel(string $id, ?string $want = null): ?string { | |
| 169 | + for ($i = 0; $i < 100; $i++) { | |
| 170 | + $o = $this->objects->info($id); | |
| 171 | + if ($o === null) return null; | |
| 172 | + if ($o[0] !== 'tag' || $want === 'tag') return $want === null || $o[0] === $want ? $id : null; | |
| 173 | + $id = $this->tag($id)->object; | |
| 174 | + } | |
| 175 | + return null; | |
| 176 | + } | |
| 177 | + | |
| 178 | + /** コミットのツリー(ツリーならそのまま、IDでなければリビジョンとして解決) */ | |
| 179 | + public function treeOf(string $id): ?string { | |
| 180 | + if (!$this->isId($id)) $id = $this->resolve($id) ?? ''; | |
| 181 | + $id = $id === '' ? null : $this->peel($id); | |
| 182 | + if ($id === null) return null; | |
| 183 | + $t = $this->objects->info($id)[0] ?? null; | |
| 184 | + if ($t === 'tree') return $id; | |
| 185 | + return $t === 'commit' ? $this->commit($id)->tree : null; | |
| 186 | + } | |
| 187 | + | |
| 188 | + /** | |
| 189 | + * ツリーの中のパスのエントリー | |
| 190 | + * | |
| 191 | + * @param string $treeish コミット又はツリー(IDかリビジョン) | |
| 192 | + * @param string $path 「src/a.php」(空ならルートのツリー) | |
| 193 | + * @return array|null [mode, name, id] | |
| 194 | + */ | |
| 195 | + public function entry(string $treeish, string $path): ?array { | |
| 196 | + $tree = $this->treeOf($treeish); | |
| 197 | + if ($tree === null) return null; | |
| 198 | + $path = trim($path, '/'); | |
| 199 | + if ($path === '') return [Tree::DIR, '', $tree]; | |
| 200 | + $parts = explode('/', $path); | |
| 201 | + $entry = null; | |
| 202 | + foreach ($parts as $i => $part) { | |
| 203 | + $entry = $this->tree($tree)->find($part); | |
| 204 | + if ($entry === null) return null; | |
| 205 | + if ($i < count($parts) - 1) { | |
| 206 | + if ($entry[0] !== Tree::DIR) return null; | |
| 207 | + $tree = $entry[2]; | |
| 208 | + } | |
| 209 | + } | |
| 210 | + return $entry; | |
| 211 | + } | |
| 212 | + | |
| 213 | + /** | |
| 214 | + * リビジョンを解決する(git rev-parseの基本) | |
| 215 | + * | |
| 216 | + * 対応:完全・短いID、参照の名前(HEAD、main、v1.0、refs/...)、rev^、rev^2、rev~3、rev^{tree}、rev^{commit}、rev:パス | |
| 217 | + * | |
| 218 | + * @return string|null ID | |
| 219 | + */ | |
| 220 | + public function resolve(string $rev): ?string { | |
| 221 | + $rev = trim($rev); | |
| 222 | + if ($rev === '') return null; | |
| 223 | + // rev:パス | |
| 224 | + if (preg_match('/^([^:]*):(.*)$/s', $rev, $m) && !preg_match('#^[A-Za-z]:[\\\\/]#', $rev)) { | |
| 225 | + $base = $this->resolve($m[1] === '' ? 'HEAD' : $m[1]); | |
| 226 | + if ($base === null) return null; | |
| 227 | + return $this->entry($base, $m[2])[2] ?? null; | |
| 228 | + } | |
| 229 | + if (!preg_match('/^(.*?)((?:\^\{[a-z]*\}|\^\d*|~\d*)*)$/s', $rev, $m)) return null; | |
| 230 | + $id = $this->resolveName($m[1]); | |
| 231 | + if ($id === null) return null; | |
| 232 | + preg_match_all('/\^\{([a-z]*)\}|\^(\d*)|~(\d*)/', $m[2], $ops, PREG_SET_ORDER); | |
| 233 | + foreach ($ops as $op) { | |
| 234 | + if (isset($op[1]) && $op[1] !== '' || str_starts_with($op[0], '^{')) { | |
| 235 | + $id = $op[1] === '' ? $this->peel($id) : ($op[1] === 'object' ? $id : $this->peelTo($id, $op[1])); | |
| 236 | + } else if (str_starts_with($op[0], '^')) { | |
| 237 | + $n = $op[2] === '' ? 1 : (int)$op[2]; | |
| 238 | + $c = $this->peel($id, 'commit'); | |
| 239 | + if ($c === null) return null; | |
| 240 | + $id = $n === 0 ? $c : ($this->commit($c)->parents[$n - 1] ?? null); | |
| 241 | + } else { | |
| 242 | + $n = ($op[3] ?? '') === '' ? 1 : (int)$op[3]; | |
| 243 | + for ($i = 0; $i < $n && $id !== null; $i++) { | |
| 244 | + $c = $this->peel($id, 'commit'); | |
| 245 | + $id = $c === null ? null : ($this->commit($c)->parents[0] ?? null); | |
| 246 | + } | |
| 247 | + } | |
| 248 | + if ($id === null) return null; | |
| 249 | + } | |
| 250 | + return $id; | |
| 251 | + } | |
| 252 | + | |
| 253 | + private function peelTo(string $id, string $type): ?string { | |
| 254 | + $id = $type === 'tag' ? $id : $this->peel($id); | |
| 255 | + if ($id === null) return null; | |
| 256 | + if ($type === 'tree') return $this->treeOf($id); | |
| 257 | + return ($this->objects->info($id)[0] ?? null) === $type ? $id : null; | |
| 258 | + } | |
| 259 | + | |
| 260 | + private function resolveName(string $name): ?string { | |
| 261 | + if ($name === '') $name = 'HEAD'; | |
| 262 | + if ($name === '@') $name = 'HEAD'; | |
| 263 | + $full = $this->refs->dwim($name); | |
| 264 | + if ($full !== null) return $this->refs->resolve($full); | |
| 265 | + if (preg_match('/^[0-9a-fA-F]{4,}$/', $name)) return $this->objects->resolvePrefix($name); | |
| 266 | + return null; | |
| 267 | + } | |
| 268 | + | |
| 269 | + /** HEADの先のブランチ(例:refs/heads/main) */ | |
| 270 | + public function headRef(): ?string { | |
| 271 | + return $this->refs->symbolic('HEAD'); | |
| 272 | + } | |
| 273 | + | |
| 274 | + /** 空のリポジトリか(コミットが1つも無い) */ | |
| 275 | + public function isEmpty(): bool { | |
| 276 | + return empty($this->refs->all('refs/')); | |
| 277 | + } | |
| 278 | + | |
| 279 | + //////// 書く | |
| 280 | + | |
| 281 | + public function writeBlob(string $data): string { | |
| 282 | + return $this->objects->write('blob', $data); | |
| 283 | + } | |
| 284 | + | |
| 285 | + /** @param array $entries [mode, name, id] */ | |
| 286 | + public function writeTree(array $entries): string { | |
| 287 | + return $this->objects->write('tree', Tree::create($entries)->toString()); | |
| 288 | + } | |
| 289 | + | |
| 290 | + public function writeCommit(Commit $c): string { | |
| 291 | + if (!$this->isId($c->tree)) throw new GitException('不正なツリーのID'); | |
| 292 | + return $c->id = $this->objects->write('commit', $c->toString()); | |
| 293 | + } | |
| 294 | + | |
| 295 | + public function writeTag(Tag $t): string { | |
| 296 | + return $t->id = $this->objects->write('tag', $t->toString()); | |
| 297 | + } | |
| 298 | + | |
| 299 | + /** | |
| 300 | + * ツリーのファイルを変えた新しいツリー(Webでの編集用) | |
| 301 | + * | |
| 302 | + * @param string|null $tree 元のツリー(nullなら空) | |
| 303 | + * @param array $changes パス => [mode, 内容(文字列)] 又はnull(削除) | |
| 304 | + * @return string 新しいツリーのID | |
| 305 | + */ | |
| 306 | + public function updateTree(?string $tree, array $changes): string { | |
| 307 | + return $this->changeTree($tree, $changes) ?? $this->writeTree([]); | |
| 308 | + } | |
| 309 | + | |
| 310 | + /** @return string|null 空になったらnull */ | |
| 311 | + private function changeTree(?string $tree, array $changes): ?string { | |
| 312 | + $byDir = []; | |
| 313 | + foreach ($changes as $path => $change) { | |
| 314 | + $path = trim(str_replace('\\', '/', (string)$path), '/'); | |
| 315 | + if ($path === '') throw new GitException('不正なパス'); | |
| 316 | + $parts = explode('/', $path); | |
| 317 | + $name = array_shift($parts); | |
| 318 | + if (empty($parts)) $byDir[''][$name] = $change; | |
| 319 | + else $byDir[$name][implode('/', $parts)] = $change; | |
| 320 | + } | |
| 321 | + $entries = []; | |
| 322 | + foreach ($tree !== null ? $this->tree($tree)->entries : [] as $e) $entries[$e[1]] = $e; | |
| 323 | + foreach ($byDir[''] ?? [] as $name => $change) { | |
| 324 | + if ($change === null) unset($entries[$name]); | |
| 325 | + else $entries[$name] = [$change[0], $name, $this->writeBlob((string)$change[1])]; | |
| 326 | + } | |
| 327 | + unset($byDir['']); | |
| 328 | + foreach ($byDir as $dir => $sub) { | |
| 329 | + $old = isset($entries[$dir]) && $entries[$dir][0] === Tree::DIR ? $entries[$dir][2] : null; | |
| 330 | + $new = $this->changeTree($old, $sub); | |
| 331 | + if ($new === null) unset($entries[$dir]); | |
| 332 | + else $entries[$dir] = [Tree::DIR, $dir, $new]; | |
| 333 | + } | |
| 334 | + return empty($entries) ? null : $this->writeTree(array_values($entries)); | |
| 335 | + } | |
| 336 | + | |
| 337 | + //////// 履歴 | |
| 338 | + | |
| 339 | + /** | |
| 340 | + * コミットの履歴(新しい順) | |
| 341 | + * | |
| 342 | + * @param string|array $from リビジョン(複数可) | |
| 343 | + * @param array $opt path(そのパスを変えたコミットだけ)、limit、skip、not(除くリビジョン)、firstParent | |
| 344 | + * @return \Generator Commit | |
| 345 | + */ | |
| 346 | + public function log(string|array $from, array $opt = []): \Generator { | |
| 347 | + yield from (new Walk($this))->log((array)$from, $opt); | |
| 348 | + } | |
| 349 | + | |
| 350 | + //////// ファイル | |
| 351 | + | |
| 352 | + public function mkdir(string $dir): void { | |
| 353 | + if (is_dir($dir)) return; | |
| 354 | + $this->mkdir(dirname($dir)); | |
| 355 | + if (!@mkdir($dir, $this->dirMode ?? 0777) && !is_dir($dir)) throw new GitException("ディレクトリを作れません:{$dir}"); | |
| 356 | + if ($this->dirMode !== null) @chmod($dir, $this->dirMode); | |
| 357 | + } | |
| 358 | + | |
| 359 | + /** 一時ファイルに書いてから置き換える */ | |
| 360 | + public function writeFile(string $file, string $data, bool $readOnly = false): void { | |
| 361 | + $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp'; | |
| 362 | + if (@file_put_contents($tmp, $data) === false) throw new GitException("ファイルに書けません:{$file}"); | |
| 363 | + $this->chmodFile($tmp, $readOnly); | |
| 364 | + if (!@rename($tmp, $file)) { | |
| 365 | + // Windowsでは有るファイルに名前を変えられない場合が有る | |
| 366 | + if (!$readOnly || !is_file($file)) { | |
| 367 | + @unlink($file); | |
| 368 | + if (!@rename($tmp, $file)) { | |
| 369 | + @unlink($tmp); | |
| 370 | + throw new GitException("ファイルに書けません:{$file}"); | |
| 371 | + } | |
| 372 | + } else { | |
| 373 | + @unlink($tmp); | |
| 374 | + } | |
| 375 | + } | |
| 376 | + } | |
| 377 | + | |
| 378 | + public function chmodFile(string $file, bool $readOnly = false): void { | |
| 379 | + if ($this->fileMode !== null) @chmod($file, $readOnly ? $this->fileMode & 0444 : $this->fileMode); | |
| 380 | + else if ($readOnly) @chmod($file, 0444 & ~umask()); | |
| 381 | + } | |
| 382 | +} |
| @@ -0,0 +1,111 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * SSH(git clone git@host:user/repo.git、git push) | |
| 42 | + * | |
| 43 | + * sshdのauthorized_keysのcommand=から呼ぶスクリプトで使う。SSH_ORIGINAL_COMMANDは | |
| 44 | + * 「git-upload-pack 'user/repo.git'」又は「git-receive-pack 'user/repo.git'」。 | |
| 45 | + * プロトコルv2はGIT_PROTOCOL(sshd_configのAcceptEnv GIT_PROTOCOLが必要)。 | |
| 46 | + */ | |
| 47 | +final class Ssh { | |
| 48 | + /** | |
| 49 | + * コマンドを解析する | |
| 50 | + * | |
| 51 | + * @return array|null [サービス(git-upload-pack又はgit-receive-pack), パス] | |
| 52 | + */ | |
| 53 | + public static function parse(string $command): ?array { | |
| 54 | + if (!preg_match('/^(git[ -](?:upload-pack|receive-pack|upload-archive))\s+(.+)$/s', trim($command), $m)) return null; | |
| 55 | + $service = str_replace('git ', 'git-', $m[1]); | |
| 56 | + $arg = trim($m[2]); | |
| 57 | + // シェルの引用符('…'、'\'') | |
| 58 | + if (str_starts_with($arg, "'")) { | |
| 59 | + if (!preg_match("/^'((?:[^']|'\\\\'')*)'$/s", $arg, $q)) return null; | |
| 60 | + $arg = str_replace("'\\''", "'", $q[1]); | |
| 61 | + } else if (str_starts_with($arg, '"')) { | |
| 62 | + $arg = trim($arg, '"'); | |
| 63 | + } | |
| 64 | + $path = preg_replace('#^(~/|/)+#', '', $arg); | |
| 65 | + if ($path === '' || str_contains($path, "\0") || preg_match('#(^|/)\.\.(/|$)#', $path)) return null; | |
| 66 | + return [$service, $path]; | |
| 67 | + } | |
| 68 | + | |
| 69 | + /** | |
| 70 | + * SSHのコマンドを処理する | |
| 71 | + * | |
| 72 | + * @param string $command SSH_ORIGINAL_COMMAND | |
| 73 | + * @param callable $open fn(string $path, bool $write): ?Repository(アクセスの確認。nullならリポジトリが無い・権限が無い) | |
| 74 | + * @param array $opt receive(ReceivePackのオプション)、agent | |
| 75 | + * @return int 終了コード | |
| 76 | + */ | |
| 77 | + public static function handle(string $command, callable $open, array $opt = [], $in = null, $out = null, $err = null): int { | |
| 78 | + $in ??= STDIN; | |
| 79 | + $out ??= STDOUT; | |
| 80 | + $err ??= STDERR; | |
| 81 | + $p = self::parse($command); | |
| 82 | + if ($p === null || $p[0] === 'git-upload-archive') { | |
| 83 | + fwrite($err, "このコマンドは使えません。\n"); | |
| 84 | + return 1; | |
| 85 | + } | |
| 86 | + [$service, $path] = $p; | |
| 87 | + $write = $service === 'git-receive-pack'; | |
| 88 | + try { | |
| 89 | + $repo = $open($path, $write); | |
| 90 | + } catch (GitException $e) { | |
| 91 | + $repo = null; | |
| 92 | + } | |
| 93 | + if (!$repo instanceof Repository) { | |
| 94 | + fwrite($err, "リポジトリ「{$path}」が有りません、又は".($write ? 'プッシュする' : '読む')."権限が有りません。\n"); | |
| 95 | + return 1; | |
| 96 | + } | |
| 97 | + $agent = ['agent' => (string)($opt['agent'] ?? 'LittleBeast')]; | |
| 98 | + try { | |
| 99 | + if ($write) { | |
| 100 | + (new ReceivePack($repo, $agent + ($opt['receive'] ?? [])))->serve($in, $out, false); | |
| 101 | + } else { | |
| 102 | + $version = str_contains((string)getenv('GIT_PROTOCOL'), 'version=2') ? 2 : 0; | |
| 103 | + (new UploadPack($repo, $agent))->serve($in, $out, false, $version); | |
| 104 | + } | |
| 105 | + } catch (GitException $e) { | |
| 106 | + fwrite($err, $e->getMessage()."\n"); | |
| 107 | + return 1; | |
| 108 | + } | |
| 109 | + return 0; | |
| 110 | + } | |
| 111 | +} |
| @@ -0,0 +1,111 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * 注釈付きタグ | |
| 42 | + */ | |
| 43 | +final class Tag { | |
| 44 | + public string $object = ''; | |
| 45 | + public string $type = 'commit'; | |
| 46 | + public string $tag = ''; | |
| 47 | + public ?Person $tagger = null; | |
| 48 | + public string $message = ''; | |
| 49 | + public array $headers = []; | |
| 50 | + public ?string $id = null; | |
| 51 | + | |
| 52 | + public static function parse(string $data, ?string $id = null): self { | |
| 53 | + $t = new self(); | |
| 54 | + $t->id = $id; | |
| 55 | + $t->headers = self::parseHeaders($data, $t->message); | |
| 56 | + foreach ($t->headers as [$k, $v]) { | |
| 57 | + match ($k) { | |
| 58 | + 'object' => $t->object = $v, | |
| 59 | + 'type' => $t->type = $v, | |
| 60 | + 'tag' => $t->tag = $v, | |
| 61 | + 'tagger' => $t->tagger = Person::parse($v), | |
| 62 | + default => null, | |
| 63 | + }; | |
| 64 | + } | |
| 65 | + return $t; | |
| 66 | + } | |
| 67 | + | |
| 68 | + public static function create(string $object, string $type, string $name, ?Person $tagger, string $message): self { | |
| 69 | + $t = new self(); | |
| 70 | + $t->object = $object; | |
| 71 | + $t->type = $type; | |
| 72 | + $t->tag = $name; | |
| 73 | + $t->tagger = $tagger; | |
| 74 | + $t->message = $message; | |
| 75 | + $t->headers = [['object', $object], ['type', $type], ['tag', $name]]; | |
| 76 | + if ($tagger !== null) $t->headers[] = ['tagger', (string)$tagger]; | |
| 77 | + return $t; | |
| 78 | + } | |
| 79 | + | |
| 80 | + public function toString(): string { | |
| 81 | + return self::buildHeaders($this->headers).$this->message; | |
| 82 | + } | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * ヘッダーとメッセージ(コミットとタグで同じ形) | |
| 86 | + * | |
| 87 | + * @return array [名前, 値]の配列 | |
| 88 | + */ | |
| 89 | + public static function parseHeaders(string $data, ?string &$message): array { | |
| 90 | + $end = strpos($data, "\n\n"); | |
| 91 | + $head = $end === false ? rtrim($data, "\n") : substr($data, 0, $end); | |
| 92 | + $message = $end === false ? '' : substr($data, $end + 2); | |
| 93 | + $headers = []; | |
| 94 | + foreach ($head === '' ? [] : explode("\n", $head) as $line) { | |
| 95 | + if ($line !== '' && $line[0] === ' ' && !empty($headers)) { | |
| 96 | + // 前のヘッダーの続き(gpgsig等) | |
| 97 | + $headers[count($headers) - 1][1] .= "\n".substr($line, 1); | |
| 98 | + continue; | |
| 99 | + } | |
| 100 | + $sp = strpos($line, ' '); | |
| 101 | + $headers[] = $sp === false ? [$line, ''] : [substr($line, 0, $sp), substr($line, $sp + 1)]; | |
| 102 | + } | |
| 103 | + return $headers; | |
| 104 | + } | |
| 105 | + | |
| 106 | + public static function buildHeaders(array $headers): string { | |
| 107 | + $out = ''; | |
| 108 | + foreach ($headers as [$k, $v]) $out .= $k.' '.str_replace("\n", "\n ", $v)."\n"; | |
| 109 | + return $out."\n"; | |
| 110 | + } | |
| 111 | +} |
| @@ -0,0 +1,110 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * ツリー(ディレクトリ) | |
| 42 | + * | |
| 43 | + * entriesは[mode, name, id](idは16進数)。modeは「100644」「100755」「120000」(シンボリックリンク)、 | |
| 44 | + * 「40000」(ツリー)、「160000」(サブモジュール)。 | |
| 45 | + */ | |
| 46 | +final class Tree { | |
| 47 | + public const FILE = '100644'; | |
| 48 | + public const EXEC = '100755'; | |
| 49 | + public const LINK = '120000'; | |
| 50 | + public const DIR = '40000'; | |
| 51 | + public const SUBMODULE = '160000'; | |
| 52 | + | |
| 53 | + /** @var array [mode, name, id]の配列(Gitの順番) */ | |
| 54 | + public array $entries = []; | |
| 55 | + public ?string $id = null; | |
| 56 | + | |
| 57 | + public static function parse(string $data, int $hashLen, ?string $id = null): self { | |
| 58 | + $t = new self(); | |
| 59 | + $t->id = $id; | |
| 60 | + $len = strlen($data); | |
| 61 | + $pos = 0; | |
| 62 | + while ($pos < $len) { | |
| 63 | + $sp = strpos($data, ' ', $pos); | |
| 64 | + $nul = $sp === false ? false : strpos($data, "\0", $sp); | |
| 65 | + if ($nul === false || $nul + 1 + $hashLen > $len) throw new GitException('壊れたツリー'.($id ? ":{$id}" : '')); | |
| 66 | + $t->entries[] = [substr($data, $pos, $sp - $pos), substr($data, $sp + 1, $nul - $sp - 1), bin2hex(substr($data, $nul + 1, $hashLen))]; | |
| 67 | + $pos = $nul + 1 + $hashLen; | |
| 68 | + } | |
| 69 | + return $t; | |
| 70 | + } | |
| 71 | + | |
| 72 | + /** | |
| 73 | + * エントリーからツリーを作る(Gitの順番に並べる) | |
| 74 | + * | |
| 75 | + * @param array $entries [mode, name, id] | |
| 76 | + */ | |
| 77 | + public static function create(array $entries): self { | |
| 78 | + $t = new self(); | |
| 79 | + foreach ($entries as [$mode, $name, $id]) { | |
| 80 | + if ($name === '' || $name === '.' || $name === '..' || str_contains($name, '/') || str_contains($name, "\0")) throw new GitException("不正なファイル名:{$name}"); | |
| 81 | + $t->entries[] = [ltrim((string)$mode, '0') ?: '0', $name, strtolower($id)]; | |
| 82 | + } | |
| 83 | + usort($t->entries, fn($a, $b) => strcmp(self::sortKey($a), self::sortKey($b))); | |
| 84 | + for ($i = 1; $i < count($t->entries); $i++) { | |
| 85 | + if ($t->entries[$i][1] === $t->entries[$i - 1][1]) throw new GitException("同じ名前が2つ有ります:{$t->entries[$i][1]}"); | |
| 86 | + } | |
| 87 | + return $t; | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** ディレクトリは名前の後に「/」が有るとして並べる */ | |
| 91 | + private static function sortKey(array $e): string { | |
| 92 | + return $e[0] === self::DIR ? $e[1].'/' : $e[1]; | |
| 93 | + } | |
| 94 | + | |
| 95 | + public function toString(): string { | |
| 96 | + $out = ''; | |
| 97 | + foreach ($this->entries as [$mode, $name, $id]) $out .= "{$mode} {$name}\0".hex2bin($id); | |
| 98 | + return $out; | |
| 99 | + } | |
| 100 | + | |
| 101 | + /** 名前のエントリー */ | |
| 102 | + public function find(string $name): ?array { | |
| 103 | + foreach ($this->entries as $e) if ($e[1] === $name) return $e; | |
| 104 | + return null; | |
| 105 | + } | |
| 106 | + | |
| 107 | + public static function isTree(string $mode): bool { | |
| 108 | + return $mode === self::DIR; | |
| 109 | + } | |
| 110 | +} |
| @@ -0,0 +1,410 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * git-upload-pack(クローン・フェッチ)。プロトコルv0・v1・v2、HTTP(stateless)とSSH | |
| 42 | + * | |
| 43 | + * 対応:multi_ack_detailed、side-band・side-band-64k、ofs-delta、shallow(--depth)、include-tag、no-progress。 | |
| 44 | + * 欲しいオブジェクトはリポジトリの中の物なら何でも良い(アクセスの確認は呼ぶ側で)。 | |
| 45 | + */ | |
| 46 | +final class UploadPack { | |
| 47 | + private string $agent; | |
| 48 | + private Output $out; | |
| 49 | + private bool $sideband = false; | |
| 50 | + private int $bandMax = PktLine::MAX; | |
| 51 | + private bool $progress = true; | |
| 52 | + | |
| 53 | + /** | |
| 54 | + * @param array $opt agent(エージェントの名前) | |
| 55 | + */ | |
| 56 | + public function __construct(private Repository $repo, array $opt = []) { | |
| 57 | + $this->agent = (string)($opt['agent'] ?? 'LittleBeast'); | |
| 58 | + } | |
| 59 | + | |
| 60 | + /** | |
| 61 | + * 参照の広告(最初の応答) | |
| 62 | + * | |
| 63 | + * @param resource $out | |
| 64 | + * @param bool $http 「# service=git-upload-pack」を付ける(v0・v1) | |
| 65 | + */ | |
| 66 | + public function advertise($out, bool $http, int $version = 0): void { | |
| 67 | + $o = new Output($out, $http); | |
| 68 | + if ($version === 2) { | |
| 69 | + $o->write(PktLine::encode("version 2\n").PktLine::encode("agent={$this->agent}\n").PktLine::encode("ls-refs=unborn\n") | |
| 70 | + .PktLine::encode("fetch=shallow wait-for-done\n").PktLine::encode("server-option\n").PktLine::encode("object-format={$this->repo->hashAlgo}\n").PktLine::flush()); | |
| 71 | + $o->flush(); | |
| 72 | + return; | |
| 73 | + } | |
| 74 | + $s = $http ? PktLine::encode("# service=git-upload-pack\n").PktLine::flush() : ''; | |
| 75 | + if ($version === 1) $s .= PktLine::encode("version 1\n"); | |
| 76 | + $caps = 'multi_ack multi_ack_detailed side-band side-band-64k ofs-delta shallow deepen-relative no-progress include-tag thin-pack'; | |
| 77 | + $head = $this->repo->headRef(); | |
| 78 | + if ($head !== null && $this->repo->refs->resolve('HEAD') !== null) $caps .= " symref=HEAD:{$head}"; | |
| 79 | + $caps .= " object-format={$this->repo->hashAlgo} agent={$this->agent}"; | |
| 80 | + $first = true; | |
| 81 | + foreach ($this->refLines() as [$id, $name]) { | |
| 82 | + $s .= PktLine::encode($id.' '.$name.($first ? "\0".$caps : '')."\n"); | |
| 83 | + $first = false; | |
| 84 | + } | |
| 85 | + if ($first) $s .= PktLine::encode($this->repo->zeroId()." capabilities^{}\0{$caps}\n"); | |
| 86 | + $o->write($s.PktLine::flush()); | |
| 87 | + $o->flush(); | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** HEADと参照(注釈付きタグは剥いた物も) */ | |
| 91 | + private function refLines(): array { | |
| 92 | + $out = []; | |
| 93 | + $head = $this->repo->refs->resolve('HEAD'); | |
| 94 | + if ($head !== null) $out[] = [$head, 'HEAD']; | |
| 95 | + foreach ($this->repo->refs->all() as $name => $id) { | |
| 96 | + $out[] = [$id, $name]; | |
| 97 | + if (str_starts_with($name, 'refs/tags/')) { | |
| 98 | + $peeled = $this->peeled($name, $id); | |
| 99 | + if ($peeled !== null && $peeled !== $id) $out[] = [$peeled, $name.'^{}']; | |
| 100 | + } | |
| 101 | + } | |
| 102 | + return $out; | |
| 103 | + } | |
| 104 | + | |
| 105 | + private function peeled(string $name, string $id): ?string { | |
| 106 | + return $this->repo->refs->peeledFromPack($name) ?? $this->repo->peel($id); | |
| 107 | + } | |
| 108 | + | |
| 109 | + /** | |
| 110 | + * リクエストを処理する | |
| 111 | + * | |
| 112 | + * @param resource $in | |
| 113 | + * @param resource $out | |
| 114 | + * @param bool $statelessRpc HTTP(1回のリクエストと応答) | |
| 115 | + */ | |
| 116 | + public function serve($in, $out, bool $statelessRpc, int $version = 0): void { | |
| 117 | + $this->out = new Output($out, $statelessRpc); | |
| 118 | + $pkt = new PktLine($in); | |
| 119 | + try { | |
| 120 | + if ($version === 2) { | |
| 121 | + if (!$statelessRpc) $this->advertise($out, false, 2); | |
| 122 | + do { | |
| 123 | + $more = $this->command($pkt); | |
| 124 | + } while ($more && !$statelessRpc); | |
| 125 | + return; | |
| 126 | + } | |
| 127 | + if (!$statelessRpc) $this->advertise($out, false, $version); | |
| 128 | + $this->fetchV0($pkt, $statelessRpc); | |
| 129 | + } catch (GitException $e) { | |
| 130 | + if ($this->sideband) $this->out->write(PktLine::band(3, $e->getMessage()."\n", $this->bandMax).PktLine::flush()); | |
| 131 | + else $this->out->write(PktLine::encode('ERR '.$e->getMessage()."\n")); | |
| 132 | + $this->out->flush(); | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 136 | + //////// v0・v1 | |
| 137 | + | |
| 138 | + private function fetchV0(PktLine $pkt, bool $stateless): void { | |
| 139 | + $wants = []; | |
| 140 | + $caps = []; | |
| 141 | + $clientShallow = []; | |
| 142 | + $depth = 0; | |
| 143 | + $relative = false; | |
| 144 | + while (true) { | |
| 145 | + $l = $pkt->read(); | |
| 146 | + if ($l === null || $l === PktLine::FLUSH) break; | |
| 147 | + if (!is_string($l)) continue; | |
| 148 | + if (preg_match('/^want ([0-9a-f]+)(?: (.*))?$/', $l, $m)) { | |
| 149 | + if (empty($wants) && isset($m[2])) $caps = explode(' ', trim($m[2])); | |
| 150 | + $this->checkWant($m[1]); | |
| 151 | + $wants[$m[1]] = true; | |
| 152 | + } else if (preg_match('/^shallow ([0-9a-f]+)$/', $l, $m)) { | |
| 153 | + $clientShallow[] = $m[1]; | |
| 154 | + } else if (preg_match('/^deepen (\d+)$/', $l, $m)) { | |
| 155 | + $depth = (int)$m[1]; | |
| 156 | + } else if ($l === 'deepen-relative') { | |
| 157 | + $relative = true; | |
| 158 | + } else if (preg_match('/^(deepen-since|deepen-not|filter) /', $l, $m)) { | |
| 159 | + throw new GitException("未対応:{$m[1]}"); | |
| 160 | + } | |
| 161 | + } | |
| 162 | + if (empty($wants)) return; | |
| 163 | + // v0では能力として送られる | |
| 164 | + if (in_array('deepen-relative', $caps, true)) $relative = true; | |
| 165 | + $this->sideband = in_array('side-band-64k', $caps, true) || in_array('side-band', $caps, true); | |
| 166 | + $this->bandMax = in_array('side-band-64k', $caps, true) ? PktLine::MAX : 1000; | |
| 167 | + $this->progress = !in_array('no-progress', $caps, true); | |
| 168 | + $multiAck = in_array('multi_ack_detailed', $caps, true) ? 2 : (in_array('multi_ack', $caps, true) ? 1 : 0); | |
| 169 | + | |
| 170 | + // 深さ | |
| 171 | + $commits = null; | |
| 172 | + $boundary = null; | |
| 173 | + if ($depth > 0) { | |
| 174 | + [$commits, $shallow] = $this->shallow(array_keys($wants), $depth, $clientShallow, $relative); | |
| 175 | + $s = ''; | |
| 176 | + foreach ($shallow['shallow'] as $id) $s .= PktLine::encode("shallow {$id}\n"); | |
| 177 | + foreach ($shallow['unshallow'] as $id) $s .= PktLine::encode("unshallow {$id}\n"); | |
| 178 | + $this->out->write($s.PktLine::flush()); | |
| 179 | + $this->out->flush(); | |
| 180 | + } | |
| 181 | + | |
| 182 | + // ネゴシエーション | |
| 183 | + $common = []; | |
| 184 | + $last = null; | |
| 185 | + $done = false; | |
| 186 | + while (true) { | |
| 187 | + $l = $pkt->read(); | |
| 188 | + if ($l === null) return; | |
| 189 | + if ($l === PktLine::FLUSH) { | |
| 190 | + if (empty($common) || $multiAck) $this->out->write(PktLine::encode("NAK\n")); | |
| 191 | + $this->out->flush(); | |
| 192 | + if ($stateless) return; | |
| 193 | + continue; | |
| 194 | + } | |
| 195 | + if ($l === 'done') { | |
| 196 | + $done = true; | |
| 197 | + break; | |
| 198 | + } | |
| 199 | + if (!is_string($l) || !preg_match('/^have ([0-9a-f]+)$/', $l, $m)) continue; | |
| 200 | + $id = $m[1]; | |
| 201 | + if (!$this->repo->isId($id) || !$this->repo->objects->exists($id)) continue; | |
| 202 | + $isNew = !isset($common[$id]); | |
| 203 | + $common[$id] = true; | |
| 204 | + $last = $id; | |
| 205 | + if ($multiAck === 2) $this->out->write(PktLine::encode("ACK {$id} common\n")); | |
| 206 | + else if ($multiAck === 1) $this->out->write(PktLine::encode("ACK {$id} continue\n")); | |
| 207 | + else if ($isNew && count($common) === 1) $this->out->write(PktLine::encode("ACK {$id}\n")); | |
| 208 | + } | |
| 209 | + if (!empty($common)) { | |
| 210 | + if ($multiAck) $this->out->write(PktLine::encode("ACK {$last}\n")); | |
| 211 | + } else { | |
| 212 | + $this->out->write(PktLine::encode("NAK\n")); | |
| 213 | + } | |
| 214 | + $this->sendPack(array_keys($wants), array_keys($common), in_array('include-tag', $caps, true), $commits, $clientShallow, $depth > 0); | |
| 215 | + } | |
| 216 | + | |
| 217 | + private function checkWant(string $id): void { | |
| 218 | + if (!$this->repo->isId($id) || !$this->repo->objects->exists($id)) throw new GitException("オブジェクトが有りません:{$id}"); | |
| 219 | + } | |
| 220 | + | |
| 221 | + /** | |
| 222 | + * 深さの制限 | |
| 223 | + * | |
| 224 | + * @return array [送るコミット, ['shallow' => [...], 'unshallow' => [...]]] | |
| 225 | + */ | |
| 226 | + private function shallow(array $wants, int $depth, array $clientShallow, bool $relative = false): array { | |
| 227 | + $walk = new Walk($this->repo); | |
| 228 | + $wantCommits = []; | |
| 229 | + foreach ($wants as $w) { | |
| 230 | + $c = $this->repo->peel($w, 'commit'); | |
| 231 | + if ($c !== null) $wantCommits[] = $c; | |
| 232 | + } | |
| 233 | + if ($relative && !empty($clientShallow)) { | |
| 234 | + // 今のshallowのコミットから更に深く(git fetch --deepen) | |
| 235 | + [$deeper, $shallow] = $walk->deepen($clientShallow, $depth + 1); | |
| 236 | + $grafted = new Walk($this->repo); | |
| 237 | + $grafted->setGrafts($clientShallow); | |
| 238 | + $commits = array_values(array_unique(array_merge($grafted->ancestors($wantCommits), $deeper))); | |
| 239 | + $sh = array_fill_keys($shallow, true); | |
| 240 | + return [$commits, ['shallow' => $shallow, 'unshallow' => array_values(array_filter($clientShallow, fn($id) => !isset($sh[$id])))]]; | |
| 241 | + } | |
| 242 | + [$commits, $shallow] = $walk->deepen($wantCommits, $depth); | |
| 243 | + $in = array_fill_keys($commits, true); | |
| 244 | + $sh = array_fill_keys($shallow, true); | |
| 245 | + $unshallow = []; | |
| 246 | + foreach ($clientShallow as $id) if (isset($in[$id]) && !isset($sh[$id])) $unshallow[] = $id; | |
| 247 | + return [$commits, ['shallow' => $shallow, 'unshallow' => $unshallow]]; | |
| 248 | + } | |
| 249 | + | |
| 250 | + /** パックを送る(サイドバンドが有れば進捗も) */ | |
| 251 | + private function sendPack(array $wants, array $haves, bool $includeTag, ?array $commits, array $clientShallow, bool $deepen): void { | |
| 252 | + $walk = new Walk($this->repo); | |
| 253 | + $walk->setGrafts($clientShallow); | |
| 254 | + $haveCommits = []; | |
| 255 | + foreach ($haves as $h) { | |
| 256 | + $c = $this->repo->peel($h, 'commit'); | |
| 257 | + if ($c !== null) $haveCommits[] = $c; | |
| 258 | + } | |
| 259 | + $boundary = null; | |
| 260 | + if ($commits !== null) { | |
| 261 | + // 深さで決めたコミットから、相手が持っている物を除く | |
| 262 | + $have = array_fill_keys($walk->ancestors($haveCommits), true); | |
| 263 | + $commits = array_values(array_filter($commits, fn($c) => !isset($have[$c]))); | |
| 264 | + $boundary = []; | |
| 265 | + foreach ($commits as $c) foreach ($this->repo->commit($c)->parents as $p) if (isset($have[$p])) $boundary[$p] = true; | |
| 266 | + $boundary = array_keys($boundary); | |
| 267 | + } | |
| 268 | + $this->progress('オブジェクトを数えています…'); | |
| 269 | + $objects = $walk->objects($wants, $haveCommits, $commits, $boundary); | |
| 270 | + if ($includeTag) { | |
| 271 | + foreach ($this->repo->refs->all('refs/tags/') as $name => $id) { | |
| 272 | + if (isset($objects[$id]) || ($this->repo->objects->info($id)[0] ?? null) !== 'tag') continue; | |
| 273 | + $target = $this->peeled($name, $id); | |
| 274 | + if ($target !== null && isset($objects[$target])) $objects[$id] = 'tag'; | |
| 275 | + } | |
| 276 | + } | |
| 277 | + $this->progress('オブジェクト:'.count($objects)."件\n"); | |
| 278 | + $writer = new PackWriter($this->repo); | |
| 279 | + $writer->write($objects, function(string $bytes): void { | |
| 280 | + $this->out->write($this->sideband ? PktLine::band(1, $bytes, $this->bandMax) : $bytes); | |
| 281 | + $this->out->flush(); | |
| 282 | + }); | |
| 283 | + if ($this->sideband) $this->out->write(PktLine::flush()); | |
| 284 | + $this->out->flush(); | |
| 285 | + } | |
| 286 | + | |
| 287 | + private function progress(string $msg): void { | |
| 288 | + if ($this->sideband && $this->progress) { | |
| 289 | + $this->out->write(PktLine::band(2, str_ends_with($msg, "\n") ? $msg : $msg."\r", $this->bandMax)); | |
| 290 | + $this->out->flush(); | |
| 291 | + } | |
| 292 | + } | |
| 293 | + | |
| 294 | + //////// v2 | |
| 295 | + | |
| 296 | + /** コマンドを1つ処理する(続けるならtrue) */ | |
| 297 | + private function command(PktLine $pkt): bool { | |
| 298 | + $command = null; | |
| 299 | + $caps = []; | |
| 300 | + while (true) { | |
| 301 | + $l = $pkt->read(); | |
| 302 | + if ($l === null) return false; | |
| 303 | + if ($l === PktLine::FLUSH) { | |
| 304 | + if ($command === null) return true; | |
| 305 | + $args = []; | |
| 306 | + break; | |
| 307 | + } | |
| 308 | + if ($l === PktLine::DELIM) { | |
| 309 | + $args = $pkt->readUntilFlush(); | |
| 310 | + break; | |
| 311 | + } | |
| 312 | + if (is_string($l) && str_starts_with($l, 'command=')) $command = substr($l, 8); | |
| 313 | + else if (is_string($l)) $caps[] = $l; | |
| 314 | + } | |
| 315 | + foreach ($caps as $c) { | |
| 316 | + if (str_starts_with($c, 'object-format=') && substr($c, 14) !== $this->repo->hashAlgo) throw new GitException('オブジェクトの形式が違います('.substr($c, 14).')'); | |
| 317 | + } | |
| 318 | + match ($command) { | |
| 319 | + 'ls-refs' => $this->lsRefs($args), | |
| 320 | + 'fetch' => $this->fetchV2($args), | |
| 321 | + default => throw new GitException("未対応のコマンド:{$command}"), | |
| 322 | + }; | |
| 323 | + return true; | |
| 324 | + } | |
| 325 | + | |
| 326 | + private function lsRefs(array $args): void { | |
| 327 | + $symrefs = in_array('symrefs', $args, true); | |
| 328 | + $peel = in_array('peel', $args, true); | |
| 329 | + $unborn = in_array('unborn', $args, true); | |
| 330 | + $prefixes = []; | |
| 331 | + foreach ($args as $a) if (str_starts_with($a, 'ref-prefix ')) $prefixes[] = substr($a, 11); | |
| 332 | + $match = function(string $name) use ($prefixes): bool { | |
| 333 | + if (empty($prefixes)) return true; | |
| 334 | + foreach ($prefixes as $p) if (str_starts_with($name, $p)) return true; | |
| 335 | + return false; | |
| 336 | + }; | |
| 337 | + $s = ''; | |
| 338 | + if ($match('HEAD')) { | |
| 339 | + $head = $this->repo->refs->resolve('HEAD'); | |
| 340 | + $target = $this->repo->headRef(); | |
| 341 | + $sym = $symrefs && $target !== null ? " symref-target:{$target}" : ''; | |
| 342 | + if ($head !== null) $s .= PktLine::encode("{$head} HEAD{$sym}\n"); | |
| 343 | + else if ($unborn && $target !== null) $s .= PktLine::encode("unborn HEAD{$sym}\n"); | |
| 344 | + } | |
| 345 | + foreach ($this->repo->refs->all() as $name => $id) { | |
| 346 | + if (!$match($name)) continue; | |
| 347 | + $line = "{$id} {$name}"; | |
| 348 | + if ($peel && str_starts_with($name, 'refs/tags/')) { | |
| 349 | + $p = $this->peeled($name, $id); | |
| 350 | + if ($p !== null && $p !== $id) $line .= " peeled:{$p}"; | |
| 351 | + } | |
| 352 | + $s .= PktLine::encode($line."\n"); | |
| 353 | + } | |
| 354 | + $this->out->write($s.PktLine::flush()); | |
| 355 | + $this->out->flush(); | |
| 356 | + } | |
| 357 | + | |
| 358 | + private function fetchV2(array $args): void { | |
| 359 | + $wants = []; | |
| 360 | + $haves = []; | |
| 361 | + $clientShallow = []; | |
| 362 | + $depth = 0; | |
| 363 | + $relative = false; | |
| 364 | + $done = false; | |
| 365 | + $includeTag = false; | |
| 366 | + foreach ($args as $a) { | |
| 367 | + if (preg_match('/^want ([0-9a-f]+)$/', $a, $m)) { | |
| 368 | + $this->checkWant($m[1]); | |
| 369 | + $wants[$m[1]] = true; | |
| 370 | + } else if (preg_match('/^have ([0-9a-f]+)$/', $a, $m)) { | |
| 371 | + if ($this->repo->isId($m[1]) && $this->repo->objects->exists($m[1])) $haves[$m[1]] = true; | |
| 372 | + } else if (preg_match('/^shallow ([0-9a-f]+)$/', $a, $m)) { | |
| 373 | + $clientShallow[] = $m[1]; | |
| 374 | + } else if (preg_match('/^deepen (\d+)$/', $a, $m)) { | |
| 375 | + $depth = (int)$m[1]; | |
| 376 | + } else if ($a === 'deepen-relative') { | |
| 377 | + $relative = true; | |
| 378 | + } else if ($a === 'done') { | |
| 379 | + $done = true; | |
| 380 | + } else if ($a === 'include-tag') { | |
| 381 | + $includeTag = true; | |
| 382 | + } else if ($a === 'no-progress') { | |
| 383 | + $this->progress = false; | |
| 384 | + } else if (preg_match('/^(deepen-since|deepen-not|filter|want-ref|packfile-uris) /', $a, $m)) { | |
| 385 | + throw new GitException("未対応:{$m[1]}"); | |
| 386 | + } | |
| 387 | + } | |
| 388 | + if (!$done) { | |
| 389 | + // 共通のコミットを知らせる(readyは送らないので、相手がdoneを送る) | |
| 390 | + $s = PktLine::encode("acknowledgments\n"); | |
| 391 | + if (empty($haves)) $s .= PktLine::encode("NAK\n"); | |
| 392 | + foreach (array_keys($haves) as $h) $s .= PktLine::encode("ACK {$h}\n"); | |
| 393 | + $this->out->write($s.PktLine::flush()); | |
| 394 | + $this->out->flush(); | |
| 395 | + return; | |
| 396 | + } | |
| 397 | + $commits = null; | |
| 398 | + if ($depth > 0) { | |
| 399 | + [$commits, $shallow] = $this->shallow(array_keys($wants), $depth, $clientShallow, $relative); | |
| 400 | + $s = PktLine::encode("shallow-info\n"); | |
| 401 | + foreach ($shallow['shallow'] as $id) $s .= PktLine::encode("shallow {$id}\n"); | |
| 402 | + foreach ($shallow['unshallow'] as $id) $s .= PktLine::encode("unshallow {$id}\n"); | |
| 403 | + $this->out->write($s.PktLine::delim()); | |
| 404 | + } | |
| 405 | + $this->out->write(PktLine::encode("packfile\n")); | |
| 406 | + $this->sideband = true; | |
| 407 | + $this->bandMax = PktLine::MAX; | |
| 408 | + $this->sendPack(array_keys($wants), array_keys($haves), $includeTag, $commits, $clientShallow, $depth > 0); | |
| 409 | + } | |
| 410 | +} |
| @@ -0,0 +1,320 @@ | ||
| 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\Git; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * コミットの履歴を辿る(log、到達可能なオブジェクト、共通の祖先) | |
| 42 | + */ | |
| 43 | +final class Walk { | |
| 44 | + /** @var array ID => [親の配列, コミット時刻] */ | |
| 45 | + private array $info = []; | |
| 46 | + /** @var array クライアントのshallowのコミット(親を辿らない) */ | |
| 47 | + private array $grafts = []; | |
| 48 | + | |
| 49 | + public function __construct(private Repository $repo) {} | |
| 50 | + | |
| 51 | + /** shallowのコミット(そこで履歴が終わるとする) */ | |
| 52 | + public function setGrafts(array $ids): void { | |
| 53 | + $this->grafts = array_fill_keys($ids, true); | |
| 54 | + } | |
| 55 | + | |
| 56 | + /** コミットの親と時刻(キャッシュ) */ | |
| 57 | + private function node(string $id): array { | |
| 58 | + if (!isset($this->info[$id])) { | |
| 59 | + $c = $this->repo->commit($id); | |
| 60 | + $this->info[$id] = [isset($this->grafts[$id]) ? [] : $c->parents, $c->committer?->time ?? 0]; | |
| 61 | + } | |
| 62 | + return $this->info[$id]; | |
| 63 | + } | |
| 64 | + | |
| 65 | + /** | |
| 66 | + * 履歴(新しい順、git logと同じ) | |
| 67 | + * | |
| 68 | + * @param array $from リビジョン | |
| 69 | + * @param array $opt path、limit、skip、not、firstParent | |
| 70 | + */ | |
| 71 | + public function log(array $from, array $opt = []): \Generator { | |
| 72 | + $path = trim((string)($opt['path'] ?? ''), '/'); | |
| 73 | + $limit = isset($opt['limit']) ? (int)$opt['limit'] : PHP_INT_MAX; | |
| 74 | + $skip = (int)($opt['skip'] ?? 0); | |
| 75 | + $firstParent = !empty($opt['firstParent']); | |
| 76 | + $hide = []; | |
| 77 | + if (!empty($opt['not'])) { | |
| 78 | + $nots = []; | |
| 79 | + foreach ((array)$opt['not'] as $r) { | |
| 80 | + $id = $this->repo->resolve($r); | |
| 81 | + $id = $id === null ? null : $this->repo->peel($id, 'commit'); | |
| 82 | + if ($id !== null) $nots[] = $id; | |
| 83 | + } | |
| 84 | + if (!empty($nots)) $hide = array_fill_keys($this->ancestors($nots), true); | |
| 85 | + } | |
| 86 | + $queue = new \SplPriorityQueue(); | |
| 87 | + $seq = PHP_INT_MAX; | |
| 88 | + $seen = []; | |
| 89 | + foreach ($from as $r) { | |
| 90 | + $id = $this->repo->resolve($r); | |
| 91 | + $id = $id === null ? null : $this->repo->peel($id, 'commit'); | |
| 92 | + if ($id === null) throw new GitException("リビジョンが有りません:{$r}"); | |
| 93 | + if (isset($seen[$id]) || isset($hide[$id])) continue; | |
| 94 | + $seen[$id] = true; | |
| 95 | + $queue->insert($id, [$this->node($id)[1], $seq--]); | |
| 96 | + } | |
| 97 | + $n = 0; | |
| 98 | + while (!$queue->isEmpty() && $limit > 0) { | |
| 99 | + $id = $queue->extract(); | |
| 100 | + $c = $this->repo->commit($id); | |
| 101 | + $parents = $firstParent ? array_slice($c->parents, 0, 1) : $c->parents; | |
| 102 | + $show = true; | |
| 103 | + if ($path !== '') { | |
| 104 | + // パスが親と同じなら表示しない(同じ親だけを辿る) | |
| 105 | + $mine = $this->repo->entry($c->tree, $path)[2] ?? null; | |
| 106 | + $same = null; | |
| 107 | + foreach ($parents as $p) { | |
| 108 | + if ($this->repo->objects->exists($p) && ($this->repo->entry($p, $path)[2] ?? null) === $mine) { | |
| 109 | + $same = $p; | |
| 110 | + break; | |
| 111 | + } | |
| 112 | + } | |
| 113 | + if ($same !== null) { | |
| 114 | + $show = false; | |
| 115 | + $parents = [$same]; | |
| 116 | + } else if (empty($parents) && $mine === null) { | |
| 117 | + $show = false; | |
| 118 | + } | |
| 119 | + } | |
| 120 | + foreach ($parents as $p) { | |
| 121 | + if (isset($seen[$p]) || isset($hide[$p]) || !$this->repo->objects->exists($p)) continue; | |
| 122 | + $seen[$p] = true; | |
| 123 | + $queue->insert($p, [$this->node($p)[1], $seq--]); | |
| 124 | + } | |
| 125 | + if (!$show) continue; | |
| 126 | + if ($n++ < $skip) continue; | |
| 127 | + $limit--; | |
| 128 | + yield $c; | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + /** 全部の祖先(自分を含む) */ | |
| 133 | + public function ancestors(array $ids): array { | |
| 134 | + $seen = []; | |
| 135 | + $stack = $ids; | |
| 136 | + while (!empty($stack)) { | |
| 137 | + $id = array_pop($stack); | |
| 138 | + if (isset($seen[$id]) || !$this->repo->objects->exists($id)) continue; | |
| 139 | + $seen[$id] = true; | |
| 140 | + foreach ($this->node($id)[0] as $p) if (!isset($seen[$p])) $stack[] = $p; | |
| 141 | + } | |
| 142 | + return array_keys($seen); | |
| 143 | + } | |
| 144 | + | |
| 145 | + /** aがbの祖先か(同じも含む) */ | |
| 146 | + public function isAncestor(string $a, string $b): bool { | |
| 147 | + if ($a === $b) return true; | |
| 148 | + $target = $this->node($a)[1]; | |
| 149 | + $seen = []; | |
| 150 | + $stack = [$b]; | |
| 151 | + while (!empty($stack)) { | |
| 152 | + $id = array_pop($stack); | |
| 153 | + if ($id === $a) return true; | |
| 154 | + if (isset($seen[$id]) || !$this->repo->objects->exists($id)) continue; | |
| 155 | + $seen[$id] = true; | |
| 156 | + [$parents, $time] = $this->node($id); | |
| 157 | + // 時刻が十分古ければ辿らない(1日のずれを許す) | |
| 158 | + if ($time < $target - 86400) continue; | |
| 159 | + foreach ($parents as $p) $stack[] = $p; | |
| 160 | + } | |
| 161 | + return false; | |
| 162 | + } | |
| 163 | + | |
| 164 | + /** 共通の祖先(一番新しい物、無ければnull) */ | |
| 165 | + public function mergeBase(string $a, string $b): ?string { | |
| 166 | + $anc = array_fill_keys($this->ancestors([$a]), true); | |
| 167 | + $best = null; | |
| 168 | + foreach ($this->ancestors([$b]) as $id) { | |
| 169 | + if (!isset($anc[$id])) continue; | |
| 170 | + if ($best === null || $this->isAncestor($best, $id)) $best = $id; | |
| 171 | + } | |
| 172 | + return $best; | |
| 173 | + } | |
| 174 | + | |
| 175 | + /** | |
| 176 | + * wantsから到達出来て、havesから到達出来ないコミット | |
| 177 | + * | |
| 178 | + * @return array [コミットの配列(新しい順), 境界のコミット(havesから到達出来る親)] | |
| 179 | + */ | |
| 180 | + public function commitsBetween(array $wants, array $haves): array { | |
| 181 | + $flag = []; | |
| 182 | + $queue = new \SplPriorityQueue(); | |
| 183 | + $seq = PHP_INT_MAX; | |
| 184 | + $interesting = 0; | |
| 185 | + foreach ($haves as $id) { | |
| 186 | + if (isset($flag[$id]) || !$this->repo->objects->exists($id)) continue; | |
| 187 | + $flag[$id] = 'u'; | |
| 188 | + $queue->insert($id, [$this->node($id)[1], $seq--]); | |
| 189 | + } | |
| 190 | + foreach ($wants as $id) { | |
| 191 | + if (isset($flag[$id])) continue; | |
| 192 | + $flag[$id] = 'i'; | |
| 193 | + $interesting++; | |
| 194 | + $queue->insert($id, [$this->node($id)[1], $seq--]); | |
| 195 | + } | |
| 196 | + $order = []; | |
| 197 | + $slop = 5; | |
| 198 | + while (!$queue->isEmpty()) { | |
| 199 | + if ($interesting === 0 && --$slop < 0) break; | |
| 200 | + $id = $queue->extract(); | |
| 201 | + $f = $flag[$id]; | |
| 202 | + if ($f === 'i') { | |
| 203 | + $interesting--; | |
| 204 | + $order[] = $id; | |
| 205 | + } | |
| 206 | + foreach ($this->node($id)[0] as $p) { | |
| 207 | + if (!isset($flag[$p])) { | |
| 208 | + if (!$this->repo->objects->exists($p)) continue; | |
| 209 | + $flag[$p] = $f; | |
| 210 | + if ($f === 'i') $interesting++; | |
| 211 | + $queue->insert($p, [$this->node($p)[1], $seq--]); | |
| 212 | + } else if ($f === 'u' && $flag[$p] === 'i') { | |
| 213 | + // 既に見たコミットも除く(時刻がずれている場合) | |
| 214 | + $this->markUninteresting($p, $flag, $interesting); | |
| 215 | + } | |
| 216 | + } | |
| 217 | + } | |
| 218 | + $commits = array_values(array_filter($order, fn($id) => $flag[$id] === 'i')); | |
| 219 | + $boundary = []; | |
| 220 | + foreach ($commits as $id) foreach ($this->node($id)[0] as $p) if (($flag[$p] ?? null) === 'u') $boundary[$p] = true; | |
| 221 | + return [$commits, array_keys($boundary)]; | |
| 222 | + } | |
| 223 | + | |
| 224 | + private function markUninteresting(string $id, array &$flag, int &$interesting): void { | |
| 225 | + $stack = [$id]; | |
| 226 | + while (!empty($stack)) { | |
| 227 | + $c = array_pop($stack); | |
| 228 | + if (($flag[$c] ?? null) !== 'i') continue; | |
| 229 | + $flag[$c] = 'u'; | |
| 230 | + $interesting--; | |
| 231 | + if (isset($this->info[$c])) foreach ($this->info[$c][0] as $p) $stack[] = $p; | |
| 232 | + } | |
| 233 | + if ($interesting < 0) $interesting = 0; | |
| 234 | + } | |
| 235 | + | |
| 236 | + /** | |
| 237 | + * 送るオブジェクト(wantsから到達出来て、havesから到達出来ない物) | |
| 238 | + * | |
| 239 | + * @param array $wants 欲しいID(コミット、タグ等) | |
| 240 | + * @param array $haves 相手が持っているコミット | |
| 241 | + * @param array|null $commits 送るコミット(深さの制限等で決めた場合) | |
| 242 | + * @return array ID => 種類(コミット、ツリー・ブロブ、タグの順) | |
| 243 | + */ | |
| 244 | + public function objects(array $wants, array $haves, ?array $commits = null, ?array $boundary = null): array { | |
| 245 | + $tags = []; | |
| 246 | + $wantCommits = []; | |
| 247 | + $extra = []; | |
| 248 | + foreach ($wants as $id) { | |
| 249 | + $info = $this->repo->objects->info($id); | |
| 250 | + if ($info === null) throw new GitException("オブジェクトが有りません:{$id}"); | |
| 251 | + while ($info[0] === 'tag') { | |
| 252 | + $tags[$id] = 'tag'; | |
| 253 | + $id = $this->repo->tag($id)->object; | |
| 254 | + $info = $this->repo->objects->info($id); | |
| 255 | + if ($info === null) throw new GitException("オブジェクトが有りません:{$id}"); | |
| 256 | + } | |
| 257 | + if ($info[0] === 'commit') $wantCommits[] = $id; | |
| 258 | + else $extra[$id] = $info[0]; | |
| 259 | + } | |
| 260 | + if ($commits === null) [$commits, $boundary] = $this->commitsBetween($wantCommits, $haves); | |
| 261 | + // 境界のコミットのツリーは相手が持っている | |
| 262 | + $have = []; | |
| 263 | + foreach ($boundary ?? [] as $b) $this->markTree($this->repo->commit($b)->tree, $have); | |
| 264 | + $out = []; | |
| 265 | + foreach ($commits as $id) $out[$id] = 'commit'; | |
| 266 | + foreach ($commits as $id) $this->addTree($this->repo->commit($id)->tree, $have, $out); | |
| 267 | + foreach ($extra as $id => $type) { | |
| 268 | + if ($type === 'tree') $this->addTree($id, $have, $out); | |
| 269 | + else if (!isset($have[$id])) $out[$id] = $type; | |
| 270 | + } | |
| 271 | + return $out + $tags; | |
| 272 | + } | |
| 273 | + | |
| 274 | + private function markTree(string $tree, array &$have): void { | |
| 275 | + if (isset($have[$tree])) return; | |
| 276 | + $have[$tree] = true; | |
| 277 | + foreach ($this->repo->tree($tree)->entries as [$mode, , $id]) { | |
| 278 | + if ($mode === Tree::DIR) $this->markTree($id, $have); | |
| 279 | + else if ($mode !== Tree::SUBMODULE) $have[$id] = true; | |
| 280 | + } | |
| 281 | + } | |
| 282 | + | |
| 283 | + private function addTree(string $tree, array &$have, array &$out): void { | |
| 284 | + if (isset($have[$tree]) || isset($out[$tree])) return; | |
| 285 | + $out[$tree] = 'tree'; | |
| 286 | + foreach ($this->repo->tree($tree)->entries as [$mode, , $id]) { | |
| 287 | + if ($mode === Tree::DIR) $this->addTree($id, $have, $out); | |
| 288 | + else if ($mode !== Tree::SUBMODULE && !isset($have[$id])) $out[$id] = 'blob'; | |
| 289 | + } | |
| 290 | + } | |
| 291 | + | |
| 292 | + /** | |
| 293 | + * 深さを制限したコミット(git clone --depth) | |
| 294 | + * | |
| 295 | + * @return array [コミットの配列, 新しいshallowのコミット] | |
| 296 | + */ | |
| 297 | + public function deepen(array $wants, int $depth): array { | |
| 298 | + $level = []; | |
| 299 | + $queue = []; | |
| 300 | + foreach ($wants as $id) { | |
| 301 | + $level[$id] = 1; | |
| 302 | + $queue[] = $id; | |
| 303 | + } | |
| 304 | + $shallow = []; | |
| 305 | + for ($i = 0; $i < count($queue); $i++) { | |
| 306 | + $id = $queue[$i]; | |
| 307 | + $parents = $this->node($id)[0]; | |
| 308 | + if ($level[$id] >= $depth) { | |
| 309 | + if (!empty($parents)) $shallow[$id] = true; | |
| 310 | + continue; | |
| 311 | + } | |
| 312 | + foreach ($parents as $p) { | |
| 313 | + if (isset($level[$p]) || !$this->repo->objects->exists($p)) continue; | |
| 314 | + $level[$p] = $level[$id] + 1; | |
| 315 | + $queue[] = $p; | |
| 316 | + } | |
| 317 | + } | |
| 318 | + return [$queue, array_keys($shallow)]; | |
| 319 | + } | |
| 320 | +} |
| @@ -0,0 +1,61 @@ | ||
| 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 | +// テスト用のGitのHTTPサーバーのルーター(php -S 127.0.0.1:ポート git-http.php、LibGitが起動する) | |
| 39 | +// LB_GIT_ROOTのディレクトリのリポジトリ(/名前.git/…)。プッシュも許す。無いリポジトリはプッシュの時に作る。 | |
| 40 | + | |
| 41 | +require_once __DIR__.'/../../../../autoload.php'; | |
| 42 | +if (!defined('ROOT')) define('ROOT', realpath(__DIR__.'/../../../..')); | |
| 43 | + | |
| 44 | +use Std\Lib\Git\Http; | |
| 45 | +use Std\Lib\Git\Repository; | |
| 46 | + | |
| 47 | +$root = (string)getenv('LB_GIT_ROOT'); | |
| 48 | +$path = (string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); | |
| 49 | +if (!preg_match('#^/([A-Za-z0-9._-]+\.git)/(info/refs|git-upload-pack|git-receive-pack)$#', $path, $m) || $root === '') { | |
| 50 | + http_response_code(404); | |
| 51 | + return; | |
| 52 | +} | |
| 53 | +$dir = $root.'/'.$m[1]; | |
| 54 | +if (!is_dir($dir)) { | |
| 55 | + if (($_GET['service'] ?? '') !== 'git-receive-pack') { | |
| 56 | + http_response_code(404); | |
| 57 | + return; | |
| 58 | + } | |
| 59 | + Repository::init($dir); | |
| 60 | +} | |
| 61 | +Http::handle(Repository::open($dir), $m[2], ['push' => true]); |
| @@ -0,0 +1,54 @@ | ||
| 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 | +// テスト用の偽物のssh(GIT_SSH_COMMAND、LibGitが使う)。最後の引数のコマンドをStd\Lib\Git\Sshで処理する | |
| 39 | +// LB_GIT_ROOTのディレクトリのリポジトリ。プッシュも許す。 | |
| 40 | + | |
| 41 | +require_once __DIR__.'/../../../../autoload.php'; | |
| 42 | +if (!defined('ROOT')) define('ROOT', realpath(__DIR__.'/../../../..')); | |
| 43 | + | |
| 44 | +use Std\Lib\Git\Repository; | |
| 45 | +use Std\Lib\Git\Ssh; | |
| 46 | + | |
| 47 | +// gitがsshの種類を調べる(ssh -G) | |
| 48 | +if (($argv[1] ?? '') === '-G') exit(0); | |
| 49 | +$root = (string)getenv('LB_GIT_ROOT'); | |
| 50 | +exit(Ssh::handle((string)end($argv), function(string $path, bool $write) use ($root): ?Repository { | |
| 51 | + $dir = $root.'/'.$path; | |
| 52 | + if (!is_dir($dir)) return null; | |
| 53 | + return Repository::open($dir); | |
| 54 | +})); |
| @@ -0,0 +1,356 @@ | ||
| 1 | +{ | |
| 2 | + "sha1": { | |
| 3 | + "files": { | |
| 4 | + "HEAD": "cmVmOiByZWZzL2hlYWRzL21haW4K", | |
| 5 | + "config": "W2NvcmVdCglyZXBvc2l0b3J5Zm9ybWF0dmVyc2lvbiA9IDAKCWZpbGVtb2RlID0gdHJ1ZQoJYmFyZSA9IHRydWUKW3JlbW90ZSAib3JpZ2luIl0KCXVybCA9IC90bXAvY2xhdWRlLTAvZngvdzEK", | |
| 6 | + "objects/05/3ee154971e7de7b05ae4abf81f34fe7c458f1f": "eAFLyslPUjC3ZCjJL+DKycxLNQSTRlw+nn6uxmC2CZg0BZNmYNIcTFqA1ViC2YYGXGmpiSWlRalciWklqUVcAJAPGtE=", | |
| 7 | + "objects/34/804d6f5407dde6d02c08b8efac80b6fd29504d": "eAErKUpNVTA0M2MwNDAwMzFRSNQrqShhYLV7GDJdrvb5hqgnq3/Im/yrce2XNzEwMDBQSMksYiguenRdaaGGxs1nWixb+7+HH7AK3mloBJbOyczLZugVOf2zefOva83iMlMkZvbOzzDhUIYan5eflwO2If/RKhubCakO20VKxO8VaV9nbvrcZGhgYG5qqlBUmmekV5zB4JjMnPcrda+X27xSpXTPfsZXZktjAI1bQng=", | |
| 8 | + "objects/d4/ed5fd77377257ebdc90aeaf756fc9245b4e4e7": "eAGVzsEJwjAUAFDPmSJ3QX7TJE1AxFWSn18VrZUa0at6URB0DFF0ACkuUwRvriC4gQM8eFgWxShykYpWrIh4Kg3IoHMlIQuBdACBYLyh3KEBr/MgrAIZ2MxVNI3cKe0TMtoJiUL4gM56qw1lPrcyhVRJtIkymrlFHJYVf1+P7/PldTvx7nyxdOOyTytXzCbUwbLo8SQDAJFoAN4GC8Dw94v0v2QDbNb31/PwqXfN9tps62bz+NR79gVyPFcw", | |
| 9 | + "objects/pack/pack-e2d2c7fef4c5ec892b78c19b2d7eed1f436f370c.idx": "/3RPYwAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAIAAAACAAAAAgAAAAIAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAQAAAAEAAAABAAAAAUAAAAFAAAABQAAAAUAAAAGAAAABwAAAAgAAAAIAAAACAAAAAgAAAAIAAAACAAAAAgAAAAIAAAACQAAAAkAAAAJAAAACQAAAAkAAAAJAAAACQAAAAkAAAAJAAAACQAAAAkAAAAJAAAACgAAAAoAAAAKAAAACgAAAAoAAAAKAAAACgAAAAoAAAALAAAACwAAAAsAAAALAAAACwAAAAsAAAALAAAACwAAAAsAAAALAAAACwAAAAsAAAALAAAACwAAAAsAAAAMAAAADQAAAA0AAAAOAAAADgAAAA4AAAAOAAAADwAAAA8AAAAPAAAADwAAAA8AAAAPAAAADwAAAA8AAAAQAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAASAAAAEgAAABIAAAASAAAAEgAAABIAAAASAAAAEgAAABIAAAASAAAAEgAAABIAAAASAAAAEgAAABIAAAASAAAAEgAAABIAAAASAAAAEgAAABIAAAASAAAAEgAAABIAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABQAAAAUAAAAFAAAABQAAAAUAAAAFAAAABUAAAAVAAAAFQAAABUAAAAVAAAAFQAAABUAAAAVAAAAFQAAABUAAAAVAAAAFQAAABUAAAAVAAAAFQAAABUAAAAVAAAAFQAAABYAAAAWAAAAFgAAABYAAAAWAAAAFgAAABYAAAAWAAAAFgAAABYAAAAWAAAAFgAAABYAAAAWAAAAFgAAABYAAAAWAAAAFgAAABcAAAAXAAAAFwAAABcAAAAXAAAAFwAAABcAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYBW4azdJT2DTkAHZn/hVURjL2F/0gy7TYkiTh7XJLf+r1xPRHniUhKiSoSwRX25n3QkzJS7Be1L1SqEFHOCypR5WYd/z+Op5VBhtHmEmBxYg7R76xEv1tDAwORrSBoaIDPuKrZT98X9X98RBDn7tLB75eaiCPzbBrQIN2api30+Xo4nbw8Jwnuh7+TVxBYwNu+mW9SkaedSJnSY8B6jalXEkSiFSxqVVFLJj1J5xpThJDaziLVYNhQxfFN+ZkOX3kALee59+z4dBdMGx9IHnLQVTOUPpCq9qMkS1URGxzduD3JInzNpMwfx3R0e6yRCO2bYZujYc6OtMvb6Sbv50Kjkqye9Vv4qo8PJBlQLcUdBfecivXA4LzgnNy4tcioSgo2eYqBLWP91fAOlO5e3afwk/jCI0qhwAt8XZXtudActZ8wzrPC312K8LmnhEv6hv5VXd+140Uy/mDs/rWgxcclBiZjZ9oNAgjpWsehqJMIr3Km5aOe/lDA1TJFYa5Yhw8fUGGXT1uB+WfFr+UTsiKEL9xxvRPIaaUdh+ESpMtaQBEHg+n0W/qzTvxWR51rvCYBq1ZJrGNJ8Pjul3DC3lsWnLWwZQri5cLJ4BdTuq5QNchvWiJnx/RCA0oR3ZkrxnxmEFxKk1fC70HSM25AwXHXtb/7Ydm6qXr5tr3J9UxflBvn/7r22UxajArooWIEwqR8xAFvg3RaDEe9gkSWhvCNTngBH2O/OpUBjZXIA+0WAs1o90fLfy9JDLYfzWKtia7AAAGLwAACGkAAAeQAAAEHwAABWUAAAIPAAAIPQAAB8kAAAgrAAAHSgAABSwAAAT2AAAIHQAAB7UAAATKAAAGgAAAB4IAAAenAAAADAAAAM8AAAOMAAABeAAAAskAAAfl4tLH/vTF7IkreMGbLX7tH0NvNwytgrTJHtKQHAA2btOVAJNwGnF5sg==", | |
| 10 | + "objects/pack/pack-e2d2c7fef4c5ec892b78c19b2d7eed1f436f370c.pack": "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", | |
| 11 | + "objects/pack/pack-e2d2c7fef4c5ec892b78c19b2d7eed1f436f370c.rev": "UklEWAAAAAEAAAABAAAAEgAAABMAAAAVAAAABQAAABYAAAAUAAAAAwAAAA4AAAALAAAACgAAAAQAAAAAAAAADwAAAAkAAAAQAAAAAgAAABEAAAANAAAABwAAABcAAAAMAAAACAAAAAYAAAAB4tLH/vTF7IkreMGbLX7tH0NvNwyekydjgm48MVFwkA4cnHCRUd15hQ==", | |
| 12 | + "packed-refs": "IyBwYWNrLXJlZnMgd2l0aDogcGVlbGVkIGZ1bGx5LXBlZWxlZCBzb3J0ZWQgCjNmN2M1ZmQ1ZmRmMTEwNDM5ZmJiNGIwN2JlNWU2YTIwOGZjZGIwNmIgcmVmcy9oZWFkcy9mZWF0dXJlCmQ0ZWQ1ZmQ3NzM3NzI1N2ViZGM5MGFlYWY3NTZmYzkyNDViNGU0ZTcgcmVmcy9oZWFkcy9tYWluCmE1NmIxZTg2YTI0YzIyYmRjYTliOTY4ZTdiZjk0MzAzNTRjOTE1ODYgcmVmcy90YWdzL2xpZ2h0CmQxNmZlYWNkM2JmMTU5MWU3NWFlZjA5ODA2YWQ1OTI2YjE4ZDI3YzMgcmVmcy90YWdzL3YxLjAKXmI5NjIxYzNjN2Q0MTg2NWQzZDZlMDdlNTlmMTZiZjk0NGVjODhhMTAK" | |
| 13 | + }, | |
| 14 | + "expect": { | |
| 15 | + "log": [ | |
| 16 | + [ | |
| 17 | + "d4ed5fd77377257ebdc90aeaf756fc9245b4e4e7", | |
| 18 | + "gcの後(ルーズ)" | |
| 19 | + ], | |
| 20 | + [ | |
| 21 | + "a56b1e86a24c22bdca9b968e7bf9430354c91586", | |
| 22 | + "マージ" | |
| 23 | + ], | |
| 24 | + [ | |
| 25 | + "b9621c3c7d41865d3d6e07e59f16bf944ec88a10", | |
| 26 | + "mainの変更" | |
| 27 | + ], | |
| 28 | + [ | |
| 29 | + "3f7c5fd5fdf110439fbb4b07be5e6a208fcdb06b", | |
| 30 | + "feature: 追加と名前の変更" | |
| 31 | + ], | |
| 32 | + [ | |
| 33 | + "e3ba5dc30b796c5a72d6c1942b8b970b27805d4e", | |
| 34 | + "二番目" | |
| 35 | + ], | |
| 36 | + [ | |
| 37 | + "bf71c6f44f21a694761f844a932d6900441e0fa7", | |
| 38 | + "最初のコミット" | |
| 39 | + ] | |
| 40 | + ], | |
| 41 | + "logPath": [ | |
| 42 | + "d4ed5fd77377257ebdc90aeaf756fc9245b4e4e7", | |
| 43 | + "a56b1e86a24c22bdca9b968e7bf9430354c91586", | |
| 44 | + "b9621c3c7d41865d3d6e07e59f16bf944ec88a10", | |
| 45 | + "3f7c5fd5fdf110439fbb4b07be5e6a208fcdb06b", | |
| 46 | + "e3ba5dc30b796c5a72d6c1942b8b970b27805d4e", | |
| 47 | + "bf71c6f44f21a694761f844a932d6900441e0fa7" | |
| 48 | + ], | |
| 49 | + "revs": { | |
| 50 | + "HEAD": "d4ed5fd77377257ebdc90aeaf756fc9245b4e4e7", | |
| 51 | + "main~1": "a56b1e86a24c22bdca9b968e7bf9430354c91586", | |
| 52 | + "main~1^2": "3f7c5fd5fdf110439fbb4b07be5e6a208fcdb06b", | |
| 53 | + "main^^": "b9621c3c7d41865d3d6e07e59f16bf944ec88a10", | |
| 54 | + "v1.0": "d16feacd3bf1591e75aef09806ad5926b18d27c3", | |
| 55 | + "v1.0^{}": "b9621c3c7d41865d3d6e07e59f16bf944ec88a10", | |
| 56 | + "v1.0^{tree}": "5d306c7d2079cb4154ce50fa42abda8c912d5444", | |
| 57 | + "HEAD:a.txt": "053ee154971e7de7b05ae4abf81f34fe7c458f1f", | |
| 58 | + "main~1:dir/サブ/日本語.txt": "24a84b0457db99f7424cc94bb05ed4bd52a84147", | |
| 59 | + "light": "a56b1e86a24c22bdca9b968e7bf9430354c91586", | |
| 60 | + "feature": "3f7c5fd5fdf110439fbb4b07be5e6a208fcdb06b", | |
| 61 | + "refs/heads/feature~1": "e3ba5dc30b796c5a72d6c1942b8b970b27805d4e" | |
| 62 | + }, | |
| 63 | + "refs": { | |
| 64 | + "refs/heads/feature": "3f7c5fd5fdf110439fbb4b07be5e6a208fcdb06b", | |
| 65 | + "refs/heads/main": "d4ed5fd77377257ebdc90aeaf756fc9245b4e4e7", | |
| 66 | + "refs/tags/light": "a56b1e86a24c22bdca9b968e7bf9430354c91586", | |
| 67 | + "refs/tags/v1.0": "d16feacd3bf1591e75aef09806ad5926b18d27c3" | |
| 68 | + }, | |
| 69 | + "tree": [ | |
| 70 | + [ | |
| 71 | + "100644", | |
| 72 | + "blob", | |
| 73 | + "053ee154971e7de7b05ae4abf81f34fe7c458f1f", | |
| 74 | + "a.txt" | |
| 75 | + ], | |
| 76 | + [ | |
| 77 | + "040000", | |
| 78 | + "tree", | |
| 79 | + "7372e2d722a12828d9e62a04b58ff757c03a53b9", | |
| 80 | + "dir" | |
| 81 | + ], | |
| 82 | + [ | |
| 83 | + "040000", | |
| 84 | + "tree", | |
| 85 | + "6c7376e0f72489f33693307f1dd1d1eeb24423b6", | |
| 86 | + "dir/サブ" | |
| 87 | + ], | |
| 88 | + [ | |
| 89 | + "100644", | |
| 90 | + "blob", | |
| 91 | + "24a84b0457db99f7424cc94bb05ed4bd52a84147", | |
| 92 | + "dir/サブ/日本語.txt" | |
| 93 | + ], | |
| 94 | + [ | |
| 95 | + "120000", | |
| 96 | + "blob", | |
| 97 | + "8d14cbf983b3fad683171c9418998d9f68340823", | |
| 98 | + "link" | |
| 99 | + ], | |
| 100 | + [ | |
| 101 | + "100644", | |
| 102 | + "blob", | |
| 103 | + "6fe2aa3c3c906540b7147417de722bd70382f382", | |
| 104 | + "nonl.txt" | |
| 105 | + ], | |
| 106 | + [ | |
| 107 | + "100755", | |
| 108 | + "blob", | |
| 109 | + "4163036efa65bd4a469e752267498f01ea36a55c", | |
| 110 | + "run2.sh" | |
| 111 | + ] | |
| 112 | + ], | |
| 113 | + "diffs": { | |
| 114 | + "main~1": "diff --git a/a.txt b/a.txt\nindex 7cc33ac..5583614 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -9,3 +9,4 @@ line7\n line8\n LINE9\n line10\n+feature\ndiff --git a/run.sh b/run2.sh\nsimilarity index 100%\nrename from run.sh\nrename to run2.sh\n", | |
| 115 | + "feature": "diff --git a/a.txt b/a.txt\nindex 6d866e8..eab940d 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -8,3 +8,4 @@ line7\n line8\n LINE9\n line10\n+feature\ndiff --git a/run.sh b/run2.sh\nsimilarity index 100%\nrename from run.sh\nrename to run2.sh\n", | |
| 116 | + "main~2": "diff --git a/a.txt b/a.txt\nindex 6d866e8..7cc33ac 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -1,3 +1,4 @@\n+top\n line1\n line2\n LINE3\ndiff --git a/data.bin b/data.bin\ndeleted file mode 100644\nindex 4912885..0000000\nBinary files a/data.bin and /dev/null differ\n", | |
| 117 | + "main~3": "diff --git a/a.txt b/a.txt\nindex 4083766..6d866e8 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -1,10 +1,10 @@\n line1\n line2\n-line3\n+LINE3\n line4\n line5\n line6\n line7\n line8\n-line9\n+LINE9\n line10\ndiff --git a/nonl.txt b/nonl.txt\nindex 20cbb4d..6fe2aa3 100644\n--- a/nonl.txt\n+++ b/nonl.txt\n@@ -1 +1 @@\n-no newline\n\\ No newline at end of file\n+no newline!\n\\ No newline at end of file\n", | |
| 118 | + "main~4": "diff --git a/a.txt b/a.txt\nnew file mode 100644\nindex 0000000..4083766\n--- /dev/null\n+++ b/a.txt\n@@ -0,0 +1,10 @@\n+line1\n+line2\n+line3\n+line4\n+line5\n+line6\n+line7\n+line8\n+line9\n+line10\ndiff --git a/data.bin b/data.bin\nnew file mode 100644\nindex 0000000..4912885\nBinary files /dev/null and b/data.bin differ\ndiff --git \"a/dir/\\343\\202\\265\\343\\203\\226/\\346\\227\\245\\346\\234\\254\\350\\252\\236.txt\" \"b/dir/\\343\\202\\265\\343\\203\\226/\\346\\227\\245\\346\\234\\254\\350\\252\\236.txt\"\nnew file mode 100644\nindex 0000000..24a84b0\n--- /dev/null\n+++ \"b/dir/\\343\\202\\265\\343\\203\\226/\\346\\227\\245\\346\\234\\254\\350\\252\\236.txt\"\n@@ -0,0 +1 @@\n+ファイル\ndiff --git a/link b/link\nnew file mode 120000\nindex 0000000..8d14cbf\n--- /dev/null\n+++ b/link\n@@ -0,0 +1 @@\n+a.txt\n\\ No newline at end of file\ndiff --git a/nonl.txt b/nonl.txt\nnew file mode 100644\nindex 0000000..20cbb4d\n--- /dev/null\n+++ b/nonl.txt\n@@ -0,0 +1 @@\n+no newline\n\\ No newline at end of file\ndiff --git a/run.sh b/run.sh\nnew file mode 100755\nindex 0000000..4163036\n--- /dev/null\n+++ b/run.sh\n@@ -0,0 +1,2 @@\n+#!/bin/sh\n+echo hi\n" | |
| 119 | + }, | |
| 120 | + "blame": [ | |
| 121 | + [ | |
| 122 | + "b9621c3c7d41865d3d6e07e59f16bf944ec88a10", | |
| 123 | + 1 | |
| 124 | + ], | |
| 125 | + [ | |
| 126 | + "bf71c6f44f21a694761f844a932d6900441e0fa7", | |
| 127 | + 1 | |
| 128 | + ], | |
| 129 | + [ | |
| 130 | + "bf71c6f44f21a694761f844a932d6900441e0fa7", | |
| 131 | + 2 | |
| 132 | + ], | |
| 133 | + [ | |
| 134 | + "e3ba5dc30b796c5a72d6c1942b8b970b27805d4e", | |
| 135 | + 3 | |
| 136 | + ], | |
| 137 | + [ | |
| 138 | + "bf71c6f44f21a694761f844a932d6900441e0fa7", | |
| 139 | + 4 | |
| 140 | + ], | |
| 141 | + [ | |
| 142 | + "bf71c6f44f21a694761f844a932d6900441e0fa7", | |
| 143 | + 5 | |
| 144 | + ], | |
| 145 | + [ | |
| 146 | + "bf71c6f44f21a694761f844a932d6900441e0fa7", | |
| 147 | + 6 | |
| 148 | + ], | |
| 149 | + [ | |
| 150 | + "bf71c6f44f21a694761f844a932d6900441e0fa7", | |
| 151 | + 7 | |
| 152 | + ], | |
| 153 | + [ | |
| 154 | + "bf71c6f44f21a694761f844a932d6900441e0fa7", | |
| 155 | + 8 | |
| 156 | + ], | |
| 157 | + [ | |
| 158 | + "e3ba5dc30b796c5a72d6c1942b8b970b27805d4e", | |
| 159 | + 9 | |
| 160 | + ], | |
| 161 | + [ | |
| 162 | + "bf71c6f44f21a694761f844a932d6900441e0fa7", | |
| 163 | + 10 | |
| 164 | + ], | |
| 165 | + [ | |
| 166 | + "3f7c5fd5fdf110439fbb4b07be5e6a208fcdb06b", | |
| 167 | + 11 | |
| 168 | + ], | |
| 169 | + [ | |
| 170 | + "d4ed5fd77377257ebdc90aeaf756fc9245b4e4e7", | |
| 171 | + 13 | |
| 172 | + ] | |
| 173 | + ], | |
| 174 | + "tagObject": "object b9621c3c7d41865d3d6e07e59f16bf944ec88a10\ntype commit\ntag v1.0\ntagger 諏訪子 <suwako@example.com> 1700018000 +0900\n\nバージョン1.0\n" | |
| 175 | + }, | |
| 176 | + "packOfs": "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", | |
| 177 | + "packRef": "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" | |
| 178 | + }, | |
| 179 | + "sha256": { | |
| 180 | + "files": { | |
| 181 | + "HEAD": "cmVmOiByZWZzL2hlYWRzL21haW4K", | |
| 182 | + "config": "W2NvcmVdCglyZXBvc2l0b3J5Zm9ybWF0dmVyc2lvbiA9IDEKCWZpbGVtb2RlID0gdHJ1ZQoJYmFyZSA9IHRydWUKW3JlbW90ZSAib3JpZ2luIl0KCXVybCA9IC90bXAvY2xhdWRlLTAvZngvdzIKW2V4dGVuc2lvbnNdCglvYmplY3Rmb3JtYXQgPSBzaGEyNTYK", | |
| 183 | + "objects/4e/ad1e226763add3275b0819f464dff743fb0ef70ac45d8ae77ff9dd520a9b5d": "eAErKUpNVTAyMmMwNDAwMzFRSNQrqShheDD5qIhiX9gMJW2dVpe5jmefnl/U5Dj/zEwRPYaVQhOnZ5gYGBgYKKRkFjEIf/P1OqClcpLJesbyrX2vfOxlt3yeNt+g0IVzx5Hk1R6thkZgpTmZedkMfP8mzmQNzFk3y/PktktFPekdt2ImGrtNFtq09UDw816pbKgT8vLzcsCuOPEscG/A6/OXz3SKN5oHrGLju1e/RW/hutaIzS7Nq2OEaw0NDMxNTRWKSvOM9IozGEKbdeR5P3Jk/bA5fDA48mVwXe3Vo/r7pMq1bKdFWLNcugsAjyFfuw==", | |
| 184 | + "objects/a5/cf4130cea7e7176ad87aadd08f0adb62386c9162f63ecb6cbd52b44381588d": "eAGVz0tKA0EQAFDXc4reC1L9qa5uCOJVuruqVHScME7QrWajIOgxxKAHkOBlhkB2uYLgDTzAW7w29P3lZFyCo2kUMUEKW3EuUvSF2TvCCslmDTGwKgWvFUQJSgvIqQiRamZGByVX5G5ZRrmZjE2tSeaMIVG1VRETxhApNI1KCaJCrljYp4SVGL3TbNVadR6lVunKaroYRrPfvO7fP3afb2Zxu7orV8OZ3Jd+eS0nbehPjSUAcDYCmGPIAF37+0zyf9mdt/nha/fzctg+zevNvN7Oj9+H7XP3C/BzZQw=", | |
| 185 | + "objects/e0/93c514218e5698222b2c85449d41cde5cfa282419fcc99142e00a912919768": "eAFLyslPUjC3ZCjJL+DKycxLNQSTRlw+nn6uxmC2CZg0BZNmYNIcTFqA1ViC2YYGXGmpiSWlRalciWklqUVcAJAPGtE=", | |
| 186 | + "objects/pack/pack-72984d219816ecf24b38b9bf58576a184fac11b0ea8c34b8612b9ddc354a3912.idx": "/3RPYwAAAAIAAAAAAAAAAAAAAAAAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAIAAAACAAAAAgAAAAIAAAACAAAAAwAAAAMAAAADAAAAAwAAAAMAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABQAAAAUAAAAFAAAABQAAAAUAAAAFAAAABQAAAAUAAAAFAAAABQAAAAUAAAAFAAAABQAAAAUAAAAFAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAGAAAABgAAAAYAAAAHAAAABwAAAAgAAAAIAAAACAAAAAgAAAAIAAAACAAAAAgAAAAIAAAACAAAAAgAAAAIAAAACAAAAAgAAAAIAAAACQAAAAkAAAAKAAAACwAAAAsAAAALAAAACwAAAAsAAAALAAAACwAAAAsAAAALAAAACwAAAAsAAAALAAAACwAAAAsAAAALAAAACwAAAAsAAAALAAAACwAAAAsAAAALAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAAwAAAAMAAAADAAAAA0AAAANAAAADQAAAA0AAAANAAAADQAAAA0AAAANAAAADQAAAA0AAAANAAAADQAAAA0AAAANAAAADQAAAA0AAAANAAAADgAAAA4AAAAOAAAAEAAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAARAAAAEQAAABEAAAASAAAAEgAAABIAAAASAAAAEgAAABIAAAASAAAAEgAAABIAAAASAAAAEgAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAEwAAABMAAAATAAAAFAAAABQAAAAUAAAAFAAAABQAAAAVAAAAFQAAABUAAAAVAAAAFQAAABUAAAAVAAAAFQAAABUAAAAVAAAAFQAAABUAAAAVAAAAFgAAABYAAAAWAAAAFwAAABcAAAAYAAAAGAAAABgAAAAYA8jl5Z7gfg8KkSSLMAznqcyJ0i0Kwt5LCY42+9BPAV0O/pGZBVFsrppJybbScoxniNpckTNGkxKytcBT540aaxP2TUrAKiTJAjuYp7WO6kw/HbTzlp8wcUQJuMRjq0iFGMzp2VSHsb9VhWRnTPb3gG8Jta04hbfVMvkfEfI1674f84dAFzq1Mf4q68lYsJKuAQPxm7oVldC5BHIiVmONnC4VszcaxsxpjgHSwSYew+P35CHBPXlTzBqg039+B51uRS8usPkJY4Z+EOAOH0LIOzPfwFMZ/EKUTosVeB5K2RFHP/X4nQHMRqoTGlGz19K5P7XloZtij1Qm9FGdRG0d/VWDLB8N8Qhq+DzDwVNZ6VN+fdXFL74adyo9llg7BNLdVweRV3dkUoPrr2LvzF2ZR8qxjPNsPJwYSks1ODYzhY9YAbx2ejjuvEZQTHCu1+uc+MBpsnxuh4rvnyxZSrRKwG0+r2INHt4Xqv02DFQBrgxOVhET7ScVxSf3ixnTmRzChkxVHMlczdzlAwL58ltsJM//CaqTmV6WhiX9jiCgzpKXTR5HC4biZYJqo4KCLm/Mrzn05YZZUkMD2f/4kMpey5oUzrEHDnGXawicaFDIFBnNNWOkoBXVSrgNcoW2K+4dmt4qVpY1Tqv8CSIV44GQOiEZgPYb04xBOH2xBDOOKZmbQ/A5eJ82um4De9LMYGPl7bVH0EVC4kDhKGDsw4l64b0NrHjo+Gnj6+hNJ10aNktAcDWACPGviAhEoDeBgTmFyOZRvVDrz9PMiReBN1CqBg7ef7Quoa6FWLNEg6tcE33lkg+5dTDq8AOzVhPpJFDRteDQzd99QgsE6YfQdCm0zeqONfCl2kTBqTzHWwzfydDWeJCOMqVqL585xunqZMyS9yWBmDGGzff5LyRucK1I9koueonsjqlu4GRop6FKER76bGEIQLCSJgH+hFier0yKGNXhDM2o0DoYy7XBDwZhyfzmwZv/NrQQ1PTICb2fh5OBfxJ4/qWe7g/dVkyJLUlMB0jNuY786lRWyGvxjnfSiapvL5D29LgzirYmuy1fNCrVMX5Qb5/+66WjXkpNXwu9HnXfJMobpvXbZTFqC9KzdzK1jDjzEAW+DdFoMZ+sm7PgjAXNj0gYJd1kP1yuiG0TAAAIcAAACIcAAAYZAAAADAAAAPgAAAjFAAAI9QAABdUAAAipAAAJXgAAAmQAAAlwAAADPgAABtwAAAg4AAAHIwAABlMAAAktAAAIlQAABpUAAAG/AAAJOwAABBsAAAS/cphNIZgW7PJLOLm/WFdqGE+sEbDqjDS4YSud3DVKORJiDOI4OMTIK+iijnZry/G9X63qJfP/GGwwiWvlFcXI1Q==", | |
| 187 | + "objects/pack/pack-72984d219816ecf24b38b9bf58576a184fac11b0ea8c34b8612b9ddc354a3912.pack": "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", | |
| 188 | + "objects/pack/pack-72984d219816ecf24b38b9bf58576a184fac11b0ea8c34b8612b9ddc354a3912.rev": "UklEWAAAAAEAAAACAAAAAwAAAAQAAAAUAAAACgAAAAwAAAAWAAAAFwAAAAcAAAACAAAAEAAAABMAAAANAAAADwAAAA4AAAAAAAAAAQAAABIAAAAIAAAABQAAAAYAAAARAAAAFQAAAAkAAAALcphNIZgW7PJLOLm/WFdqGE+sEbDqjDS4YSud3DVKORK76zVSrvvMItG1p0pA3qutO3s2U4NXUz/cXcdEQmbAVw==", | |
| 189 | + "packed-refs": "IyBwYWNrLXJlZnMgd2l0aDogcGVlbGVkIGZ1bGx5LXBlZWxlZCBzb3J0ZWQgCjU4MDFiYzc2N2EzOGVlYmM0NjUwNGM3MGFlZDdlYjljZjhjMDY5YjI3YzZlODc4YWVmOWYyYzU5NGFiNDRhYzAgcmVmcy9oZWFkcy9mZWF0dXJlCmE1Y2Y0MTMwY2VhN2U3MTc2YWQ4N2FhZGQwOGYwYWRiNjIzODZjOTE2MmY2M2VjYjZjYmQ1MmI0NDM4MTU4OGQgcmVmcy9oZWFkcy9tYWluCjE4Y2NlOWQ5NTQ4N2IxYmY1NTg1NjQ2NzRjZjZmNzgwNmYwOWI1YWQzODg1YjdkNTMyZjkxZjExZjIzNWViYmUgcmVmcy90YWdzL2xpZ2h0CmVhOGUzNWYwYTVkYTQ0YzFhOTNjYzc1YjBjZGZjOWQwZDY3ODkwOGUzMmE1NmEyZjlmMzljNmU5ZWE2NGNjOTIgcmVmcy90YWdzL3YxLjAKXjFmZjM4NzQwMTczYWI1MzFmZTJhZWJjOTU4YjA5MmFlMDEwM2YxOWJiYTE1OTVkMGI5MDQ3MjIyNTY2MzhkOWMK" | |
| 190 | + }, | |
| 191 | + "expect": { | |
| 192 | + "log": [ | |
| 193 | + [ | |
| 194 | + "a5cf4130cea7e7176ad87aadd08f0adb62386c9162f63ecb6cbd52b44381588d", | |
| 195 | + "gcの後(ルーズ)" | |
| 196 | + ], | |
| 197 | + [ | |
| 198 | + "18cce9d95487b1bf558564674cf6f7806f09b5ad3885b7d532f91f11f235ebbe", | |
| 199 | + "マージ" | |
| 200 | + ], | |
| 201 | + [ | |
| 202 | + "1ff38740173ab531fe2aebc958b092ae0103f19bba1595d0b904722256638d9c", | |
| 203 | + "mainの変更" | |
| 204 | + ], | |
| 205 | + [ | |
| 206 | + "5801bc767a38eebc46504c70aed7eb9cf8c069b27c6e878aef9f2c594ab44ac0", | |
| 207 | + "feature: 追加と名前の変更" | |
| 208 | + ], | |
| 209 | + [ | |
| 210 | + "864c551cc95ccddce50302f9f25b6c24cfff09aa93995e968625fd8e20a0ce92", | |
| 211 | + "二番目" | |
| 212 | + ], | |
| 213 | + [ | |
| 214 | + "fa6c610840b0922601fe84589eaf4c8a18d5e10ccda8d03a18cbb5c10f0661c9", | |
| 215 | + "最初のコミット" | |
| 216 | + ] | |
| 217 | + ], | |
| 218 | + "logPath": [ | |
| 219 | + "a5cf4130cea7e7176ad87aadd08f0adb62386c9162f63ecb6cbd52b44381588d", | |
| 220 | + "18cce9d95487b1bf558564674cf6f7806f09b5ad3885b7d532f91f11f235ebbe", | |
| 221 | + "1ff38740173ab531fe2aebc958b092ae0103f19bba1595d0b904722256638d9c", | |
| 222 | + "5801bc767a38eebc46504c70aed7eb9cf8c069b27c6e878aef9f2c594ab44ac0", | |
| 223 | + "864c551cc95ccddce50302f9f25b6c24cfff09aa93995e968625fd8e20a0ce92", | |
| 224 | + "fa6c610840b0922601fe84589eaf4c8a18d5e10ccda8d03a18cbb5c10f0661c9" | |
| 225 | + ], | |
| 226 | + "revs": { | |
| 227 | + "HEAD": "a5cf4130cea7e7176ad87aadd08f0adb62386c9162f63ecb6cbd52b44381588d", | |
| 228 | + "main~1": "18cce9d95487b1bf558564674cf6f7806f09b5ad3885b7d532f91f11f235ebbe", | |
| 229 | + "main~1^2": "5801bc767a38eebc46504c70aed7eb9cf8c069b27c6e878aef9f2c594ab44ac0", | |
| 230 | + "main^^": "1ff38740173ab531fe2aebc958b092ae0103f19bba1595d0b904722256638d9c", | |
| 231 | + "v1.0": "ea8e35f0a5da44c1a93cc75b0cdfc9d0d678908e32a56a2f9f39c6e9ea64cc92", | |
| 232 | + "v1.0^{}": "1ff38740173ab531fe2aebc958b092ae0103f19bba1595d0b904722256638d9c", | |
| 233 | + "v1.0^{tree}": "e5920fb97530eaf003b35613e92450d1b5e0d0cddf7d420b04e987d07429b4cd", | |
| 234 | + "HEAD:a.txt": "e093c514218e5698222b2c85449d41cde5cfa282419fcc99142e00a912919768", | |
| 235 | + "main~1:dir/サブ/日本語.txt": "03c8e5e59ee07e0f0a91248b300ce7a9cc89d22d0ac2de4b098e36fbd04f015d", | |
| 236 | + "light": "18cce9d95487b1bf558564674cf6f7806f09b5ad3885b7d532f91f11f235ebbe", | |
| 237 | + "feature": "5801bc767a38eebc46504c70aed7eb9cf8c069b27c6e878aef9f2c594ab44ac0", | |
| 238 | + "refs/heads/feature~1": "864c551cc95ccddce50302f9f25b6c24cfff09aa93995e968625fd8e20a0ce92" | |
| 239 | + }, | |
| 240 | + "refs": { | |
| 241 | + "refs/heads/feature": "5801bc767a38eebc46504c70aed7eb9cf8c069b27c6e878aef9f2c594ab44ac0", | |
| 242 | + "refs/heads/main": "a5cf4130cea7e7176ad87aadd08f0adb62386c9162f63ecb6cbd52b44381588d", | |
| 243 | + "refs/tags/light": "18cce9d95487b1bf558564674cf6f7806f09b5ad3885b7d532f91f11f235ebbe", | |
| 244 | + "refs/tags/v1.0": "ea8e35f0a5da44c1a93cc75b0cdfc9d0d678908e32a56a2f9f39c6e9ea64cc92" | |
| 245 | + }, | |
| 246 | + "tree": [ | |
| 247 | + [ | |
| 248 | + "100644", | |
| 249 | + "blob", | |
| 250 | + "e093c514218e5698222b2c85449d41cde5cfa282419fcc99142e00a912919768", | |
| 251 | + "a.txt" | |
| 252 | + ], | |
| 253 | + [ | |
| 254 | + "040000", | |
| 255 | + "tree", | |
| 256 | + "13f64d4ac02a24c9023b98a7b58eea4c3f1db4f3969f30714409b8c463ab4885", | |
| 257 | + "dir" | |
| 258 | + ], | |
| 259 | + [ | |
| 260 | + "040000", | |
| 261 | + "tree", | |
| 262 | + "9b43f039789f36ba6e037bd2cc6063e5edb547d04542e240e12860ecc3897ae1", | |
| 263 | + "dir/サブ" | |
| 264 | + ], | |
| 265 | + [ | |
| 266 | + "100644", | |
| 267 | + "blob", | |
| 268 | + "03c8e5e59ee07e0f0a91248b300ce7a9cc89d22d0ac2de4b098e36fbd04f015d", | |
| 269 | + "dir/サブ/日本語.txt" | |
| 270 | + ], | |
| 271 | + [ | |
| 272 | + "120000", | |
| 273 | + "blob", | |
| 274 | + "0efe919905516cae9a49c9b6d2728c6788da5c9133469312b2b5c053e78d1a6b", | |
| 275 | + "link" | |
| 276 | + ], | |
| 277 | + [ | |
| 278 | + "100644", | |
| 279 | + "blob", | |
| 280 | + "c8e651bd50ebcfd3cc8917813750aa060ede7fb42ea1ae8558b34483ab5c137d", | |
| 281 | + "nonl.txt" | |
| 282 | + ], | |
| 283 | + [ | |
| 284 | + "100755", | |
| 285 | + "blob", | |
| 286 | + "55832c1f0df1086af83cc3c15359e9537e7dd5c52fbe1a772a3d96583b04d2dd", | |
| 287 | + "run2.sh" | |
| 288 | + ] | |
| 289 | + ], | |
| 290 | + "diffs": { | |
| 291 | + "main~1": "diff --git a/a.txt b/a.txt\nindex 2e15b33..9a14ceb 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -9,3 +9,4 @@ line7\n line8\n LINE9\n line10\n+feature\ndiff --git a/run.sh b/run2.sh\nsimilarity index 100%\nrename from run.sh\nrename to run2.sh\n", | |
| 292 | + "feature": "diff --git a/a.txt b/a.txt\nindex bd0dac7..452f2eb 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -8,3 +8,4 @@ line7\n line8\n LINE9\n line10\n+feature\ndiff --git a/run.sh b/run2.sh\nsimilarity index 100%\nrename from run.sh\nrename to run2.sh\n", | |
| 293 | + "main~2": "diff --git a/a.txt b/a.txt\nindex bd0dac7..2e15b33 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -1,3 +1,4 @@\n+top\n line1\n line2\n LINE3\ndiff --git a/data.bin b/data.bin\ndeleted file mode 100644\nindex 5707915..0000000\nBinary files a/data.bin and /dev/null differ\n", | |
| 294 | + "main~3": "diff --git a/a.txt b/a.txt\nindex f725819..bd0dac7 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -1,10 +1,10 @@\n line1\n line2\n-line3\n+LINE3\n line4\n line5\n line6\n line7\n line8\n-line9\n+LINE9\n line10\ndiff --git a/nonl.txt b/nonl.txt\nindex 6d3eaf6..c8e651b 100644\n--- a/nonl.txt\n+++ b/nonl.txt\n@@ -1 +1 @@\n-no newline\n\\ No newline at end of file\n+no newline!\n\\ No newline at end of file\n", | |
| 295 | + "main~4": "diff --git a/a.txt b/a.txt\nnew file mode 100644\nindex 0000000..f725819\n--- /dev/null\n+++ b/a.txt\n@@ -0,0 +1,10 @@\n+line1\n+line2\n+line3\n+line4\n+line5\n+line6\n+line7\n+line8\n+line9\n+line10\ndiff --git a/data.bin b/data.bin\nnew file mode 100644\nindex 0000000..5707915\nBinary files /dev/null and b/data.bin differ\ndiff --git \"a/dir/\\343\\202\\265\\343\\203\\226/\\346\\227\\245\\346\\234\\254\\350\\252\\236.txt\" \"b/dir/\\343\\202\\265\\343\\203\\226/\\346\\227\\245\\346\\234\\254\\350\\252\\236.txt\"\nnew file mode 100644\nindex 0000000..03c8e5e\n--- /dev/null\n+++ \"b/dir/\\343\\202\\265\\343\\203\\226/\\346\\227\\245\\346\\234\\254\\350\\252\\236.txt\"\n@@ -0,0 +1 @@\n+ファイル\ndiff --git a/link b/link\nnew file mode 120000\nindex 0000000..0efe919\n--- /dev/null\n+++ b/link\n@@ -0,0 +1 @@\n+a.txt\n\\ No newline at end of file\ndiff --git a/nonl.txt b/nonl.txt\nnew file mode 100644\nindex 0000000..6d3eaf6\n--- /dev/null\n+++ b/nonl.txt\n@@ -0,0 +1 @@\n+no newline\n\\ No newline at end of file\ndiff --git a/run.sh b/run.sh\nnew file mode 100755\nindex 0000000..55832c1\n--- /dev/null\n+++ b/run.sh\n@@ -0,0 +1,2 @@\n+#!/bin/sh\n+echo hi\n" | |
| 296 | + }, | |
| 297 | + "blame": [ | |
| 298 | + [ | |
| 299 | + "1ff38740173ab531fe2aebc958b092ae0103f19bba1595d0b904722256638d9c", | |
| 300 | + 1 | |
| 301 | + ], | |
| 302 | + [ | |
| 303 | + "fa6c610840b0922601fe84589eaf4c8a18d5e10ccda8d03a18cbb5c10f0661c9", | |
| 304 | + 1 | |
| 305 | + ], | |
| 306 | + [ | |
| 307 | + "fa6c610840b0922601fe84589eaf4c8a18d5e10ccda8d03a18cbb5c10f0661c9", | |
| 308 | + 2 | |
| 309 | + ], | |
| 310 | + [ | |
| 311 | + "864c551cc95ccddce50302f9f25b6c24cfff09aa93995e968625fd8e20a0ce92", | |
| 312 | + 3 | |
| 313 | + ], | |
| 314 | + [ | |
| 315 | + "fa6c610840b0922601fe84589eaf4c8a18d5e10ccda8d03a18cbb5c10f0661c9", | |
| 316 | + 4 | |
| 317 | + ], | |
| 318 | + [ | |
| 319 | + "fa6c610840b0922601fe84589eaf4c8a18d5e10ccda8d03a18cbb5c10f0661c9", | |
| 320 | + 5 | |
| 321 | + ], | |
| 322 | + [ | |
| 323 | + "fa6c610840b0922601fe84589eaf4c8a18d5e10ccda8d03a18cbb5c10f0661c9", | |
| 324 | + 6 | |
| 325 | + ], | |
| 326 | + [ | |
| 327 | + "fa6c610840b0922601fe84589eaf4c8a18d5e10ccda8d03a18cbb5c10f0661c9", | |
| 328 | + 7 | |
| 329 | + ], | |
| 330 | + [ | |
| 331 | + "fa6c610840b0922601fe84589eaf4c8a18d5e10ccda8d03a18cbb5c10f0661c9", | |
| 332 | + 8 | |
| 333 | + ], | |
| 334 | + [ | |
| 335 | + "864c551cc95ccddce50302f9f25b6c24cfff09aa93995e968625fd8e20a0ce92", | |
| 336 | + 9 | |
| 337 | + ], | |
| 338 | + [ | |
| 339 | + "fa6c610840b0922601fe84589eaf4c8a18d5e10ccda8d03a18cbb5c10f0661c9", | |
| 340 | + 10 | |
| 341 | + ], | |
| 342 | + [ | |
| 343 | + "5801bc767a38eebc46504c70aed7eb9cf8c069b27c6e878aef9f2c594ab44ac0", | |
| 344 | + 11 | |
| 345 | + ], | |
| 346 | + [ | |
| 347 | + "a5cf4130cea7e7176ad87aadd08f0adb62386c9162f63ecb6cbd52b44381588d", | |
| 348 | + 13 | |
| 349 | + ] | |
| 350 | + ], | |
| 351 | + "tagObject": "object 1ff38740173ab531fe2aebc958b092ae0103f19bba1595d0b904722256638d9c\ntype commit\ntag v1.0\ntagger 諏訪子 <suwako@example.com> 1700018000 +0900\n\nバージョン1.0\n" | |
| 352 | + }, | |
| 353 | + "packOfs": "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", | |
| 354 | + "packRef": "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" | |
| 355 | + } | |
| 356 | +} |
| @@ -0,0 +1,648 @@ | ||
| 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\Test; | |
| 39 | + | |
| 40 | +require_once __DIR__.'/../../../autoload.php'; | |
| 41 | + | |
| 42 | +use Std\Lib\Tester; | |
| 43 | +use Std\Lib\Git\Blame; | |
| 44 | +use Std\Lib\Git\Commit; | |
| 45 | +use Std\Lib\Git\Config; | |
| 46 | +use Std\Lib\Git\Delta; | |
| 47 | +use Std\Lib\Git\Diff; | |
| 48 | +use Std\Lib\Git\GitException; | |
| 49 | +use Std\Lib\Git\Maintenance; | |
| 50 | +use Std\Lib\Git\PackIndexer; | |
| 51 | +use Std\Lib\Git\PackWriter; | |
| 52 | +use Std\Lib\Git\Person; | |
| 53 | +use Std\Lib\Git\PktLine; | |
| 54 | +use Std\Lib\Git\ReceivePack; | |
| 55 | +use Std\Lib\Git\Refs; | |
| 56 | +use Std\Lib\Git\Repository; | |
| 57 | +use Std\Lib\Git\Ssh; | |
| 58 | +use Std\Lib\Git\Tag; | |
| 59 | +use Std\Lib\Git\Tree; | |
| 60 | +use Std\Lib\Git\UploadPack; | |
| 61 | +use Std\Lib\Git\Walk; | |
| 62 | + | |
| 63 | +$test = new Tester([ | |
| 64 | + 'colorOutput' => true, | |
| 65 | + 'verboseOutput' => true | |
| 66 | +]); | |
| 67 | + | |
| 68 | +$tmp = sys_get_temp_dir().'/lbgit_'.bin2hex(random_bytes(6)); | |
| 69 | +mkdir($tmp, 0700); | |
| 70 | +$rmTree = function(string $dir) use (&$rmTree): void { | |
| 71 | + foreach (@scandir($dir) ?: [] as $f) { | |
| 72 | + if ($f === '.' || $f === '..') continue; | |
| 73 | + $p = "{$dir}/{$f}"; | |
| 74 | + if (is_dir($p) && !is_link($p)) $rmTree($p); | |
| 75 | + else { | |
| 76 | + @chmod($p, 0666); | |
| 77 | + @unlink($p); | |
| 78 | + } | |
| 79 | + } | |
| 80 | + @rmdir($dir); | |
| 81 | +}; | |
| 82 | +register_shutdown_function(fn() => $rmTree($tmp)); | |
| 83 | + | |
| 84 | +// 本物のgitで作ったリポジトリ(SHA-1とSHA-256)と、gitが出した結果 | |
| 85 | +$fixtures = json_decode((string)file_get_contents(__DIR__.'/Helper/git.json'), true); | |
| 86 | +$extract = function(string $fmt, string $name) use ($fixtures, $tmp): string { | |
| 87 | + $dir = "{$tmp}/{$name}"; | |
| 88 | + foreach ($fixtures[$fmt]['files'] as $rel => $b64) { | |
| 89 | + @mkdir(dirname("{$dir}/{$rel}"), 0777, true); | |
| 90 | + file_put_contents("{$dir}/{$rel}", base64_decode($b64)); | |
| 91 | + } | |
| 92 | + foreach (['refs/heads', 'refs/tags'] as $d) @mkdir("{$dir}/{$d}", 0777, true); | |
| 93 | + return $dir; | |
| 94 | +}; | |
| 95 | +$catch = function(callable $fn): ?\Throwable { | |
| 96 | + try { | |
| 97 | + $fn(); | |
| 98 | + } catch (\Throwable $e) { | |
| 99 | + return $e; | |
| 100 | + } | |
| 101 | + return null; | |
| 102 | +}; | |
| 103 | +$me = new Person('諏訪子', 'suwako@example.com', 1700000000, '+0900'); | |
| 104 | +// 本物のgit(任意) | |
| 105 | +$git = null; | |
| 106 | +if (function_exists('proc_open') && function_exists('shell_exec')) { | |
| 107 | + $v = @shell_exec('git --version 2>&1'); | |
| 108 | + if (is_string($v) && str_starts_with($v, 'git version')) $git = 'git'; | |
| 109 | +} | |
| 110 | +$run = function(array $cmd, ?string $cwd = null, array $env = []) use ($tmp): array { | |
| 111 | + $p = proc_open($cmd, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd, | |
| 112 | + $env + ['GIT_CONFIG_GLOBAL' => $tmp.'/gitconfig', 'GIT_CONFIG_NOSYSTEM' => '1', 'HOME' => $tmp, 'PATH' => (string)getenv('PATH'), | |
| 113 | + 'GIT_TERMINAL_PROMPT' => '0', 'SystemRoot' => (string)getenv('SystemRoot')]); | |
| 114 | + fclose($pipes[0]); | |
| 115 | + $out = stream_get_contents($pipes[1]); | |
| 116 | + $err = stream_get_contents($pipes[2]); | |
| 117 | + fclose($pipes[1]); | |
| 118 | + fclose($pipes[2]); | |
| 119 | + return [proc_close($p), (string)$out, (string)$err]; | |
| 120 | +}; | |
| 121 | +file_put_contents($tmp.'/gitconfig', "[user]\n\tname = テスト\n\temail = test@example.com\n[init]\n\tdefaultBranch = main\n[commit]\n\tgpgsign = false\n"); | |
| 122 | + | |
| 123 | +// プロトコルのテスト用 | |
| 124 | +$pkts = function(array $lines): string { | |
| 125 | + $s = ''; | |
| 126 | + foreach ($lines as $l) $s .= $l === 0 ? '0000' : ($l === 1 ? '0001' : PktLine::encode($l)); | |
| 127 | + return $s; | |
| 128 | +}; | |
| 129 | +$stream = function(string $s) { | |
| 130 | + $f = fopen('php://temp', 'w+b'); | |
| 131 | + fwrite($f, $s); | |
| 132 | + rewind($f); | |
| 133 | + return $f; | |
| 134 | +}; | |
| 135 | +/** 応答を行に分ける(サイドバンドの1はパック、2は進捗) */ | |
| 136 | +$parse = function($out, bool $sideband = true): array { | |
| 137 | + rewind($out); | |
| 138 | + $pkt = new PktLine($out); | |
| 139 | + $lines = []; | |
| 140 | + $pack = ''; | |
| 141 | + $progress = ''; | |
| 142 | + while (($l = $pkt->read(true)) !== null) { | |
| 143 | + if (is_int($l)) { | |
| 144 | + $lines[] = $l; | |
| 145 | + continue; | |
| 146 | + } | |
| 147 | + if (str_starts_with($l, 'PACK')) { | |
| 148 | + $pack = $l.stream_get_contents($out); | |
| 149 | + break; | |
| 150 | + } | |
| 151 | + if ($sideband && $pack === '' && strlen($l) > 0 && in_array(ord($l[0]), [1, 2, 3], true) && !preg_match('/^[\x20-\x7e]/', $l)) { | |
| 152 | + if ($l[0] === "\1") $pack .= substr($l, 1); | |
| 153 | + else if ($l[0] === "\2") $progress .= substr($l, 1); | |
| 154 | + else $lines[] = 'ERROR '.substr($l, 1); | |
| 155 | + continue; | |
| 156 | + } | |
| 157 | + if ($sideband && $l !== '' && $l[0] === "\1") { | |
| 158 | + $pack .= substr($l, 1); | |
| 159 | + continue; | |
| 160 | + } | |
| 161 | + $lines[] = rtrim($l, "\n"); | |
| 162 | + } | |
| 163 | + return ['lines' => $lines, 'pack' => $pack, 'progress' => $progress]; | |
| 164 | +}; | |
| 165 | +/** 新しいリポジトリにパックを入れる */ | |
| 166 | +$unpack = function(Repository $repo, string $pack): array { | |
| 167 | + $f = fopen('php://temp', 'w+b'); | |
| 168 | + fwrite($f, $pack); | |
| 169 | + rewind($f); | |
| 170 | + return (new PackIndexer($repo))->receive($f, 0); | |
| 171 | +}; | |
| 172 | + | |
| 173 | + | |
| 174 | +$test->describe('Git(設定とオブジェクト)', function($test) use ($me): void { | |
| 175 | + $test->it('設定ファイルを読み書き出来るはず', function($test): void { | |
| 176 | + $c = Config::fromString("# コメント\n[core]\n\tbare = true\n\tFileMode=false ; コメント\n[remote \"origin\"]\n\turl = \"https://x/a b\" # c\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n" | |
| 177 | + ."[Section \"Sub \\\"q\\\"\"]\n\tkey = a \\\n b\n\tflag\n[pack]\n\twindowMemory = 2k\n"); | |
| 178 | + $test->assertTrue($c->getBool('core.bare')); | |
| 179 | + $test->assertFalse($c->getBool('core.filemode', true)); | |
| 180 | + $test->assertSame('https://x/a b', $c->get('remote.origin.url')); | |
| 181 | + $test->assertSame('+refs/heads/*:refs/remotes/origin/*', $c->get('Remote.origin.Fetch')); | |
| 182 | + $test->assertNull($c->get('remote.Origin.url'), 'サブセクションは大文字・小文字を区別する'); | |
| 183 | + $test->assertSame('a b', $c->get('section.Sub "q".key')); | |
| 184 | + $test->assertTrue($c->getBool('section.Sub "q".flag')); | |
| 185 | + $test->assertSame(2048, $c->getInt('pack.windowmemory')); | |
| 186 | + $c->set('user.name', ' 前後に空白 '); | |
| 187 | + $c->set('core.bare', null); | |
| 188 | + $back = Config::fromString($c->toString()); | |
| 189 | + $test->assertSame(' 前後に空白 ', $back->get('user.name')); | |
| 190 | + $test->assertNull($back->get('core.bare')); | |
| 191 | + $test->assertSame('https://x/a b', $back->get('remote.origin.url')); | |
| 192 | + }); | |
| 193 | + | |
| 194 | + $test->it('コミット・タグ・作者を解析して元に戻せるはず', function($test): void { | |
| 195 | + $raw = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nparent 1111111111111111111111111111111111111111\nparent 2222222222222222222222222222222222222222\n" | |
| 196 | + ."author 諏訪子 <suwako@example.com> 1700000000 +0900\ncommitter Kanako <k@example.com> 1700000100 -0130\n" | |
| 197 | + ."gpgsig -----BEGIN SSH SIGNATURE-----\n U1NIU0lH\n \n -----END SSH SIGNATURE-----\n\n件名\n\n本文\n"; | |
| 198 | + $c = Commit::parse($raw); | |
| 199 | + $test->assertSame(['1111111111111111111111111111111111111111', '2222222222222222222222222222222222222222'], $c->parents); | |
| 200 | + $test->assertSame('諏訪子', $c->author->name); | |
| 201 | + $test->assertSame(1700000100, $c->committer->time); | |
| 202 | + $test->assertSame('-0130', $c->committer->tz); | |
| 203 | + $test->assertSame('2023-11-14 20:45:00', $c->committer->date()->format('Y-m-d H:i:s')); | |
| 204 | + $test->assertSame('件名', $c->subject()); | |
| 205 | + $test->assertSame("-----BEGIN SSH SIGNATURE-----\nU1NIU0lH\n\n-----END SSH SIGNATURE-----", $c->header('gpgsig')); | |
| 206 | + $test->assertSame($raw, $c->toString(), '同じバイト列に戻る'); | |
| 207 | + $t = Tag::parse("object 1111111111111111111111111111111111111111\ntype commit\ntag v1.0\ntagger 諏訪子 <s@x> 1 +0000\n\nリリース\n"); | |
| 208 | + $test->assertSame(['v1.0', 'commit', 'リリース'], [$t->tag, $t->type, rtrim($t->message)]); | |
| 209 | + $p = Person::parse('名前だけ <> 0 +0000'); | |
| 210 | + $test->assertSame(['名前だけ', ''], [$p->name, $p->email]); | |
| 211 | + }); | |
| 212 | + | |
| 213 | + $test->it('ツリーはGitの順番に並べ、不正な名前を拒否するはず', function($test) use ($me): void { | |
| 214 | + $id = str_repeat('a', 40); | |
| 215 | + $t = Tree::create([[Tree::FILE, 'a.b', $id], [Tree::DIR, 'a', $id], [Tree::FILE, 'a-', $id], [Tree::FILE, 'B', $id]]); | |
| 216 | + // ディレクトリ「a」は「a/」として並ぶ(「a-」「a.b」の後) | |
| 217 | + $test->assertSame(['B', 'a-', 'a.b', 'a'], array_column($t->entries, 1)); | |
| 218 | + $test->assertSame($t->entries, Tree::parse($t->toString(), 20)->entries); | |
| 219 | + foreach (['', '.', '..', 'a/b', "a\0b"] as $bad) { | |
| 220 | + $test->assertThrows(fn() => Tree::create([[Tree::FILE, $bad, $id]]), GitException::class); | |
| 221 | + } | |
| 222 | + $test->assertThrows(fn() => Tree::create([[Tree::FILE, 'x', $id], [Tree::EXEC, 'x', $id]]), GitException::class); | |
| 223 | + }); | |
| 224 | + | |
| 225 | + $test->it('差分を当てられるはず', function($test): void { | |
| 226 | + $base = str_repeat('0123456789', 100); | |
| 227 | + // 元の大きさ、結果の大きさ、コピー(オフセット10、長さ20)、挿入(3バイト) | |
| 228 | + $delta = chr(232).chr(7).chr(23).chr(0x80 | 0x01 | 0x10).chr(10).chr(20).chr(3).'XYZ'; | |
| 229 | + $test->assertSame('01234567890123456789XYZ', Delta::apply($base, $delta)); | |
| 230 | + $test->assertSame(23, Delta::targetSize($delta)); | |
| 231 | + $test->assertThrows(fn() => Delta::apply('short', $delta), GitException::class); | |
| 232 | + }); | |
| 233 | + | |
| 234 | + $test->it('参照の名前を確かめるはず', function($test): void { | |
| 235 | + foreach (['refs/heads/main', 'refs/tags/v1.0', 'refs/heads/feature/日本語', 'HEAD'] as $ok) $test->assertTrue(Refs::validName($ok), $ok); | |
| 236 | + foreach (['main', 'refs/heads/a..b', 'refs/heads/a.lock', 'refs/heads/.x', 'refs/heads/a b', 'refs/heads/a~1', 'refs/heads/a:b', 'refs/heads/', 'refs/heads//a', 'refs/heads/a@{1}', "refs/heads/\x7f"] as $bad) { | |
| 237 | + $test->assertFalse(Refs::validName($bad), $bad); | |
| 238 | + } | |
| 239 | + }); | |
| 240 | +}); | |
| 241 | + | |
| 242 | +foreach (['sha1', 'sha256'] as $fmt) { | |
| 243 | + $test->describe("Git(本物のリポジトリを読む、{$fmt})", function($test) use ($fmt, $fixtures, $extract): void { | |
| 244 | + $dir = $extract($fmt, "read-{$fmt}"); | |
| 245 | + $e = $fixtures[$fmt]['expect']; | |
| 246 | + $repo = Repository::open($dir); | |
| 247 | + | |
| 248 | + $test->it('参照と履歴を読めるはず', function($test) use ($repo, $e, $fmt): void { | |
| 249 | + $test->assertSame($fmt, $repo->hashAlgo); | |
| 250 | + $test->assertSame('refs/heads/main', $repo->headRef()); | |
| 251 | + $test->assertSame($e['refs'], $repo->refs->all()); | |
| 252 | + $test->assertSame(array_column($e['log'], 0), array_map(fn($c) => $c->id, iterator_to_array($repo->log('main'), false))); | |
| 253 | + $test->assertSame(array_column($e['log'], 1), array_map(fn($c) => $c->subject(), iterator_to_array($repo->log('main'), false))); | |
| 254 | + $test->assertSame($e['logPath'], array_map(fn($c) => $c->id, iterator_to_array($repo->log('main', ['path' => 'a.txt']), false))); | |
| 255 | + $test->assertSame(array_slice(array_column($e['log'], 0), 1, 2), array_map(fn($c) => $c->id, iterator_to_array($repo->log('main', ['skip' => 1, 'limit' => 2]), false))); | |
| 256 | + $test->assertSame([$e['log'][0][0]], array_map(fn($c) => $c->id, iterator_to_array($repo->log('main', ['not' => 'main~1']), false))); | |
| 257 | + }); | |
| 258 | + | |
| 259 | + $test->it('リビジョンを解決出来るはず', function($test) use ($repo, $e): void { | |
| 260 | + foreach ($e['revs'] as $rev => $id) $test->assertSame($id, $repo->resolve($rev), $rev); | |
| 261 | + $test->assertSame($e['revs']['HEAD'], $repo->resolve(substr($e['revs']['HEAD'], 0, 7)), '短いID'); | |
| 262 | + $test->assertNull($repo->resolve('nothing')); | |
| 263 | + $test->assertNull($repo->resolve('HEAD~100')); | |
| 264 | + $test->assertNull($repo->resolve('HEAD:no/such/file')); | |
| 265 | + }); | |
| 266 | + | |
| 267 | + $test->it('ツリーとオブジェクトを読めるはず', function($test) use ($repo, $e): void { | |
| 268 | + $list = []; | |
| 269 | + $walk = function(string $tree, string $prefix) use (&$walk, &$list, $repo): void { | |
| 270 | + foreach ($repo->tree($tree)->entries as [$mode, $name, $id]) { | |
| 271 | + $type = $mode === Tree::DIR ? 'tree' : ($mode === Tree::SUBMODULE ? 'commit' : 'blob'); | |
| 272 | + $list[] = [str_pad($mode, 6, '0', STR_PAD_LEFT), $type, $id, $prefix.$name]; | |
| 273 | + if ($mode === Tree::DIR) $walk($id, $prefix.$name.'/'); | |
| 274 | + } | |
| 275 | + }; | |
| 276 | + $walk($repo->treeOf('main'), ''); | |
| 277 | + usort($list, fn($a, $b) => strcmp($a[3], $b[3])); | |
| 278 | + $expect = $e['tree']; | |
| 279 | + usort($expect, fn($a, $b) => strcmp($a[3], $b[3])); | |
| 280 | + $test->assertSame($expect, $list); | |
| 281 | + $test->assertSame(Tree::LINK, $repo->entry('main', 'link')[0]); | |
| 282 | + $test->assertSame('a.txt', $repo->blob($repo->entry('main', 'link')[2])); | |
| 283 | + $test->assertSame("ファイル\n", $repo->blob($repo->resolve('main:dir/サブ/日本語.txt'))); | |
| 284 | + $tag = $repo->tag($e['revs']['v1.0']); | |
| 285 | + $test->assertSame($e['tagObject'], $tag->toString()); | |
| 286 | + $test->assertSame('v1.0', $tag->tag); | |
| 287 | + // 全部のオブジェクトのIDが正しい(パックの差分も) | |
| 288 | + $n = 0; | |
| 289 | + foreach ($repo->objects->ids() as $id) { | |
| 290 | + [$type, $data] = $repo->objects->read($id); | |
| 291 | + $test->assertSame($id, $repo->hashObject($type, $data)); | |
| 292 | + $test->assertSame([$type, strlen($data)], $repo->objects->info($id)); | |
| 293 | + $n++; | |
| 294 | + } | |
| 295 | + $test->assertTrue($n > 20); | |
| 296 | + }); | |
| 297 | + | |
| 298 | + $test->it('gitと同じ差分を出すはず', function($test) use ($repo, $e): void { | |
| 299 | + foreach ($e['diffs'] as $rev => $expect) { | |
| 300 | + $c = $repo->commit($repo->resolve($rev)); | |
| 301 | + $test->assertSame($expect, Diff::unified($repo, $c->parents[0] ?? null, $c->id), $rev); | |
| 302 | + } | |
| 303 | + }); | |
| 304 | + | |
| 305 | + $test->it('gitと同じblameを出すはず', function($test) use ($repo, $e): void { | |
| 306 | + $b = Blame::file($repo, 'main', 'a.txt'); | |
| 307 | + $test->assertSame($e['blame'], array_map(fn($l) => [$l['commit'], $l['line']], $b)); | |
| 308 | + $test->assertSame('top', $b[0]['text']); | |
| 309 | + }); | |
| 310 | + | |
| 311 | + $test->it('祖先と共通の祖先が分かるはず', function($test) use ($repo, $e): void { | |
| 312 | + $w = new Walk($repo); | |
| 313 | + $test->assertTrue($w->isAncestor($e['revs']['main~1^2'], $e['revs']['HEAD'])); | |
| 314 | + $test->assertFalse($w->isAncestor($e['revs']['HEAD'], $e['revs']['feature'])); | |
| 315 | + $test->assertSame($e['revs']['refs/heads/feature~1'], $w->mergeBase($e['revs']['feature'], $e['revs']['main^^'])); | |
| 316 | + }); | |
| 317 | + }); | |
| 318 | +} | |
| 319 | + | |
| 320 | +$test->describe('Git(書く)', function($test) use ($tmp, $me, $catch, $git, $run): void { | |
| 321 | + foreach (['sha1', 'sha256'] as $fmt) { | |
| 322 | + $test->it("リポジトリを作り、コミット・タグ・参照を書けるはず({$fmt})", function($test) use ($tmp, $me, $fmt, $git, $run, $catch): void { | |
| 323 | + $dir = "{$tmp}/write-{$fmt}.git"; | |
| 324 | + $repo = Repository::init($dir, $fmt, 'main', 'group', 'テスト'); | |
| 325 | + $test->assertTrue(Repository::isRepository($dir)); | |
| 326 | + $test->assertTrue($repo->isEmpty()); | |
| 327 | + $test->assertThrows(fn() => Repository::init($dir), GitException::class); | |
| 328 | + $t1 = $repo->updateTree(null, ['README.md' => [Tree::FILE, "# テスト\n"], 'src/a.php' => [Tree::FILE, "<?php\n"], 'bin/run' => [Tree::EXEC, "#!/bin/sh\n"]]); | |
| 329 | + $c1 = $repo->writeCommit(Commit::create($t1, [], $me, $me, "最初\n")); | |
| 330 | + $repo->refs->update('refs/heads/main', $c1, $repo->zeroId()); | |
| 331 | + $t2 = $repo->updateTree($t1, ['src/a.php' => [Tree::FILE, "<?php\necho 1;\n"], 'bin/run' => null, 'docs/x/y.txt' => [Tree::FILE, "y\n"]]); | |
| 332 | + $test->assertSame(['README.md', 'docs', 'src'], array_column($repo->tree($t2)->entries, 1), '空になったbinは消える'); | |
| 333 | + $c2 = $repo->writeCommit(Commit::create($t2, [$c1], $me, $me, "二番目\n")); | |
| 334 | + $repo->refs->update('refs/heads/main', $c2, $c1); | |
| 335 | + $test->assertNotNull($catch(fn() => $repo->refs->update('refs/heads/main', $c1, $c1)), '古い値が違えば失敗'); | |
| 336 | + $test->assertNotNull($catch(fn() => $repo->refs->update('refs/heads/new', $c1, $c2)), '無い参照'); | |
| 337 | + $test->assertNotNull($catch(fn() => $repo->refs->update('refs/heads/main/x', $c1)), '参照と重なる'); | |
| 338 | + $test->assertNotNull($catch(fn() => $repo->refs->update('refs/heads/bad..name', $c1))); | |
| 339 | + $tag = $repo->writeTag(Tag::create($c1, 'commit', 'v0.1', $me, "リリース\n")); | |
| 340 | + $repo->refs->transaction([['refs/tags/v0.1', $tag, $repo->zeroId()], ['refs/heads/dev', $c1, null]]); | |
| 341 | + $test->assertSame($c1, $repo->resolve('v0.1^{commit}')); | |
| 342 | + $repo->refs->update('refs/heads/dev', null, $c1); | |
| 343 | + $test->assertNull($repo->refs->resolve('refs/heads/dev')); | |
| 344 | + $test->assertSame($c2, $repo->resolve('HEAD')); | |
| 345 | + $test->assertSame(["二番目", "最初"], array_map(fn($c) => $c->subject(), iterator_to_array($repo->log('HEAD'), false))); | |
| 346 | + // HEADの更新はブランチを更新する | |
| 347 | + $repo->refs->update('HEAD', $c1, $c2); | |
| 348 | + $test->assertSame($c1, $repo->refs->resolve('refs/heads/main')); | |
| 349 | + $repo->refs->update('HEAD', $c2); | |
| 350 | + if (DIRECTORY_SEPARATOR === '/') $test->assertSame(02775, fileperms($dir.'/objects') & 07777, 'グループで共有'); | |
| 351 | + // 整理しても全部読める | |
| 352 | + $r = Maintenance::gc($repo, 0); | |
| 353 | + $test->assertTrue($r['objects'] >= 9); | |
| 354 | + $repo2 = Repository::open($dir); | |
| 355 | + $test->assertCount(1, $repo2->objects->packs(true)); | |
| 356 | + $test->assertSame("<?php\necho 1;\n", $repo2->blob($repo2->resolve('main:src/a.php'))); | |
| 357 | + $test->assertSame($c1, $repo2->resolve('v0.1^{}')); | |
| 358 | + if ($git) { | |
| 359 | + [$code, , $err] = $run(['git', '-C', $dir, 'fsck', '--strict']); | |
| 360 | + $test->assertSame(0, $code, $err); | |
| 361 | + [, $out] = $run(['git', '-C', $dir, 'log', '--format=%s']); | |
| 362 | + $test->assertSame("二番目\n最初\n", $out); | |
| 363 | + } | |
| 364 | + }); | |
| 365 | + } | |
| 366 | +}); | |
| 367 | + | |
| 368 | +foreach (['packOfs' => 'OFS_DELTA', 'packRef' => 'REF_DELTA'] as $key => $label) { | |
| 369 | + $test->describe("Git(パック、{$label})", function($test) use ($key, $fixtures, $tmp, $extract, $stream, $catch, $unpack): void { | |
| 370 | + foreach (['sha1', 'sha256'] as $fmt) { | |
| 371 | + $test->it("gitのパックを受け取り、全部のオブジェクトが同じはず({$fmt})", function($test) use ($key, $fmt, $fixtures, $tmp, $extract, $stream): void { | |
| 372 | + $src = Repository::open($extract($fmt, "pk-{$key}-{$fmt}")); | |
| 373 | + foreach ([0, 1000] as $limit) { | |
| 374 | + $dst = Repository::init("{$tmp}/pk-{$key}-{$fmt}-{$limit}.git", $fmt); | |
| 375 | + $r = (new PackIndexer($dst))->receive($stream(base64_decode($fixtures[$fmt][$key])), $limit); | |
| 376 | + $test->assertSame($limit === 0, $r['pack'] !== null); | |
| 377 | + $ids = iterator_to_array($src->objects->ids(), false); | |
| 378 | + $test->assertCount(count(array_unique($ids)), $r['objects']); | |
| 379 | + foreach (array_unique($ids) as $id) $test->assertSame($src->objects->read($id), $dst->objects->read($id)); | |
| 380 | + $test->assertSame([], array_values(array_filter(array_keys($r['links']), fn($l) => !$dst->objects->exists($l)))); | |
| 381 | + } | |
| 382 | + }); | |
| 383 | + } | |
| 384 | + | |
| 385 | + $test->it('壊れたパックは拒否するはず', function($test) use ($key, $fixtures, $tmp, $stream, $catch): void { | |
| 386 | + $pack = base64_decode($fixtures['sha1'][$key]); | |
| 387 | + $dst = Repository::init("{$tmp}/bad-{$key}.git"); | |
| 388 | + foreach (['短い' => substr($pack, 0, -30), 'チェックサム' => substr($pack, 0, -1).chr(ord($pack[-1]) ^ 1), '中身' => substr($pack, 0, 40).chr(ord($pack[40]) ^ 0xff).substr($pack, 41)] as $what => $bad) { | |
| 389 | + $e = $catch(fn() => (new PackIndexer($dst))->receive($stream($bad), 0)); | |
| 390 | + $test->assertTrue($e instanceof GitException, $what); | |
| 391 | + } | |
| 392 | + $test->assertSame([], glob("{$tmp}/bad-{$key}.git/objects/pack/*") ?: [], '一時ファイルも残さない'); | |
| 393 | + }); | |
| 394 | + }); | |
| 395 | +} | |
| 396 | + | |
| 397 | +$test->describe('Git(パックを作る)', function($test) use ($tmp, $extract, $unpack): void { | |
| 398 | + $test->it('パックを作って受け取ると同じオブジェクトになるはず(差分の再利用)', function($test) use ($tmp, $extract, $unpack): void { | |
| 399 | + foreach (['sha1', 'sha256'] as $fmt) { | |
| 400 | + $src = Repository::open($extract($fmt, "pw-{$fmt}")); | |
| 401 | + $objects = (new Walk($src))->objects(array_values($src->refs->all()), []); | |
| 402 | + $pack = ''; | |
| 403 | + $sum = (new PackWriter($src))->write($objects, function(string $b) use (&$pack): void { $pack .= $b; }); | |
| 404 | + $test->assertSame($sum, bin2hex(substr($pack, -$src->hashLen))); | |
| 405 | + $dst = Repository::init("{$tmp}/pw-{$fmt}-dst.git", $fmt); | |
| 406 | + $r = $unpack($dst, $pack); | |
| 407 | + $test->assertSame(count($objects), count($r['objects'])); | |
| 408 | + foreach ($objects as $id => $type) $test->assertSame($src->objects->read($id), $dst->objects->read($id)); | |
| 409 | + // 一部(相手が持っている物を除く) | |
| 410 | + $head = $src->resolve('main'); | |
| 411 | + $part = (new Walk($src))->objects([$head], [$src->resolve('main~1')]); | |
| 412 | + $test->assertSame('commit', $part[$head]); | |
| 413 | + $test->assertTrue(count($part) < count($objects)); | |
| 414 | + $test->assertFalse(isset($part[$src->resolve('main~1')])); | |
| 415 | + } | |
| 416 | + }); | |
| 417 | +}); | |
| 418 | + | |
| 419 | +$test->describe('Git(プロトコル)', function($test) use ($tmp, $me, $extract, $fixtures, $pkts, $stream, $parse, $unpack): void { | |
| 420 | + $src = Repository::open($extract('sha1', 'proto')); | |
| 421 | + $e = $fixtures['sha1']['expect']; | |
| 422 | + | |
| 423 | + $test->it('v0の広告はHEAD・能力・剥いたタグを含むはず', function($test) use ($src, $e, $parse): void { | |
| 424 | + $out = fopen('php://temp', 'w+b'); | |
| 425 | + (new UploadPack($src))->advertise($out, true); | |
| 426 | + $r = $parse($out, false); | |
| 427 | + $test->assertSame('# service=git-upload-pack', $r['lines'][0]); | |
| 428 | + $test->assertSame(0, $r['lines'][1]); | |
| 429 | + [$first, $caps] = explode("\0", $r['lines'][2]); | |
| 430 | + $test->assertSame($e['revs']['HEAD'].' HEAD', $first); | |
| 431 | + $test->assertStringContains('symref=HEAD:refs/heads/main', $caps); | |
| 432 | + $test->assertStringContains('side-band-64k', $caps); | |
| 433 | + $test->assertStringContains('object-format=sha1', $caps); | |
| 434 | + $test->assertTrue(in_array($e['revs']['v1.0^{}'].' refs/tags/v1.0^{}', $r['lines'], true)); | |
| 435 | + $test->assertSame(0, end($r['lines'])); | |
| 436 | + }); | |
| 437 | + | |
| 438 | + $test->it('v2のls-refsはシンボリック参照と剥いたタグを返すはず', function($test) use ($src, $e, $pkts, $stream, $parse): void { | |
| 439 | + $out = fopen('php://temp', 'w+b'); | |
| 440 | + (new UploadPack($src))->serve($stream($pkts(["command=ls-refs\n", "agent=git/2\n", 1, "symrefs\n", "peel\n", "ref-prefix HEAD\n", "ref-prefix refs/tags/\n", 0])), $out, true, 2); | |
| 441 | + $r = $parse($out, false); | |
| 442 | + $test->assertSame($e['revs']['HEAD'].' HEAD symref-target:refs/heads/main', $r['lines'][0]); | |
| 443 | + $test->assertTrue(in_array($e['refs']['refs/tags/v1.0'].' refs/tags/v1.0 peeled:'.$e['revs']['v1.0^{}'], $r['lines'], true)); | |
| 444 | + $test->assertFalse((bool)array_filter($r['lines'], fn($l) => is_string($l) && str_contains($l, ' refs/heads/'))); | |
| 445 | + }); | |
| 446 | + | |
| 447 | + $test->it('v0でクローンとフェッチが出来るはず', function($test) use ($src, $e, $tmp, $pkts, $stream, $parse, $unpack): void { | |
| 448 | + $head = $e['revs']['HEAD']; | |
| 449 | + // 全部 | |
| 450 | + $out = fopen('php://temp', 'w+b'); | |
| 451 | + (new UploadPack($src))->serve($stream($pkts(["want {$head} multi_ack_detailed side-band-64k ofs-delta include-tag\n", 0, "done\n"])), $out, true, 0); | |
| 452 | + $r = $parse($out); | |
| 453 | + $test->assertSame('NAK', $r['lines'][0]); | |
| 454 | + $dst = Repository::init("{$tmp}/proto-v0.git"); | |
| 455 | + $got = $unpack($dst, $r['pack']); | |
| 456 | + $test->assertTrue(isset($got['objects'][$e['refs']['refs/tags/v1.0']]), 'include-tag'); | |
| 457 | + $test->assertSame(count(iterator_to_array($src->log('HEAD'), false)), count(iterator_to_array((new Walk($dst))->log([$head]), false))); | |
| 458 | + // 持っている物を伝える(ネゴシエーションとdone) | |
| 459 | + $out = fopen('php://temp', 'w+b'); | |
| 460 | + (new UploadPack($src))->serve($stream($pkts(["want {$head} multi_ack_detailed side-band-64k\n", 0, "have {$e['revs']['main~1']}\n", "have ".str_repeat('1', 40)."\n", 0])), $out, true, 0); | |
| 461 | + $test->assertSame(["ACK {$e['revs']['main~1']} common", 'NAK'], $parse($out)['lines']); | |
| 462 | + $out = fopen('php://temp', 'w+b'); | |
| 463 | + (new UploadPack($src))->serve($stream($pkts(["want {$head} multi_ack_detailed side-band-64k no-progress\n", 0, "have {$e['revs']['main~1']}\n", "done\n"])), $out, true, 0); | |
| 464 | + $r = $parse($out); | |
| 465 | + $test->assertSame(["ACK {$e['revs']['main~1']} common", "ACK {$e['revs']['main~1']}"], array_values(array_filter($r['lines'], 'is_string'))); | |
| 466 | + $test->assertSame('', $r['progress']); | |
| 467 | + $n = unpack('N', $r['pack'], 8)[1]; | |
| 468 | + $test->assertTrue($n >= 2 && $n <= 4, '新しいコミットとツリーとブロブだけ'); | |
| 469 | + }); | |
| 470 | + | |
| 471 | + $test->it('v2でフェッチと深さの制限が出来るはず', function($test) use ($src, $e, $tmp, $pkts, $stream, $parse, $unpack): void { | |
| 472 | + $head = $e['revs']['HEAD']; | |
| 473 | + $out = fopen('php://temp', 'w+b'); | |
| 474 | + (new UploadPack($src))->serve($stream($pkts(["command=fetch\n", "object-format=sha1\n", 1, "want {$head}\n", "have {$e['revs']['main~1']}\n", 0])), $out, true, 2); | |
| 475 | + $test->assertSame(['acknowledgments', "ACK {$e['revs']['main~1']}", 0], $parse($out)['lines']); | |
| 476 | + $out = fopen('php://temp', 'w+b'); | |
| 477 | + (new UploadPack($src))->serve($stream($pkts(["command=fetch\n", 1, "want {$head}\n", "deepen 1\n", "ofs-delta\n", "done\n", 0])), $out, true, 2); | |
| 478 | + $r = $parse($out); | |
| 479 | + $test->assertSame(['shallow-info', "shallow {$head}", 1, 'packfile'], array_slice($r['lines'], 0, 4)); | |
| 480 | + $dst = Repository::init("{$tmp}/proto-v2.git"); | |
| 481 | + $got = $unpack($dst, $r['pack']); | |
| 482 | + $test->assertSame(1, count(array_filter($got['objects'], fn($t) => $t === 'commit')), '1つのコミットだけ'); | |
| 483 | + // 不明なオブジェクトはエラー | |
| 484 | + $out = fopen('php://temp', 'w+b'); | |
| 485 | + (new UploadPack($src))->serve($stream($pkts(["command=fetch\n", 1, 'want '.str_repeat('f', 40)."\n", "done\n", 0])), $out, true, 2); | |
| 486 | + $test->assertStringContains('ERR', implode("\n", array_filter($parse($out, false)['lines'], 'is_string'))); | |
| 487 | + }); | |
| 488 | + | |
| 489 | + $test->it('プッシュを受け取り、参照を更新するはず', function($test) use ($tmp, $me, $pkts, $stream, $parse): void { | |
| 490 | + $repo = Repository::init("{$tmp}/push.git"); | |
| 491 | + $zero = $repo->zeroId(); | |
| 492 | + // 送る側のリポジトリでコミットを作る | |
| 493 | + $client = Repository::init("{$tmp}/push-client.git"); | |
| 494 | + $t = $client->updateTree(null, ['a.txt' => [Tree::FILE, "a\n"]]); | |
| 495 | + $c1 = $client->writeCommit(Commit::create($t, [], $me, $me, "1\n")); | |
| 496 | + $c2 = $client->writeCommit(Commit::create($client->updateTree($t, ['b.txt' => [Tree::FILE, "b\n"]]), [$c1], $me, $me, "2\n")); | |
| 497 | + $c3 = $client->writeCommit(Commit::create($client->updateTree($t, ['c.txt' => [Tree::FILE, "c\n"]]), [$c1], $me, $me, "別の2\n")); | |
| 498 | + $pack = function(array $want, array $have = []) use ($client): string { | |
| 499 | + $s = ''; | |
| 500 | + (new PackWriter($client))->write((new Walk($client))->objects($want, $have), function($b) use (&$s): void { $s .= $b; }); | |
| 501 | + return $s; | |
| 502 | + }; | |
| 503 | + $after = []; | |
| 504 | + $push = function(array $commands, string $packData, array $opt = []) use ($repo, $pkts, $stream, $parse, &$after): array { | |
| 505 | + $lines = []; | |
| 506 | + foreach ($commands as $i => $c) $lines[] = $c.($i === 0 ? "\0report-status side-band-64k quiet".($opt['atomic'] ?? false ? ' atomic' : '') : '')."\n"; | |
| 507 | + $lines[] = 0; | |
| 508 | + $out = fopen('php://temp', 'w+b'); | |
| 509 | + (new ReceivePack($repo, $opt + ['after' => function(array $done) use (&$after): void { $after = $done; }]))->serve($stream($pkts($lines).$packData), $out, true); | |
| 510 | + // サイドバンドの中のpkt-line | |
| 511 | + $inner = $parse($out)['pack']; | |
| 512 | + return $parse($stream($inner), false)['lines']; | |
| 513 | + }; | |
| 514 | + $test->assertSame(['unpack ok', 'ok refs/heads/main', 0], $push(["{$zero} {$c2} refs/heads/main"], $pack([$c2]))); | |
| 515 | + $test->assertSame($c2, $repo->resolve('main')); | |
| 516 | + $test->assertSame([['refs/heads/main', $zero, $c2]], $after); | |
| 517 | + // 早送りでない更新(denyNonFastForwards) | |
| 518 | + $r = $push(["{$c2} {$c3} refs/heads/main"], $pack([$c3], [$c2]), ['denyNonFastForwards' => true]); | |
| 519 | + $test->assertSame('ng refs/heads/main non-fast-forward', $r[1]); | |
| 520 | + $test->assertSame($c2, $repo->resolve('main')); | |
| 521 | + // 古い値が違う | |
| 522 | + $r = $push(["{$c1} {$c3} refs/heads/main"], $pack([$c3], [$c2])); | |
| 523 | + $test->assertSame('ng refs/heads/main fetch first', $r[1]); | |
| 524 | + // フック(update)とatomic | |
| 525 | + $r = $push(["{$zero} {$c3} refs/heads/ok", "{$zero} {$c3} refs/heads/protected"], $pack([$c3], [$c2]), | |
| 526 | + ['atomic' => true, 'update' => fn(string $ref) => $ref === 'refs/heads/protected' ? '保護されています' : null]); | |
| 527 | + $test->assertSame(['unpack ok', 'ng refs/heads/ok atomic push failed', 'ng refs/heads/protected 保護されています', 0], $r); | |
| 528 | + $test->assertNull($repo->refs->resolve('refs/heads/ok')); | |
| 529 | + $r = $push(["{$zero} {$c3} refs/heads/ok", "{$zero} {$c3} refs/heads/protected"], $pack([$c3], [$c2]), | |
| 530 | + ['update' => fn(string $ref) => $ref === 'refs/heads/protected' ? '保護されています' : null]); | |
| 531 | + $test->assertSame(['unpack ok', 'ok refs/heads/ok', 'ng refs/heads/protected 保護されています', 0], $r); | |
| 532 | + // 削除(パック無し) | |
| 533 | + $test->assertSame(['unpack ok', 'ok refs/heads/ok', 0], $push(["{$c3} {$zero} refs/heads/ok"], '')); | |
| 534 | + $test->assertNull($repo->refs->resolve('refs/heads/ok')); | |
| 535 | + // 足りないオブジェクト(親が無い) | |
| 536 | + $other = Repository::init("{$tmp}/push-other.git"); | |
| 537 | + $c9 = $other->writeCommit(Commit::create($other->updateTree(null, ['z' => [Tree::FILE, "z\n"]]), [str_repeat('9', 40)], $me, $me, "x\n")); | |
| 538 | + $s = ''; | |
| 539 | + (new PackWriter($other))->write([$c9 => 'commit'], function($b) use (&$s): void { $s .= $b; }); | |
| 540 | + $r = $push(["{$zero} {$c9} refs/heads/broken"], $s); | |
| 541 | + $test->assertStringContains('unpack ', $r[0]); | |
| 542 | + $test->assertTrue($r[0] !== 'unpack ok'); | |
| 543 | + $test->assertSame('ng refs/heads/broken unpacker error', $r[1]); | |
| 544 | + $test->assertSame(['unpack ok', 'ng HEAD funny refname', 0], $push(["{$zero} {$c1} HEAD"], $pack([$c1]))); | |
| 545 | + }); | |
| 546 | + | |
| 547 | + $test->it('SSHのコマンドを解析するはず', function($test): void { | |
| 548 | + $test->assertSame(['git-upload-pack', 'user/repo.git'], Ssh::parse("git-upload-pack 'user/repo.git'")); | |
| 549 | + $test->assertSame(['git-receive-pack', 'user/repo.git'], Ssh::parse("git-receive-pack '/user/repo.git'")); | |
| 550 | + $test->assertSame(['git-upload-pack', "it's.git"], Ssh::parse("git upload-pack 'it'\\''s.git'")); | |
| 551 | + $test->assertSame(['git-upload-pack', 'a/b.git'], Ssh::parse('git-upload-pack ~/a/b.git')); | |
| 552 | + foreach (['ls -la', "git-upload-pack '../etc'", "git-upload-pack 'a/../../b'", "git-upload-pack 'a", 'git-upload-pack'] as $bad) $test->assertNull(Ssh::parse($bad), $bad); | |
| 553 | + }); | |
| 554 | +}); | |
| 555 | + | |
| 556 | +$test->describe('Git(本物のgitと)', function($test) use ($tmp, $git, $run, $extract): void { | |
| 557 | + if ($git === null) { | |
| 558 | + $test->skipAll('gitのコマンドが無い(又はproc_openが無効)'); | |
| 559 | + return; | |
| 560 | + } | |
| 561 | + $root = "{$tmp}/srv"; | |
| 562 | + mkdir($root); | |
| 563 | + $probe = @stream_socket_server('tcp://127.0.0.1:0'); | |
| 564 | + $port = (int)substr(strrchr(stream_socket_get_name($probe, false), ':'), 1); | |
| 565 | + fclose($probe); | |
| 566 | + $env = ['LB_GIT_ROOT' => $root]; | |
| 567 | + $proc = proc_open([PHP_BINARY, '-S', "127.0.0.1:{$port}", __DIR__.'/Helper/git-http.php'], [0 => ['pipe', 'r'], 1 => ['file', $tmp.'/srv.log', 'a'], 2 => ['file', $tmp.'/srv.log', 'a']], | |
| 568 | + $pipes, null, $env + ['PATH' => (string)getenv('PATH'), 'SystemRoot' => (string)getenv('SystemRoot')]); | |
| 569 | + for ($i = 0; $i < 50; $i++) { | |
| 570 | + $c = @stream_socket_client("tcp://127.0.0.1:{$port}", $errno, $errstr, 0.1); | |
| 571 | + if ($c) { | |
| 572 | + fclose($c); | |
| 573 | + break; | |
| 574 | + } | |
| 575 | + usleep(100000); | |
| 576 | + } | |
| 577 | + $url = "http://127.0.0.1:{$port}"; | |
| 578 | + $ssh = escapeshellarg(PHP_BINARY).' '.escapeshellarg(__DIR__.'/Helper/git-ssh.php'); | |
| 579 | + $gitEnv = $env + ['GIT_SSH_COMMAND' => $ssh, 'GIT_PROTOCOL_FROM_USER' => '0']; | |
| 580 | + $g = fn(array $args, ?string $cwd = null) => $run(array_merge(['git'], $args), $cwd, $gitEnv); | |
| 581 | + | |
| 582 | + foreach (['sha1', 'sha256'] as $fmt) { | |
| 583 | + $test->it("HTTPとSSHでクローン・プッシュ出来るはず({$fmt})", function($test) use ($fmt, $tmp, $root, $url, $g, $extract): void { | |
| 584 | + rename($extract($fmt, "srv/{$fmt}.git"), "{$root}/{$fmt}.git"); | |
| 585 | + $src = Repository::open("{$root}/{$fmt}.git"); | |
| 586 | + $count = count(iterator_to_array($src->log('main'), false)); | |
| 587 | + foreach (['http-v0' => [0, "{$url}/{$fmt}.git"], 'http-v2' => [2, "{$url}/{$fmt}.git"], 'ssh-v0' => [0, "git@example:{$fmt}.git"], 'ssh-v2' => [2, "git@example:{$fmt}.git"]] as $name => [$v, $remote]) { | |
| 588 | + $dir = "{$tmp}/c-{$fmt}-{$name}"; | |
| 589 | + [$code, , $err] = $g(['-c', "protocol.version={$v}", 'clone', '-q', $remote, $dir]); | |
| 590 | + $test->assertSame(0, $code, "{$name}: {$err}"); | |
| 591 | + [, $out] = $g(['-C', $dir, 'rev-list', '--count', 'HEAD']); | |
| 592 | + $test->assertSame((string)$count, trim($out), $name); | |
| 593 | + [$code, , $err] = $g(['-C', $dir, 'fsck']); | |
| 594 | + $test->assertSame(0, $code, $err); | |
| 595 | + // プッシュ | |
| 596 | + file_put_contents("{$dir}/{$name}.txt", "{$name}\n"); | |
| 597 | + $g(['-C', $dir, 'add', '-A']); | |
| 598 | + $g(['-C', $dir, 'commit', '-q', '-m', "push {$name}"]); | |
| 599 | + [$code, , $err] = $g(['-C', $dir, '-c', "protocol.version={$v}", 'push', '-q', 'origin', 'main']); | |
| 600 | + $test->assertSame(0, $code, "{$name}: {$err}"); | |
| 601 | + $test->assertSame("push {$name}", Repository::open("{$root}/{$fmt}.git")->commit(Repository::open("{$root}/{$fmt}.git")->resolve('main'))->subject()); | |
| 602 | + $count++; | |
| 603 | + } | |
| 604 | + // 浅いクローンと深くする | |
| 605 | + $dir = "{$tmp}/c-{$fmt}-depth"; | |
| 606 | + [$code, , $err] = $g(['clone', '-q', '--depth', '1', "{$url}/{$fmt}.git", $dir]); | |
| 607 | + $test->assertSame(0, $code, $err); | |
| 608 | + [, $out] = $g(['-C', $dir, 'rev-list', '--count', 'HEAD']); | |
| 609 | + $test->assertSame('1', trim($out)); | |
| 610 | + [$code, , $err] = $g(['-C', $dir, 'fetch', '-q', '--deepen', '2']); | |
| 611 | + $test->assertSame(0, $code, $err); | |
| 612 | + [, $out] = $g(['-C', $dir, 'rev-list', '--count', 'HEAD']); | |
| 613 | + $test->assertSame('3', trim($out)); | |
| 614 | + [$code, , $err] = $g(['-C', $dir, 'fetch', '-q', '--unshallow']); | |
| 615 | + $test->assertSame(0, $code, $err); | |
| 616 | + [, $out] = $g(['-C', $dir, 'rev-list', '--count', 'HEAD']); | |
| 617 | + $test->assertSame((string)$count, trim($out)); | |
| 618 | + // ブランチとタグの追加・削除、強制プッシュの拒否は無い(既定) | |
| 619 | + $dir = "{$tmp}/c-{$fmt}-http-v2"; | |
| 620 | + $g(['-C', $dir, 'pull', '-q']); | |
| 621 | + [$code, , $err] = $g(['-C', $dir, 'push', '-q', 'origin', 'main:refs/heads/topic', 'refs/tags/v1.0']); | |
| 622 | + $test->assertSame(0, $code, $err); | |
| 623 | + [$code, , $err] = $g(['-C', $dir, 'push', '-q', 'origin', '--delete', 'topic']); | |
| 624 | + $test->assertSame(0, $code, $err); | |
| 625 | + $repo = Repository::open("{$root}/{$fmt}.git"); | |
| 626 | + $test->assertNull($repo->refs->resolve('refs/heads/topic')); | |
| 627 | + // 新しいリポジトリへのプッシュ(空) | |
| 628 | + [$code, , $err] = $g(['-C', $dir, 'push', '-q', "{$url}/new-{$fmt}.git", 'main']); | |
| 629 | + if ($fmt === 'sha1') { | |
| 630 | + $test->assertSame(0, $code, $err); | |
| 631 | + $test->assertSame($repo->resolve('main'), Repository::open("{$root}/new-sha1.git")->resolve('main')); | |
| 632 | + } | |
| 633 | + }); | |
| 634 | + } | |
| 635 | + | |
| 636 | + $test->it('存在しないリポジトリはエラーのはず', function($test) use ($tmp, $url, $g): void { | |
| 637 | + [$code] = $g(['clone', '-q', "{$url}/none.git", "{$tmp}/none1"]); | |
| 638 | + $test->assertTrue($code !== 0); | |
| 639 | + [$code, , $err] = $g(['clone', '-q', 'git@example:none.git', "{$tmp}/none2"]); | |
| 640 | + $test->assertTrue($code !== 0); | |
| 641 | + $test->assertStringContains('none.git', $err); | |
| 642 | + }); | |
| 643 | + | |
| 644 | + proc_terminate($proc); | |
| 645 | + proc_close($proc); | |
| 646 | +}); | |
| 647 | + | |
| 648 | +$test->printSummary(); |