Skip to content

Commit c622800

Browse files
authored
feat: blend command (#5)
1 parent 09acdf3 commit c622800

File tree

9 files changed

+341
-13
lines changed

9 files changed

+341
-13
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,6 @@ jobs:
5555
- run: composer install
5656
- working-directory: ./tests/monorepo
5757
run: |
58-
composer install
59-
composer all install
58+
composer update
59+
composer all update
6060
- run: composer functional

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,28 @@ For example to change the branch alias:
6363
composer all config extra.branch-alias.dev-main 3.3.x-dev -vvv
6464
```
6565

66+
### Blend dependencies
67+
68+
Blend your root `composer.json` constraints in each of the projects.
69+
70+
```
71+
composer blend [--dev] [project-name]
72+
```
73+
74+
Note: there's no dry mode on this command, use a VCS to rollback on unwanted changes.
75+
76+
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`.
77+
78+
We do not check if a dependency is valid, you should probably run `composer all validate` or `composer all update` after running this.
79+
80+
Blend can also transfer any json path:
81+
82+
```
83+
composer blend --json-path=extra.branch-alias.dev-main --force
84+
```
85+
86+
Where `force` will write even if the value is not present in the project's `composer.json`.
87+
6688
### Run a graph of dependencies
6789

6890
```

src/Command/BlendCommand.php

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the PMU project.
5+
*
6+
* (c) Antoine Bluchet <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Pmu\Command;
15+
16+
use Composer\Command\BaseCommand;
17+
use Composer\Composer;
18+
use Composer\Console\Input\InputArgument;
19+
use Composer\Console\Input\InputOption;
20+
use Composer\Json\JsonFile;
21+
use Composer\Package\PackageInterface;
22+
use Pmu\Config;
23+
use Symfony\Component\Console\Input\InputInterface;
24+
use Symfony\Component\Console\Output\OutputInterface;
25+
26+
final class BlendCommand extends BaseCommand
27+
{
28+
private Composer $composer;
29+
private Config $config;
30+
31+
protected function configure(): void
32+
{
33+
$this->setName('blend')
34+
->setDefinition([
35+
new InputOption('dev', 'D', InputOption::VALUE_NONE, 'Blend dev requirements.'),
36+
new InputOption('json-path', null, InputOption::VALUE_REQUIRED, 'Json path to blend'),
37+
new InputOption('force', null, InputOption::VALUE_NONE, 'Force'),
38+
new InputArgument('projects', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, ''),
39+
])
40+
->setDescription('Blend the mono-repository dependencies into each projects.');
41+
}
42+
43+
protected function execute(InputInterface $input, OutputInterface $output): int
44+
{
45+
$this->composer = $this->requireComposer();
46+
$this->config = Config::create($this->composer);
47+
$requireKey = true === $input->getOption('dev') ? 'require-dev' : 'require';
48+
$packageAccessor = true === $input->getOption('dev') ? 'getDevRequires' : 'getRequires';
49+
50+
if (is_string($input->getOption('json-path'))) {
51+
return $this->blendJsonPath($input, $output);
52+
}
53+
54+
$projects = $this->getProjects($input);
55+
$repo = $this->composer->getRepositoryManager();
56+
$package = $this->composer->getPackage();
57+
foreach ($this->config->projects as $p) {
58+
if ($projects && !in_array($p, $projects, true)) {
59+
continue;
60+
}
61+
62+
$projectPackage = $repo->findPackage($p, '*');
63+
if (!$projectPackage || !$projectPackage instanceof PackageInterface) {
64+
$output->writeln(sprintf('Package "%s" could not be found.', $p));
65+
return 1;
66+
}
67+
68+
$dir = $projectPackage->getDistUrl();
69+
if (!is_string($dir) || !is_dir($dir)) {
70+
$output->writeln(sprintf('Package "%s" could not be found at path "%s".', $p, $dir));
71+
continue;
72+
}
73+
74+
$packagesToUpdate = [];
75+
foreach ($package->{$packageAccessor}() as $g) {
76+
// Only update the package if it's found in the project's package
77+
if (!$input->getOption('force')) {
78+
$hasPackage = false;
79+
foreach ($projectPackage->{$packageAccessor}() as $r) {
80+
if ($g->getTarget() === $r->getTarget()) {
81+
$hasPackage = true;
82+
break;
83+
}
84+
}
85+
86+
if (!$hasPackage) {
87+
continue;
88+
}
89+
}
90+
91+
$packagesToUpdate[$g->getTarget()] = $g->getPrettyConstraint();
92+
}
93+
94+
if (!$packagesToUpdate) {
95+
continue;
96+
}
97+
98+
$json = new JsonFile($dir . '/composer.json');
99+
/** @var array{require: array<string, string>, 'require-dev': array<string, string>} */
100+
$composerDefinition = $json->read();
101+
foreach ($packagesToUpdate as $target => $constraint) {
102+
$composerDefinition[$requireKey][$target] = $constraint;
103+
}
104+
105+
$json->write($composerDefinition);
106+
}
107+
108+
return 0;
109+
}
110+
111+
private function blendJsonPath(InputInterface $input, OutputInterface $output): int {
112+
/** @var string */
113+
$jsonPath = $input->getOption('json-path');
114+
$path = $this->composer->getConfig()->getConfigSource()->getName();
115+
$file = file_get_contents($path) ?: throw new \RuntimeException(sprintf('File "%s" not found.', $path));
116+
$data = json_decode($file, true) ?: throw new \RuntimeException(sprintf('File "%s" is not JSON.', $path));
117+
$pointers = explode('.', $jsonPath);
118+
119+
$p = $pointers;
120+
$value = $data;
121+
while($pointer = array_shift($p)) {
122+
/** @var string $pointer */
123+
if (!is_array($value) || !isset($value[$pointer])) {
124+
$output->writeln(sprintf('Node "%s" not found.', $jsonPath));
125+
return 1;
126+
}
127+
128+
$value = $value[$pointer];
129+
}
130+
131+
$repo = $this->composer->getRepositoryManager();
132+
$projects = $this->getProjects($input);
133+
foreach ($this->config->projects as $project) {
134+
if ($projects && !in_array($project, $projects, true)) {
135+
continue;
136+
}
137+
138+
$package = $repo->findPackage($project, '*');
139+
if (!$package || !$package instanceof PackageInterface) {
140+
$output->writeln(sprintf('Package "%s" could not be found.', $project));
141+
return 1;
142+
}
143+
144+
$dir = $package->getDistUrl();
145+
if (!is_string($dir) || !is_dir($dir)) {
146+
$output->writeln(sprintf('Package "%s" could not be found at path "%s".', $project, $dir));
147+
continue;
148+
}
149+
150+
$path = $dir . '/composer.json';
151+
$fileContent = file_get_contents($path) ?: throw new \RuntimeException(sprintf('File "%s" not found.', $path));
152+
$json = json_decode($fileContent, true) ?: throw new \RuntimeException(sprintf('File "%s" is not JSON.', $path));
153+
$force = $input->getOption('force');
154+
155+
$p = $pointers;
156+
$ref =& $json;
157+
while($pointer = array_shift($p)) {
158+
if (!is_array($ref)) {
159+
$output->writeln(sprintf('Package "%s" has no pointer "%s".', $project, $pointer));
160+
break 2;
161+
}
162+
163+
if (!isset($ref[$pointer])) {
164+
if (!$force) {
165+
$output->writeln(sprintf('Package "%s" has no pointer "%s".', $project, $pointer));
166+
break 2;
167+
}
168+
169+
$ref[$pointer] = [];
170+
}
171+
172+
if (\count($p) === 0) {
173+
$ref[$pointer] = $value;
174+
break;
175+
}
176+
177+
$ref = &$ref[$pointer];
178+
}
179+
180+
unset($ref);
181+
$ref = $value;
182+
$fileContent = file_put_contents($path, json_encode($json, JSON_PRETTY_PRINT));
183+
184+
if (!$fileContent) {
185+
$output->writeln(sprintf('Could not write JSON at path "%s".', $path));
186+
}
187+
}
188+
189+
return 0;
190+
}
191+
192+
/**
193+
* @return string[]|null
194+
*/
195+
private function getProjects(InputInterface $input): ?array {
196+
if (is_array($p = $input->getArgument('projects')) && $p) {
197+
return array_map('strval', $p);
198+
}
199+
200+
return null;
201+
}
202+
}

src/Composer/Application.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,13 @@ public function getComposer(bool $required = true, ?bool $disablePlugins = null,
5858
throw new \RuntimeException('Configuration should be an array.');
5959
}
6060

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

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

src/Composer/CommandProvider.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Composer\IO\IOInterface;
1818
use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;
1919
use Pmu\Command\AllCommand;
20+
use Pmu\Command\BlendCommand;
2021
use Pmu\Command\CheckCommand;
2122
use Pmu\Command\ComposerCommand;
2223
use Pmu\Command\GraphCommand;
@@ -37,7 +38,7 @@ public function __construct(array $ctorArgs)
3738
public function getCommands(): array
3839
{
3940
$config = Config::create($this->composer);
40-
$commands = [new GraphCommand(), new AllCommand(), new CheckCommand()];
41+
$commands = [new GraphCommand(), new AllCommand(), new CheckCommand(), new BlendCommand()];
4142
foreach ($config->projects as $project) {
4243
$commands[] = new ComposerCommand($project);
4344
}

tests/functional/BlendCommandTest.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the PMU project.
5+
*
6+
* (c) Antoine Bluchet <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Pmu\Tests\Functional;
15+
16+
use Composer\Console\Application;
17+
use PHPUnit\Framework\TestCase;
18+
use Pmu\Command\BlendCommand;
19+
use RuntimeException;
20+
use Symfony\Component\Console\Input\StringInput;
21+
use Symfony\Component\Console\Output\BufferedOutput;
22+
23+
final class BlendCommandTest extends TestCase {
24+
private Application $application;
25+
/**
26+
* @var string[]
27+
*/
28+
private array $files;
29+
/**
30+
* @var array<int, string|false>
31+
*/
32+
private array $backups;
33+
public function setUp(): void {
34+
$this->application = new Application();
35+
$this->application->add(new BlendCommand);
36+
$this->application->setAutoExit(false);
37+
chdir(__DIR__ . '/../monorepo');
38+
}
39+
40+
public function testBlend(): void {
41+
$this->files = [__DIR__ . '/../monorepo/packages/A/composer.json'];
42+
$this->backups = [file_get_contents($this->files[0])];
43+
$output = new BufferedOutput;
44+
$this->application->run(new StringInput('blend'), $output);
45+
$json = file_get_contents($this->files[0]) ?: throw new \RuntimeException;
46+
47+
/** @var array{require: array<string, string>, 'require-dev': array<string, string>} */
48+
$new = json_decode($json, true);
49+
$this->assertEquals($new['require']['soyuka/contexts'], '^3.0.0');
50+
$this->assertEquals("", $output->fetch());
51+
}
52+
53+
public function testBlendDev(): void {
54+
$this->files = [__DIR__ . '/../monorepo/packages/A/composer.json'];
55+
$this->backups = [file_get_contents($this->files[0])];
56+
$output = new BufferedOutput;
57+
$this->application->run(new StringInput('blend --dev'), $output);
58+
$json = file_get_contents($this->files[0]) ?: throw new \RuntimeException;
59+
60+
/** @var array{require: array<string, string>, 'require-dev': array<string, string>} */
61+
$new = json_decode($json, true);
62+
$this->assertEquals($new['require-dev']['symfony/contracts'], '^2.0.0');
63+
$this->assertEquals("", $output->fetch());
64+
}
65+
66+
public function testBlendWithProject(): void {
67+
$this->files = [__DIR__ . '/../monorepo/packages/A/composer.json'];
68+
$this->backups = [file_get_contents($this->files[0])];
69+
$output = new BufferedOutput;
70+
$this->application->run(new StringInput('blend test/b'), $output);
71+
$json = file_get_contents($this->files[0]) ?: throw new \RuntimeException;
72+
73+
/** @var array{require: array<string, string>, 'require-dev': array<string, string>} */
74+
$new = json_decode($json, true);
75+
$this->assertEquals($new['require']['soyuka/contexts'], '^2.0.0');
76+
$this->assertEquals("", $output->fetch());
77+
}
78+
79+
public function testBlendJsonPath(): void {
80+
$this->files = [__DIR__ . '/../monorepo/packages/A/composer.json', __DIR__ . '/../monorepo/packages/B/composer.json', __DIR__ . '/../monorepo/packages/C/composer.json'];
81+
$this->backups = array_map('file_get_contents', $this->files);
82+
$output = new BufferedOutput;
83+
$this->application->run(new StringInput('blend --json-path=extra.branch-alias.dev-main --force'), $output);
84+
85+
foreach ($this->files as $f) {
86+
$json = file_get_contents($f) ?: throw new RuntimeException;
87+
/** @var array{extra?: array{branch-alias?: array<string, string>}} */
88+
$new = json_decode($json, true);
89+
$this->assertEquals($new['extra']['branch-alias']['dev-main'] ?? null, '3.3.x-dev');
90+
}
91+
$this->assertEquals("", $output->fetch());
92+
}
93+
94+
protected function tearDown(): void {
95+
while ($file = array_shift($this->files)) {
96+
if ($b = array_shift($this->backups)) {
97+
file_put_contents($file, $b);
98+
}
99+
}
100+
}
101+
}

tests/monorepo/composer.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
"description": "test project",
55
"license": "MIT",
66
"require-dev": {
7-
"soyuka/pmu": "*@dev"
7+
"soyuka/pmu": "*@dev",
8+
"symfony/contracts": "^2.0.0"
9+
},
10+
"require": {
11+
"soyuka/contexts": "^3.0.0"
812
},
913
"minimum-stability": "dev",
1014
"autoload": {

0 commit comments

Comments
 (0)