diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7486bbd..8fd7040 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -48,26 +48,27 @@ a local git bundle. Using a bundle instead of a network clone keeps the tests fa fully offline. If you need a new fixture scenario (a specific merge, encoding, or signed-commit shape, -for example), regenerate the bundle locally, entirely from the one already in the repo: +for example), regenerate the bundle with `tests/fixtures/generate-bundle.php`, entirely +from the one already in the repo: ```bash -$ git clone tests/fixtures/foobar.bundle /tmp/foobar-fixture && cd /tmp/foobar-fixture -$ for b in $(git branch -r | grep -v HEAD | sed 's#origin/##'); do -$ git branch --track "$b" "origin/$b" -$ done -# ... add your commits, branches or tags ... -$ git bundle create foobar.bundle \ - HEAD refs/heads/master refs/heads/new-feature refs/heads/diff-features \ - refs/heads/pagination refs/heads/path-resolving refs/tags/0.1 refs/tags/annotated -$ cp foobar.bundle /path/to/gitlib/tests/fixtures/foobar.bundle +$ php tests/fixtures/generate-bundle.php extract +# ... add your commits, branches or tags in the printed directory ... +$ php tests/fixtures/generate-bundle.php build /path/printed/above ``` +`extract` clones the current bundle to a working directory with every branch checked out +locally, ready to receive new commits. `build` rebuilds `tests/fixtures/foobar.bundle` +from that directory, restricted to the refs listed in `tests/fixtures/bundle-refs.txt` — +add your new branch or tag there first if you introduced one. + Then update the commit SHA constants in `AbstractTestCase` to match, and run `tests/fixtures/verify-bundle.sh`. It checks the bundle's integrity, its ref list against -an allow-list, and its size, since GitHub renders any change to this binary file as an +`bundle-refs.txt`, and its size, since GitHub renders any change to this binary file as an opaque diff. If your change intentionally adds a ref or grows the file, update -`ALLOWED_REFS` or `MAX_SIZE_KB` in that script as part of the same pull request, so the -reason for the change is explicit and reviewable rather than a silent binary diff. +`tests/fixtures/bundle-refs.txt` or `MAX_SIZE_KB` in that script as part of the same pull +request, so the reason for the change is explicit and reviewable rather than a silent +binary diff. ## Standard code diff --git a/composer.json b/composer.json index 1d0afbc..7104d44 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,8 @@ "require-dev": { "ext-fileinfo": "*", "phpunit/phpunit": "^12.0", - "psr/log": "^3.0" + "psr/log": "^3.0", + "symfony/console": "^8.1" }, "config": { "preferred-install": "dist", diff --git a/tests/fixtures/bundle-refs.txt b/tests/fixtures/bundle-refs.txt new file mode 100644 index 0000000..4fb23c2 --- /dev/null +++ b/tests/fixtures/bundle-refs.txt @@ -0,0 +1,8 @@ +HEAD +refs/heads/master +refs/heads/new-feature +refs/heads/diff-features +refs/heads/pagination +refs/heads/path-resolving +refs/tags/0.1 +refs/tags/annotated diff --git a/tests/fixtures/generate-bundle.php b/tests/fixtures/generate-bundle.php new file mode 100755 index 0000000..59040b5 --- /dev/null +++ b/tests/fixtures/generate-bundle.php @@ -0,0 +1,133 @@ +#!/usr/bin/env php + + * (c) Julien DIDIER + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +require dirname(__DIR__, 2).'/vendor/autoload.php'; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Process\Process; + +const BUNDLE_PATH = __DIR__.'/foobar.bundle'; +const REFS_PATH = __DIR__.'/bundle-refs.txt'; + +function git(array $args, ?string $cwd = null): Process +{ + $process = new Process(['git', ...$args], $cwd); + $process->mustRun(); + + return $process; +} + +function bundleRefs(): array +{ + return array_values(array_filter(explode("\n", trim(file_get_contents(REFS_PATH))))); +} + +#[AsCommand(name: 'extract', description: 'Clone the fixture bundle to a working directory, ready for new commits, branches or tags')] +final class ExtractCommand extends Command +{ + protected function configure(): void + { + $this->addArgument('path', InputArgument::OPTIONAL, 'Where to clone the fixture', sys_get_temp_dir().'/foobar-fixture-'.bin2hex(random_bytes(4))); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dest = $input->getArgument('path'); + + if (file_exists($dest)) { + $io->error(sprintf('Destination "%s" already exists.', $dest)); + + return Command::FAILURE; + } + + git(['clone', '--quiet', BUNDLE_PATH, $dest]); + + $localBranches = explode("\n", trim(git(['branch', '--format=%(refname:short)'], $dest)->getOutput())); + + // Full ref names, not `--format=%(refname:short)`: git shortens the symbolic + // refs/remotes/origin/HEAD pointer to a bare "origin" on some git versions, which + // would otherwise be mistaken for a real branch called "origin". + $refs = trim(git(['for-each-ref', '--format=%(refname)', 'refs/remotes/origin'], $dest)->getOutput()); + foreach (explode("\n", $refs) as $ref) { + if ('' === $ref) { + continue; + } + $local = preg_replace('#^refs/remotes/origin/#', '', $ref); + if ('HEAD' === $local || in_array($local, $localBranches, true)) { + // Symbolic HEAD pointer, or already checked out as the clone's default branch. + continue; + } + git(['branch', '--track', $local, "origin/{$local}"], $dest); + } + + $io->success('Fixture extracted.'); + $io->writeln([ + sprintf('Path: %s', $dest), + '', + 'Make your changes there (commits, branches, tags), then run:', + sprintf(' %s build %s', $_SERVER['argv'][0], $dest), + ]); + + return Command::SUCCESS; + } +} + +#[AsCommand(name: 'build', description: 'Rebuild tests/fixtures/foobar.bundle from a working directory produced by "extract"')] +final class BuildCommand extends Command +{ + protected function configure(): void + { + $this->addArgument('path', InputArgument::REQUIRED, 'The working directory produced by "extract"'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $src = $input->getArgument('path'); + + if (!is_dir($src)) { + $io->error(sprintf('Source "%s" does not exist. Run "extract" first.', $src)); + + return Command::FAILURE; + } + + $tmpBundle = tempnam(sys_get_temp_dir(), 'foobar_bundle_'); + git(['bundle', 'create', $tmpBundle, ...bundleRefs()], $src); + git(['bundle', 'verify', $tmpBundle]); + + copy($tmpBundle, BUNDLE_PATH); + unlink($tmpBundle); + + $io->success('Bundle rebuilt.'); + $io->writeln([ + sprintf('Path: %s', BUNDLE_PATH), + '', + sprintf('If you added a ref not in %s, update that file first and re-run build.', REFS_PATH), + 'Otherwise, update the commit constants in AbstractTestCase if needed, then run:', + ' tests/fixtures/verify-bundle.sh', + ]); + + return Command::SUCCESS; + } +} + +$app = new Application('gitlib fixture bundle tool'); +$app->addCommands([new ExtractCommand(), new BuildCommand()]); +exit($app->run()); diff --git a/tests/fixtures/verify-bundle.sh b/tests/fixtures/verify-bundle.sh index f543e21..aaa52d6 100755 --- a/tests/fixtures/verify-bundle.sh +++ b/tests/fixtures/verify-bundle.sh @@ -15,17 +15,7 @@ cd "$(dirname "${BASH_SOURCE[0]}")" > /dev/null BUNDLE="foobar.bundle" MAX_SIZE_KB=200 - -ALLOWED_REFS=" -HEAD -refs/heads/diff-features -refs/heads/master -refs/heads/new-feature -refs/heads/pagination -refs/heads/path-resolving -refs/tags/0.1 -refs/tags/annotated -" +ALLOWED_REFS="$(cat bundle-refs.txt)" echo "== Verifying $BUNDLE ==" @@ -46,7 +36,7 @@ UNEXPECTED_REFS="$(comm -23 <(echo "$ACTUAL_REFS") <(sort -u <<< "$ALLOWED_REFS" if [ -n "$UNEXPECTED_REFS" ]; then echo "ERROR: $BUNDLE contains refs that are not in the allow-list:" >&2 echo "$UNEXPECTED_REFS" >&2 - echo "If this is expected, update ALLOWED_REFS in $0 as part of the same PR." >&2 + echo "If this is expected, update tests/fixtures/bundle-refs.txt as part of the same PR." >&2 exit 1 fi