Skip to content

Commit ccfecaf

Browse files
Fix global GrumPHP hook fallback rendering (#306)
* Fix global GrumPHP hook fallback rendering * Update wiki submodule pointer for PR #306 * Resolve git hooks project path from working directory * Restore unreleased changelog entry for #305 * Fallback to absolute packaged hook paths across roots * Update wiki submodule pointer for PR #306 * Simplify DevTools path resolution helpers --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 2d1d2ed commit ccfecaf

11 files changed

Lines changed: 206 additions & 69 deletions

File tree

.github/wiki

Submodule wiki updated from 6feadca to d3bec40

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+
- Render managed GrumPHP hook fallback paths relative to the consumer project so global `dev-tools:sync` installs keep local GrumPHP hook execution working (#305)
13+
1014
## [1.24.4] - 2026-04-30
1115

1216
### Fixed

docs/getting-started/installation.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ following steps:
3838
``.editorconfig``, and ``.github/dependabot.yml``, and refreshes
3939
``.gitignore``, ``.gitattributes``, the project license, and packaged Git
4040
hooks that prefer a project-local ``grumphp.yml`` override and otherwise
41-
use the active packaged DevTools ``grumphp.yml`` path.
41+
use a project-relative reference to the active packaged DevTools
42+
``grumphp.yml`` path.
4243
6. If ``.github/wiki`` is missing, ``dev-tools:sync`` adds it as a Git
4344
submodule that points to the repository wiki.
4445
7. ``dev-tools:sync`` runs ``gitignore`` to merge canonical ignore rules into

docs/running/specialized-commands.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,8 @@ Important details:
390390
.gitignore;
391391
- it calls ``gitattributes`` to manage export-ignore rules in .gitattributes;
392392
- 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;
393+
override and otherwise use a project-relative reference to the active
394+
packaged DevTools ``grumphp.yml`` path resolved when sync installs them;
395395
- it calls ``skills`` so ``.agents/skills`` contains links to the packaged
396396
skill set;
397397
- it calls ``agents`` so ``.agents/agents`` contains links to the packaged

docs/usage/syncing-consumer-projects.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ What the Command Changes
5353
- Only when missing.
5454
* - ``.git/hooks/*``
5555
- 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.
56+
otherwise use a project-relative reference to the active packaged
57+
DevTools ``grumphp.yml`` path resolved when sync installs them.
5858
- Replaced when drift is detected.
5959

6060
When to Run It

src/Console/Command/GitHooksCommand.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ protected function configure(): void
130130
protected function execute(InputInterface $input, OutputInterface $output): int
131131
{
132132
$sourcePath = $this->fileLocator->locate((string) $input->getOption('source'));
133+
$projectPath = (string) $this->filesystem->getAbsolutePath('.');
133134
$targetPath = (string) $this->filesystem->getAbsolutePath((string) $input->getOption('target'));
134135
$overwrite = ! $input->getOption('no-overwrite');
135136
$dryRun = (bool) $input->getOption('dry-run');
@@ -147,7 +148,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
147148
foreach ($files as $file) {
148149
$sourcePath = $file->getRealPath();
149150
$sourceContents = $this->filesystem->readFile($sourcePath);
150-
$renderedSourceContents = $this->hookContentRenderer->render($sourceContents);
151+
$renderedSourceContents = $this->hookContentRenderer->render($sourceContents, $projectPath);
151152
$hookPath = Path::join($targetPath, $file->getRelativePathname());
152153

153154
if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($hookPath)) {

src/GitHooks/HookContentRenderer.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,23 @@
2727
final class HookContentRenderer
2828
{
2929
/**
30-
* Placeholder replaced with the active packaged GrumPHP config path.
30+
* Placeholder replaced with the packaged GrumPHP config path rendered relative to the project when possible.
3131
*/
3232
public const string MANAGED_GRUMPHP_CONFIG_PLACEHOLDER = '__DEV_TOOLS_GRUMPHP_CONFIG__';
3333

3434
/**
3535
* Renders the hook contents for the active DevTools runtime.
3636
*
3737
* @param string $contents the packaged hook contents
38+
* @param string $projectPath the consumer project root that will own the synchronized hook
3839
*
3940
* @return string the rendered hook contents
4041
*/
41-
public function render(string $contents): string
42+
public function render(string $contents, string $projectPath = ''): string
4243
{
4344
return str_replace(
4445
self::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER,
45-
escapeshellarg(DevToolsPathResolver::getPackagePath('grumphp.yml')),
46+
escapeshellarg(DevToolsPathResolver::getPackagePathRelativeToProject('grumphp.yml', $projectPath)),
4647
$contents,
4748
);
4849
}

src/Path/DevToolsPathResolver.php

Lines changed: 140 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,7 @@ final class DevToolsPathResolver
4949
*/
5050
public static function getPackagePath(string $path = ''): string
5151
{
52-
$packageDirectory = \dirname(__DIR__, 2);
53-
54-
if ('' !== $path && Path::isAbsolute($path)) {
55-
throw new InvalidArgumentException('The DevTools package path MUST be relative to the package root.');
56-
}
57-
58-
return Path::join($packageDirectory, $path);
52+
return self::resolvePackageRelativePath($path);
5953
}
6054

6155
/**
@@ -88,6 +82,28 @@ public static function getResourcesPath(string $path = ''): string
8882
return self::getPackagePath(Path::join(self::RESOURCES, $path));
8983
}
9084

85+
/**
86+
* Returns a packaged path rendered relative to the active project root when possible.
87+
*
88+
* When the project root and package root do not share a filesystem root,
89+
* the packaged absolute path MUST be returned unchanged so globally
90+
* installed DevTools can still point hooks at the packaged fallback file.
91+
*
92+
* @param string $path the relative path under the package root
93+
* @param string $projectPath an optional project root path; defaults to the working project root
94+
* @param string $packagePath an optional package root path; defaults to the current package root
95+
*/
96+
public static function getPackagePathRelativeToProject(
97+
string $path,
98+
string $projectPath = '',
99+
string $packagePath = '',
100+
): string {
101+
return self::relativizePathFromProject(
102+
self::resolvePackageRelativePath($path, $packagePath),
103+
self::resolveProjectPath($projectPath),
104+
);
105+
}
106+
91107
/**
92108
* Returns the active Composer autoload file for the current DevTools installation mode.
93109
*
@@ -99,13 +115,7 @@ public static function getResourcesPath(string $path = ''): string
99115
*/
100116
public static function getRuntimeAutoloadPath(string $packagePath = ''): string
101117
{
102-
$packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath);
103-
104-
if (self::isInstalledAsDependency($packagePath)) {
105-
return Path::canonicalize(Path::join($packagePath, '..', '..', 'autoload.php'));
106-
}
107-
108-
return Path::join($packagePath, 'vendor', 'autoload.php');
118+
return Path::join(self::getRuntimeVendorRoot($packagePath), 'autoload.php');
109119
}
110120

111121
/**
@@ -119,13 +129,7 @@ public static function getRuntimeAutoloadPath(string $packagePath = ''): string
119129
*/
120130
public static function getRuntimeToolBinaryPath(string $binary, string $packagePath = ''): string
121131
{
122-
$packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath);
123-
124-
if (self::isInstalledAsDependency($packagePath)) {
125-
return Path::canonicalize(Path::join($packagePath, '..', '..', 'bin', $binary));
126-
}
127-
128-
return Path::join($packagePath, 'vendor', 'bin', $binary);
132+
return self::getRuntimeVendorPath(Path::join('bin', $binary), $packagePath);
129133
}
130134

131135
/**
@@ -139,14 +143,7 @@ public static function getRuntimeToolBinaryPath(string $binary, string $packageP
139143
*/
140144
public static function getRuntimeVendorPath(string $path, string $packagePath = ''): string
141145
{
142-
$packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath);
143-
$vendorPath = self::normalizeVendorRelativePath($path);
144-
145-
if (self::isInstalledAsDependency($packagePath)) {
146-
return Path::canonicalize(Path::join($packagePath, '..', '..', $vendorPath));
147-
}
148-
149-
return Path::join($packagePath, 'vendor', $vendorPath);
146+
return Path::join(self::getRuntimeVendorRoot($packagePath), self::normalizeVendorRelativePath($path));
150147
}
151148

152149
/**
@@ -165,14 +162,10 @@ public static function getPreferredToolBinaryPath(
165162
string $projectPath = '',
166163
string $packagePath = '',
167164
): string {
168-
$projectPath = '' === $projectPath ? WorkingProjectPathResolver::getProjectPath() : $projectPath;
169-
$projectBinaryPath = Path::join($projectPath, 'vendor', 'bin', $binary);
170-
171-
if (file_exists($projectBinaryPath)) {
172-
return $projectBinaryPath;
173-
}
174-
175-
return self::getRuntimeToolBinaryPath($binary, $packagePath);
165+
return self::preferExistingPath(
166+
self::getProjectVendorPath(Path::join('bin', $binary), $projectPath),
167+
self::getRuntimeToolBinaryPath($binary, $packagePath),
168+
);
176169
}
177170

178171
/**
@@ -191,15 +184,10 @@ public static function getPreferredVendorPath(
191184
string $projectPath = '',
192185
string $packagePath = '',
193186
): string {
194-
$projectPath = '' === $projectPath ? WorkingProjectPathResolver::getProjectPath() : $projectPath;
195-
$vendorPath = self::normalizeVendorRelativePath($path);
196-
$projectVendorPath = Path::join($projectPath, 'vendor', $vendorPath);
197-
198-
if (file_exists($projectVendorPath)) {
199-
return $projectVendorPath;
200-
}
201-
202-
return self::getRuntimeVendorPath($vendorPath, $packagePath);
187+
return self::preferExistingPath(
188+
self::getProjectVendorPath($path, $projectPath),
189+
self::getRuntimeVendorPath($path, $packagePath),
190+
);
203191
}
204192

205193
/**
@@ -209,9 +197,7 @@ public static function getPreferredVendorPath(
209197
*/
210198
public static function isInstalledAsDependency(string $packagePath = ''): bool
211199
{
212-
$packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath);
213-
214-
return str_contains($packagePath, self::VENDOR_PACKAGE_PATH);
200+
return str_contains(self::resolvePackageRoot($packagePath), self::VENDOR_PACKAGE_PATH);
215201
}
216202

217203
/**
@@ -239,4 +225,109 @@ private static function normalizeVendorRelativePath(string $path): string
239225

240226
return $path;
241227
}
228+
229+
/**
230+
* Ensures packaged paths stay relative to the DevTools package root.
231+
*
232+
* @param string $path the package-relative path to validate
233+
*/
234+
private static function assertRelativePackagePath(string $path): void
235+
{
236+
if ('' !== $path && Path::isAbsolute($path)) {
237+
throw new InvalidArgumentException('The DevTools package path MUST be relative to the package root.');
238+
}
239+
}
240+
241+
/**
242+
* Returns a canonical path under the DevTools package root.
243+
*
244+
* @param string $path the package-relative path to resolve
245+
* @param string $packagePath an optional package root path; defaults to the current package root
246+
*/
247+
private static function resolvePackageRelativePath(string $path = '', string $packagePath = ''): string
248+
{
249+
self::assertRelativePackagePath($path);
250+
251+
return Path::canonicalize(Path::join(self::resolvePackageRoot($packagePath), $path));
252+
}
253+
254+
/**
255+
* Returns the canonical DevTools package root.
256+
*
257+
* @param string $packagePath an optional package root path; defaults to the current package root
258+
*/
259+
private static function resolvePackageRoot(string $packagePath = ''): string
260+
{
261+
return Path::canonicalize('' === $packagePath ? \dirname(__DIR__, 2) : $packagePath);
262+
}
263+
264+
/**
265+
* Returns the canonical working project root.
266+
*
267+
* @param string $projectPath an optional project root path; defaults to the working project root
268+
*/
269+
private static function resolveProjectPath(string $projectPath = ''): string
270+
{
271+
return Path::canonicalize(WorkingProjectPathResolver::getProjectPath($projectPath));
272+
}
273+
274+
/**
275+
* Returns the active Composer vendor root for the current DevTools installation mode.
276+
*
277+
* @param string $packagePath an optional package root path; defaults to the current package root
278+
*/
279+
private static function getRuntimeVendorRoot(string $packagePath = ''): string
280+
{
281+
$packagePath = self::resolvePackageRoot($packagePath);
282+
283+
if (self::isInstalledAsDependency($packagePath)) {
284+
return Path::canonicalize(Path::join($packagePath, '..', '..'));
285+
}
286+
287+
return Path::join($packagePath, 'vendor');
288+
}
289+
290+
/**
291+
* Returns a vendor path under the active project root.
292+
*
293+
* @param string $path the vendor-relative path to resolve
294+
* @param string $projectPath an optional project root path; defaults to the working project root
295+
*/
296+
private static function getProjectVendorPath(string $path, string $projectPath = ''): string
297+
{
298+
return Path::join(self::resolveProjectPath($projectPath), 'vendor', self::normalizeVendorRelativePath($path));
299+
}
300+
301+
/**
302+
* Returns the preferred path when a project-local candidate exists.
303+
*
304+
* @param string $preferredPath the project-local candidate path
305+
* @param string $fallbackPath the runtime fallback path
306+
*/
307+
private static function preferExistingPath(string $preferredPath, string $fallbackPath): string
308+
{
309+
if (file_exists($preferredPath)) {
310+
return $preferredPath;
311+
}
312+
313+
return $fallbackPath;
314+
}
315+
316+
/**
317+
* Returns a path relative to the project root when possible.
318+
*
319+
* When paths do not share the same filesystem root, the original absolute
320+
* path MUST be returned unchanged so callers still receive a usable path.
321+
*
322+
* @param string $path the absolute path to relativize
323+
* @param string $projectPath the absolute project root used as base path
324+
*/
325+
private static function relativizePathFromProject(string $path, string $projectPath): string
326+
{
327+
try {
328+
return Path::makeRelative($path, $projectPath);
329+
} catch (InvalidArgumentException) {
330+
return $path;
331+
}
332+
}
242333
}

0 commit comments

Comments
 (0)