Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/wiki
Submodule wiki updated from 7f52fa to 02bb3c
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/commands/update-composer-json.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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?
----------------------------------------------------
Expand Down
10 changes: 6 additions & 4 deletions docs/getting-started/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions docs/running/specialized-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``;
Expand All @@ -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
Expand Down Expand Up @@ -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.
9 changes: 8 additions & 1 deletion docs/usage/syncing-consumer-projects.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
--------------
Expand Down
17 changes: 16 additions & 1 deletion resources/git-hooks/commit-msg
Original file line number Diff line number Diff line change
Expand Up @@ -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"
17 changes: 16 additions & 1 deletion resources/git-hooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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'
67 changes: 63 additions & 4 deletions src/Console/Command/GitHooksCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)) {
Expand All @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -282,21 +298,28 @@ 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
*/
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;
Expand All @@ -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),
);
}
}
24 changes: 9 additions & 15 deletions src/Console/Command/UpdateComposerJsonCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize composer.json base directory before sync

updatedComposerJsonContents() passes dirname($file) directly into ManagedConfigPathSynchronizer::synchronize(). With the default --file value (composer.json), this becomes . (a relative path), and isManagedConfigPath() then calls Path::makeRelative() with an absolute managed path and a relative base path, which raises InvalidArgumentException. In practice this causes default update-composer-json/dev-tools:sync runs to fail instead of updating metadata.

Useful? React with 👍 / 👎.

$this->fileLocator->locate('grumphp.yml', DevToolsPathResolver::getPackagePath())
);

return json_encode(
$composerJsonData,
Expand Down
Loading
Loading