diff --git a/.github/wiki b/.github/wiki index 7f52fa9aa4..02bb3c4c9c 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 7f52fa9aa4690618459a255e42009aa687559a9b +Subproject commit 02bb3c4c9cc8df45bddda258c4f0ee7bbd29928c diff --git a/CHANGELOG.md b/CHANGELOG.md index d4bec273b8..6dfed9aee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Keep global `dev-tools:sync` runs machine-independent by removing only deprecated DevTools-managed Composer GrumPHP default-path metadata while wiring packaged Git hooks to prefer a project-local `grumphp.yml` and otherwise use the active packaged DevTools config path resolved at sync time (#288) + ## [1.24.2] - 2026-04-30 ### Fixed diff --git a/docs/commands/update-composer-json.rst b/docs/commands/update-composer-json.rst index 5afe2fe1ec..cf96290da2 100644 --- a/docs/commands/update-composer-json.rst +++ b/docs/commands/update-composer-json.rst @@ -11,7 +11,8 @@ dev-tools integration scripts and GrumPHP configuration: 1. Adds the ``dev-tools`` script entrypoint 2. Adds the ``dev-tools:fix`` script for automated fixing -3. Adds GrumPHP extra configuration pointing to the packaged ``grumphp.yml`` +3. Removes deprecated DevTools-managed GrumPHP default-path metadata while + preserving consumer-owned settings Usage ----- @@ -64,7 +65,8 @@ Behavior - If the target composer.json does not exist, the command exits silently with code 0. - Existing scripts with the same name are overwritten. -- The GrumPHP extra configuration is merged with existing configuration. +- Consumer-owned GrumPHP settings are preserved, while only deprecated + DevTools-managed ``extra.grumphp.config-default-path`` values are removed. - ``--dry-run`` and ``--check`` render a diff against the managed ``composer.json`` result before deciding whether to write. diff --git a/docs/faq.rst b/docs/faq.rst index 33a287f0e8..d4b5bae6be 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -11,8 +11,9 @@ Why did my ``composer.json`` change after installing the package? ----------------------------------------------------------------- The Composer plugin runs ``dev-tools:sync`` after install and update. -That command adds the ``dev-tools`` scripts and updates -``extra.grumphp.config-default-path`` in the consumer project. +That command adds the ``dev-tools`` scripts, removes deprecated +DevTools-managed ``extra.grumphp.config-default-path`` values, and refreshes +the packaged Git hooks. Do I always need to run ``dev-tools:sync`` manually? ---------------------------------------------------- diff --git a/docs/getting-started/installation.rst b/docs/getting-started/installation.rst index 062486ae24..3b7a96a2b9 100644 --- a/docs/getting-started/installation.rst +++ b/docs/getting-started/installation.rst @@ -32,11 +32,13 @@ following steps: update. 4. ``dev-tools:sync`` adds or refreshes the ``dev-tools`` and ``dev-tools:fix`` scripts in the consumer ``composer.json``. -5. ``dev-tools:sync`` updates ``extra.grumphp.config-default-path``, - synchronizes funding metadata, copies automation assets such as workflow - stubs, ``.editorconfig``, and ``.github/dependabot.yml``, and refreshes +5. ``dev-tools:sync`` removes deprecated DevTools-managed + ``extra.grumphp.config-default-path`` entries, synchronizes funding + metadata, copies automation assets such as workflow stubs, + ``.editorconfig``, and ``.github/dependabot.yml``, and refreshes ``.gitignore``, ``.gitattributes``, the project license, and packaged Git - hooks. + hooks that prefer a project-local ``grumphp.yml`` override and otherwise + use the active packaged DevTools ``grumphp.yml`` path. 6. If ``.github/wiki`` is missing, ``dev-tools:sync`` adds it as a Git submodule that points to the repository wiki. 7. ``dev-tools:sync`` runs ``gitignore`` to merge canonical ignore rules into diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst index ff52d8121b..7208ae3a63 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -377,8 +377,8 @@ Synchronizes consumer-facing automation and defaults. Important details: -- it updates ``composer.json`` scripts and - ``extra.grumphp.config-default-path``; +- it updates ``composer.json`` scripts and removes only deprecated + DevTools-managed ``extra.grumphp.config-default-path`` values; - it calls ``funding`` so supported funding metadata stays aligned between ``composer.json`` and ``.github/FUNDING.yml``; - it copies missing workflow stubs, ``.editorconfig``, and ``dependabot.yml``; @@ -389,6 +389,9 @@ Important details: - it calls ``gitignore`` to merge the canonical .gitignore with the project's .gitignore; - it calls ``gitattributes`` to manage export-ignore rules in .gitattributes; +- it refreshes packaged Git hooks that prefer a local ``grumphp.yml`` + override and otherwise use the active packaged DevTools ``grumphp.yml`` + path resolved when sync installs them; - it calls ``skills`` so ``.agents/skills`` contains links to the packaged skill set; - it calls ``agents`` so ``.agents/agents`` contains links to the packaged @@ -502,6 +505,8 @@ Important details: - adds ``dev-tools`` script entrypoint to composer.json; - adds ``dev-tools:fix`` script for automated fixing; -- adds GrumPHP extra configuration pointing to packaged ``grumphp.yml``; +- removes only deprecated DevTools-managed + ``extra.grumphp.config-default-path`` values; - if the target file does not exist, exits silently with code 0; -- existing scripts with the same name are overwritten. +- existing scripts with the same name are overwritten; +- preserves consumer-owned GrumPHP settings. diff --git a/docs/usage/syncing-consumer-projects.rst b/docs/usage/syncing-consumer-projects.rst index c6e18bad17..d2223fa79a 100644 --- a/docs/usage/syncing-consumer-projects.rst +++ b/docs/usage/syncing-consumer-projects.rst @@ -22,7 +22,9 @@ What the Command Changes - Adds or updates ``dev-tools`` and ``dev-tools:fix``. - Updated in place. * - ``composer.json`` extra - - Sets ``extra.grumphp.config-default-path``. + - Removes only deprecated DevTools-managed + ``extra.grumphp.config-default-path`` values while preserving + consumer-owned GrumPHP settings. - Updated in place. * - ``.github/workflows/*.yml`` - Copies thin wrapper workflows from ``resources/github-actions`` that @@ -49,6 +51,11 @@ What the Command Changes * - ``.github/wiki`` - Adds a Git submodule derived from ``git remote origin``. - Only when missing. + * - ``.git/hooks/*`` + - Copies packaged hooks that prefer a local ``grumphp.yml`` override and + otherwise use the active packaged DevTools ``grumphp.yml`` path + resolved when sync installs them. + - Replaced when drift is detected. When to Run It -------------- diff --git a/resources/git-hooks/commit-msg b/resources/git-hooks/commit-msg index 7778266f5e..e7ab0c96f3 100755 --- a/resources/git-hooks/commit-msg +++ b/resources/git-hooks/commit-msg @@ -16,5 +16,20 @@ DIFF=$(git -c diff.mnemonicprefix=false -c diff.noprefix=false --no-pager diff - export GRUMPHP_GIT_WORKING_DIR="$(git rev-parse --show-toplevel)" +cd "${GRUMPHP_GIT_WORKING_DIR}" || exit 1 + +GRUMPHP_CONFIG_FILE='' +DEVTOOLS_GRUMPHP_CONFIG=__DEV_TOOLS_GRUMPHP_CONFIG__ + +if [ -f './grumphp.yml' ]; then + GRUMPHP_CONFIG_FILE='./grumphp.yml' +elif [ -f "${DEVTOOLS_GRUMPHP_CONFIG}" ]; then + GRUMPHP_CONFIG_FILE="${DEVTOOLS_GRUMPHP_CONFIG}" +fi + # Run GrumPHP -(cd "./" && printf "%s\n" "${DIFF}" | exec 'vendor/bin/grumphp.phar' 'git:commit-msg' "--git-user='$GIT_USER'" "--git-email='$GIT_EMAIL'" "$COMMIT_MSG_FILE") +if [ -n "${GRUMPHP_CONFIG_FILE}" ]; then + printf "%s\n" "${DIFF}" | exec 'vendor/bin/grumphp.phar' '--config' "${GRUMPHP_CONFIG_FILE}" 'git:commit-msg' "--git-user=$GIT_USER" "--git-email=$GIT_EMAIL" "$COMMIT_MSG_FILE" +fi + +printf "%s\n" "${DIFF}" | exec 'vendor/bin/grumphp.phar' 'git:commit-msg' "--git-user=$GIT_USER" "--git-email=$GIT_EMAIL" "$COMMIT_MSG_FILE" diff --git a/resources/git-hooks/pre-commit b/resources/git-hooks/pre-commit index 93054fb5d9..074161de5d 100755 --- a/resources/git-hooks/pre-commit +++ b/resources/git-hooks/pre-commit @@ -12,5 +12,20 @@ DIFF=$(git -c diff.mnemonicprefix=false -c diff.noprefix=false --no-pager diff - export GRUMPHP_GIT_WORKING_DIR="$(git rev-parse --show-toplevel)" +cd "${GRUMPHP_GIT_WORKING_DIR}" || exit 1 + +GRUMPHP_CONFIG_FILE='' +DEVTOOLS_GRUMPHP_CONFIG=__DEV_TOOLS_GRUMPHP_CONFIG__ + +if [ -f './grumphp.yml' ]; then + GRUMPHP_CONFIG_FILE='./grumphp.yml' +elif [ -f "${DEVTOOLS_GRUMPHP_CONFIG}" ]; then + GRUMPHP_CONFIG_FILE="${DEVTOOLS_GRUMPHP_CONFIG}" +fi + # Run GrumPHP -(cd "./" && printf "%s\n" "${DIFF}" | exec 'vendor/bin/grumphp.phar' 'git:pre-commit' '--skip-success-output') +if [ -n "${GRUMPHP_CONFIG_FILE}" ]; then + printf "%s\n" "${DIFF}" | exec 'vendor/bin/grumphp.phar' '--config' "${GRUMPHP_CONFIG_FILE}" 'git:pre-commit' '--skip-success-output' +fi + +printf "%s\n" "${DIFF}" | exec 'vendor/bin/grumphp.phar' 'git:pre-commit' '--skip-success-output' diff --git a/src/Console/Command/GitHooksCommand.php b/src/Console/Command/GitHooksCommand.php index be98f49125..ae169c290b 100644 --- a/src/Console/Command/GitHooksCommand.php +++ b/src/Console/Command/GitHooksCommand.php @@ -23,6 +23,8 @@ use FastForward\DevTools\Console\Input\HasJsonOption; use FastForward\DevTools\Filesystem\FinderFactoryInterface; use FastForward\DevTools\Filesystem\FilesystemInterface; +use FastForward\DevTools\GitHooks\HookContentRenderer; +use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; use Psr\Log\LoggerInterface; use Symfony\Component\Config\FileLocatorInterface; @@ -35,6 +37,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Filesystem\Exception\IOExceptionInterface; use Symfony\Component\Filesystem\Path; +use Throwable; /** * Installs packaged Git hooks for the consumer repository. @@ -55,6 +58,7 @@ final class GitHooksCommand extends Command * @param FilesystemInterface $filesystem the filesystem used to copy hooks * @param FileLocatorInterface $fileLocator the locator used to find packaged hooks * @param FinderFactoryInterface $finderFactory the factory used to create finders for hook files + * @param HookContentRenderer $hookContentRenderer renders packaged hooks with runtime-specific placeholders * @param FileDiffer $fileDiffer the file differ used to summarize synchronization changes * @param LoggerInterface $logger the output-aware logger * @param SymfonyStyle $io the input/output service used to interact with the user @@ -63,6 +67,7 @@ public function __construct( private readonly FilesystemInterface $filesystem, private readonly FileLocatorInterface $fileLocator, private readonly FinderFactoryInterface $finderFactory, + private readonly HookContentRenderer $hookContentRenderer, private readonly FileDiffer $fileDiffer, private readonly LoggerInterface $logger, private readonly SymfonyStyle $io, @@ -140,6 +145,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $installFailure = false; foreach ($files as $file) { + $sourcePath = $file->getRealPath(); + $sourceContents = $this->filesystem->readFile($sourcePath); + $renderedSourceContents = $this->hookContentRenderer->render($sourceContents); $hookPath = Path::join($targetPath, $file->getRelativePathname()); if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($hookPath)) { @@ -156,7 +164,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if (($overwrite || $dryRun || $check || $interactive) && $this->filesystem->exists($hookPath)) { - $comparison = $this->fileDiffer->diff($file->getRealPath(), $hookPath); + $comparison = $sourceContents === $renderedSourceContents + ? $this->fileDiffer->diff($sourcePath, $hookPath) + : $this->compareRenderedHookContents($sourcePath, $hookPath, $renderedSourceContents); $this->logger->notice( $comparison->getSummary(), @@ -211,7 +221,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - if (! $this->installHook($file->getRealPath(), $hookPath, $overwrite || $interactive, $input)) { + if (! $this->installHook( + $sourcePath, + $hookPath, + $overwrite || $interactive, + $input, + $sourceContents === $renderedSourceContents ? null : $renderedSourceContents, + )) { $installFailure = true; continue; @@ -282,6 +298,7 @@ private function shouldReplaceHook(string $hookPath): bool * @param string $hookPath the target repository hook path * @param bool $replaceExisting whether an existing hook SHOULD be removed first * @param InputInterface $input the originating command input + * @param string|null $renderedContents optional rendered hook contents that SHOULD be written instead of copied * * @return bool true when the hook was installed successfully */ @@ -289,14 +306,20 @@ private function installHook( string $sourcePath, string $hookPath, bool $replaceExisting, - InputInterface $input + InputInterface $input, + ?string $renderedContents = null, ): bool { try { if ($replaceExisting && $this->filesystem->exists($hookPath)) { $this->filesystem->remove($hookPath); } - $this->filesystem->copy($sourcePath, $hookPath, false); + if (null === $renderedContents) { + $this->filesystem->copy($sourcePath, $hookPath, false); + } else { + $this->filesystem->dumpFile($hookPath, $renderedContents); + } + $this->filesystem->chmod(files: $hookPath, mode: 0o755); return true; @@ -316,4 +339,40 @@ private function installHook( return false; } } + + /** + * Compares rendered hook contents with an existing installed hook. + * + * @param string $sourcePath the packaged hook source path + * @param string $hookPath the target installed hook path + * @param string $renderedContents the rendered hook contents + * + * @return FileDiff the rendered comparison result + */ + private function compareRenderedHookContents( + string $sourcePath, + string $hookPath, + string $renderedContents + ): FileDiff { + try { + $targetContents = $this->filesystem->readFile($hookPath); + } catch (Throwable) { + return new FileDiff( + FileDiff::STATUS_UNREADABLE, + \sprintf( + 'Target %s will be overwritten from %s, but the existing or source content could not be read.', + $hookPath, + $sourcePath, + ), + ); + } + + return $this->fileDiffer->diffContents( + $sourcePath, + $hookPath, + $renderedContents, + $targetContents, + \sprintf('Overwriting resource %s from %s.', $hookPath, $sourcePath), + ); + } } diff --git a/src/Console/Command/UpdateComposerJsonCommand.php b/src/Console/Command/UpdateComposerJsonCommand.php index 02aff4cf51..88382d82e5 100644 --- a/src/Console/Command/UpdateComposerJsonCommand.php +++ b/src/Console/Command/UpdateComposerJsonCommand.php @@ -23,6 +23,7 @@ use FastForward\DevTools\Composer\Json\ComposerJsonInterface; use FastForward\DevTools\Console\Input\HasJsonOption; use FastForward\DevTools\Filesystem\FilesystemInterface; +use FastForward\DevTools\GrumPhp\ManagedConfigPathSynchronizer; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Resource\FileDiffer; use Psr\Log\LoggerInterface; @@ -35,11 +36,9 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\Filesystem\Path; use function Safe\json_decode; use function Safe\json_encode; -use function Safe\getcwd; /** * Updates composer.json with the Fast Forward dev-tools integration metadata. @@ -60,6 +59,7 @@ final class UpdateComposerJsonCommand extends Command * @param ComposerJsonInterface $composer the composer.json metadata accessor * @param FilesystemInterface $filesystem the filesystem used to read and write composer.json * @param FileLocatorInterface $fileLocator the locator used to resolve packaged configuration files + * @param ManagedConfigPathSynchronizer $managedConfigPathSynchronizer synchronizes managed GrumPHP metadata * @param FileDiffer $fileDiffer the file differ used to summarize synchronization changes * @param LoggerInterface $logger the output-aware logger * @param SymfonyStyle $io the input/output service used to interact with the user @@ -68,6 +68,7 @@ public function __construct( private readonly ComposerJsonInterface $composer, private readonly FilesystemInterface $filesystem, private readonly FileLocatorInterface $fileLocator, + private readonly ManagedConfigPathSynchronizer $managedConfigPathSynchronizer, private readonly FileDiffer $fileDiffer, private readonly LoggerInterface $logger, private readonly SymfonyStyle $io, @@ -80,10 +81,7 @@ public function __construct( */ protected function configure(): void { - $this->setHelp( - 'This command adds or updates composer.json scripts and GrumPHP extra configuration required by' - . ' dev-tools.' - ); + $this->setHelp('This command adds or updates composer.json scripts and managed dev-tools metadata.'); $this->addJsonOption() ->addOption( @@ -244,15 +242,11 @@ private function updatedComposerJsonContents(string $currentContents, string $fi $extra = []; } - $grumphpConfig = DevToolsPathResolver::getPackagePath('grumphp.yml'); - $grumphpExtra = $extra['grumphp'] ?? []; - if (! \is_array($grumphpExtra)) { - $grumphpExtra = []; - } - - $grumphpExtra['config-default-path'] = Path::makeRelative($grumphpConfig, getcwd()); - $extra['grumphp'] = $grumphpExtra; - $composerJsonData['extra'] = $extra; + $composerJsonData['extra'] = $this->managedConfigPathSynchronizer->synchronize( + $extra, + \dirname($file), + $this->fileLocator->locate('grumphp.yml', DevToolsPathResolver::getPackagePath()) + ); return json_encode( $composerJsonData, diff --git a/src/GitHooks/HookContentRenderer.php b/src/GitHooks/HookContentRenderer.php new file mode 100644 index 0000000000..65e063a8db --- /dev/null +++ b/src/GitHooks/HookContentRenderer.php @@ -0,0 +1,49 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GitHooks; + +use FastForward\DevTools\Path\DevToolsPathResolver; + +/** + * Renders packaged Git hooks with runtime-specific DevTools hook configuration paths. + */ +final class HookContentRenderer +{ + /** + * Placeholder replaced with the active packaged GrumPHP config path. + */ + public const string MANAGED_GRUMPHP_CONFIG_PLACEHOLDER = '__DEV_TOOLS_GRUMPHP_CONFIG__'; + + /** + * Renders the hook contents for the active DevTools runtime. + * + * @param string $contents the packaged hook contents + * + * @return string the rendered hook contents + */ + public function render(string $contents): string + { + return str_replace( + self::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER, + escapeshellarg(DevToolsPathResolver::getPackagePath('grumphp.yml')), + $contents, + ); + } +} diff --git a/src/GrumPhp/ManagedConfigPathSynchronizer.php b/src/GrumPhp/ManagedConfigPathSynchronizer.php new file mode 100644 index 0000000000..f45298b46e --- /dev/null +++ b/src/GrumPhp/ManagedConfigPathSynchronizer.php @@ -0,0 +1,99 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\GrumPhp; + +use Symfony\Component\Filesystem\Path; + +/** + * Synchronizes deprecated DevTools-managed GrumPHP composer metadata without leaking package paths into consumers. + */ +final class ManagedConfigPathSynchronizer +{ + /** + * @var string relative packaged config suffix managed by DevTools installs + */ + private const string MANAGED_CONFIG_SUFFIX = 'vendor/fast-forward/dev-tools/grumphp.yml'; + + /** + * Removes deprecated DevTools-managed GrumPHP config-default-path entries while preserving consumer-owned values. + * + * @param array $extra the composer.json extra payload + * @param string $workingDirectory the consumer project directory + * @param string $managedConfigPath the active packaged GrumPHP config path + * + * @return array the synchronized composer extra payload + */ + public function synchronize(array $extra, string $workingDirectory, string $managedConfigPath): array + { + if (isset($extra['grumphp']) && ! \is_array($extra['grumphp'])) { + return $extra; + } + + $grumphpExtra = $extra['grumphp'] ?? []; + $configDefaultPath = $grumphpExtra['config-default-path'] ?? null; + + if (\is_string($configDefaultPath) && $this->isManagedConfigPath( + $configDefaultPath, + $workingDirectory, + $managedConfigPath + )) { + unset($grumphpExtra['config-default-path']); + } + + if ([] === $grumphpExtra) { + unset($extra['grumphp']); + + return $extra; + } + + $extra['grumphp'] = $grumphpExtra; + + return $extra; + } + + /** + * Reports whether a config-default-path value is managed by DevTools. + * + * @param string $configDefaultPath the stored composer extra path value + * @param string $workingDirectory the consumer project directory + * @param string $managedConfigPath the active packaged GrumPHP config path + */ + public function isManagedConfigPath( + string $configDefaultPath, + string $workingDirectory, + string $managedConfigPath, + ): bool { + $normalizedConfigPath = $this->normalize($configDefaultPath); + $normalizedManagedRelativePath = $this->normalize(Path::makeRelative($managedConfigPath, $workingDirectory)); + + return $normalizedConfigPath === $normalizedManagedRelativePath + || str_ends_with($normalizedConfigPath, self::MANAGED_CONFIG_SUFFIX); + } + + /** + * Normalizes a path for stable comparisons across platforms. + * + * @param string $path the path to normalize + */ + private function normalize(string $path): string + { + return trim(str_replace('\\', '/', Path::canonicalize($path)), '/'); + } +} diff --git a/tests/Console/Command/GitHooksCommandTest.php b/tests/Console/Command/GitHooksCommandTest.php index beb38ce593..11afe1b407 100644 --- a/tests/Console/Command/GitHooksCommandTest.php +++ b/tests/Console/Command/GitHooksCommandTest.php @@ -19,20 +19,20 @@ namespace FastForward\DevTools\Tests\Console\Command; -use Symfony\Component\Console\Question\ConfirmationQuestion; -use FastForward\DevTools\Resource\FileDiff; -use Symfony\Component\Console\Style\SymfonyStyle; use FastForward\DevTools\Console\Command\GitHooksCommand; use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; use FastForward\DevTools\Filesystem\FinderFactoryInterface; use FastForward\DevTools\Filesystem\FilesystemInterface; +use FastForward\DevTools\GitHooks\HookContentRenderer; +use FastForward\DevTools\Path\DevToolsPathResolver; +use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; -use Psr\Log\LoggerInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesTrait; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; @@ -40,16 +40,22 @@ use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Finder\Finder; +use function is_file; +use function Safe\glob; use function Safe\mkdir; use function Safe\file_put_contents; use function Safe\unlink; use function Safe\rmdir; #[CoversClass(GitHooksCommand::class)] +#[UsesClass(DevToolsPathResolver::class)] #[UsesClass(FileDiff::class)] +#[UsesClass(HookContentRenderer::class)] #[UsesTrait(LogsCommandResults::class)] final class GitHooksCommandTest extends TestCase { @@ -108,11 +114,14 @@ protected function setUp(): void ->willReturn(false); $this->input->isInteractive() ->willReturn(false); + $this->filesystem->readFile(Argument::containingString('/post-merge')) + ->willReturn('#!/bin/sh'); $this->command = new GitHooksCommand( $this->filesystem->reveal(), $this->fileLocator->reveal(), $this->finderFactory->reveal(), + new HookContentRenderer(), $this->fileDiffer->reveal(), $this->logger->reveal(), $this->io->reveal(), @@ -125,7 +134,12 @@ protected function setUp(): void protected function tearDown(): void { if (is_dir($this->sourceDirectory)) { - unlink($this->sourceDirectory . '/post-merge'); + foreach (glob($this->sourceDirectory . '/*') as $file) { + if (is_file($file)) { + unlink($file); + } + } + rmdir($this->sourceDirectory); } } @@ -178,6 +192,58 @@ public function executeWillCopyPackagedHooks(): void self::assertSame(GitHooksCommand::SUCCESS, $this->executeCommand()); } + /** + * @return void + */ + #[Test] + public function executeWillRenderManagedGrumPhpConfigIntoPlaceholderHooks(): void + { + file_put_contents( + $this->sourceDirectory . '/pre-commit', + "DEVTOOLS_GRUMPHP_CONFIG=__DEV_TOOLS_GRUMPHP_CONFIG__\n" + ); + + $this->input->getOption('source') + ->willReturn('resources/git-hooks'); + $this->input->getOption('target') + ->willReturn('.git/hooks'); + $this->input->getOption('no-overwrite') + ->willReturn(false); + + $this->fileLocator->locate('resources/git-hooks') + ->willReturn($this->sourceDirectory); + $this->finderFactory->create() + ->willReturn(new Finder()) + ->shouldBeCalledOnce(); + $this->filesystem->getAbsolutePath('.git/hooks') + ->willReturn('/app/.git/hooks'); + $this->filesystem->exists('/app/.git/hooks/post-merge') + ->willReturn(false); + $this->filesystem->exists('/app/.git/hooks/pre-commit') + ->willReturn(false); + $this->filesystem->readFile(Argument::containingString('/pre-commit')) + ->willReturn("DEVTOOLS_GRUMPHP_CONFIG=__DEV_TOOLS_GRUMPHP_CONFIG__\n"); + $this->filesystem->copy(Argument::containingString('/post-merge'), '/app/.git/hooks/post-merge', false) + ->shouldBeCalledOnce(); + $this->filesystem->dumpFile( + '/app/.git/hooks/pre-commit', + Argument::that( + static fn(string $contents): bool => str_contains( + $contents, + escapeshellarg(DevToolsPathResolver::getPackagePath('grumphp.yml')) + ) + ), + )->shouldBeCalledOnce(); + $this->filesystem->chmod(Argument::type('string'), 0o755) + ->shouldBeCalledTimes(2); + $this->logger->log('info', 'Installed {hook_name} hook.', Argument::type('array')) + ->shouldBeCalledTimes(2); + $this->logger->log('info', 'Git hook synchronization completed successfully.', Argument::type('array')) + ->shouldBeCalledOnce(); + + self::assertSame(GitHooksCommand::SUCCESS, $this->executeCommand()); + } + /** * @return void */ diff --git a/tests/Console/Command/UpdateComposerJsonCommandTest.php b/tests/Console/Command/UpdateComposerJsonCommandTest.php index 5427826ec6..d53cfc21de 100644 --- a/tests/Console/Command/UpdateComposerJsonCommandTest.php +++ b/tests/Console/Command/UpdateComposerJsonCommandTest.php @@ -19,19 +19,18 @@ namespace FastForward\DevTools\Tests\Console\Command; -use Symfony\Component\Console\Question\ConfirmationQuestion; -use Symfony\Component\Console\Style\SymfonyStyle; use FastForward\DevTools\Composer\Json\ComposerJsonInterface; use FastForward\DevTools\Console\Command\UpdateComposerJsonCommand; use FastForward\DevTools\Filesystem\FilesystemInterface; +use FastForward\DevTools\GrumPhp\ManagedConfigPathSynchronizer; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; -use Psr\Log\LoggerInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; @@ -39,12 +38,15 @@ use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Style\SymfonyStyle; use function Safe\json_decode; #[CoversClass(UpdateComposerJsonCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(FileDiff::class)] +#[UsesClass(ManagedConfigPathSynchronizer::class)] final class UpdateComposerJsonCommandTest extends TestCase { use ProphecyTrait; @@ -96,11 +98,14 @@ protected function setUp(): void ->willReturn(false); $this->input->isInteractive() ->willReturn(false); + $this->fileLocator->locate('grumphp.yml', Argument::type('string')) + ->willReturn('/app/vendor/fast-forward/dev-tools/grumphp.yml'); $this->command = new UpdateComposerJsonCommand( $this->composer->reveal(), $this->filesystem->reveal(), $this->fileLocator->reveal(), + new ManagedConfigPathSynchronizer(), $this->fileDiffer->reveal(), $this->logger->reveal(), $this->io->reveal(), @@ -119,7 +124,7 @@ public function commandWillSetExpectedNameDescriptionAndHelp(): void $this->command->getDescription() ); self::assertSame( - 'This command adds or updates composer.json scripts and GrumPHP extra configuration required by dev-tools.', + 'This command adds or updates composer.json scripts and managed dev-tools metadata.', $this->command->getHelp() ); } @@ -128,7 +133,7 @@ public function commandWillSetExpectedNameDescriptionAndHelp(): void * @return void */ #[Test] - public function executeWillUpdateComposerJsonScriptsAndExtraConfiguration(): void + public function executeWillUpdateComposerJsonScriptsAndManagedMetadata(): void { $this->input->getOption('file') ->willReturn('/app/composer.json'); @@ -140,8 +145,6 @@ public function executeWillUpdateComposerJsonScriptsAndExtraConfiguration(): voi ->willReturn(''); $this->filesystem->exists('README.md', '/app') ->willReturn(false); - $this->fileLocator->locate('grumphp.yml', Argument::type('string')) - ->willReturn('/app/vendor/fast-forward/dev-tools/grumphp.yml'); $this->fileDiffer->diffContents( 'generated dev-tools composer.json configuration', '/app/composer.json', @@ -155,8 +158,85 @@ public function executeWillUpdateComposerJsonScriptsAndExtraConfiguration(): voi ))->shouldBeCalledOnce(); $this->filesystem->dumpFile( '/app/composer.json', - Argument::that(static fn(string $contents): bool => str_contains($contents, '"dev-tools"') - && str_contains($contents, '"grumphp"')), + Argument::that(static function (string $contents): bool { + $composerJson = json_decode($contents, true); + + return 'dev-tools' === $composerJson['scripts']['dev-tools'] + && '@dev-tools --fix' === $composerJson['scripts']['dev-tools:fix'] + && ! isset($composerJson['extra']['grumphp']['config-default-path']); + }), + )->shouldBeCalledOnce(); + + self::assertSame(UpdateComposerJsonCommand::SUCCESS, $this->executeCommand()); + } + + /** + * @return void + */ + #[Test] + public function executeWillRemoveOnlyManagedGrumPhpConfigDefaultPath(): void + { + $this->input->getOption('file') + ->willReturn('/app/composer.json'); + $this->filesystem->exists('/app/composer.json') + ->willReturn(true); + $this->filesystem->readFile('/app/composer.json') + ->willReturn( + '{"name":"example/package","extra":{"grumphp":{"config-default-path":"vendor/fast-forward/dev-tools/grumphp.yml","stop_on_failure":true}}}' + ); + $this->composer->getReadme() + ->willReturn(''); + $this->filesystem->exists('README.md', '/app') + ->willReturn(false); + $this->fileDiffer->diffContents(Argument::cetera()) + ->willReturn(new FileDiff( + FileDiff::STATUS_CHANGED, + 'Updating managed file /app/composer.json from generated dev-tools composer.json configuration.', + ))->shouldBeCalledOnce(); + $this->filesystem->dumpFile( + '/app/composer.json', + Argument::that(static function (string $contents): bool { + $composerJson = json_decode($contents, true); + + return true === $composerJson['extra']['grumphp']['stop_on_failure'] + && ! isset($composerJson['extra']['grumphp']['config-default-path']); + }), + )->shouldBeCalledOnce(); + + self::assertSame(UpdateComposerJsonCommand::SUCCESS, $this->executeCommand()); + } + + /** + * @return void + */ + #[Test] + public function executeWillPreserveConsumerOwnedGrumPhpConfigDefaultPath(): void + { + $this->input->getOption('file') + ->willReturn('/app/composer.json'); + $this->filesystem->exists('/app/composer.json') + ->willReturn(true); + $this->filesystem->readFile('/app/composer.json') + ->willReturn( + '{"name":"example/package","extra":{"grumphp":{"config-default-path":"tools/grumphp.yml","stop_on_failure":true}}}' + ); + $this->composer->getReadme() + ->willReturn(''); + $this->filesystem->exists('README.md', '/app') + ->willReturn(false); + $this->fileDiffer->diffContents(Argument::cetera()) + ->willReturn(new FileDiff( + FileDiff::STATUS_CHANGED, + 'Updating managed file /app/composer.json from generated dev-tools composer.json configuration.', + ))->shouldBeCalledOnce(); + $this->filesystem->dumpFile( + '/app/composer.json', + Argument::that(static function (string $contents): bool { + $composerJson = json_decode($contents, true); + + return 'tools/grumphp.yml' === $composerJson['extra']['grumphp']['config-default-path'] + && true === $composerJson['extra']['grumphp']['stop_on_failure']; + }), )->shouldBeCalledOnce(); self::assertSame(UpdateComposerJsonCommand::SUCCESS, $this->executeCommand()); diff --git a/tests/GitHooks/HookContentRendererTest.php b/tests/GitHooks/HookContentRendererTest.php new file mode 100644 index 0000000000..b58308d1b4 --- /dev/null +++ b/tests/GitHooks/HookContentRendererTest.php @@ -0,0 +1,46 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitHooks; + +use FastForward\DevTools\GitHooks\HookContentRenderer; +use FastForward\DevTools\Path\DevToolsPathResolver; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; + +#[CoversClass(HookContentRenderer::class)] +#[UsesClass(DevToolsPathResolver::class)] +final class HookContentRendererTest extends TestCase +{ + /** + * @return void + */ + #[Test] + public function renderWillReplaceTheManagedGrumPhpConfigPlaceholder(): void + { + $renderer = new HookContentRenderer(); + + self::assertSame( + 'DEVTOOLS_GRUMPHP_CONFIG=' . escapeshellarg(DevToolsPathResolver::getPackagePath('grumphp.yml')), + $renderer->render('DEVTOOLS_GRUMPHP_CONFIG=' . HookContentRenderer::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER), + ); + } +} diff --git a/tests/GitHooks/PackagedHooksTest.php b/tests/GitHooks/PackagedHooksTest.php new file mode 100644 index 0000000000..f53e74f031 --- /dev/null +++ b/tests/GitHooks/PackagedHooksTest.php @@ -0,0 +1,63 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitHooks; + +use FastForward\DevTools\GitHooks\HookContentRenderer; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +use function Safe\file_get_contents; + +#[CoversNothing] +final class PackagedHooksTest extends TestCase +{ + /** + * @return void + */ + #[Test] + public function packagedPreCommitHookWillPreferProjectConfigAndFallbackToTheManagedPlaceholder(): void + { + $contents = file_get_contents(\dirname(__DIR__, 2) . '/resources/git-hooks/pre-commit'); + + self::assertStringContainsString("./grumphp.yml' ]; then", $contents); + self::assertStringContainsString( + 'DEVTOOLS_GRUMPHP_CONFIG=' . HookContentRenderer::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER, + $contents, + ); + self::assertStringContainsString("'--config' \"\${GRUMPHP_CONFIG_FILE}\" 'git:pre-commit'", $contents); + } + + /** + * @return void + */ + #[Test] + public function packagedCommitMsgHookWillPreferProjectConfigAndFallbackToTheManagedPlaceholder(): void + { + $contents = file_get_contents(\dirname(__DIR__, 2) . '/resources/git-hooks/commit-msg'); + + self::assertStringContainsString("./grumphp.yml' ]; then", $contents); + self::assertStringContainsString( + 'DEVTOOLS_GRUMPHP_CONFIG=' . HookContentRenderer::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER, + $contents, + ); + self::assertStringContainsString("'--config' \"\${GRUMPHP_CONFIG_FILE}\" 'git:commit-msg'", $contents); + } +} diff --git a/tests/GrumPhp/ManagedConfigPathSynchronizerTest.php b/tests/GrumPhp/ManagedConfigPathSynchronizerTest.php new file mode 100644 index 0000000000..9fc2581b47 --- /dev/null +++ b/tests/GrumPhp/ManagedConfigPathSynchronizerTest.php @@ -0,0 +1,121 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GrumPhp; + +use FastForward\DevTools\GrumPhp\ManagedConfigPathSynchronizer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(ManagedConfigPathSynchronizer::class)] +final class ManagedConfigPathSynchronizerTest extends TestCase +{ + /** + * @return void + */ + #[Test] + public function synchronizeWillRemoveManagedVendorConfigDefaultPaths(): void + { + $synchronizer = new ManagedConfigPathSynchronizer(); + + self::assertSame( + [], + $synchronizer->synchronize( + [ + 'grumphp' => [ + 'config-default-path' => 'vendor/fast-forward/dev-tools/grumphp.yml', + ], + ], + '/app', + '/Users/example/.composer/vendor/fast-forward/dev-tools/grumphp.yml' + ), + ); + } + + /** + * @return void + */ + #[Test] + public function synchronizeWillRemoveCurrentManagedRelativePathsOutsideTheProject(): void + { + $synchronizer = new ManagedConfigPathSynchronizer(); + + self::assertSame( + [], + $synchronizer->synchronize( + [ + 'grumphp' => [ + 'config-default-path' => '../global/dev-tools/grumphp.yml', + ], + ], + '/app', + '/global/dev-tools/grumphp.yml' + ), + ); + } + + /** + * @return void + */ + #[Test] + public function synchronizeWillPreserveConsumerOwnedConfigDefaultPaths(): void + { + $synchronizer = new ManagedConfigPathSynchronizer(); + + self::assertSame( + [ + 'grumphp' => [ + 'config-default-path' => 'tools/grumphp.yml', + ], + ], + $synchronizer->synchronize( + [ + 'grumphp' => [ + 'config-default-path' => 'tools/grumphp.yml', + ], + ], + '/app', + '/Users/example/.composer/vendor/fast-forward/dev-tools/grumphp.yml' + ), + ); + } + + /** + * @return void + */ + #[Test] + public function synchronizeWillPreserveNonArrayLegacyConsumerConfiguration(): void + { + $synchronizer = new ManagedConfigPathSynchronizer(); + + self::assertSame( + [ + 'grumphp' => 'consumer-managed', + ], + $synchronizer->synchronize( + [ + 'grumphp' => 'consumer-managed', + ], + '/app', + '/Users/example/.composer/vendor/fast-forward/dev-tools/grumphp.yml' + ), + ); + } +}