true, 'verboseOutput' => true ]); $tmp = sys_get_temp_dir().'/lbgit_'.bin2hex(random_bytes(6)); mkdir($tmp, 0700); $rmTree = function(string $dir) use (&$rmTree): void { foreach (@scandir($dir) ?: [] as $f) { if ($f === '.' || $f === '..') continue; $p = "{$dir}/{$f}"; if (is_dir($p) && !is_link($p)) $rmTree($p); else { @chmod($p, 0666); @unlink($p); } } @rmdir($dir); }; register_shutdown_function(fn() => $rmTree($tmp)); // 本物のgitで作ったリポジトリ(SHA-1とSHA-256)と、gitが出した結果 $fixtures = json_decode((string)file_get_contents(__DIR__.'/Helper/git.json'), true); $extract = function(string $fmt, string $name) use ($fixtures, $tmp): string { $dir = "{$tmp}/{$name}"; foreach ($fixtures[$fmt]['files'] as $rel => $b64) { @mkdir(dirname("{$dir}/{$rel}"), 0777, true); file_put_contents("{$dir}/{$rel}", base64_decode($b64)); } foreach (['refs/heads', 'refs/tags'] as $d) @mkdir("{$dir}/{$d}", 0777, true); return $dir; }; $catch = function(callable $fn): ?\Throwable { try { $fn(); } catch (\Throwable $e) { return $e; } return null; }; $me = new Person('諏訪子', 'suwako@example.com', 1700000000, '+0900'); // 本物のgit(任意) $git = null; if (function_exists('proc_open') && function_exists('shell_exec')) { $v = @shell_exec('git --version 2>&1'); if (is_string($v) && str_starts_with($v, 'git version')) $git = 'git'; } $run = function(array $cmd, ?string $cwd = null, array $env = []) use ($tmp): array { $p = proc_open($cmd, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd, $env + ['GIT_CONFIG_GLOBAL' => $tmp.'/gitconfig', 'GIT_CONFIG_NOSYSTEM' => '1', 'HOME' => $tmp, 'PATH' => (string)getenv('PATH'), 'GIT_TERMINAL_PROMPT' => '0', 'SystemRoot' => (string)getenv('SystemRoot')]); fclose($pipes[0]); $out = stream_get_contents($pipes[1]); $err = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); return [proc_close($p), (string)$out, (string)$err]; }; file_put_contents($tmp.'/gitconfig', "[user]\n\tname = テスト\n\temail = test@example.com\n[init]\n\tdefaultBranch = main\n[commit]\n\tgpgsign = false\n"); // プロトコルのテスト用 $pkts = function(array $lines): string { $s = ''; foreach ($lines as $l) $s .= $l === 0 ? '0000' : ($l === 1 ? '0001' : PktLine::encode($l)); return $s; }; $stream = function(string $s) { $f = fopen('php://temp', 'w+b'); fwrite($f, $s); rewind($f); return $f; }; /** 応答を行に分ける(サイドバンドの1はパック、2は進捗) */ $parse = function($out, bool $sideband = true): array { rewind($out); $pkt = new PktLine($out); $lines = []; $pack = ''; $progress = ''; while (($l = $pkt->read(true)) !== null) { if (is_int($l)) { $lines[] = $l; continue; } if (str_starts_with($l, 'PACK')) { $pack = $l.stream_get_contents($out); break; } if ($sideband && $pack === '' && strlen($l) > 0 && in_array(ord($l[0]), [1, 2, 3], true) && !preg_match('/^[\x20-\x7e]/', $l)) { if ($l[0] === "\1") $pack .= substr($l, 1); else if ($l[0] === "\2") $progress .= substr($l, 1); else $lines[] = 'ERROR '.substr($l, 1); continue; } if ($sideband && $l !== '' && $l[0] === "\1") { $pack .= substr($l, 1); continue; } $lines[] = rtrim($l, "\n"); } return ['lines' => $lines, 'pack' => $pack, 'progress' => $progress]; }; /** 新しいリポジトリにパックを入れる */ $unpack = function(Repository $repo, string $pack): array { $f = fopen('php://temp', 'w+b'); fwrite($f, $pack); rewind($f); return (new PackIndexer($repo))->receive($f, 0); }; $test->describe('Git(設定とオブジェクト)', function($test) use ($me): void { $test->it('設定ファイルを読み書き出来るはず', function($test): void { $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" ."[Section \"Sub \\\"q\\\"\"]\n\tkey = a \\\n b\n\tflag\n[pack]\n\twindowMemory = 2k\n"); $test->assertTrue($c->getBool('core.bare')); $test->assertFalse($c->getBool('core.filemode', true)); $test->assertSame('https://x/a b', $c->get('remote.origin.url')); $test->assertSame('+refs/heads/*:refs/remotes/origin/*', $c->get('Remote.origin.Fetch')); $test->assertNull($c->get('remote.Origin.url'), 'サブセクションは大文字・小文字を区別する'); $test->assertSame('a b', $c->get('section.Sub "q".key')); $test->assertTrue($c->getBool('section.Sub "q".flag')); $test->assertSame(2048, $c->getInt('pack.windowmemory')); $c->set('user.name', ' 前後に空白 '); $c->set('core.bare', null); $back = Config::fromString($c->toString()); $test->assertSame(' 前後に空白 ', $back->get('user.name')); $test->assertNull($back->get('core.bare')); $test->assertSame('https://x/a b', $back->get('remote.origin.url')); }); $test->it('コミット・タグ・作者を解析して元に戻せるはず', function($test): void { $raw = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nparent 1111111111111111111111111111111111111111\nparent 2222222222222222222222222222222222222222\n" ."author 諏訪子 1700000000 +0900\ncommitter Kanako 1700000100 -0130\n" ."gpgsig -----BEGIN SSH SIGNATURE-----\n U1NIU0lH\n \n -----END SSH SIGNATURE-----\n\n件名\n\n本文\n"; $c = Commit::parse($raw); $test->assertSame(['1111111111111111111111111111111111111111', '2222222222222222222222222222222222222222'], $c->parents); $test->assertSame('諏訪子', $c->author->name); $test->assertSame(1700000100, $c->committer->time); $test->assertSame('-0130', $c->committer->tz); $test->assertSame('2023-11-14 20:45:00', $c->committer->date()->format('Y-m-d H:i:s')); $test->assertSame('件名', $c->subject()); $test->assertSame("-----BEGIN SSH SIGNATURE-----\nU1NIU0lH\n\n-----END SSH SIGNATURE-----", $c->header('gpgsig')); $test->assertSame($raw, $c->toString(), '同じバイト列に戻る'); $t = Tag::parse("object 1111111111111111111111111111111111111111\ntype commit\ntag v1.0\ntagger 諏訪子 1 +0000\n\nリリース\n"); $test->assertSame(['v1.0', 'commit', 'リリース'], [$t->tag, $t->type, rtrim($t->message)]); $p = Person::parse('名前だけ <> 0 +0000'); $test->assertSame(['名前だけ', ''], [$p->name, $p->email]); }); $test->it('ツリーはGitの順番に並べ、不正な名前を拒否するはず', function($test) use ($me): void { $id = str_repeat('a', 40); $t = Tree::create([[Tree::FILE, 'a.b', $id], [Tree::DIR, 'a', $id], [Tree::FILE, 'a-', $id], [Tree::FILE, 'B', $id]]); // ディレクトリ「a」は「a/」として並ぶ(「a-」「a.b」の後) $test->assertSame(['B', 'a-', 'a.b', 'a'], array_column($t->entries, 1)); $test->assertSame($t->entries, Tree::parse($t->toString(), 20)->entries); foreach (['', '.', '..', 'a/b', "a\0b"] as $bad) { $test->assertThrows(fn() => Tree::create([[Tree::FILE, $bad, $id]]), GitException::class); } $test->assertThrows(fn() => Tree::create([[Tree::FILE, 'x', $id], [Tree::EXEC, 'x', $id]]), GitException::class); }); $test->it('差分を当てられるはず', function($test): void { $base = str_repeat('0123456789', 100); // 元の大きさ、結果の大きさ、コピー(オフセット10、長さ20)、挿入(3バイト) $delta = chr(232).chr(7).chr(23).chr(0x80 | 0x01 | 0x10).chr(10).chr(20).chr(3).'XYZ'; $test->assertSame('01234567890123456789XYZ', Delta::apply($base, $delta)); $test->assertSame(23, Delta::targetSize($delta)); $test->assertThrows(fn() => Delta::apply('short', $delta), GitException::class); }); $test->it('参照の名前を確かめるはず', function($test): void { foreach (['refs/heads/main', 'refs/tags/v1.0', 'refs/heads/feature/日本語', 'HEAD'] as $ok) $test->assertTrue(Refs::validName($ok), $ok); 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) { $test->assertFalse(Refs::validName($bad), $bad); } }); }); foreach (['sha1', 'sha256'] as $fmt) { $test->describe("Git(本物のリポジトリを読む、{$fmt})", function($test) use ($fmt, $fixtures, $extract): void { $dir = $extract($fmt, "read-{$fmt}"); $e = $fixtures[$fmt]['expect']; $repo = Repository::open($dir); $test->it('参照と履歴を読めるはず', function($test) use ($repo, $e, $fmt): void { $test->assertSame($fmt, $repo->hashAlgo); $test->assertSame('refs/heads/main', $repo->headRef()); $test->assertSame($e['refs'], $repo->refs->all()); $test->assertSame(array_column($e['log'], 0), array_map(fn($c) => $c->id, iterator_to_array($repo->log('main'), false))); $test->assertSame(array_column($e['log'], 1), array_map(fn($c) => $c->subject(), iterator_to_array($repo->log('main'), false))); $test->assertSame($e['logPath'], array_map(fn($c) => $c->id, iterator_to_array($repo->log('main', ['path' => 'a.txt']), false))); $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))); $test->assertSame([$e['log'][0][0]], array_map(fn($c) => $c->id, iterator_to_array($repo->log('main', ['not' => 'main~1']), false))); }); $test->it('リビジョンを解決出来るはず', function($test) use ($repo, $e): void { foreach ($e['revs'] as $rev => $id) $test->assertSame($id, $repo->resolve($rev), $rev); $test->assertSame($e['revs']['HEAD'], $repo->resolve(substr($e['revs']['HEAD'], 0, 7)), '短いID'); $test->assertNull($repo->resolve('nothing')); $test->assertNull($repo->resolve('HEAD~100')); $test->assertNull($repo->resolve('HEAD:no/such/file')); }); $test->it('ツリーとオブジェクトを読めるはず', function($test) use ($repo, $e): void { $list = []; $walk = function(string $tree, string $prefix) use (&$walk, &$list, $repo): void { foreach ($repo->tree($tree)->entries as [$mode, $name, $id]) { $type = $mode === Tree::DIR ? 'tree' : ($mode === Tree::SUBMODULE ? 'commit' : 'blob'); $list[] = [str_pad($mode, 6, '0', STR_PAD_LEFT), $type, $id, $prefix.$name]; if ($mode === Tree::DIR) $walk($id, $prefix.$name.'/'); } }; $walk($repo->treeOf('main'), ''); usort($list, fn($a, $b) => strcmp($a[3], $b[3])); $expect = $e['tree']; usort($expect, fn($a, $b) => strcmp($a[3], $b[3])); $test->assertSame($expect, $list); $test->assertSame(Tree::LINK, $repo->entry('main', 'link')[0]); $test->assertSame('a.txt', $repo->blob($repo->entry('main', 'link')[2])); $test->assertSame("ファイル\n", $repo->blob($repo->resolve('main:dir/サブ/日本語.txt'))); $tag = $repo->tag($e['revs']['v1.0']); $test->assertSame($e['tagObject'], $tag->toString()); $test->assertSame('v1.0', $tag->tag); // 全部のオブジェクトのIDが正しい(パックの差分も) $n = 0; foreach ($repo->objects->ids() as $id) { [$type, $data] = $repo->objects->read($id); $test->assertSame($id, $repo->hashObject($type, $data)); $test->assertSame([$type, strlen($data)], $repo->objects->info($id)); $n++; } $test->assertTrue($n > 20); }); $test->it('gitと同じ差分を出すはず', function($test) use ($repo, $e): void { foreach ($e['diffs'] as $rev => $expect) { $c = $repo->commit($repo->resolve($rev)); $test->assertSame($expect, Diff::unified($repo, $c->parents[0] ?? null, $c->id), $rev); } }); $test->it('gitと同じblameを出すはず', function($test) use ($repo, $e): void { $b = Blame::file($repo, 'main', 'a.txt'); $test->assertSame($e['blame'], array_map(fn($l) => [$l['commit'], $l['line']], $b)); $test->assertSame('top', $b[0]['text']); }); $test->it('祖先と共通の祖先が分かるはず', function($test) use ($repo, $e): void { $w = new Walk($repo); $test->assertTrue($w->isAncestor($e['revs']['main~1^2'], $e['revs']['HEAD'])); $test->assertFalse($w->isAncestor($e['revs']['HEAD'], $e['revs']['feature'])); $test->assertSame($e['revs']['refs/heads/feature~1'], $w->mergeBase($e['revs']['feature'], $e['revs']['main^^'])); }); }); } $test->describe('Git(書く)', function($test) use ($tmp, $me, $catch, $git, $run): void { foreach (['sha1', 'sha256'] as $fmt) { $test->it("リポジトリを作り、コミット・タグ・参照を書けるはず({$fmt})", function($test) use ($tmp, $me, $fmt, $git, $run, $catch): void { $dir = "{$tmp}/write-{$fmt}.git"; $repo = Repository::init($dir, $fmt, 'main', 'group', 'テスト'); $test->assertTrue(Repository::isRepository($dir)); $test->assertTrue($repo->isEmpty()); $test->assertThrows(fn() => Repository::init($dir), GitException::class); $t1 = $repo->updateTree(null, ['README.md' => [Tree::FILE, "# テスト\n"], 'src/a.php' => [Tree::FILE, " [Tree::EXEC, "#!/bin/sh\n"]]); $c1 = $repo->writeCommit(Commit::create($t1, [], $me, $me, "最初\n")); $repo->refs->update('refs/heads/main', $c1, $repo->zeroId()); $t2 = $repo->updateTree($t1, ['src/a.php' => [Tree::FILE, " null, 'docs/x/y.txt' => [Tree::FILE, "y\n"]]); $test->assertSame(['README.md', 'docs', 'src'], array_column($repo->tree($t2)->entries, 1), '空になったbinは消える'); $c2 = $repo->writeCommit(Commit::create($t2, [$c1], $me, $me, "二番目\n")); $repo->refs->update('refs/heads/main', $c2, $c1); $test->assertNotNull($catch(fn() => $repo->refs->update('refs/heads/main', $c1, $c1)), '古い値が違えば失敗'); $test->assertNotNull($catch(fn() => $repo->refs->update('refs/heads/new', $c1, $c2)), '無い参照'); $test->assertNotNull($catch(fn() => $repo->refs->update('refs/heads/main/x', $c1)), '参照と重なる'); $test->assertNotNull($catch(fn() => $repo->refs->update('refs/heads/bad..name', $c1))); $tag = $repo->writeTag(Tag::create($c1, 'commit', 'v0.1', $me, "リリース\n")); $repo->refs->transaction([['refs/tags/v0.1', $tag, $repo->zeroId()], ['refs/heads/dev', $c1, null]]); $test->assertSame($c1, $repo->resolve('v0.1^{commit}')); $repo->refs->update('refs/heads/dev', null, $c1); $test->assertNull($repo->refs->resolve('refs/heads/dev')); $test->assertSame($c2, $repo->resolve('HEAD')); $test->assertSame(["二番目", "最初"], array_map(fn($c) => $c->subject(), iterator_to_array($repo->log('HEAD'), false))); // HEADの更新はブランチを更新する $repo->refs->update('HEAD', $c1, $c2); $test->assertSame($c1, $repo->refs->resolve('refs/heads/main')); $repo->refs->update('HEAD', $c2); if (DIRECTORY_SEPARATOR === '/') $test->assertSame(02775, fileperms($dir.'/objects') & 07777, 'グループで共有'); // 整理しても全部読める $r = Maintenance::gc($repo, 0); $test->assertTrue($r['objects'] >= 9); $repo2 = Repository::open($dir); $test->assertCount(1, $repo2->objects->packs(true)); $test->assertSame("blob($repo2->resolve('main:src/a.php'))); $test->assertSame($c1, $repo2->resolve('v0.1^{}')); if ($git) { [$code, , $err] = $run(['git', '-C', $dir, 'fsck', '--strict']); $test->assertSame(0, $code, $err); [, $out] = $run(['git', '-C', $dir, 'log', '--format=%s']); $test->assertSame("二番目\n最初\n", $out); } }); } }); foreach (['packOfs' => 'OFS_DELTA', 'packRef' => 'REF_DELTA'] as $key => $label) { $test->describe("Git(パック、{$label})", function($test) use ($key, $fixtures, $tmp, $extract, $stream, $catch, $unpack): void { foreach (['sha1', 'sha256'] as $fmt) { $test->it("gitのパックを受け取り、全部のオブジェクトが同じはず({$fmt})", function($test) use ($key, $fmt, $fixtures, $tmp, $extract, $stream): void { $src = Repository::open($extract($fmt, "pk-{$key}-{$fmt}")); foreach ([0, 1000] as $limit) { $dst = Repository::init("{$tmp}/pk-{$key}-{$fmt}-{$limit}.git", $fmt); $r = (new PackIndexer($dst))->receive($stream(base64_decode($fixtures[$fmt][$key])), $limit); $test->assertSame($limit === 0, $r['pack'] !== null); $ids = iterator_to_array($src->objects->ids(), false); $test->assertCount(count(array_unique($ids)), $r['objects']); foreach (array_unique($ids) as $id) $test->assertSame($src->objects->read($id), $dst->objects->read($id)); $test->assertSame([], array_values(array_filter(array_keys($r['links']), fn($l) => !$dst->objects->exists($l)))); } }); } $test->it('壊れたパックは拒否するはず', function($test) use ($key, $fixtures, $tmp, $stream, $catch): void { $pack = base64_decode($fixtures['sha1'][$key]); $dst = Repository::init("{$tmp}/bad-{$key}.git"); 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) { $e = $catch(fn() => (new PackIndexer($dst))->receive($stream($bad), 0)); $test->assertTrue($e instanceof GitException, $what); } $test->assertSame([], glob("{$tmp}/bad-{$key}.git/objects/pack/*") ?: [], '一時ファイルも残さない'); }); }); } $test->describe('Git(パックを作る)', function($test) use ($tmp, $extract, $unpack): void { $test->it('パックを作って受け取ると同じオブジェクトになるはず(差分の再利用)', function($test) use ($tmp, $extract, $unpack): void { foreach (['sha1', 'sha256'] as $fmt) { $src = Repository::open($extract($fmt, "pw-{$fmt}")); $objects = (new Walk($src))->objects(array_values($src->refs->all()), []); $pack = ''; $sum = (new PackWriter($src))->write($objects, function(string $b) use (&$pack): void { $pack .= $b; }); $test->assertSame($sum, bin2hex(substr($pack, -$src->hashLen))); $dst = Repository::init("{$tmp}/pw-{$fmt}-dst.git", $fmt); $r = $unpack($dst, $pack); $test->assertSame(count($objects), count($r['objects'])); foreach ($objects as $id => $type) $test->assertSame($src->objects->read($id), $dst->objects->read($id)); // 一部(相手が持っている物を除く) $head = $src->resolve('main'); $part = (new Walk($src))->objects([$head], [$src->resolve('main~1')]); $test->assertSame('commit', $part[$head]); $test->assertTrue(count($part) < count($objects)); $test->assertFalse(isset($part[$src->resolve('main~1')])); } }); }); $test->describe('Git(プロトコル)', function($test) use ($tmp, $me, $extract, $fixtures, $pkts, $stream, $parse, $unpack): void { $src = Repository::open($extract('sha1', 'proto')); $e = $fixtures['sha1']['expect']; $test->it('v0の広告はHEAD・能力・剥いたタグを含むはず', function($test) use ($src, $e, $parse): void { $out = fopen('php://temp', 'w+b'); (new UploadPack($src))->advertise($out, true); $r = $parse($out, false); $test->assertSame('# service=git-upload-pack', $r['lines'][0]); $test->assertSame(0, $r['lines'][1]); [$first, $caps] = explode("\0", $r['lines'][2]); $test->assertSame($e['revs']['HEAD'].' HEAD', $first); $test->assertStringContains('symref=HEAD:refs/heads/main', $caps); $test->assertStringContains('side-band-64k', $caps); $test->assertStringContains('object-format=sha1', $caps); $test->assertTrue(in_array($e['revs']['v1.0^{}'].' refs/tags/v1.0^{}', $r['lines'], true)); $test->assertSame(0, end($r['lines'])); }); $test->it('v2のls-refsはシンボリック参照と剥いたタグを返すはず', function($test) use ($src, $e, $pkts, $stream, $parse): void { $out = fopen('php://temp', 'w+b'); (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); $r = $parse($out, false); $test->assertSame($e['revs']['HEAD'].' HEAD symref-target:refs/heads/main', $r['lines'][0]); $test->assertTrue(in_array($e['refs']['refs/tags/v1.0'].' refs/tags/v1.0 peeled:'.$e['revs']['v1.0^{}'], $r['lines'], true)); $test->assertFalse((bool)array_filter($r['lines'], fn($l) => is_string($l) && str_contains($l, ' refs/heads/'))); }); $test->it('v0でクローンとフェッチが出来るはず', function($test) use ($src, $e, $tmp, $pkts, $stream, $parse, $unpack): void { $head = $e['revs']['HEAD']; // 全部 $out = fopen('php://temp', 'w+b'); (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); $r = $parse($out); $test->assertSame('NAK', $r['lines'][0]); $dst = Repository::init("{$tmp}/proto-v0.git"); $got = $unpack($dst, $r['pack']); $test->assertTrue(isset($got['objects'][$e['refs']['refs/tags/v1.0']]), 'include-tag'); $test->assertSame(count(iterator_to_array($src->log('HEAD'), false)), count(iterator_to_array((new Walk($dst))->log([$head]), false))); // 持っている物を伝える(ネゴシエーションとdone) $out = fopen('php://temp', 'w+b'); (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); $test->assertSame(["ACK {$e['revs']['main~1']} common", 'NAK'], $parse($out)['lines']); $out = fopen('php://temp', 'w+b'); (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); $r = $parse($out); $test->assertSame(["ACK {$e['revs']['main~1']} common", "ACK {$e['revs']['main~1']}"], array_values(array_filter($r['lines'], 'is_string'))); $test->assertSame('', $r['progress']); $n = unpack('N', $r['pack'], 8)[1]; $test->assertTrue($n >= 2 && $n <= 4, '新しいコミットとツリーとブロブだけ'); }); $test->it('v2でフェッチと深さの制限が出来るはず', function($test) use ($src, $e, $tmp, $pkts, $stream, $parse, $unpack): void { $head = $e['revs']['HEAD']; $out = fopen('php://temp', 'w+b'); (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); $test->assertSame(['acknowledgments', "ACK {$e['revs']['main~1']}", 0], $parse($out)['lines']); $out = fopen('php://temp', 'w+b'); (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); $r = $parse($out); $test->assertSame(['shallow-info', "shallow {$head}", 1, 'packfile'], array_slice($r['lines'], 0, 4)); $dst = Repository::init("{$tmp}/proto-v2.git"); $got = $unpack($dst, $r['pack']); $test->assertSame(1, count(array_filter($got['objects'], fn($t) => $t === 'commit')), '1つのコミットだけ'); // 不明なオブジェクトはエラー $out = fopen('php://temp', 'w+b'); (new UploadPack($src))->serve($stream($pkts(["command=fetch\n", 1, 'want '.str_repeat('f', 40)."\n", "done\n", 0])), $out, true, 2); $test->assertStringContains('ERR', implode("\n", array_filter($parse($out, false)['lines'], 'is_string'))); }); $test->it('プッシュを受け取り、参照を更新するはず', function($test) use ($tmp, $me, $pkts, $stream, $parse): void { $repo = Repository::init("{$tmp}/push.git"); $zero = $repo->zeroId(); // 送る側のリポジトリでコミットを作る $client = Repository::init("{$tmp}/push-client.git"); $t = $client->updateTree(null, ['a.txt' => [Tree::FILE, "a\n"]]); $c1 = $client->writeCommit(Commit::create($t, [], $me, $me, "1\n")); $c2 = $client->writeCommit(Commit::create($client->updateTree($t, ['b.txt' => [Tree::FILE, "b\n"]]), [$c1], $me, $me, "2\n")); $c3 = $client->writeCommit(Commit::create($client->updateTree($t, ['c.txt' => [Tree::FILE, "c\n"]]), [$c1], $me, $me, "別の2\n")); $pack = function(array $want, array $have = []) use ($client): string { $s = ''; (new PackWriter($client))->write((new Walk($client))->objects($want, $have), function($b) use (&$s): void { $s .= $b; }); return $s; }; $after = []; $push = function(array $commands, string $packData, array $opt = []) use ($repo, $pkts, $stream, $parse, &$after): array { $lines = []; foreach ($commands as $i => $c) $lines[] = $c.($i === 0 ? "\0report-status side-band-64k quiet".($opt['atomic'] ?? false ? ' atomic' : '') : '')."\n"; $lines[] = 0; $out = fopen('php://temp', 'w+b'); (new ReceivePack($repo, $opt + ['after' => function(array $done) use (&$after): void { $after = $done; }]))->serve($stream($pkts($lines).$packData), $out, true); // サイドバンドの中のpkt-line $inner = $parse($out)['pack']; return $parse($stream($inner), false)['lines']; }; $test->assertSame(['unpack ok', 'ok refs/heads/main', 0], $push(["{$zero} {$c2} refs/heads/main"], $pack([$c2]))); $test->assertSame($c2, $repo->resolve('main')); $test->assertSame([['refs/heads/main', $zero, $c2]], $after); // 早送りでない更新(denyNonFastForwards) $r = $push(["{$c2} {$c3} refs/heads/main"], $pack([$c3], [$c2]), ['denyNonFastForwards' => true]); $test->assertSame('ng refs/heads/main non-fast-forward', $r[1]); $test->assertSame($c2, $repo->resolve('main')); // 古い値が違う $r = $push(["{$c1} {$c3} refs/heads/main"], $pack([$c3], [$c2])); $test->assertSame('ng refs/heads/main fetch first', $r[1]); // フック(update)とatomic $r = $push(["{$zero} {$c3} refs/heads/ok", "{$zero} {$c3} refs/heads/protected"], $pack([$c3], [$c2]), ['atomic' => true, 'update' => fn(string $ref) => $ref === 'refs/heads/protected' ? '保護されています' : null]); $test->assertSame(['unpack ok', 'ng refs/heads/ok atomic push failed', 'ng refs/heads/protected 保護されています', 0], $r); $test->assertNull($repo->refs->resolve('refs/heads/ok')); $r = $push(["{$zero} {$c3} refs/heads/ok", "{$zero} {$c3} refs/heads/protected"], $pack([$c3], [$c2]), ['update' => fn(string $ref) => $ref === 'refs/heads/protected' ? '保護されています' : null]); $test->assertSame(['unpack ok', 'ok refs/heads/ok', 'ng refs/heads/protected 保護されています', 0], $r); // 削除(パック無し) $test->assertSame(['unpack ok', 'ok refs/heads/ok', 0], $push(["{$c3} {$zero} refs/heads/ok"], '')); $test->assertNull($repo->refs->resolve('refs/heads/ok')); // 足りないオブジェクト(親が無い) $other = Repository::init("{$tmp}/push-other.git"); $c9 = $other->writeCommit(Commit::create($other->updateTree(null, ['z' => [Tree::FILE, "z\n"]]), [str_repeat('9', 40)], $me, $me, "x\n")); $s = ''; (new PackWriter($other))->write([$c9 => 'commit'], function($b) use (&$s): void { $s .= $b; }); $r = $push(["{$zero} {$c9} refs/heads/broken"], $s); $test->assertStringContains('unpack ', $r[0]); $test->assertTrue($r[0] !== 'unpack ok'); $test->assertSame('ng refs/heads/broken unpacker error', $r[1]); $test->assertSame(['unpack ok', 'ng HEAD funny refname', 0], $push(["{$zero} {$c1} HEAD"], $pack([$c1]))); // intercept(書かない)とprocess(結果の前に呼ぶ) $processed = null; $r = $push(["{$zero} {$c3} refs/for/main/topic"], $pack([$c3], [$c2]), ['intercept' => fn(string $ref) => str_starts_with($ref, 'refs/for/'), 'process' => function(array $done) use (&$processed): array { $processed = $done; return ['作りました']; }]); $test->assertSame(['unpack ok', 'ok refs/for/main/topic', 0], $r); $test->assertNull($repo->refs->resolve('refs/for/main/topic'), '書かない'); $test->assertTrue($repo->objects->exists($c3), 'オブジェクトは受け取る'); $test->assertSame([['refs/for/main/topic', $zero, $c3]], $processed); }); $test->it('SSHのコマンドを解析するはず', function($test): void { $test->assertSame(['git-upload-pack', 'user/repo.git'], Ssh::parse("git-upload-pack 'user/repo.git'")); $test->assertSame(['git-receive-pack', 'user/repo.git'], Ssh::parse("git-receive-pack '/user/repo.git'")); $test->assertSame(['git-upload-pack', "it's.git"], Ssh::parse("git upload-pack 'it'\\''s.git'")); $test->assertSame(['git-upload-pack', 'a/b.git'], Ssh::parse('git-upload-pack ~/a/b.git')); 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); }); }); $test->describe('Git(マージ)', function($test) use ($tmp, $me, $git, $run): void { $l = fn(string $s) => Merge::split($s); $test->it('行の3方向のマージ(離れた変更、同じ変更、コンフリクト)', function($test) use ($l): void { $base = $l("1\n2\n3\n4\n5\n6\n7\n"); $r = Merge::lines($base, $l("1\nA\n3\n4\n5\n6\n7\n"), $l("1\n2\n3\n4\n5\nB\n7\n")); $test->assertSame(0, $r['conflicts']); $test->assertSame("1\nA\n3\n4\n5\nB\n7\n", implode('', $r['lines'])); $r = Merge::lines($base, $l("1\nX\n3\n4\n5\n6\n7\n"), $l("1\nX\n3\n4\n5\n6\n7\n8\n")); $test->assertSame("1\nX\n3\n4\n5\n6\n7\n8\n", implode('', $r['lines']), '同じ変更'); $r = Merge::lines($base, $l("1\nA\n3\n4\n5\n6\n7\n"), $l("1\nB\n3\n4\n5\n6\n7\n"), 'main', 'topic'); $test->assertSame(1, $r['conflicts']); $test->assertSame("1\n<<<<<<< main\nA\n=======\nB\n>>>>>>> topic\n3\n4\n5\n6\n7\n", implode('', $r['lines'])); $test->assertSame(1, Merge::lines($base, $l("1\nA\n3\n4\n5\n6\n7\n"), $l("1\n2\nB\n4\n5\n6\n7\n"))['conflicts'], '隣り合う変更'); $test->assertSame("x\ny", Merge::text("x\n", "x\ny", "x\n"), '最後の改行'); $test->assertNull(Merge::text("a\n", "b\n", "c\n")); $test->assertNull(Merge::text("a\0", "b\0", "c\0"), 'バイナリ'); }); $test->it('ツリーの3方向のマージとコミットの適用(cherry-pick)', function($test) use ($tmp, $me): void { $repo = Repository::init("{$tmp}/merge.git"); $base = $repo->updateTree(null, ['a.txt' => [Tree::FILE, "1\n2\n3\n4\n5\n"], 'dir/b.txt' => [Tree::FILE, "b\n"], 'gone.txt' => [Tree::FILE, "g\n"]]); $ours = $repo->updateTree($base, ['a.txt' => [Tree::FILE, "1\nO\n3\n4\n5\n"], 'dir/new.txt' => [Tree::FILE, "n\n"], 'gone.txt' => null]); $theirs = $repo->updateTree($base, ['a.txt' => [Tree::FILE, "1\n2\n3\n4\nT\n"], 'dir/b.txt' => [Tree::EXEC, "b\n"], 'c.txt' => [Tree::FILE, "c\n"]]); $r = Merge::trees($repo, $base, $ours, $theirs); $test->assertSame([], $r['conflicts']); $test->assertSame("1\nO\n3\n4\nT\n", $repo->blob($repo->entry($r['tree'], 'a.txt')[2])); $test->assertSame(Tree::EXEC, $repo->entry($r['tree'], 'dir/b.txt')[0]); $test->assertNotNull($repo->entry($r['tree'], 'dir/new.txt')); $test->assertNotNull($repo->entry($r['tree'], 'c.txt')); $test->assertNull($repo->entry($r['tree'], 'gone.txt'), '削除'); // コンフリクト:同じ行、削除と変更 $t2 = $repo->updateTree($base, ['a.txt' => [Tree::FILE, "1\nT\n3\n4\n5\n"], 'gone.txt' => [Tree::FILE, "changed\n"]]); $r = Merge::trees($repo, $base, $ours, $t2); $test->assertNull($r['tree']); $test->assertSame(['a.txt', 'gone.txt'], $r['conflicts']); // cherry-pick $c0 = $repo->writeCommit(Commit::create($base, [], $me, $me, "0\n")); $c1 = $repo->writeCommit(Commit::create($theirs, [$c0], $me, $me, "1\n")); $c2 = $repo->writeCommit(Commit::create($ours, [$c0], $me, $me, "2\n")); $test->assertSame($r = Merge::trees($repo, $base, $ours, $theirs)['tree'], Merge::pick($repo, $c1, $c2)); }); }); $test->describe('Git(本物のgitと)', function($test) use ($tmp, $git, $run, $extract): void { if ($git === null) { $test->skipAll('gitのコマンドが無い(又はproc_openが無効)'); return; } $root = "{$tmp}/srv"; mkdir($root); $probe = @stream_socket_server('tcp://127.0.0.1:0'); $port = (int)substr(strrchr(stream_socket_get_name($probe, false), ':'), 1); fclose($probe); $env = ['LB_GIT_ROOT' => $root]; $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']], $pipes, null, $env + ['PATH' => (string)getenv('PATH'), 'SystemRoot' => (string)getenv('SystemRoot')]); for ($i = 0; $i < 50; $i++) { $c = @stream_socket_client("tcp://127.0.0.1:{$port}", $errno, $errstr, 0.1); if ($c) { fclose($c); break; } usleep(100000); } $url = "http://127.0.0.1:{$port}"; $ssh = escapeshellarg(PHP_BINARY).' '.escapeshellarg(__DIR__.'/Helper/git-ssh.php'); $gitEnv = $env + ['GIT_SSH_COMMAND' => $ssh, 'GIT_PROTOCOL_FROM_USER' => '0']; $g = fn(array $args, ?string $cwd = null) => $run(array_merge(['git'], $args), $cwd, $gitEnv); foreach (['sha1', 'sha256'] as $fmt) { $test->it("HTTPとSSHでクローン・プッシュ出来るはず({$fmt})", function($test) use ($fmt, $tmp, $root, $url, $g, $extract): void { rename($extract($fmt, "srv/{$fmt}.git"), "{$root}/{$fmt}.git"); $src = Repository::open("{$root}/{$fmt}.git"); $count = count(iterator_to_array($src->log('main'), false)); 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]) { $dir = "{$tmp}/c-{$fmt}-{$name}"; [$code, , $err] = $g(['-c', "protocol.version={$v}", 'clone', '-q', $remote, $dir]); $test->assertSame(0, $code, "{$name}: {$err}"); [, $out] = $g(['-C', $dir, 'rev-list', '--count', 'HEAD']); $test->assertSame((string)$count, trim($out), $name); [$code, , $err] = $g(['-C', $dir, 'fsck']); $test->assertSame(0, $code, $err); // プッシュ file_put_contents("{$dir}/{$name}.txt", "{$name}\n"); $g(['-C', $dir, 'add', '-A']); $g(['-C', $dir, 'commit', '-q', '-m', "push {$name}"]); [$code, , $err] = $g(['-C', $dir, '-c', "protocol.version={$v}", 'push', '-q', 'origin', 'main']); $test->assertSame(0, $code, "{$name}: {$err}"); $test->assertSame("push {$name}", Repository::open("{$root}/{$fmt}.git")->commit(Repository::open("{$root}/{$fmt}.git")->resolve('main'))->subject()); $count++; } // 浅いクローンと深くする $dir = "{$tmp}/c-{$fmt}-depth"; [$code, , $err] = $g(['clone', '-q', '--depth', '1', "{$url}/{$fmt}.git", $dir]); $test->assertSame(0, $code, $err); [, $out] = $g(['-C', $dir, 'rev-list', '--count', 'HEAD']); $test->assertSame('1', trim($out)); [$code, , $err] = $g(['-C', $dir, 'fetch', '-q', '--deepen', '2']); $test->assertSame(0, $code, $err); [, $out] = $g(['-C', $dir, 'rev-list', '--count', 'HEAD']); $test->assertSame('3', trim($out)); [$code, , $err] = $g(['-C', $dir, 'fetch', '-q', '--unshallow']); $test->assertSame(0, $code, $err); [, $out] = $g(['-C', $dir, 'rev-list', '--count', 'HEAD']); $test->assertSame((string)$count, trim($out)); // ブランチとタグの追加・削除、強制プッシュの拒否は無い(既定) $dir = "{$tmp}/c-{$fmt}-http-v2"; $g(['-C', $dir, 'pull', '-q']); [$code, , $err] = $g(['-C', $dir, 'push', '-q', 'origin', 'main:refs/heads/topic', 'refs/tags/v1.0']); $test->assertSame(0, $code, $err); [$code, , $err] = $g(['-C', $dir, 'push', '-q', 'origin', '--delete', 'topic']); $test->assertSame(0, $code, $err); $repo = Repository::open("{$root}/{$fmt}.git"); $test->assertNull($repo->refs->resolve('refs/heads/topic')); // 新しいリポジトリへのプッシュ(空) [$code, , $err] = $g(['-C', $dir, 'push', '-q', "{$url}/new-{$fmt}.git", 'main']); if ($fmt === 'sha1') { $test->assertSame(0, $code, $err); $test->assertSame($repo->resolve('main'), Repository::open("{$root}/new-sha1.git")->resolve('main')); } }); } $test->it('存在しないリポジトリはエラーのはず', function($test) use ($tmp, $url, $g): void { [$code] = $g(['clone', '-q', "{$url}/none.git", "{$tmp}/none1"]); $test->assertTrue($code !== 0); [$code, , $err] = $g(['clone', '-q', 'git@example:none.git', "{$tmp}/none2"]); $test->assertTrue($code !== 0); $test->assertStringContains('none.git', $err); }); proc_terminate($proc); proc_close($proc); }); $test->printSummary();