From 22021f7a849e446197f2c6b7c9580c62bc0528b2 Mon Sep 17 00:00:00 2001 From: Benni Mack Date: Wed, 12 Aug 2026 15:46:59 +0200 Subject: [PATCH] [FEATURE] Determine the version to publish from the extension The version argument of `ter:publish` is optional now. If it is not given, the version is taken from the extension itself, in this order: 1. The tag of the checked out commit, with an optional `v` prefix. Tags which are no version, e.g. `latest`, are ignored. 2. The version in `ext_emconf.php`. 3. The version in `composer.json`, either on root level or at `[extra][typo3/cms][version]`. The last two are the ones maintained by the `set-version` command, so a release does not have to repeat its version on the command line. Tailor states which version it uses and where it comes from. If the checked out commit is tagged with more than one version, the version has to be given as argument. Since an extension key never looks like a version, `ter:publish my_extension` still works. The extension key is now read from the `composer.json` of the given `--path` as well, instead of always from the current working directory. --- README.md | 42 +++- .../UploadExtensionVersionCommand.php | 17 +- src/Dto/ResolvedVersion.php | 54 +++++ src/Exception/VersionMissingException.php | 15 ++ src/Filesystem/ComposerReader.php | 13 ++ src/Filesystem/EmConfReader.php | 47 +++++ src/Helper/CommandHelper.php | 94 ++++++++- src/Service/GitService.php | 76 +++++++ .../UploadExtensionVersionCommandTest.php | 57 ++++++ tests/Unit/Filesystem/ComposerReaderTest.php | 29 +++ tests/Unit/Filesystem/EmConfReaderTest.php | 81 ++++++++ tests/Unit/GitRepositoryTrait.php | 65 ++++++ tests/Unit/Helper/CommandHelperTest.php | 192 ++++++++++++++++++ tests/Unit/Service/GitServiceTest.php | 112 ++++++++++ 14 files changed, 888 insertions(+), 6 deletions(-) create mode 100644 src/Dto/ResolvedVersion.php create mode 100644 src/Exception/VersionMissingException.php create mode 100644 src/Filesystem/EmConfReader.php create mode 100644 src/Service/GitService.php create mode 100644 tests/Unit/Filesystem/EmConfReaderTest.php create mode 100644 tests/Unit/GitRepositoryTrait.php create mode 100644 tests/Unit/Service/GitServiceTest.php diff --git a/README.md b/README.md index 03733ac..5da7dec 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,41 @@ current root directory the whole command simplifies to: ./vendor/bin/tailor ter:publish 1.2.0 ``` +The version can be skipped as well, since Tailor is able to +determine it from your extension: + +```bash +./vendor/bin/tailor ter:publish +``` + +The following sources are used, in this order: + +1. The **version argument**, if given. +2. The **tag of the checked out commit**, no matter whether it's + prefixed with `v` (e.g. `v1.2.0`) or not. +3. The version in **`ext_emconf.php`**. +4. The version in **`composer.json`**, either on root level or at + `[extra][typo3/cms][version]`. + +The last two are the ones written by the +[`set-version`](#update-the-version-in-your-extension-files) +command. Tailor tells you which version it uses and where it +comes from, e.g. +`Using version 1.2.0 from the tag of the checked out commit.` + +Since an extension key never looks like a version, it can still +be passed as only argument: + +```bash +./vendor/bin/tailor ter:publish my_extension +``` + +> [!NOTE] +> The version has to be stated as argument if the checked out +> commit is tagged with more than one version, or if you publish +> an `--artefact` which does not belong to the extension in your +> current working directory. + > [!IMPORTANT] > A couple of directories and files are excluded from packaging > by default. Read more about @@ -469,9 +504,14 @@ git push origin --tags **Step 4: Push this version to TER** ```bash -./vendor/bin/tailor ter:publish 1.5.0 +./vendor/bin/tailor ter:publish ``` +Since **Step 1** wrote the version into your extension files and +**Step 2** tagged the commit with it, the version does not have to +be repeated here. State it as argument, e.g. `ter:publish 1.5.0`, +if you want to publish another version. + > [!NOTE] > Both `set-version` and `ter:publish` provide options > to specify the location of your extension. If, like in the example diff --git a/src/Command/Extension/UploadExtensionVersionCommand.php b/src/Command/Extension/UploadExtensionVersionCommand.php index 3c06f10..f5965f0 100644 --- a/src/Command/Extension/UploadExtensionVersionCommand.php +++ b/src/Command/Extension/UploadExtensionVersionCommand.php @@ -48,19 +48,30 @@ protected function configure(): void $this ->setDescription('Publishes a new version of an extension to TER') ->setResultFormat(ConsoleFormatter::FORMAT_DETAIL) - ->addArgument('version', InputArgument::REQUIRED, 'The version to publish, e.g. 1.2.3') + ->addArgument('version', InputArgument::OPTIONAL, 'The version to publish, e.g. 1.2.3. Defaults to the tag of the checked out commit.') ->addArgument('extensionkey', InputArgument::OPTIONAL, 'The extension key') - ->addOption('path', '', InputOption::VALUE_OPTIONAL, 'Path to the extension folder') + ->addOption('path', '', InputOption::VALUE_OPTIONAL, 'Path to the extension folder. Defaults to the current working directory.') ->addOption('artefact', '', InputOption::VALUE_OPTIONAL, 'Path or URL to a zip file') ->addOption('comment', '', InputOption::VALUE_OPTIONAL, 'Upload comment of the new version (e.g. release notes)'); } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->version = $input->getArgument('version'); + CommandHelper::normalizeVersionAndExtensionKeyArguments($input); + + $resolvedVersion = CommandHelper::getVersionFromInput($input); + $this->version = $resolvedVersion->getVersion(); $this->extensionKey = CommandHelper::getExtensionKeyFromInput($input); $this->transactionPath = rtrim(realpath(getcwd() ?: './'), '/') . '/tailor-version-upload'; + if (!$resolvedVersion->isFromArgument() && $input->getOption('raw') === false) { + $output->writeln(sprintf( + 'Using version %s from %s.', + $resolvedVersion->getVersion(), + $resolvedVersion->getSource() + )); + } + if (!(new Filesystem\Directory())->create($this->transactionPath)) { throw new \RuntimeException(sprintf('Directory could not be created.')); } diff --git a/src/Dto/ResolvedVersion.php b/src/Dto/ResolvedVersion.php new file mode 100644 index 0000000..74ebd92 --- /dev/null +++ b/src/Dto/ResolvedVersion.php @@ -0,0 +1,54 @@ +version = $version; + $this->source = $source; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getSource(): string + { + return $this->source; + } + + public function isFromArgument(): bool + { + return $this->source === self::SOURCE_ARGUMENT; + } +} diff --git a/src/Exception/VersionMissingException.php b/src/Exception/VersionMissingException.php new file mode 100644 index 0000000..f9b0e33 --- /dev/null +++ b/src/Exception/VersionMissingException.php @@ -0,0 +1,15 @@ +composerSchema['extra']['typo3/cms']['extension-key'] ?? ''; } + + /** + * The version of the extension, as maintained by the `set-version` command. + * Both the root level and the TYPO3 specific section are taken into account. + */ + public function getVersion(): string + { + $version = $this->composerSchema['version'] + ?? $this->composerSchema['extra']['typo3/cms']['version'] + ?? ''; + + return trim((string)$version); + } } diff --git a/src/Filesystem/EmConfReader.php b/src/Filesystem/EmConfReader.php new file mode 100644 index 0000000..d86e2f5 --- /dev/null +++ b/src/Filesystem/EmConfReader.php @@ -0,0 +1,47 @@ +configuration = $configuration; + } + } + + public function getVersion(): string + { + return trim((string)($this->configuration['version'] ?? '')); + } +} diff --git a/src/Helper/CommandHelper.php b/src/Helper/CommandHelper.php index f15a806..33446a6 100644 --- a/src/Helper/CommandHelper.php +++ b/src/Helper/CommandHelper.php @@ -13,9 +13,14 @@ namespace TYPO3\Tailor\Helper; use Symfony\Component\Console\Input\InputInterface; +use TYPO3\Tailor\Dto\ResolvedVersion; use TYPO3\Tailor\Environment\Variables; use TYPO3\Tailor\Exception\ExtensionKeyMissingException; +use TYPO3\Tailor\Exception\VersionMissingException; use TYPO3\Tailor\Filesystem\ComposerReader; +use TYPO3\Tailor\Filesystem\EmConfReader; +use TYPO3\Tailor\Service\GitService; +use TYPO3\Tailor\Validation\VersionValidator; /** * Helper class for console commands. @@ -47,6 +52,91 @@ final class CommandHelper 'typo3extension', ]; + /** + * The version is looked up in the extension itself if not given as argument, + * so a release does not have to repeat its version on the command line. + */ + public static function getVersionFromInput(InputInterface $input): ResolvedVersion + { + // 1. CLI argument has highest priority + $version = (string)($input->getArgument('version') ?? ''); + if ($version !== '') { + return new ResolvedVersion($version, ResolvedVersion::SOURCE_ARGUMENT); + } + + $path = self::getPathFromInput($input); + + // 2. The tag of the checked out commit marks the released version + $versions = (new GitService())->getVersionsFromTagsOfHead($path); + + if (count($versions) > 1) { + throw new VersionMissingException( + sprintf( + 'The checked out commit is tagged with more than one version (%s). Please state the version to use as argument.', + implode(', ', $versions) + ), + 1786492801 + ); + } + + if ($versions !== []) { + return new ResolvedVersion($versions[0], ResolvedVersion::SOURCE_GIT_TAG); + } + + // 3. The version files, as written by the `set-version` command + $versionValidator = new VersionValidator(); + + $version = (new EmConfReader($path))->getVersion(); + if ($versionValidator->isValid($version)) { + return new ResolvedVersion($version, ResolvedVersion::SOURCE_EMCONF); + } + + $version = (new ComposerReader($path))->getVersion(); + if ($versionValidator->isValid($version)) { + return new ResolvedVersion($version, ResolvedVersion::SOURCE_COMPOSER); + } + + throw new VersionMissingException( + 'The version must either be set as argument, or be available in the tag of the checked out commit, ' + . 'in `ext_emconf.php` or in `composer.json`.', + 1786492802 + ); + } + + /** + * Move the extension key to its argument if it was passed as only argument. + * + * Since the version argument comes first and is optional, a single argument + * is ambiguous. Extension keys never look like a version though, so + * `ter:publish my_extension` can safely be told apart from `ter:publish 1.2.3`. + */ + public static function normalizeVersionAndExtensionKeyArguments(InputInterface $input): void + { + if (!$input->hasArgument('version') || !$input->hasArgument('extensionkey')) { + return; + } + + $version = (string)($input->getArgument('version') ?? ''); + $extensionKey = (string)($input->getArgument('extensionkey') ?? ''); + + if ($extensionKey !== '' || !preg_match('/^[a-z][a-z0-9_]+$/', $version)) { + return; + } + + $input->setArgument('extensionkey', $version); + $input->setArgument('version', null); + } + + /** + * The path of the extension to work with. Defaults to the current working directory. + */ + public static function getPathFromInput(InputInterface $input): string + { + $path = $input->hasOption('path') ? (string)($input->getOption('path') ?? '') : ''; + + return $path !== '' ? $path : (string)(getcwd() ?: '.'); + } + public static function getExtensionKeyFromInput(InputInterface $input): string { // 1. CLI argument has highest priority @@ -56,8 +146,8 @@ public static function getExtensionKeyFromInput(InputInterface $input): string return $key; } - // 2. composer.json is the recommended source - $extensionKeyFromComposer = (new ComposerReader())->getExtensionKey(); + // 2. composer.json of the extension is the recommended source + $extensionKeyFromComposer = (new ComposerReader(self::getPathFromInput($input)))->getExtensionKey(); if ($extensionKeyFromComposer !== '') { return $extensionKeyFromComposer; } diff --git a/src/Service/GitService.php b/src/Service/GitService.php new file mode 100644 index 0000000..bfba6a3 --- /dev/null +++ b/src/Service/GitService.php @@ -0,0 +1,76 @@ + The versions, without duplicates + */ + public function getVersionsFromTagsOfHead(string $path): array + { + $versionValidator = new VersionValidator(); + $versions = []; + + foreach ($this->getTagsOfHead($path) as $tag) { + $version = (string)preg_replace('/^v/i', '', $tag); + + if ($versionValidator->isValid($version) && !in_array($version, $versions, true)) { + $versions[] = $version; + } + } + + return $versions; + } + + /** + * Return all tags of the currently checked out commit. + * + * Anything preventing us from asking git - a missing binary, a disabled + * exec() or a path which is no repository at all - just means there is + * no tag to work with. + * + * @param string $path A path within the git repository + * @return array + */ + protected function getTagsOfHead(string $path): array + { + if (!function_exists('exec') || !is_dir($path)) { + return []; + } + + $output = []; + $exitCode = 0; + + // stderr is redirected into the captured output, so failures of the + // command (e.g. no repository) do not show up on the console. + @exec(sprintf('git -C %s tag --points-at HEAD 2>&1', escapeshellarg($path)), $output, $exitCode); + + if ($exitCode !== 0) { + return []; + } + + return array_values(array_filter(array_map('trim', $output))); + } +} diff --git a/tests/Unit/Command/Extension/UploadExtensionVersionCommandTest.php b/tests/Unit/Command/Extension/UploadExtensionVersionCommandTest.php index 1e90327..ee5a8d8 100644 --- a/tests/Unit/Command/Extension/UploadExtensionVersionCommandTest.php +++ b/tests/Unit/Command/Extension/UploadExtensionVersionCommandTest.php @@ -14,11 +14,14 @@ use PHPUnit\Framework\Attributes\Test; use TYPO3\Tailor\Command\Extension\UploadExtensionVersionCommand; +use TYPO3\Tailor\Exception\VersionMissingException; use TYPO3\Tailor\Tests\Unit\Command\AbstractCommandTestCase; +use TYPO3\Tailor\Tests\Unit\GitRepositoryTrait; class UploadExtensionVersionCommandTest extends AbstractCommandTestCase { use ExtensionDirectoryTrait; + use GitRepositoryTrait; protected function setUp(): void { @@ -110,6 +113,60 @@ public function transactionDirectoryIsRemovedAfterwards(): void self::assertDirectoryDoesNotExist($this->workingDirectory . '/tailor-version-upload'); } + #[Test] + public function versionIsTakenFromTheTagOfTheCheckedOutCommit(): void + { + $this->createGitRepository($this->extensionDirectory, '1.2.3'); + $tester = $this->apiTester($this->command(), self::jsonResponse(['number' => '1.2.3'], 201)); + + self::assertSame(0, $tester->execute(['--path' => $this->extensionDirectory])); + self::assertSame(self::BASE_URI . 'extension/my_ext/1.2.3', $this->request()['url']); + self::assertDisplayContains('Using version 1.2.3 from the tag of the checked out commit.', $tester); + } + + #[Test] + public function versionTagMayBePrefixed(): void + { + $this->createGitRepository($this->extensionDirectory, 'v1.2.3'); + $tester = $this->apiTester($this->command(), self::jsonResponse([], 201)); + + self::assertSame(0, $tester->execute(['--path' => $this->extensionDirectory])); + self::assertSame(self::BASE_URI . 'extension/my_ext/1.2.3', $this->request()['url']); + } + + #[Test] + public function extensionKeyCanBeGivenAsSoleArgument(): void + { + $this->createGitRepository($this->extensionDirectory, '1.2.3'); + $tester = $this->apiTester($this->command(), self::jsonResponse([], 201)); + + self::assertSame(0, $tester->execute(['version' => 'my_ext', '--path' => $this->extensionDirectory])); + self::assertSame(self::BASE_URI . 'extension/my_ext/1.2.3', $this->request()['url']); + } + + #[Test] + public function versionIsTakenFromExtEmconfIfTheCommitIsNotTagged(): void + { + $this->createGitRepository($this->extensionDirectory); + $tester = $this->apiTester($this->command(), self::jsonResponse([], 201)); + + self::assertSame(0, $tester->execute(['--path' => $this->extensionDirectory])); + self::assertSame(self::BASE_URI . 'extension/my_ext/1.2.3', $this->request()['url']); + self::assertDisplayContains('Using version 1.2.3 from ext_emconf.php.', $tester); + } + + #[Test] + public function ambiguousVersionTagsAreRejected(): void + { + $this->createGitRepository($this->extensionDirectory, '1.2.3', '1.2.4'); + $tester = $this->apiTester($this->command(), self::jsonResponse([], 201)); + + $this->expectException(VersionMissingException::class); + $this->expectExceptionMessage('tagged with more than one version (1.2.3, 1.2.4)'); + + $tester->execute(['--path' => $this->extensionDirectory]); + } + #[Test] public function failingRequestReturnsFailure(): void { diff --git a/tests/Unit/Filesystem/ComposerReaderTest.php b/tests/Unit/Filesystem/ComposerReaderTest.php index 781b249..45e9b64 100644 --- a/tests/Unit/Filesystem/ComposerReaderTest.php +++ b/tests/Unit/Filesystem/ComposerReaderTest.php @@ -70,4 +70,33 @@ public function readCorrectExtensionKeyFromGivenComposerJsonFile(): void $subject = new ComposerReader('tmp'); self::assertSame('my-extension', $subject->getExtensionKey()); } + + #[Test] + public function returnEmptyStringIfVersionNotGiven(): void + { + $composerContent = file_get_contents(__DIR__ . '/../Fixtures/Composer/composer_no_extension_key.json'); + file_put_contents(self::COMPOSER_FILE, $composerContent); + $subject = new ComposerReader('tmp'); + self::assertEmpty($subject->getVersion()); + } + + #[Test] + public function readCorrectVersionFromGivenComposerJsonFile(): void + { + $composerContent = file_get_contents(__DIR__ . '/../Fixtures/Composer/composer_with_extension_key.json'); + file_put_contents(self::COMPOSER_FILE, $composerContent); + $subject = new ComposerReader('tmp'); + self::assertSame('1.0.0', $subject->getVersion()); + } + + #[Test] + public function versionOnRootLevelIsPreferred(): void + { + file_put_contents(self::COMPOSER_FILE, (string)json_encode([ + 'version' => '2.0.0', + 'extra' => ['typo3/cms' => ['version' => '1.0.0']], + ])); + $subject = new ComposerReader('tmp'); + self::assertSame('2.0.0', $subject->getVersion()); + } } diff --git a/tests/Unit/Filesystem/EmConfReaderTest.php b/tests/Unit/Filesystem/EmConfReaderTest.php new file mode 100644 index 0000000..f13f8d1 --- /dev/null +++ b/tests/Unit/Filesystem/EmConfReaderTest.php @@ -0,0 +1,81 @@ +getVersion()); + } + + #[Test] + public function returnEmptyStringIfVersionNotGiven(): void + { + $this->useFixture('emconf_no_version.php'); + + self::assertEmpty((new EmConfReader(self::EMCONF_DIRECTORY))->getVersion()); + } + + #[Test] + public function returnEmptyStringIfEmConfHasNoValidStructure(): void + { + $this->useFixture('emconf_invalid.php'); + + self::assertEmpty((new EmConfReader(self::EMCONF_DIRECTORY))->getVersion()); + } + + #[Test] + public function readCorrectVersionFromGivenEmConfFile(): void + { + $this->useFixture('emconf_valid.php'); + + self::assertSame('1.0.0', (new EmConfReader(self::EMCONF_DIRECTORY))->getVersion()); + } + + #[Test] + public function surroundingWhitespaceOfTheVersionIsStripped(): void + { + file_put_contents(self::EMCONF_FILE, " '1.0.0 '];\n"); + + self::assertSame('1.0.0', (new EmConfReader(self::EMCONF_DIRECTORY))->getVersion()); + } + + private function useFixture(string $filename): void + { + copy(__DIR__ . '/../Fixtures/EmConf/' . $filename, self::EMCONF_FILE); + } +} diff --git a/tests/Unit/GitRepositoryTrait.php b/tests/Unit/GitRepositoryTrait.php new file mode 100644 index 0000000..138f226 --- /dev/null +++ b/tests/Unit/GitRepositoryTrait.php @@ -0,0 +1,65 @@ +git($path, 'init -q'); + $this->git($path, 'add -A'); + $this->git($path, 'commit -q -m "Add extension"'); + + foreach ($tags as $tag) { + $this->git($path, 'tag ' . escapeshellarg($tag)); + } + } + + private function git(string $path, string $arguments): void + { + $command = sprintf( + 'git -C %s -c user.name=Tailor -c user.email=tailor@example.org -c commit.gpgsign=false %s 2>&1', + escapeshellarg($path), + $arguments + ); + + $output = []; + $exitCode = 0; + exec($command, $output, $exitCode); + + if ($exitCode !== 0) { + self::fail(sprintf('Command "%s" failed: %s', $command, implode(PHP_EOL, $output))); + } + } + + private static function isGitAvailable(): bool + { + $output = []; + $exitCode = 0; + exec('git --version 2>&1', $output, $exitCode); + + return $exitCode === 0; + } +} diff --git a/tests/Unit/Helper/CommandHelperTest.php b/tests/Unit/Helper/CommandHelperTest.php index 894d4aa..70459f6 100644 --- a/tests/Unit/Helper/CommandHelperTest.php +++ b/tests/Unit/Helper/CommandHelperTest.php @@ -18,11 +18,18 @@ use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use TYPO3\Tailor\Dto\ResolvedVersion; use TYPO3\Tailor\Exception\ExtensionKeyMissingException; +use TYPO3\Tailor\Exception\VersionMissingException; use TYPO3\Tailor\Helper\CommandHelper; +use TYPO3\Tailor\Tests\Unit\GitRepositoryTrait; final class CommandHelperTest extends TestCase { + use GitRepositoryTrait; + /** * @var InputDefinition */ @@ -33,16 +40,24 @@ final class CommandHelperTest extends TestCase */ private $input; + /** + * @var string + */ + private $extensionPath = ''; + protected function setUp(): void { $this->definition = new InputDefinition(); $this->input = new ArrayInput([], $this->definition); + $this->extensionPath = sys_get_temp_dir() . '/tailor-helper-' . bin2hex(random_bytes(6)); + mkdir($this->extensionPath, 0777, true); } protected function tearDown(): void { // Clean up environment variable after each test putenv('TYPO3_EXTENSION_KEY'); + $this->removeExtensionPath(); } #[Test] @@ -100,6 +115,127 @@ public function getExtensionKeyFromInputReturnsExtensionKeyFromEnvironmentVariab } } + #[Test] + public function getVersionFromInputPrefersTheArgumentOverEveryOtherSource(): void + { + $this->writeEmConf('1.2.3'); + $this->createGitRepository($this->extensionPath, '2.0.0'); + + $version = CommandHelper::getVersionFromInput($this->versionInput(['version' => '3.0.0'])); + + self::assertSame('3.0.0', $version->getVersion()); + self::assertSame(ResolvedVersion::SOURCE_ARGUMENT, $version->getSource()); + self::assertTrue($version->isFromArgument()); + } + + #[Test] + public function getVersionFromInputPrefersTheTagOverTheVersionFiles(): void + { + $this->writeEmConf('1.2.3'); + $this->writeComposerJson('1.2.3'); + $this->createGitRepository($this->extensionPath, '2.0.0'); + + $version = CommandHelper::getVersionFromInput($this->versionInput()); + + self::assertSame('2.0.0', $version->getVersion()); + self::assertSame(ResolvedVersion::SOURCE_GIT_TAG, $version->getSource()); + } + + #[Test] + public function getVersionFromInputReturnsEmConfVersionForUntaggedCommits(): void + { + $this->writeEmConf('1.2.3'); + $this->writeComposerJson('2.0.0'); + $this->createGitRepository($this->extensionPath); + + $version = CommandHelper::getVersionFromInput($this->versionInput()); + + self::assertSame('1.2.3', $version->getVersion()); + self::assertSame(ResolvedVersion::SOURCE_EMCONF, $version->getSource()); + } + + #[Test] + public function getVersionFromInputFallsBackToComposerJson(): void + { + $this->writeEmConf(null); + $this->writeComposerJson('2.0.0'); + + $version = CommandHelper::getVersionFromInput($this->versionInput()); + + self::assertSame('2.0.0', $version->getVersion()); + self::assertSame(ResolvedVersion::SOURCE_COMPOSER, $version->getSource()); + } + + #[Test] + public function getVersionFromInputTakesTheTypo3SectionOfComposerJsonIntoAccount(): void + { + file_put_contents($this->extensionPath . '/composer.json', (string)json_encode([ + 'name' => 'vendor/my-ext', + 'extra' => ['typo3/cms' => ['extension-key' => 'my_ext', 'version' => '2.0.0']], + ])); + + self::assertSame('2.0.0', CommandHelper::getVersionFromInput($this->versionInput())->getVersion()); + } + + #[Test] + public function getVersionFromInputIgnoresValuesWhichAreNoVersion(): void + { + $this->writeEmConf('dev-main'); + $this->writeComposerJson('2.0.0'); + + self::assertSame('2.0.0', CommandHelper::getVersionFromInput($this->versionInput())->getVersion()); + } + + #[Test] + public function getVersionFromInputThrowsExceptionIfNoVersionCanBeDetermined(): void + { + $this->writeEmConf(null); + $this->writeComposerJson(null); + + $this->expectException(VersionMissingException::class); + $this->expectExceptionCode(1786492802); + + CommandHelper::getVersionFromInput($this->versionInput()); + } + + #[Test] + public function getVersionFromInputThrowsExceptionIfTheCommitIsTaggedWithSeveralVersions(): void + { + $this->writeEmConf('1.2.3'); + $this->createGitRepository($this->extensionPath, '2.0.0', '2.0.1'); + + $this->expectException(VersionMissingException::class); + $this->expectExceptionCode(1786492801); + + CommandHelper::getVersionFromInput($this->versionInput()); + } + + #[Test] + public function normalizeVersionAndExtensionKeyArgumentsMovesASoleExtensionKeyToItsArgument(): void + { + $input = $this->versionInput(['version' => 'my_ext']); + CommandHelper::normalizeVersionAndExtensionKeyArguments($input); + + self::assertNull($input->getArgument('version')); + self::assertSame('my_ext', $input->getArgument('extensionkey')); + } + + #[Test] + public function normalizeVersionAndExtensionKeyArgumentsKeepsGivenArguments(): void + { + $input = $this->versionInput(['version' => '1.2.3', 'extensionkey' => 'my_ext']); + CommandHelper::normalizeVersionAndExtensionKeyArguments($input); + + self::assertSame('1.2.3', $input->getArgument('version')); + self::assertSame('my_ext', $input->getArgument('extensionkey')); + } + + #[Test] + public function getPathFromInputFallsBackToTheCurrentWorkingDirectory(): void + { + self::assertSame((string)getcwd(), CommandHelper::getPathFromInput($this->input)); + } + /** * @param string[] $expected */ @@ -137,4 +273,60 @@ public static function tagsImpliedByTerDataProvider(): array 'domain tag with separator survives' => ['e-commerce,tt_news', []], ]; } + + /** + * @param array $parameters + */ + private function versionInput(array $parameters = []): InputInterface + { + return new ArrayInput($parameters + ['--path' => $this->extensionPath], new InputDefinition([ + new InputArgument('version', InputArgument::OPTIONAL), + new InputArgument('extensionkey', InputArgument::OPTIONAL), + new InputOption('path', '', InputOption::VALUE_OPTIONAL), + ])); + } + + private function writeEmConf(?string $version): void + { + $version = $version === null ? '' : sprintf(" 'version' => '%s',\n", $version); + + file_put_contents($this->extensionPath . '/ext_emconf.php', << 'My extension', + {$version}]; + PHP); + } + + private function writeComposerJson(?string $version): void + { + $composerSchema = ['name' => 'vendor/my-ext', 'type' => 'typo3-cms-extension']; + + if ($version !== null) { + $composerSchema['version'] = $version; + } + + file_put_contents($this->extensionPath . '/composer.json', (string)json_encode($composerSchema)); + } + + private function removeExtensionPath(): void + { + if ($this->extensionPath === '' || !is_dir($this->extensionPath)) { + return; + } + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->extensionPath, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + + rmdir($this->extensionPath); + $this->extensionPath = ''; + } + } diff --git a/tests/Unit/Service/GitServiceTest.php b/tests/Unit/Service/GitServiceTest.php new file mode 100644 index 0000000..584188f --- /dev/null +++ b/tests/Unit/Service/GitServiceTest.php @@ -0,0 +1,112 @@ +repositoryPath = sys_get_temp_dir() . '/tailor-git-' . bin2hex(random_bytes(6)); + mkdir($this->repositoryPath, 0777, true); + file_put_contents($this->repositoryPath . '/ext_emconf.php', 'repositoryPath === '' || !is_dir($this->repositoryPath)) { + return; + } + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->repositoryPath, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + + rmdir($this->repositoryPath); + $this->repositoryPath = ''; + } + + #[Test] + public function tagOfTheCheckedOutCommitIsReturned(): void + { + $this->createGitRepository($this->repositoryPath, '1.2.3'); + + self::assertSame(['1.2.3'], (new GitService())->getVersionsFromTagsOfHead($this->repositoryPath)); + } + + #[Test] + public function versionPrefixIsStripped(): void + { + $this->createGitRepository($this->repositoryPath, 'v1.2.3'); + + self::assertSame(['1.2.3'], (new GitService())->getVersionsFromTagsOfHead($this->repositoryPath)); + } + + #[Test] + public function tagsWhichAreNoVersionAreIgnored(): void + { + $this->createGitRepository($this->repositoryPath, 'latest', '1.2.3', 'release-1.2.3'); + + self::assertSame(['1.2.3'], (new GitService())->getVersionsFromTagsOfHead($this->repositoryPath)); + } + + #[Test] + public function prefixedAndUnprefixedTagOfTheSameVersionAreReturnedOnce(): void + { + $this->createGitRepository($this->repositoryPath, '1.2.3', 'v1.2.3'); + + self::assertSame(['1.2.3'], (new GitService())->getVersionsFromTagsOfHead($this->repositoryPath)); + } + + #[Test] + public function allVersionsOfTheCheckedOutCommitAreReturned(): void + { + $this->createGitRepository($this->repositoryPath, '1.2.3', '1.2.4'); + + self::assertSame(['1.2.3', '1.2.4'], (new GitService())->getVersionsFromTagsOfHead($this->repositoryPath)); + } + + #[Test] + public function untaggedRepositoryReturnsNoVersion(): void + { + $this->createGitRepository($this->repositoryPath); + + self::assertSame([], (new GitService())->getVersionsFromTagsOfHead($this->repositoryPath)); + } + + #[Test] + public function pathWithoutRepositoryReturnsNoVersion(): void + { + self::assertSame([], (new GitService())->getVersionsFromTagsOfHead($this->repositoryPath)); + } + + #[Test] + public function nonExistingPathReturnsNoVersion(): void + { + self::assertSame([], (new GitService())->getVersionsFromTagsOfHead($this->repositoryPath . '/does-not-exist')); + } +}