Skip to content

Commit 031fba0

Browse files
authored
Merge pull request #358 from coenjacobs/backport/issue-355-release-1.0
Backport shared dependency preservation to release-1.0
2 parents 08aded7 + 348c1f7 commit 031fba0

6 files changed

Lines changed: 485 additions & 8 deletions

File tree

src/Commands/Compose.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
namespace CoenJacobs\Mozart\Commands;
44

5+
use CoenJacobs\Mozart\Composer\InstalledPackageDependencyGraph;
6+
use CoenJacobs\Mozart\Config\Mozart;
7+
use CoenJacobs\Mozart\Config\Package;
58
use CoenJacobs\Mozart\Exceptions\ConfigurationException;
69
use CoenJacobs\Mozart\Mover;
710
use CoenJacobs\Mozart\PackageFactory;
@@ -69,7 +72,27 @@ public function execute(): void
6972
$replacer->replaceParentClassesInDirectory($config->getClassmapDirectory());
7073

7174
if ($config->getDeleteVendorDirectories()) {
72-
$mover->deletePackageVendorDirectories();
75+
$processedPackages = $this->getProcessedPackageNames($packages, $config);
76+
$dependencyGraph = new InstalledPackageDependencyGraph($this->workingDir);
77+
78+
$mover->deletePackageVendorDirectories(
79+
$dependencyGraph->getSharedProcessedPackages($processedPackages)
80+
);
7381
}
7482
}
83+
84+
/**
85+
* @param Package[] $packages
86+
* @return string[]
87+
*/
88+
private function getProcessedPackageNames(array $packages, Mozart $config): array
89+
{
90+
$packages = array_filter($packages, function (Package $package) use ($config): bool {
91+
return ! $config->isExcludedPackage($package);
92+
});
93+
94+
return array_values(array_unique(array_map(function (Package $package): string {
95+
return $package->getName();
96+
}, $packages)));
97+
}
7598
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
namespace CoenJacobs\Mozart\Composer;
4+
5+
class InstalledPackageDependencyGraph
6+
{
7+
private string $workingDir;
8+
9+
public function __construct(string $workingDir)
10+
{
11+
$this->workingDir = $workingDir;
12+
}
13+
14+
/**
15+
* Build a package -> required package names graph from Composer's
16+
* installed.json metadata.
17+
*
18+
* @return array<string, string[]>
19+
*/
20+
public function getDependencyGraph(): array
21+
{
22+
$packages = $this->loadInstalledPackages();
23+
$graph = [];
24+
25+
foreach ($packages as $package) {
26+
if (!is_array($package) || empty($package['name']) || !is_string($package['name'])) {
27+
continue;
28+
}
29+
30+
$graph[$package['name']] = $this->extractRequiredPackageNames($package);
31+
}
32+
33+
return $graph;
34+
}
35+
36+
/**
37+
* Find processed packages that must stay in vendor because they are still
38+
* required by installed packages outside the processed set.
39+
*
40+
* @param string[] $processedPackages
41+
* @return string[]
42+
*/
43+
public function getSharedProcessedPackages(array $processedPackages): array
44+
{
45+
$graph = $this->getDependencyGraph();
46+
$processedLookup = array_fill_keys($processedPackages, true);
47+
$sharedPackages = [];
48+
$visited = [];
49+
$queue = [];
50+
51+
foreach (array_keys($graph) as $packageName) {
52+
if (!isset($processedLookup[$packageName])) {
53+
$queue[] = $packageName;
54+
}
55+
}
56+
57+
while (!empty($queue)) {
58+
$packageName = array_shift($queue);
59+
60+
if (isset($visited[$packageName])) {
61+
continue;
62+
}
63+
64+
$visited[$packageName] = true;
65+
66+
foreach ($graph[$packageName] ?? [] as $dependency) {
67+
if (isset($processedLookup[$dependency])) {
68+
$sharedPackages[$dependency] = true;
69+
}
70+
71+
if (!isset($visited[$dependency])) {
72+
$queue[] = $dependency;
73+
}
74+
}
75+
}
76+
77+
return array_keys($sharedPackages);
78+
}
79+
80+
/**
81+
* @return array<int, array<string, mixed>>
82+
*/
83+
private function loadInstalledPackages(): array
84+
{
85+
$installedPath = $this->workingDir
86+
. DIRECTORY_SEPARATOR . 'vendor'
87+
. DIRECTORY_SEPARATOR . 'composer'
88+
. DIRECTORY_SEPARATOR . 'installed.json';
89+
90+
if (!is_readable($installedPath)) {
91+
return [];
92+
}
93+
94+
$installedJson = file_get_contents($installedPath);
95+
if ($installedJson === false) {
96+
return [];
97+
}
98+
99+
$decoded = json_decode($installedJson, true);
100+
if (!is_array($decoded)) {
101+
return [];
102+
}
103+
104+
if (isset($decoded['packages']) && is_array($decoded['packages'])) {
105+
return $decoded['packages'];
106+
}
107+
108+
if (array_is_list($decoded)) {
109+
return $decoded;
110+
}
111+
112+
return [];
113+
}
114+
115+
/**
116+
* @param array<string, mixed> $package
117+
* @return string[]
118+
*/
119+
private function extractRequiredPackageNames(array $package): array
120+
{
121+
$requires = $package['require'] ?? [];
122+
if (!is_array($requires)) {
123+
return [];
124+
}
125+
126+
return array_values(array_filter(array_keys($requires), function ($require): bool {
127+
return is_string($require) && str_contains($require, '/');
128+
}));
129+
}
130+
}

src/Mover.php

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class Mover
1515
protected Mozart $config;
1616
protected FilesHandler $files;
1717

18-
/** @var array<string> */
18+
/** @var array<string, string> Package names mapped to installed directory names. */
1919
protected array $movedPackages = [];
2020

2121
/** @var array<string> */
@@ -125,14 +125,14 @@ private function movePackage(Package $package): void
125125
}
126126
}
127127

128-
if (!in_array($package->getDirectoryName(), $this->movedPackages)) {
129-
$this->movedPackages[] = $package->getDirectoryName();
128+
if (!isset($this->movedPackages[$package->getName()])) {
129+
$this->movedPackages[$package->getName()] = $package->getDirectoryName();
130130
}
131131
}
132132

133133
private function shouldPackageBeMoved(Package $package): bool
134134
{
135-
if (in_array($package->getDirectoryName(), $this->movedPackages)) {
135+
if (isset($this->movedPackages[$package->getName()])) {
136136
return false;
137137
}
138138

@@ -167,11 +167,20 @@ private function copyFile(SplFileInfo $file, string $targetFile): void
167167
* Deletes all the packages that are moved from the /vendor/ directory to
168168
* prevent packages that are prefixed/namespaced from being used or
169169
* influencing the output of the code. They just need to be gone.
170+
*
171+
* @param string[] $preservedPackages Package names that should remain in
172+
* vendor because non-processed installed packages still depend on them.
170173
*/
171-
public function deletePackageVendorDirectories(): void
174+
public function deletePackageVendorDirectories(array $preservedPackages = []): void
172175
{
173-
foreach ($this->movedPackages as $movedPackage) {
174-
$packageDir = 'vendor' . DIRECTORY_SEPARATOR . $movedPackage;
176+
$preservedLookup = array_fill_keys($preservedPackages, true);
177+
178+
foreach ($this->movedPackages as $packageName => $packageDirectory) {
179+
if (isset($preservedLookup[$packageName])) {
180+
continue;
181+
}
182+
183+
$packageDir = 'vendor' . DIRECTORY_SEPARATOR . $packageDirectory;
175184
if (!is_dir($packageDir) || is_link($packageDir)) {
176185
continue;
177186
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
use CoenJacobs\Mozart\Console\Commands\Compose;
5+
use PHPUnit\Framework\Attributes\Test;
6+
use PHPUnit\Framework\TestCase;
7+
use Symfony\Component\Console\Input\InputInterface;
8+
use Symfony\Component\Console\Output\OutputInterface;
9+
10+
class SharedDependencyPreservationTest extends TestCase
11+
{
12+
private string $testsWorkingDir;
13+
14+
public function setUp(): void
15+
{
16+
parent::setUp();
17+
18+
$this->testsWorkingDir = __DIR__ . '/temptestdir';
19+
if (!file_exists($this->testsWorkingDir)) {
20+
mkdir($this->testsWorkingDir);
21+
}
22+
}
23+
24+
/**
25+
* Verifies that Mozart still prefixes a shared dependency while
26+
* preserving the original vendor copy when an installed non-processed
27+
* package depends on it as well.
28+
*
29+
* @test
30+
*/
31+
#[Test]
32+
public function it_preserves_shared_processed_dependencies_in_vendor(): void
33+
{
34+
copy(__DIR__ . '/composer.json', $this->testsWorkingDir . '/composer.json');
35+
36+
chdir($this->testsWorkingDir);
37+
exec('composer update');
38+
39+
$inputInterfaceMock = $this->createMock(InputInterface::class);
40+
$outputInterfaceMock = $this->createMock(OutputInterface::class);
41+
42+
$mozartCompose = new Compose();
43+
$result = $mozartCompose->run($inputInterfaceMock, $outputInterfaceMock);
44+
45+
$this->assertEquals(0, $result);
46+
$this->assertDirectoryDoesNotExist($this->testsWorkingDir . '/vendor/pimple/pimple');
47+
$this->assertDirectoryExists($this->testsWorkingDir . '/vendor/symfony/service-contracts');
48+
$this->assertDirectoryExists($this->testsWorkingDir . '/vendor/psr/container');
49+
$this->assertFileExists(
50+
$this->testsWorkingDir . '/src/dependencies/Psr/Container/ContainerInterface.php'
51+
);
52+
53+
$containerInterface = file_get_contents(
54+
$this->testsWorkingDir . '/src/dependencies/Psr/Container/ContainerInterface.php'
55+
);
56+
57+
$this->assertStringContainsString(
58+
'namespace Mozart\\TestProject\\Dependencies\\Psr\\Container;',
59+
$containerInterface
60+
);
61+
}
62+
63+
public function tearDown(): void
64+
{
65+
parent::tearDown();
66+
67+
$dir = $this->testsWorkingDir;
68+
69+
if (!is_dir($dir)) {
70+
return;
71+
}
72+
73+
$iterator = new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS);
74+
$files = new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::CHILD_FIRST);
75+
76+
foreach ($files as $file) {
77+
if ($file->isDir()) {
78+
rmdir($file->getRealPath());
79+
} else {
80+
unlink($file->getRealPath());
81+
}
82+
}
83+
84+
rmdir($dir);
85+
chdir(__DIR__);
86+
}
87+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"require": {
3+
"pimple/pimple": "^3.5",
4+
"symfony/service-contracts": "^3.0"
5+
},
6+
"autoload": {
7+
"psr-4": {
8+
"Mozart\\TestProject\\": "src/"
9+
}
10+
},
11+
"extra": {
12+
"mozart": {
13+
"dep_namespace": "Mozart\\TestProject\\Dependencies",
14+
"dep_directory": "/src/dependencies",
15+
"classmap_directory": "/classes/",
16+
"classmap_prefix": "MozartDependency_",
17+
"packages": [
18+
"pimple/pimple"
19+
],
20+
"override_autoload": {},
21+
"delete_vendor_directories": true
22+
}
23+
}
24+
}

0 commit comments

Comments
 (0)