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);
+ }
+}