Skip to content

Commit

Permalink
feat: blend command
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Jul 11, 2024
1 parent 09acdf3 commit 7a0ee54
Show file tree
Hide file tree
Showing 9 changed files with 341 additions and 13 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,6 @@ jobs:
- run: composer install
- working-directory: ./tests/monorepo
run: |
composer install
composer all install
composer update
composer all update
- run: composer functional
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,28 @@ For example to change the branch alias:
composer all config extra.branch-alias.dev-main 3.3.x-dev -vvv
```

### Blend dependencies

Blend your root `composer.json` constraints in each of the projects.

```
composer blend [--dev] [project-name]
```

Note: there's no dry mode on this command, use a VCS to rollback on unwanted changes.

When `project-a` depends on `dependency-a:^2.0.0` and your root project has `dependency-a:^3.0.0`, running `composer blend` will set the requirement of `dependency-a` to `^3.0.0` in `project-a`.

We do not check if a dependency is valid, you should probably run `composer all validate` or `composer all update` after running this.

Blend can also transfer any json path:

```
composer blend --json-path=extra.branch-alias.dev-main --force
```

Where `force` will write even if the value is not present in the project's `composer.json`.

### Run a graph of dependencies

```
Expand Down
202 changes: 202 additions & 0 deletions src/Command/BlendCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
<?php

/*
* This file is part of the PMU project.
*
* (c) Antoine Bluchet <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Pmu\Command;

use Composer\Command\BaseCommand;
use Composer\Composer;
use Composer\Console\Input\InputArgument;
use Composer\Console\Input\InputOption;
use Composer\Json\JsonFile;
use Composer\Package\PackageInterface;
use Pmu\Config;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

final class BlendCommand extends BaseCommand
{
private Composer $composer;
private Config $config;

protected function configure(): void
{
$this->setName('blend')
->setDefinition([
new InputOption('dev', 'D', InputOption::VALUE_NONE, 'Blend dev requirements.'),
new InputOption('json-path', null, InputOption::VALUE_REQUIRED, 'Json path to blend'),
new InputOption('force', null, InputOption::VALUE_NONE, 'Force'),
new InputArgument('projects', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, ''),
])
->setDescription('Blend the mono-repository dependencies into each projects.');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->composer = $this->requireComposer();
$this->config = Config::create($this->composer);
$requireKey = true === $input->getOption('dev') ? 'require-dev' : 'require';
$packageAccessor = true === $input->getOption('dev') ? 'getDevRequires' : 'getRequires';

if (is_string($input->getOption('json-path'))) {
return $this->blendJsonPath($input, $output);
}

$projects = $this->getProjects($input);
$repo = $this->composer->getRepositoryManager();
$package = $this->composer->getPackage();
foreach ($this->config->projects as $p) {
if ($projects && !in_array($p, $projects, true)) {
continue;
}

$projectPackage = $repo->findPackage($p, '*');
if (!$projectPackage || !$projectPackage instanceof PackageInterface) {
$output->writeln(sprintf('Package "%s" could not be found.', $p));
return 1;
}

$dir = $projectPackage->getDistUrl();
if (!is_string($dir) || !is_dir($dir)) {
$output->writeln(sprintf('Package "%s" could not be found at path "%s".', $p, $dir));
continue;
}

$packagesToUpdate = [];
foreach ($package->{$packageAccessor}() as $g) {
// Only update the package if it's found in the project's package
if (!$input->getOption('force')) {
$hasPackage = false;
foreach ($projectPackage->{$packageAccessor}() as $r) {
if ($g->getTarget() === $r->getTarget()) {
$hasPackage = true;
break;
}
}

if (!$hasPackage) {
continue;
}
}

$packagesToUpdate[$g->getTarget()] = $g->getPrettyConstraint();
}

if (!$packagesToUpdate) {
continue;
}

$json = new JsonFile($dir . '/composer.json');
/** @var array{require: array<string, string>, 'require-dev': array<string, string>} */
$composerDefinition = $json->read();
foreach ($packagesToUpdate as $target => $constraint) {
$composerDefinition[$requireKey][$target] = $constraint;
}

$json->write($composerDefinition);
}

return 0;
}

private function blendJsonPath(InputInterface $input, OutputInterface $output): int {
/** @var string */
$jsonPath = $input->getOption('json-path');
$path = $this->composer->getConfig()->getConfigSource()->getName();
$file = file_get_contents($path) ?: throw new \RuntimeException(sprintf('File "%s" not found.', $path));
$data = json_decode($file, true) ?: throw new \RuntimeException(sprintf('File "%s" is not JSON.', $path));
$pointers = explode('.', $jsonPath);

$p = $pointers;
$value = $data;
while($pointer = array_shift($p)) {
/** @var string $pointer */
if (!is_array($value) || !isset($value[$pointer])) {
$output->writeln(sprintf('Node "%s" not found.', $jsonPath));
return 1;
}

$value = $value[$pointer];
}

$repo = $this->composer->getRepositoryManager();
$projects = $this->getProjects($input);
foreach ($this->config->projects as $project) {
if ($projects && !in_array($project, $projects, true)) {
continue;
}

$package = $repo->findPackage($project, '*');
if (!$package || !$package instanceof PackageInterface) {
$output->writeln(sprintf('Package "%s" could not be found.', $project));
return 1;
}

$dir = $package->getDistUrl();
if (!is_string($dir) || !is_dir($dir)) {
$output->writeln(sprintf('Package "%s" could not be found at path "%s".', $project, $dir));
continue;
}

$path = $dir . '/composer.json';
$fileContent = file_get_contents($path) ?: throw new \RuntimeException(sprintf('File "%s" not found.', $path));
$json = json_decode($fileContent, true) ?: throw new \RuntimeException(sprintf('File "%s" is not JSON.', $path));
$force = $input->getOption('force');

$p = $pointers;
$ref =& $json;
while($pointer = array_shift($p)) {
if (!is_array($ref)) {
$output->writeln(sprintf('Package "%s" has no pointer "%s".', $project, $pointer));
break 2;
}

if (!isset($ref[$pointer])) {
if (!$force) {
$output->writeln(sprintf('Package "%s" has no pointer "%s".', $project, $pointer));
break 2;
}

$ref[$pointer] = [];
}

if (\count($p) === 0) {
$ref[$pointer] = $value;
break;
}

$ref = &$ref[$pointer];
}

unset($ref);
$ref = $value;
$fileContent = file_put_contents($path, json_encode($json, JSON_PRETTY_PRINT));

if (!$fileContent) {
$output->writeln(sprintf('Could not write JSON at path "%s".', $path));
}
}

return 0;
}

/**
* @return string[]|null
*/
private function getProjects(InputInterface $input): ?array {
if (is_array($p = $input->getArgument('projects')) && $p) {
return array_map('strval', $p);
}

return null;
}
}
4 changes: 2 additions & 2 deletions src/Composer/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@ public function getComposer(bool $required = true, ?bool $disablePlugins = null,
throw new \RuntimeException('Configuration should be an array.');
}

foreach ($config['require'] as $name => $constraint) {
foreach ($config['require'] ?? [] as $name => $constraint) {
if (in_array($name, $this->projects, true)) {
$config['require'][$name] = '@dev || ' . $constraint;
}
}

foreach ($config['require-dev'] as $name => $constraint) {
foreach ($config['require-dev'] ?? [] as $name => $constraint) {
if (in_array($name, $this->projects, true)) {
$config['require-dev'][$name] = '@dev || ' . $constraint;
}
Expand Down
3 changes: 2 additions & 1 deletion src/Composer/CommandProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Composer\IO\IOInterface;
use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;
use Pmu\Command\AllCommand;
use Pmu\Command\BlendCommand;
use Pmu\Command\CheckCommand;
use Pmu\Command\ComposerCommand;
use Pmu\Command\GraphCommand;
Expand All @@ -37,7 +38,7 @@ public function __construct(array $ctorArgs)
public function getCommands(): array
{
$config = Config::create($this->composer);
$commands = [new GraphCommand(), new AllCommand(), new CheckCommand()];
$commands = [new GraphCommand(), new AllCommand(), new CheckCommand(), new BlendCommand()];
foreach ($config->projects as $project) {
$commands[] = new ComposerCommand($project);
}
Expand Down
101 changes: 101 additions & 0 deletions tests/functional/BlendCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

/*
* This file is part of the PMU project.
*
* (c) Antoine Bluchet <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Pmu\Tests\Functional;

use Composer\Console\Application;
use PHPUnit\Framework\TestCase;
use Pmu\Command\BlendCommand;
use RuntimeException;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\BufferedOutput;

final class BlendCommandTest extends TestCase {
private Application $application;
/**
* @var string[]
*/
private array $files;
/**
* @var array<int, string|false>
*/
private array $backups;
public function setUp(): void {
$this->application = new Application();
$this->application->add(new BlendCommand);
$this->application->setAutoExit(false);
chdir(__DIR__ . '/../monorepo');
}

public function testBlend(): void {
$this->files = [__DIR__ . '/../monorepo/packages/A/composer.json'];
$this->backups = [file_get_contents($this->files[0])];
$output = new BufferedOutput;
$this->application->run(new StringInput('blend'), $output);
$json = file_get_contents($this->files[0]) ?: throw new \RuntimeException;

/** @var array{require: array<string, string>, 'require-dev': array<string, string>} */
$new = json_decode($json, true);
$this->assertEquals($new['require']['soyuka/contexts'], '^3.0.0');
$this->assertEquals("", $output->fetch());
}

public function testBlendDev(): void {
$this->files = [__DIR__ . '/../monorepo/packages/A/composer.json'];
$this->backups = [file_get_contents($this->files[0])];
$output = new BufferedOutput;
$this->application->run(new StringInput('blend --dev'), $output);
$json = file_get_contents($this->files[0]) ?: throw new \RuntimeException;

/** @var array{require: array<string, string>, 'require-dev': array<string, string>} */
$new = json_decode($json, true);
$this->assertEquals($new['require-dev']['symfony/contracts'], '^2.0.0');
$this->assertEquals("", $output->fetch());
}

public function testBlendWithProject(): void {
$this->files = [__DIR__ . '/../monorepo/packages/A/composer.json'];
$this->backups = [file_get_contents($this->files[0])];
$output = new BufferedOutput;
$this->application->run(new StringInput('blend test/b'), $output);
$json = file_get_contents($this->files[0]) ?: throw new \RuntimeException;

/** @var array{require: array<string, string>, 'require-dev': array<string, string>} */
$new = json_decode($json, true);
$this->assertEquals($new['require']['soyuka/contexts'], '^2.0.0');
$this->assertEquals("", $output->fetch());
}

public function testBlendJsonPath(): void {
$this->files = [__DIR__ . '/../monorepo/packages/A/composer.json', __DIR__ . '/../monorepo/packages/B/composer.json', __DIR__ . '/../monorepo/packages/C/composer.json'];
$this->backups = array_map('file_get_contents', $this->files);
$output = new BufferedOutput;
$this->application->run(new StringInput('blend --json-path=extra.branch-alias.dev-main --force'), $output);

foreach ($this->files as $f) {
$json = file_get_contents($f) ?: throw new RuntimeException;
/** @var array{extra?: array{branch-alias?: array<string, string>}} */
$new = json_decode($json, true);
$this->assertEquals($new['extra']['branch-alias']['dev-main'] ?? null, '3.3.x-dev');
}
$this->assertEquals("", $output->fetch());
}

protected function tearDown(): void {
while ($file = array_shift($this->files)) {
if ($b = array_shift($this->backups)) {
file_put_contents($file, $b);
}
}
}
}
6 changes: 5 additions & 1 deletion tests/monorepo/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
"description": "test project",
"license": "MIT",
"require-dev": {
"soyuka/pmu": "*@dev"
"soyuka/pmu": "*@dev",
"symfony/contracts": "^2.0.0"
},
"require": {
"soyuka/contexts": "^3.0.0"
},
"minimum-stability": "dev",
"autoload": {
Expand Down
Loading

0 comments on commit 7a0ee54

Please sign in to comment.