Gitライブラリ

守矢諏訪子 <suwako@076.moe>、2026-11-13 11:32
コミット d68bcbb560d90ec114b9db137d789e54aa0d66b9 ・親 1b438ae ・ファイルを見る

29個のファイル、+5564 -1

変更 README-JP.md
@@ -250,6 +250,35 @@
250250 * JSON のデータ:ユーザー、コメント、通知、Cron のジョブ、フォロワー、ActivityPub の投稿は名前が `lb_` で始まるテーブルになり、他のファイル(`cache/`、`ratelimit/`、`tmp/`、ロック・一時ファイル、SQLite のデータベースを除く)は `lb_files` に入るので、戻すと同じファイルになります。他のテーブルは `data/db/{テーブル}.json` になります。`data/` が空・無い場合は、テーブルが無いだけです。`JsonTarget` は、2番目の引数(上書き)が `true` か `drop` が無ければ、空でないディレクトリには書きません。
251251 * サイトが使っているデータベースを読む・書く場合は、先に書き込みを止めて下さい(又は新しいデータベースに変換)。変換中に元をロックしません。
252252
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+
253282 ### TLS 1.3・1.2(php_openssl は不要)
254283 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 の鍵もそのまま使えます。
255284 * 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 @@
327356 ## 必要な PHP モジュール
328357 * php_fileinfo(OpenBSDでデフォルトで有効している)
329358 * php_gmp
330-* php_mbstring(OpenBSDでデフォルトで有効している)
359+* php_mbstring(OpenBSDでデフォルトで有効している)
360+* php_zlib(Git、普通はPHPに組み込まれている)
変更 README.md
@@ -248,6 +248,35 @@
248248 * 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.
249249 * 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.
250250
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+
251280 ### TLS 1.3 and 1.2 (no php_openssl)
252281 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.
253282 * 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 @@
316345
317346 ## Required PHP module
318347 * 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();