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 6feadc to d3bec4
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

- Render managed GrumPHP hook fallback paths relative to the consumer project so global `dev-tools:sync` installs keep local GrumPHP hook execution working (#305)

## [1.24.4] - 2026-04-30

### Fixed
Expand Down
3 changes: 2 additions & 1 deletion docs/getting-started/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ following steps:
``.editorconfig``, and ``.github/dependabot.yml``, and refreshes
``.gitignore``, ``.gitattributes``, the project license, and packaged Git
hooks that prefer a project-local ``grumphp.yml`` override and otherwise
use the active packaged DevTools ``grumphp.yml`` path.
use a project-relative reference to 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
4 changes: 2 additions & 2 deletions docs/running/specialized-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -390,8 +390,8 @@ Important details:
.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;
override and otherwise use a project-relative reference to 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
4 changes: 2 additions & 2 deletions docs/usage/syncing-consumer-projects.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ What the Command Changes
- 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.
otherwise use a project-relative reference to the active packaged
DevTools ``grumphp.yml`` path resolved when sync installs them.
- Replaced when drift is detected.

When to Run It
Expand Down
3 changes: 2 additions & 1 deletion src/Console/Command/GitHooksCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ protected function configure(): void
protected function execute(InputInterface $input, OutputInterface $output): int
{
$sourcePath = $this->fileLocator->locate((string) $input->getOption('source'));
$projectPath = (string) $this->filesystem->getAbsolutePath('.');
Comment thread
coisa marked this conversation as resolved.
$targetPath = (string) $this->filesystem->getAbsolutePath((string) $input->getOption('target'));
$overwrite = ! $input->getOption('no-overwrite');
$dryRun = (bool) $input->getOption('dry-run');
Expand All @@ -147,7 +148,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
foreach ($files as $file) {
$sourcePath = $file->getRealPath();
$sourceContents = $this->filesystem->readFile($sourcePath);
$renderedSourceContents = $this->hookContentRenderer->render($sourceContents);
$renderedSourceContents = $this->hookContentRenderer->render($sourceContents, $projectPath);
$hookPath = Path::join($targetPath, $file->getRelativePathname());

if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($hookPath)) {
Expand Down
7 changes: 4 additions & 3 deletions src/GitHooks/HookContentRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,23 @@
final class HookContentRenderer
{
/**
* Placeholder replaced with the active packaged GrumPHP config path.
* Placeholder replaced with the packaged GrumPHP config path rendered relative to the project when possible.
*/
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
* @param string $projectPath the consumer project root that will own the synchronized hook
*
* @return string the rendered hook contents
*/
public function render(string $contents): string
public function render(string $contents, string $projectPath = ''): string
{
return str_replace(
self::MANAGED_GRUMPHP_CONFIG_PLACEHOLDER,
escapeshellarg(DevToolsPathResolver::getPackagePath('grumphp.yml')),
escapeshellarg(DevToolsPathResolver::getPackagePathRelativeToProject('grumphp.yml', $projectPath)),
$contents,
);
}
Expand Down
189 changes: 140 additions & 49 deletions src/Path/DevToolsPathResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,7 @@ final class DevToolsPathResolver
*/
public static function getPackagePath(string $path = ''): string
{
$packageDirectory = \dirname(__DIR__, 2);

if ('' !== $path && Path::isAbsolute($path)) {
throw new InvalidArgumentException('The DevTools package path MUST be relative to the package root.');
}

return Path::join($packageDirectory, $path);
return self::resolvePackageRelativePath($path);
}

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

/**
* Returns a packaged path rendered relative to the active project root when possible.
*
* When the project root and package root do not share a filesystem root,
* the packaged absolute path MUST be returned unchanged so globally
* installed DevTools can still point hooks at the packaged fallback file.
*
* @param string $path the relative path under the package root
* @param string $projectPath an optional project root path; defaults to the working project root
* @param string $packagePath an optional package root path; defaults to the current package root
*/
public static function getPackagePathRelativeToProject(
string $path,
string $projectPath = '',
string $packagePath = '',
): string {
return self::relativizePathFromProject(
self::resolvePackageRelativePath($path, $packagePath),
self::resolveProjectPath($projectPath),
);
}

/**
* Returns the active Composer autoload file for the current DevTools installation mode.
*
Expand All @@ -99,13 +115,7 @@ public static function getResourcesPath(string $path = ''): string
*/
public static function getRuntimeAutoloadPath(string $packagePath = ''): string
{
$packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath);

if (self::isInstalledAsDependency($packagePath)) {
return Path::canonicalize(Path::join($packagePath, '..', '..', 'autoload.php'));
}

return Path::join($packagePath, 'vendor', 'autoload.php');
return Path::join(self::getRuntimeVendorRoot($packagePath), 'autoload.php');
}

/**
Expand All @@ -119,13 +129,7 @@ public static function getRuntimeAutoloadPath(string $packagePath = ''): string
*/
public static function getRuntimeToolBinaryPath(string $binary, string $packagePath = ''): string
{
$packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath);

if (self::isInstalledAsDependency($packagePath)) {
return Path::canonicalize(Path::join($packagePath, '..', '..', 'bin', $binary));
}

return Path::join($packagePath, 'vendor', 'bin', $binary);
return self::getRuntimeVendorPath(Path::join('bin', $binary), $packagePath);
}

/**
Expand All @@ -139,14 +143,7 @@ public static function getRuntimeToolBinaryPath(string $binary, string $packageP
*/
public static function getRuntimeVendorPath(string $path, string $packagePath = ''): string
{
$packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath);
$vendorPath = self::normalizeVendorRelativePath($path);

if (self::isInstalledAsDependency($packagePath)) {
return Path::canonicalize(Path::join($packagePath, '..', '..', $vendorPath));
}

return Path::join($packagePath, 'vendor', $vendorPath);
return Path::join(self::getRuntimeVendorRoot($packagePath), self::normalizeVendorRelativePath($path));
}

/**
Expand All @@ -165,14 +162,10 @@ public static function getPreferredToolBinaryPath(
string $projectPath = '',
string $packagePath = '',
): string {
$projectPath = '' === $projectPath ? WorkingProjectPathResolver::getProjectPath() : $projectPath;
$projectBinaryPath = Path::join($projectPath, 'vendor', 'bin', $binary);

if (file_exists($projectBinaryPath)) {
return $projectBinaryPath;
}

return self::getRuntimeToolBinaryPath($binary, $packagePath);
return self::preferExistingPath(
self::getProjectVendorPath(Path::join('bin', $binary), $projectPath),
self::getRuntimeToolBinaryPath($binary, $packagePath),
);
}

/**
Expand All @@ -191,15 +184,10 @@ public static function getPreferredVendorPath(
string $projectPath = '',
string $packagePath = '',
): string {
$projectPath = '' === $projectPath ? WorkingProjectPathResolver::getProjectPath() : $projectPath;
$vendorPath = self::normalizeVendorRelativePath($path);
$projectVendorPath = Path::join($projectPath, 'vendor', $vendorPath);

if (file_exists($projectVendorPath)) {
return $projectVendorPath;
}

return self::getRuntimeVendorPath($vendorPath, $packagePath);
return self::preferExistingPath(
self::getProjectVendorPath($path, $projectPath),
self::getRuntimeVendorPath($path, $packagePath),
);
}

/**
Expand All @@ -209,9 +197,7 @@ public static function getPreferredVendorPath(
*/
public static function isInstalledAsDependency(string $packagePath = ''): bool
{
$packagePath = Path::canonicalize('' === $packagePath ? self::getPackagePath() : $packagePath);

return str_contains($packagePath, self::VENDOR_PACKAGE_PATH);
return str_contains(self::resolvePackageRoot($packagePath), self::VENDOR_PACKAGE_PATH);
}

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

return $path;
}

/**
* Ensures packaged paths stay relative to the DevTools package root.
*
* @param string $path the package-relative path to validate
*/
private static function assertRelativePackagePath(string $path): void
{
if ('' !== $path && Path::isAbsolute($path)) {
throw new InvalidArgumentException('The DevTools package path MUST be relative to the package root.');
}
}

/**
* Returns a canonical path under the DevTools package root.
*
* @param string $path the package-relative path to resolve
* @param string $packagePath an optional package root path; defaults to the current package root
*/
private static function resolvePackageRelativePath(string $path = '', string $packagePath = ''): string
{
self::assertRelativePackagePath($path);

return Path::canonicalize(Path::join(self::resolvePackageRoot($packagePath), $path));
}

/**
* Returns the canonical DevTools package root.
*
* @param string $packagePath an optional package root path; defaults to the current package root
*/
private static function resolvePackageRoot(string $packagePath = ''): string
{
return Path::canonicalize('' === $packagePath ? \dirname(__DIR__, 2) : $packagePath);
}

/**
* Returns the canonical working project root.
*
* @param string $projectPath an optional project root path; defaults to the working project root
*/
private static function resolveProjectPath(string $projectPath = ''): string
{
return Path::canonicalize(WorkingProjectPathResolver::getProjectPath($projectPath));
}

/**
* Returns the active Composer vendor root for the current DevTools installation mode.
*
* @param string $packagePath an optional package root path; defaults to the current package root
*/
private static function getRuntimeVendorRoot(string $packagePath = ''): string
{
$packagePath = self::resolvePackageRoot($packagePath);

if (self::isInstalledAsDependency($packagePath)) {
return Path::canonicalize(Path::join($packagePath, '..', '..'));
}

return Path::join($packagePath, 'vendor');
}

/**
* Returns a vendor path under the active project root.
*
* @param string $path the vendor-relative path to resolve
* @param string $projectPath an optional project root path; defaults to the working project root
*/
private static function getProjectVendorPath(string $path, string $projectPath = ''): string
{
return Path::join(self::resolveProjectPath($projectPath), 'vendor', self::normalizeVendorRelativePath($path));
}

/**
* Returns the preferred path when a project-local candidate exists.
*
* @param string $preferredPath the project-local candidate path
* @param string $fallbackPath the runtime fallback path
*/
private static function preferExistingPath(string $preferredPath, string $fallbackPath): string
{
if (file_exists($preferredPath)) {
return $preferredPath;
}

return $fallbackPath;
}

/**
* Returns a path relative to the project root when possible.
*
* When paths do not share the same filesystem root, the original absolute
* path MUST be returned unchanged so callers still receive a usable path.
*
* @param string $path the absolute path to relativize
* @param string $projectPath the absolute project root used as base path
*/
private static function relativizePathFromProject(string $path, string $projectPath): string
{
try {
return Path::makeRelative($path, $projectPath);
} catch (InvalidArgumentException) {
return $path;
}
}
}
Loading
Loading