Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 119
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cleanup
0.00% covered (danger)
0.00%
0 / 119
0.00% covered (danger)
0.00%
0 / 8
1260
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 deleteFiles
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 cleanupVendorInstalledJson
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 rebuildVendorAutoloader
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 deleteEmptyDirectories
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
72
 dirIsEmpty
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doIsDeleteVendorPackages
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 doIsDeleteVendorFiles
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Deletes source files and empty directories.
4 */
5
6namespace BrianHenryIE\Strauss\Pipeline\Cleanup;
7
8use BrianHenryIE\Strauss\Composer\ComposerPackage;
9use BrianHenryIE\Strauss\Config\CleanupConfigInterface;
10use BrianHenryIE\Strauss\Files\DiscoveredFiles;
11use BrianHenryIE\Strauss\Files\File;
12use BrianHenryIE\Strauss\Files\FileWithDependency;
13use BrianHenryIE\Strauss\Helpers\FileSystem;
14use BrianHenryIE\Strauss\Pipeline\Autoload\DumpAutoload;
15use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
16use Composer\Autoload\AutoloadGenerator;
17use Composer\Config as ComposerConfig;
18use Composer\Factory;
19use Composer\IO\NullIO;
20use Composer\Json\JsonFile;
21use Composer\Repository\InstalledFilesystemRepository;
22use League\Flysystem\FilesystemException;
23use Psr\Log\LoggerAwareTrait;
24use Psr\Log\LoggerInterface;
25
26class Cleanup
27{
28    use LoggerAwareTrait;
29
30    protected Filesystem $filesystem;
31
32    protected bool $isDeleteVendorFiles;
33    protected bool $isDeleteVendorPackages;
34
35    protected CleanupConfigInterface $config;
36
37    public function __construct(
38        CleanupConfigInterface $config,
39        Filesystem $filesystem,
40        LoggerInterface $logger
41    ) {
42        $this->config = $config;
43        $this->logger = $logger;
44
45        $this->isDeleteVendorFiles = $config->isDeleteVendorFiles() && $config->getTargetDirectory() !== $config->getVendorDirectory();
46        $this->isDeleteVendorPackages = $config->isDeleteVendorPackages() && $config->getTargetDirectory() !== $config->getVendorDirectory();
47
48        $this->filesystem = $filesystem;
49    }
50
51    /**
52     * Maybe delete the source files that were copied (depending on config),
53     * then delete empty directories.
54     *
55     * @param array<string,ComposerPackage> $flatDependencyTree
56     *
57     * @throws FilesystemException
58     */
59    public function deleteFiles(array $flatDependencyTree, DiscoveredFiles $discoveredFiles): void
60    {
61        if (!$this->isDeleteVendorPackages && !$this->isDeleteVendorFiles) {
62            $this->logger->info('No cleanup required.');
63            return;
64        }
65
66        $this->logger->info('Beginning cleanup.');
67
68        if ($this->isDeleteVendorPackages) {
69            $this->doIsDeleteVendorPackages($flatDependencyTree, $discoveredFiles);
70        }
71
72        if ($this->isDeleteVendorFiles) {
73            $this->doIsDeleteVendorFiles($discoveredFiles->getFiles());
74        }
75
76        $this->deleteEmptyDirectories($discoveredFiles->getFiles());
77    }
78
79    /** @param array<string,ComposerPackage> $flatDependencyTree */
80    public function cleanupVendorInstalledJson(array $flatDependencyTree, DiscoveredSymbols $discoveredSymbols): void
81    {
82        $installedJson = new InstalledJson(
83            $this->config,
84            $this->filesystem,
85            $this->logger
86        );
87
88        if ($this->config->getTargetDirectory() !== $this->config->getVendorDirectory()
89        && !$this->config->isDeleteVendorFiles() && !$this->config->isDeleteVendorPackages()
90        ) {
91            $installedJson->cleanTargetDirInstalledJson($flatDependencyTree, $discoveredSymbols);
92        } elseif ($this->config->getTargetDirectory() !== $this->config->getVendorDirectory()
93            &&
94            ($this->config->isDeleteVendorFiles() ||$this->config->isDeleteVendorPackages())
95        ) {
96            $installedJson->cleanTargetDirInstalledJson($flatDependencyTree, $discoveredSymbols);
97            $installedJson->cleanupVendorInstalledJson($flatDependencyTree, $discoveredSymbols);
98        } elseif ($this->config->getTargetDirectory() === $this->config->getVendorDirectory()) {
99            $installedJson->cleanupVendorInstalledJson($flatDependencyTree, $discoveredSymbols);
100        }
101    }
102
103    /**
104     * After packages or files have been deleted, the autoloader still contains references to them, in particular
105     * `files` are `require`d on boot (whereas classes are on demand) so that must be fixed.
106     *
107     * Assumes {@see Cleanup::cleanupVendorInstalledJson()} has been called first.
108     *
109     * TODO refactor so this object is passed around rather than reloaded.
110     *
111     * Shares a lot of code with {@see DumpAutoload::generatedPrefixedAutoloader()} but I've done lots of work
112     * on that in another branch so I don't want to cause merge conflicts.
113     */
114    public function rebuildVendorAutoloader(): void
115    {
116        if ($this->config->isDryRun()) {
117            return;
118        }
119
120        $projectComposerJson = new JsonFile($this->config->getProjectDirectory() . 'composer.json');
121        $projectComposerJsonArray = $projectComposerJson->read();
122        $composer = Factory::create(new NullIO(), $projectComposerJsonArray);
123        $installationManager = $composer->getInstallationManager();
124        $package = $composer->getPackage();
125        $config = $composer->getConfig();
126        $generator = new AutoloadGenerator($composer->getEventDispatcher());
127
128        $generator->setClassMapAuthoritative(true);
129        $generator->setRunScripts(false);
130//        $generator->setApcu($apcu, $apcuPrefix);
131//        $generator->setPlatformRequirementFilter($this->getPlatformRequirementFilter($input));
132        $optimize = true; // $input->getOption('optimize') || $config->get('optimize-autoloader');
133        $installedJson = new JsonFile($this->config->getVendorDirectory() . 'composer/installed.json');
134        $localRepo = new InstalledFilesystemRepository($installedJson);
135        $strictAmbiguous = false; // $input->getOption('strict-ambiguous')
136        $installedJsonArray = $installedJson->read();
137        $generator->setDevMode($installedJsonArray['dev'] ?? false);
138        // This will output the autoload_static.php etc. files to `vendor/composer`.
139        $generator->dump(
140            $config,
141            $localRepo,
142            $package,
143            $installationManager,
144            'composer',
145            $optimize,
146            null,
147            $composer->getLocker(),
148            $strictAmbiguous
149        );
150    }
151
152    /**
153     * @throws FilesystemException
154     */
155    protected function deleteEmptyDirectories(array $files)
156    {
157        $this->logger->info('Deleting empty directories.');
158
159        $sourceFiles = array_map(
160            fn($file) => $file->getSourcePath(),
161            $files
162        );
163
164        // Get the root folders of the moved files.
165        $rootSourceDirectories = [];
166        foreach ($sourceFiles as $sourceFile) {
167            $arr = explode("/", $sourceFile, 2);
168            $dir = $arr[0];
169            $rootSourceDirectories[ $dir ] = $dir;
170        }
171        $rootSourceDirectories = array_map(
172            function (string $path): string {
173                return $this->config->getVendorDirectory() . $path;
174            },
175            array_keys($rootSourceDirectories)
176        );
177
178        foreach ($rootSourceDirectories as $rootSourceDirectory) {
179            if (!$this->filesystem->directoryExists($rootSourceDirectory) || is_link($rootSourceDirectory)) {
180                continue;
181            }
182
183            $dirList = $this->filesystem->listContents($rootSourceDirectory, true);
184
185            $allFilePaths = array_map(
186                fn($file) => $file->path(),
187                $dirList->toArray()
188            );
189
190            // Sort by longest path first, so subdirectories are deleted before the parent directories are checked.
191            usort(
192                $allFilePaths,
193                fn($a, $b) => count(explode('/', $b)) - count(explode('/', $a))
194            );
195
196            foreach ($allFilePaths as $filePath) {
197                if ($this->filesystem->directoryExists($filePath)
198                    && $this->dirIsEmpty($filePath)
199                ) {
200                    $this->logger->debug('Deleting empty directory ' . $filePath);
201                    $this->filesystem->deleteDirectory($filePath);
202                }
203            }
204        }
205
206//        foreach ($this->filesystem->listContents($this->getAbsoluteVendorDir()) as $dirEntry) {
207//            if ($dirEntry->isDir() && $this->dirIsEmpty($dirEntry->path()) && !is_link($dirEntry->path())) {
208//                $this->logger->info('Deleting empty directory ' .  $dirEntry->path());
209//                $this->filesystem->deleteDirectory($dirEntry->path());
210//            } else {
211//                $this->logger->debug('Skipping non-empty directory ' . $dirEntry->path());
212//            }
213//        }
214    }
215
216    // TODO: Move to FileSystem class.
217    protected function dirIsEmpty(string $dir): bool
218    {
219        // TODO BUG this deletes directories with only symlinks inside. How does it behave with hidden files?
220        return empty($this->filesystem->listContents($dir)->toArray());
221    }
222
223    /**
224     */
225    protected function doIsDeleteVendorPackages(array $flatDependencyTree, DiscoveredFiles $discoveredFiles)
226    {
227        $this->logger->info('Deleting original vendor packages.');
228
229//        if ($this->isDeleteVendorPackages) {
230//            foreach ($flatDependencyTree as $packageName => $package) {
231//                if ($package->isDoDelete()) {
232//                    $this->filesystem->deleteDirectory($package->getPackageAbsolutePath());
233//                    $package->setDidDelete(true);
234////                $files = $package->getFiles();
235////                foreach($files as $file){
236////                    $file->setDidDelete(true);
237////                }
238//                }
239//            }
240//        }
241
242        $files = $discoveredFiles->getFiles();
243
244        /** @var ComposerPackage $package */
245        foreach ($flatDependencyTree as $packageName => $package) {
246            // Normal package.
247            if ($this->filesystem->isSubDirOf($this->config->getVendorDirectory(), $package->getPackageAbsolutePath())) {
248                $this->logger->info('Deleting ' . $package->getPackageAbsolutePath());
249
250                $this->filesystem->deleteDirectory($package->getPackageAbsolutePath());
251
252                $package->setDidDelete(true);
253            } else {
254                // TODO: log _where_ the symlink is pointing to.
255                $this->logger->info('Deleting symlink at ' . $package->getRelativePath());
256
257                // If it's a symlink, remove the symlink in the directory
258                $symlinkPath =
259                    rtrim(
260                        $this->config->getVendorDirectory() . $package->getRelativePath(),
261                        '/'
262                    );
263
264                if (false !== strpos('WIN', PHP_OS)) {
265                    /**
266                     * `unlink()` will not work on Windows. `rmdir()` will not work if there are files in the directory.
267                     * "On windows, take care that `is_link()` returns false for Junctions."
268                     *
269                     * @see https://www.php.net/manual/en/function.is-link.php#113263
270                     * @see https://stackoverflow.com/a/18262809/336146
271                     */
272                    rmdir($symlinkPath);
273                } else {
274                    unlink($symlinkPath);
275                }
276
277                $package->setDidDelete(true);
278            }
279            if ($this->dirIsEmpty(dirname($package->getPackageAbsolutePath()))) {
280                $this->logger->info('Deleting empty directory ' . dirname($package->getPackageAbsolutePath()));
281                $this->filesystem->deleteDirectory(dirname($package->getPackageAbsolutePath()));
282            }
283        }
284    }
285
286    /**
287     * @param array $files
288     *
289     * @throws FilesystemException
290     */
291    public function doIsDeleteVendorFiles(array $files)
292    {
293        $this->logger->info('Deleting original vendor files.');
294
295        foreach ($files as $file) {
296            if (! $file->isDoDelete()) {
297                $this->logger->debug('Skipping/preserving ' . $file->getSourcePath());
298                continue;
299            }
300
301            $sourceRelativePath = $file->getSourcePath();
302
303            $this->logger->info('Deleting ' . $sourceRelativePath);
304
305            $this->filesystem->delete($file->getSourcePath());
306
307            $file->setDidDelete(true);
308        }
309    }
310}