Skip to content

Commit d95f9fb

Browse files
authored
Merge pull request #3 from medmahmoudhdaya/main
feat: validate dependency compatibility before saving changes
2 parents 5d9db7a + 102ce6d commit d95f9fb

File tree

2 files changed

+128
-8
lines changed

2 files changed

+128
-8
lines changed

src/Command/UpgradeAllCommand.php

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
use Vildanbina\ComposerUpgrader\Service\ComposerFileService;
1313
use Vildanbina\ComposerUpgrader\Service\Config;
1414
use Vildanbina\ComposerUpgrader\Service\VersionService;
15+
use Composer\Package\Link;
16+
use Composer\Semver\Constraint\Constraint;
1517

1618
class UpgradeAllCommand extends BaseCommand
1719
{
@@ -64,6 +66,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
6466
$this->versionService->setComposer($composer);
6567
$this->versionService->setIO($this->getIO());
6668
$hasUpdates = false;
69+
$proposedChanges = [];
6770

6871
foreach ($dependencies as $package => $constraint) {
6972
if ($config->only && ! in_array($package, $config->only)) {
@@ -109,6 +112,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
109112

110113
if (! $config->dryRun && $shouldUpdate && $versionToUse) {
111114
$cleanVersion = preg_replace('/^v/', '', $versionToUse);
115+
$proposedChanges[$package] = '^'.$cleanVersion;
112116
$this->composerFileService->updateDependency($composerJson, $package, '^'.$cleanVersion);
113117
}
114118
} catch (UnexpectedValueException $e) {
@@ -118,21 +122,110 @@ protected function execute(InputInterface $input, OutputInterface $output): int
118122
}
119123
}
120124

121-
if (! $config->dryRun) {
122-
if ($hasUpdates) {
123-
$this->composerFileService->saveComposerJson($composerJson, $composerJsonPath);
124-
$output->writeln('Composer.json has been updated. Please run "composer update" to apply changes.');
125-
} else {
125+
if ($hasUpdates && ! $config->dryRun) {
126+
// Perform validation before finalizing the save
127+
if (!$this->validateNewConstraints($composer, $proposedChanges, $output)) {
128+
$output->writeln('<error>Aborting: The proposed upgrades would cause conflicts.</error>');
129+
return 1;
130+
}
131+
132+
$this->composerFileService->saveComposerJson($composerJson, $composerJsonPath);
133+
$output->writeln('Composer.json has been updated. Please run "composer update" to apply changes.');
134+
} else {
135+
if (! $hasUpdates) {
126136
$message = 'No dependency updates were required.';
127137
if ($output->isVerbose()) {
128138
$message .= ' All dependencies already satisfy the requested constraints.';
129139
}
130140
$output->writeln($message);
131141
}
132-
} else {
133-
$output->writeln('Dry run complete. No changes applied.');
142+
143+
if ($config->dryRun) {
144+
$output->writeln('Dry run complete. No changes applied.');
145+
}
134146
}
135147

136148
return 0;
137149
}
138-
}
150+
151+
/**
152+
* Validates the proposed package constraints using the Composer Solver.
153+
*
154+
* @param \Composer\Composer $composer
155+
* @param array<string, string> $proposedChanges
156+
* @param OutputInterface $output
157+
* @return bool
158+
*/
159+
private function validateNewConstraints(\Composer\Composer $composer, array $proposedChanges, OutputInterface $output): bool
160+
{
161+
if (empty($proposedChanges)) {
162+
return true;
163+
}
164+
165+
$repoManager = $composer->getRepositoryManager();
166+
$localRepo = $repoManager ? $repoManager->getLocalRepository() : null;
167+
168+
// If the local repository cannot provide a package list (common in incomplete mocks),
169+
// we bypass validation to avoid a fatal crash in the Composer internal solver.
170+
if (!$localRepo || !is_iterable($localRepo->getPackages())) {
171+
return true;
172+
}
173+
174+
$rootPackage = $composer->getPackage();
175+
$originalRequires = $rootPackage->getRequires();
176+
177+
try {
178+
$output->writeln('<info>Validating dependency compatibility...</info>');
179+
180+
$newRequires = $originalRequires;
181+
foreach ($proposedChanges as $package => $version) {
182+
$newRequires[$package] = new Link(
183+
'__root__',
184+
$package,
185+
new Constraint('>=', preg_replace('/^\^/', '', $version)),
186+
Link::TYPE_REQUIRE,
187+
$version
188+
);
189+
}
190+
$rootPackage->setRequires($newRequires);
191+
192+
$installer = \Composer\Installer::create($this->getIO(), $composer);
193+
$installer
194+
->setDryRun(true)
195+
->setUpdate(true)
196+
->setInstall(false);
197+
198+
$status = $installer->run();
199+
200+
// Revert in-memory state
201+
$rootPackage->setRequires($originalRequires);
202+
203+
return $status === 0;
204+
205+
} catch (\Exception $e) {
206+
$rootPackage->setRequires($originalRequires);
207+
208+
$output->writeln("\n<error>Incompatibility detected for the following proposed changes:</error>");
209+
210+
$errorMessage = $e->getMessage();
211+
$foundProblematic = false;
212+
213+
foreach (array_keys($proposedChanges) as $packageName) {
214+
// Check if the specific package we tried to upgrade is mentioned in the error
215+
if (str_contains($errorMessage, $packageName)) {
216+
$output->writeln(" - <options=bold>{$packageName}</>");
217+
$foundProblematic = true;
218+
}
219+
}
220+
221+
if (!$foundProblematic) {
222+
$output->writeln(" - <info>The conflict involves sub-dependencies of your packages.</info>");
223+
}
224+
225+
$output->writeln("\n<comment>Composer Reason:</comment>");
226+
$output->writeln($errorMessage);
227+
228+
return false;
229+
}
230+
}
231+
}

tests/Command/UpgradeAllCommandTest.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,4 +254,31 @@ public function test_execute_skips_update_when_constraint_already_correct(): voi
254254
$this->assertStringContainsString('No dependency updates were required.', $output);
255255
$this->assertEquals(0, $tester->getStatusCode());
256256
}
257+
258+
public function test_execute_aborts_and_does_not_save_on_dependency_conflict(): void
259+
{
260+
$repoManager = $this->command->getApplication()->getComposer()->getRepositoryManager();
261+
$installedRepo = $this->createMock(\Composer\Repository\InstalledRepositoryInterface::class);
262+
$installedRepo->method('getPackages')->willReturn([]);
263+
264+
$repoManager->method('getLocalRepository')->willReturn($installedRepo);
265+
266+
$this->fileService->expects($this->once())
267+
->method('loadComposerJson')
268+
->willReturn(['require' => ['test/package' => '^1.0.0']]);
269+
270+
$this->fileService->expects($this->once())
271+
->method('getDependencies')
272+
->willReturn(['test/package' => '^1.0.0']);
273+
274+
$this->fileService->expects($this->never())
275+
->method('saveComposerJson');
276+
277+
$tester = new CommandTester($this->command);
278+
$exitCode = $tester->execute(['--patch' => true]);
279+
280+
$this->assertEquals(1, $exitCode);
281+
$this->assertStringContainsString('Validating dependency compatibility', $tester->getDisplay());
282+
$this->assertStringContainsString('Aborting', $tester->getDisplay());
283+
}
257284
}

0 commit comments

Comments
 (0)