Skip to content

Commit 725f6f7

Browse files
Fix GrumPHP config paths for global dev-tools sync (#295)
* [sync] Move managed GrumPHP config path into hooks * Update wiki submodule pointer for PR #295 --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 69de785 commit 725f6f7

18 files changed

Lines changed: 677 additions & 49 deletions

.github/wiki

Submodule wiki updated from 7f52fa9 to 02bb3c4

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- 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)
13+
1014
## [1.24.2] - 2026-04-30
1115

1216
### Fixed

docs/commands/update-composer-json.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ dev-tools integration scripts and GrumPHP configuration:
1111

1212
1. Adds the ``dev-tools`` script entrypoint
1313
2. Adds the ``dev-tools:fix`` script for automated fixing
14-
3. Adds GrumPHP extra configuration pointing to the packaged ``grumphp.yml``
14+
3. Removes deprecated DevTools-managed GrumPHP default-path metadata while
15+
preserving consumer-owned settings
1516

1617
Usage
1718
-----
@@ -64,7 +65,8 @@ Behavior
6465

6566
- If the target composer.json does not exist, the command exits silently with code 0.
6667
- Existing scripts with the same name are overwritten.
67-
- The GrumPHP extra configuration is merged with existing configuration.
68+
- Consumer-owned GrumPHP settings are preserved, while only deprecated
69+
DevTools-managed ``extra.grumphp.config-default-path`` values are removed.
6870
- ``--dry-run`` and ``--check`` render a diff against the managed
6971
``composer.json`` result before deciding whether to write.
7072

docs/faq.rst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ Why did my ``composer.json`` change after installing the package?
1111
-----------------------------------------------------------------
1212

1313
The Composer plugin runs ``dev-tools:sync`` after install and update.
14-
That command adds the ``dev-tools`` scripts and updates
15-
``extra.grumphp.config-default-path`` in the consumer project.
14+
That command adds the ``dev-tools`` scripts, removes deprecated
15+
DevTools-managed ``extra.grumphp.config-default-path`` values, and refreshes
16+
the packaged Git hooks.
1617

1718
Do I always need to run ``dev-tools:sync`` manually?
1819
----------------------------------------------------

docs/getting-started/installation.rst

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ following steps:
3232
update.
3333
4. ``dev-tools:sync`` adds or refreshes the ``dev-tools`` and
3434
``dev-tools:fix`` scripts in the consumer ``composer.json``.
35-
5. ``dev-tools:sync`` updates ``extra.grumphp.config-default-path``,
36-
synchronizes funding metadata, copies automation assets such as workflow
37-
stubs, ``.editorconfig``, and ``.github/dependabot.yml``, and refreshes
35+
5. ``dev-tools:sync`` removes deprecated DevTools-managed
36+
``extra.grumphp.config-default-path`` entries, synchronizes funding
37+
metadata, copies automation assets such as workflow stubs,
38+
``.editorconfig``, and ``.github/dependabot.yml``, and refreshes
3839
``.gitignore``, ``.gitattributes``, the project license, and packaged Git
39-
hooks.
40+
hooks that prefer a project-local ``grumphp.yml`` override and otherwise
41+
use the active packaged DevTools ``grumphp.yml`` path.
4042
6. If ``.github/wiki`` is missing, ``dev-tools:sync`` adds it as a Git
4143
submodule that points to the repository wiki.
4244
7. ``dev-tools:sync`` runs ``gitignore`` to merge canonical ignore rules into

docs/running/specialized-commands.rst

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -377,8 +377,8 @@ Synchronizes consumer-facing automation and defaults.
377377
378378
Important details:
379379

380-
- it updates ``composer.json`` scripts and
381-
``extra.grumphp.config-default-path``;
380+
- it updates ``composer.json`` scripts and removes only deprecated
381+
DevTools-managed ``extra.grumphp.config-default-path`` values;
382382
- it calls ``funding`` so supported funding metadata stays aligned between
383383
``composer.json`` and ``.github/FUNDING.yml``;
384384
- it copies missing workflow stubs, ``.editorconfig``, and ``dependabot.yml``;
@@ -389,6 +389,9 @@ Important details:
389389
- it calls ``gitignore`` to merge the canonical .gitignore with the project's
390390
.gitignore;
391391
- it calls ``gitattributes`` to manage export-ignore rules in .gitattributes;
392+
- it refreshes packaged Git hooks that prefer a local ``grumphp.yml``
393+
override and otherwise use the active packaged DevTools ``grumphp.yml``
394+
path resolved when sync installs them;
392395
- it calls ``skills`` so ``.agents/skills`` contains links to the packaged
393396
skill set;
394397
- it calls ``agents`` so ``.agents/agents`` contains links to the packaged
@@ -502,6 +505,8 @@ Important details:
502505

503506
- adds ``dev-tools`` script entrypoint to composer.json;
504507
- adds ``dev-tools:fix`` script for automated fixing;
505-
- adds GrumPHP extra configuration pointing to packaged ``grumphp.yml``;
508+
- removes only deprecated DevTools-managed
509+
``extra.grumphp.config-default-path`` values;
506510
- if the target file does not exist, exits silently with code 0;
507-
- existing scripts with the same name are overwritten.
511+
- existing scripts with the same name are overwritten;
512+
- preserves consumer-owned GrumPHP settings.

docs/usage/syncing-consumer-projects.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ What the Command Changes
2222
- Adds or updates ``dev-tools`` and ``dev-tools:fix``.
2323
- Updated in place.
2424
* - ``composer.json`` extra
25-
- Sets ``extra.grumphp.config-default-path``.
25+
- Removes only deprecated DevTools-managed
26+
``extra.grumphp.config-default-path`` values while preserving
27+
consumer-owned GrumPHP settings.
2628
- Updated in place.
2729
* - ``.github/workflows/*.yml``
2830
- Copies thin wrapper workflows from ``resources/github-actions`` that
@@ -49,6 +51,11 @@ What the Command Changes
4951
* - ``.github/wiki``
5052
- Adds a Git submodule derived from ``git remote origin``.
5153
- Only when missing.
54+
* - ``.git/hooks/*``
55+
- Copies packaged hooks that prefer a local ``grumphp.yml`` override and
56+
otherwise use the active packaged DevTools ``grumphp.yml`` path
57+
resolved when sync installs them.
58+
- Replaced when drift is detected.
5259

5360
When to Run It
5461
--------------

resources/git-hooks/commit-msg

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,20 @@ DIFF=$(git -c diff.mnemonicprefix=false -c diff.noprefix=false --no-pager diff -
1616

1717
export GRUMPHP_GIT_WORKING_DIR="$(git rev-parse --show-toplevel)"
1818

19+
cd "${GRUMPHP_GIT_WORKING_DIR}" || exit 1
20+
21+
GRUMPHP_CONFIG_FILE=''
22+
DEVTOOLS_GRUMPHP_CONFIG=__DEV_TOOLS_GRUMPHP_CONFIG__
23+
24+
if [ -f './grumphp.yml' ]; then
25+
GRUMPHP_CONFIG_FILE='./grumphp.yml'
26+
elif [ -f "${DEVTOOLS_GRUMPHP_CONFIG}" ]; then
27+
GRUMPHP_CONFIG_FILE="${DEVTOOLS_GRUMPHP_CONFIG}"
28+
fi
29+
1930
# Run GrumPHP
20-
(cd "./" && printf "%s\n" "${DIFF}" | exec 'vendor/bin/grumphp.phar' 'git:commit-msg' "--git-user='$GIT_USER'" "--git-email='$GIT_EMAIL'" "$COMMIT_MSG_FILE")
31+
if [ -n "${GRUMPHP_CONFIG_FILE}" ]; then
32+
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"
33+
fi
34+
35+
printf "%s\n" "${DIFF}" | exec 'vendor/bin/grumphp.phar' 'git:commit-msg' "--git-user=$GIT_USER" "--git-email=$GIT_EMAIL" "$COMMIT_MSG_FILE"

resources/git-hooks/pre-commit

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,20 @@ DIFF=$(git -c diff.mnemonicprefix=false -c diff.noprefix=false --no-pager diff -
1212

1313
export GRUMPHP_GIT_WORKING_DIR="$(git rev-parse --show-toplevel)"
1414

15+
cd "${GRUMPHP_GIT_WORKING_DIR}" || exit 1
16+
17+
GRUMPHP_CONFIG_FILE=''
18+
DEVTOOLS_GRUMPHP_CONFIG=__DEV_TOOLS_GRUMPHP_CONFIG__
19+
20+
if [ -f './grumphp.yml' ]; then
21+
GRUMPHP_CONFIG_FILE='./grumphp.yml'
22+
elif [ -f "${DEVTOOLS_GRUMPHP_CONFIG}" ]; then
23+
GRUMPHP_CONFIG_FILE="${DEVTOOLS_GRUMPHP_CONFIG}"
24+
fi
25+
1526
# Run GrumPHP
16-
(cd "./" && printf "%s\n" "${DIFF}" | exec 'vendor/bin/grumphp.phar' 'git:pre-commit' '--skip-success-output')
27+
if [ -n "${GRUMPHP_CONFIG_FILE}" ]; then
28+
printf "%s\n" "${DIFF}" | exec 'vendor/bin/grumphp.phar' '--config' "${GRUMPHP_CONFIG_FILE}" 'git:pre-commit' '--skip-success-output'
29+
fi
30+
31+
printf "%s\n" "${DIFF}" | exec 'vendor/bin/grumphp.phar' 'git:pre-commit' '--skip-success-output'

src/Console/Command/GitHooksCommand.php

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
use FastForward\DevTools\Console\Input\HasJsonOption;
2424
use FastForward\DevTools\Filesystem\FinderFactoryInterface;
2525
use FastForward\DevTools\Filesystem\FilesystemInterface;
26+
use FastForward\DevTools\GitHooks\HookContentRenderer;
27+
use FastForward\DevTools\Resource\FileDiff;
2628
use FastForward\DevTools\Resource\FileDiffer;
2729
use Psr\Log\LoggerInterface;
2830
use Symfony\Component\Config\FileLocatorInterface;
@@ -35,6 +37,7 @@
3537
use Symfony\Component\Console\Style\SymfonyStyle;
3638
use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
3739
use Symfony\Component\Filesystem\Path;
40+
use Throwable;
3841

3942
/**
4043
* Installs packaged Git hooks for the consumer repository.
@@ -55,6 +58,7 @@ final class GitHooksCommand extends Command
5558
* @param FilesystemInterface $filesystem the filesystem used to copy hooks
5659
* @param FileLocatorInterface $fileLocator the locator used to find packaged hooks
5760
* @param FinderFactoryInterface $finderFactory the factory used to create finders for hook files
61+
* @param HookContentRenderer $hookContentRenderer renders packaged hooks with runtime-specific placeholders
5862
* @param FileDiffer $fileDiffer the file differ used to summarize synchronization changes
5963
* @param LoggerInterface $logger the output-aware logger
6064
* @param SymfonyStyle $io the input/output service used to interact with the user
@@ -63,6 +67,7 @@ public function __construct(
6367
private readonly FilesystemInterface $filesystem,
6468
private readonly FileLocatorInterface $fileLocator,
6569
private readonly FinderFactoryInterface $finderFactory,
70+
private readonly HookContentRenderer $hookContentRenderer,
6671
private readonly FileDiffer $fileDiffer,
6772
private readonly LoggerInterface $logger,
6873
private readonly SymfonyStyle $io,
@@ -140,6 +145,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
140145
$installFailure = false;
141146

142147
foreach ($files as $file) {
148+
$sourcePath = $file->getRealPath();
149+
$sourceContents = $this->filesystem->readFile($sourcePath);
150+
$renderedSourceContents = $this->hookContentRenderer->render($sourceContents);
143151
$hookPath = Path::join($targetPath, $file->getRelativePathname());
144152

145153
if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($hookPath)) {
@@ -156,7 +164,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
156164
}
157165

158166
if (($overwrite || $dryRun || $check || $interactive) && $this->filesystem->exists($hookPath)) {
159-
$comparison = $this->fileDiffer->diff($file->getRealPath(), $hookPath);
167+
$comparison = $sourceContents === $renderedSourceContents
168+
? $this->fileDiffer->diff($sourcePath, $hookPath)
169+
: $this->compareRenderedHookContents($sourcePath, $hookPath, $renderedSourceContents);
160170

161171
$this->logger->notice(
162172
$comparison->getSummary(),
@@ -211,7 +221,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
211221
}
212222
}
213223

214-
if (! $this->installHook($file->getRealPath(), $hookPath, $overwrite || $interactive, $input)) {
224+
if (! $this->installHook(
225+
$sourcePath,
226+
$hookPath,
227+
$overwrite || $interactive,
228+
$input,
229+
$sourceContents === $renderedSourceContents ? null : $renderedSourceContents,
230+
)) {
215231
$installFailure = true;
216232

217233
continue;
@@ -282,21 +298,28 @@ private function shouldReplaceHook(string $hookPath): bool
282298
* @param string $hookPath the target repository hook path
283299
* @param bool $replaceExisting whether an existing hook SHOULD be removed first
284300
* @param InputInterface $input the originating command input
301+
* @param string|null $renderedContents optional rendered hook contents that SHOULD be written instead of copied
285302
*
286303
* @return bool true when the hook was installed successfully
287304
*/
288305
private function installHook(
289306
string $sourcePath,
290307
string $hookPath,
291308
bool $replaceExisting,
292-
InputInterface $input
309+
InputInterface $input,
310+
?string $renderedContents = null,
293311
): bool {
294312
try {
295313
if ($replaceExisting && $this->filesystem->exists($hookPath)) {
296314
$this->filesystem->remove($hookPath);
297315
}
298316

299-
$this->filesystem->copy($sourcePath, $hookPath, false);
317+
if (null === $renderedContents) {
318+
$this->filesystem->copy($sourcePath, $hookPath, false);
319+
} else {
320+
$this->filesystem->dumpFile($hookPath, $renderedContents);
321+
}
322+
300323
$this->filesystem->chmod(files: $hookPath, mode: 0o755);
301324

302325
return true;
@@ -316,4 +339,40 @@ private function installHook(
316339
return false;
317340
}
318341
}
342+
343+
/**
344+
* Compares rendered hook contents with an existing installed hook.
345+
*
346+
* @param string $sourcePath the packaged hook source path
347+
* @param string $hookPath the target installed hook path
348+
* @param string $renderedContents the rendered hook contents
349+
*
350+
* @return FileDiff the rendered comparison result
351+
*/
352+
private function compareRenderedHookContents(
353+
string $sourcePath,
354+
string $hookPath,
355+
string $renderedContents
356+
): FileDiff {
357+
try {
358+
$targetContents = $this->filesystem->readFile($hookPath);
359+
} catch (Throwable) {
360+
return new FileDiff(
361+
FileDiff::STATUS_UNREADABLE,
362+
\sprintf(
363+
'Target %s will be overwritten from %s, but the existing or source content could not be read.',
364+
$hookPath,
365+
$sourcePath,
366+
),
367+
);
368+
}
369+
370+
return $this->fileDiffer->diffContents(
371+
$sourcePath,
372+
$hookPath,
373+
$renderedContents,
374+
$targetContents,
375+
\sprintf('Overwriting resource %s from %s.', $hookPath, $sourcePath),
376+
);
377+
}
319378
}

0 commit comments

Comments
 (0)