diff --git a/src/ComposerIntegration/InstallAndBuildProcess.php b/src/ComposerIntegration/InstallAndBuildProcess.php index c6944720..7616f2a3 100644 --- a/src/ComposerIntegration/InstallAndBuildProcess.php +++ b/src/ComposerIntegration/InstallAndBuildProcess.php @@ -10,7 +10,12 @@ use Php\Pie\DependencyResolver\Package; use Php\Pie\Downloading\DownloadedPackage; use Php\Pie\Installing\Install; +use Php\Pie\Platform\Git\Exception\InvalidGitBinaryPath; +use Php\Pie\Platform\Git\GitBinaryPath; +use function file_exists; +use function implode; +use function rtrim; use function sprintf; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ @@ -43,6 +48,27 @@ public function __invoke( $downloadedPackage->extractedSourcePath, )); + if (file_exists(rtrim($downloadedPackage->extractedSourcePath, '/') . '/.gitmodules')) { + try { + $output->writeln(sprintf( + 'Found .gitmodules file in %s, fetching submodules...', + $downloadedPackage->extractedSourcePath, + )); + + $git = GitBinaryPath::fromGitBinaryPath('/opt/homebrew/bin/git'); + $clonedSubmodules = $git->fetchSubmodules($downloadedPackage->extractedSourcePath); + + $output->writeln(sprintf( + 'Cloned submodules: %s', + implode(', ', $clonedSubmodules), + )); + } catch (InvalidGitBinaryPath $exception) { + $output->writeln('Could not find a valid git binary path to clone submodules.'); + + throw $exception; + } + } + $this->installedJsonMetadata->addDownloadMetadata( $composer, $composerRequest, diff --git a/src/Platform/Git/Exception/InvalidGitBinaryPath.php b/src/Platform/Git/Exception/InvalidGitBinaryPath.php new file mode 100644 index 00000000..5134b8ee --- /dev/null +++ b/src/Platform/Git/Exception/InvalidGitBinaryPath.php @@ -0,0 +1,36 @@ + The list of cloned submodules */ + public function fetchSubmodules(string $path): array + { + $modulesPath = rtrim($path, '/') . '/.gitmodules'; + + if (! file_exists($modulesPath)) { + throw new RuntimeException('No .gitmodules file found in the specified path.'); + } + + $content = file_get_contents($modulesPath); + if ($content === false) { + throw new RuntimeException('Unable to read .gitmodules file.'); + } + + $modules = $this->parseGitModules($content); + + if (! $modules) { + return []; + } + + return $this->processModules($modules, $path); + } + + /** + * @param string $content Raw content of .gitmodules file + * + * @return array List of parsed modules + */ + private function parseGitModules(string $content): array + { + $lines = array_filter(array_map('trim', explode("\n", $content))); + $modules = []; + $currentName = null; + $currentPath = ''; + $currentUrl = ''; + + $modulePattern = '/^\[submodule "(?P[^"]+)"]$/'; + $configPattern = '/^(?Ppath|url)\s*=\s*(?P.+)$/'; + + foreach ($lines as $line) { + // do we enter a new module? + if (preg_match($modulePattern, $line, $matches)) { + if ($currentName !== null) { + $modules[] = new Submodule($currentPath, $currentUrl); + } + + $currentName = $matches['name']; + $currentPath = ''; + $currentUrl = ''; + + continue; + } + + if ($currentName === null || ! preg_match($configPattern, $line, $matches)) { + continue; + } + + if ($matches['key'] === 'path') { + $currentPath = trim($matches['value']); + } elseif ($matches['key'] === 'url') { + $currentUrl = trim($matches['value']); + } + } + + if ($currentName !== null) { + $modules[] = new Submodule($currentPath, $currentUrl); + } + + return $modules; + } + + /** + * Process the parsed modules by cloning them and handling recursive submodules. + * + * @param non-empty-array $modules List of parsed modules + * + * @return array The list of cloned submodules + */ + private function processModules(array $modules, string $basePath): array + { + $clonedModules = []; + + foreach ($modules as $module) { + if (! $module->path || ! $module->url) { + // incomplete module, skip + continue; + } + + $targetPath = rtrim($basePath, '/') . '/' . $module->path; + + Process::run([$this->gitBinaryPath, 'clone', $module->url, $targetPath], $basePath, timeout: null); + $clonedModules[] = $module->url; + + if (! file_exists($targetPath . '/.gitmodules')) { + continue; + } + + $clonedModules = array_merge($clonedModules, $this->fetchSubmodules($targetPath)); + } + + return $clonedModules; + } + + private static function assertValidLookingGitBinary(string $gitBinary): void + { + if (! file_exists($gitBinary)) { + throw Exception\InvalidGitBinaryPath::fromNonExistentgitBinary($gitBinary); + } + + if (! Platform::isWindows() && ! is_executable($gitBinary)) { + throw Exception\InvalidGitBinaryPath::fromNonExecutableGitBinary($gitBinary); + } + + $output = Process::run([$gitBinary, '--version']); + + if (! preg_match('/git version \d+\.\d+\.\d+/', $output)) { + throw Exception\InvalidGitBinaryPath::fromInvalidGitBinary($gitBinary); + } + } +} diff --git a/src/Platform/Git/Submodule.php b/src/Platform/Git/Submodule.php new file mode 100644 index 00000000..8716f067 --- /dev/null +++ b/src/Platform/Git/Submodule.php @@ -0,0 +1,19 @@ +expectException(InvalidGitBinaryPath::class); + $this->expectExceptionMessage('does not exist'); + GitBinaryPath::fromGitBinaryPath(__DIR__ . '/path/to/a/non/existent/git/binary'); + } + + public function testNonExecutablePhpBinaryIsRejected(): void + { + if (Platform::isWindows()) { + self::markTestSkipped('is_executable always returns false on Windows'); + } + + $this->expectException(InvalidGitBinaryPath::class); + $this->expectExceptionMessage('is not executable'); + GitBinaryPath::fromGitBinaryPath(__FILE__); + } + + public function testInvalidGitBinaryIsRejected(): void + { + $this->expectException(InvalidGitBinaryPath::class); + $this->expectExceptionMessage('does not appear to be a git binary'); + GitBinaryPath::fromGitBinaryPath(self::FAKE_GIT_EXECUTABLE); + } +}