Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Download Git submodules #160

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
26 changes: 26 additions & 0 deletions src/ComposerIntegration/InstallAndBuildProcess.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -43,6 +48,27 @@ public function __invoke(
$downloadedPackage->extractedSourcePath,
));

if (file_exists(rtrim($downloadedPackage->extractedSourcePath, '/') . '/.gitmodules')) {
try {
$output->writeln(sprintf(
'<info>Found .gitmodules file in</info> %s<info>, fetching submodules...</info>',
$downloadedPackage->extractedSourcePath,
));

$git = GitBinaryPath::fromGitBinaryPath('/opt/homebrew/bin/git');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This path is specific to macOS package manager, it won't work on other systems.

I'm not even sure it is needed to specify git path as it should be in $PATH of the user

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes indeed, it was for test purposes to validate the implementation. Thanks for spotting it 👍

$clonedSubmodules = $git->fetchSubmodules($downloadedPackage->extractedSourcePath);

$output->writeln(sprintf(
'<info>Cloned submodules:</info> %s',
implode(', ', $clonedSubmodules),
));
} catch (InvalidGitBinaryPath $exception) {
$output->writeln('<error>Could not find a valid git binary path to clone submodules.</error>');

throw $exception;
}
}

$this->installedJsonMetadata->addDownloadMetadata(
$composer,
$composerRequest,
Expand Down
36 changes: 36 additions & 0 deletions src/Platform/Git/Exception/InvalidGitBinaryPath.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Php\Pie\Platform\Git\Exception;

use RuntimeException;

use function sprintf;

class InvalidGitBinaryPath extends RuntimeException
{
public static function fromNonExistentGitBinary(string $phpBinaryPath): self
{
return new self(sprintf(
'The git binary at "%s" does not exist',
$phpBinaryPath,
));
}

public static function fromNonExecutableGitBinary(string $phpBinaryPath): self
{
return new self(sprintf(
'The git binary at "%s" is not executable',
$phpBinaryPath,
));
}

public static function fromInvalidGitBinary(string $phpBinaryPath): self
{
return new self(sprintf(
'The git binary at "%s" does not appear to be a git binary',
$phpBinaryPath,
));
}
}
161 changes: 161 additions & 0 deletions src/Platform/Git/GitBinaryPath.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

declare(strict_types=1);

namespace Php\Pie\Platform\Git;

use Composer\Util\Platform;
use Php\Pie\Util\Process;
use RuntimeException;

use function array_filter;
use function array_map;
use function array_merge;
use function explode;
use function file_exists;
use function file_get_contents;
use function is_executable;
use function preg_match;
use function rtrim;
use function trim;

/**
* @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks
*
* @immutable
*/
class GitBinaryPath
{
private function __construct(
public readonly string $gitBinaryPath,
) {
}

/** @param non-empty-string $gitBinary */
public static function fromGitBinaryPath(string $gitBinary): self
{
self::assertValidLookingGitBinary($gitBinary);

return new self($gitBinary);
}

/** @return array<string> 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<Submodule> 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<name>[^"]+)"]$/';
$configPattern = '/^(?P<key>path|url)\s*=\s*(?P<value>.+)$/';

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<Submodule> $modules List of parsed modules
*
* @return array<array-key, string> 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);
}
}
}
19 changes: 19 additions & 0 deletions src/Platform/Git/Submodule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Php\Pie\Platform\Git;

/**
* @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks
*
* @immutable
*/
final class Submodule
{
public function __construct(
public readonly string $path,
public readonly string $url,
) {
}
}
3 changes: 3 additions & 0 deletions test/assets/fake-git.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash

echo "Hah! I am not really Git.";
42 changes: 42 additions & 0 deletions test/unit/Platform/Git/GitBinaryPathTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Php\PieUnitTest\Platform\Git;

use Composer\Util\Platform;
use Php\Pie\Platform\Git\Exception\InvalidGitBinaryPath;
use Php\Pie\Platform\Git\GitBinaryPath;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(GitBinaryPath::class)]
class GitBinaryPathTest extends TestCase
{
private const FAKE_GIT_EXECUTABLE = __DIR__ . '/../../../assets/fake-git.sh';

public function testNonExistentPhpBinaryIsRejected(): void
{
$this->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);
}
}
Loading