Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.58% covered (danger)
18.58%
21 / 113
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileEnumerator
18.58% covered (danger)
18.58%
21 / 113
0.00% covered (danger)
0.00%
0 / 6
735.41
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 compileFileListForDependencies
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 compileFileListForPaths
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
6.44
 excludeGitFiles
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
72
 isGitExcluded
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
132
 addFile
9.09% covered (danger)
9.09%
4 / 44
0.00% covered (danger)
0.00%
0 / 1
43.81
1<?php
2/**
3 * Build a list of files for the Composer packages.
4 */
5
6namespace BrianHenryIE\Strauss\Pipeline;
7
8use BrianHenryIE\Strauss\Composer\ComposerPackage;
9use BrianHenryIE\Strauss\Composer\DependenciesCollection;
10use BrianHenryIE\Strauss\Config\FileEnumeratorConfig;
11use BrianHenryIE\Strauss\Files\DiscoveredFiles;
12use BrianHenryIE\Strauss\Files\File;
13use BrianHenryIE\Strauss\Files\FileWithDependency;
14use BrianHenryIE\Strauss\Helpers\Flysystem\FileSystem;
15use BrianHenryIE\Strauss\Helpers\GitAttributes;
16use Inmarelibero\GitIgnoreChecker\Exception\GitIgnoreCherkerException;
17use Inmarelibero\GitIgnoreChecker\GitIgnoreChecker;
18use League\Flysystem\FilesystemException;
19use Psr\Log\LoggerAwareTrait;
20use Psr\Log\LoggerInterface;
21
22class FileEnumerator
23{
24    use LoggerAwareTrait;
25
26    protected FileEnumeratorConfig $config;
27
28    protected FileSystem $filesystem;
29
30    protected DiscoveredFiles $discoveredFiles;
31
32    /**
33     * Copier constructor.
34     */
35    public function __construct(
36        FileEnumeratorConfig $config,
37        FileSystem $filesystem,
38        LoggerInterface $logger
39    ) {
40        $this->discoveredFiles = new DiscoveredFiles();
41
42        $this->config = $config;
43
44        $this->filesystem = $filesystem;
45
46        $this->logger = $logger;
47    }
48
49    /**
50     * @param DependenciesCollection $flatDependencies
51     *
52     * @throws FilesystemException
53     */
54    public function compileFileListForDependencies(DependenciesCollection $flatDependencies): DiscoveredFiles
55    {
56        /** @var ComposerPackage $dependency */
57        foreach ($flatDependencies as $dependency) {
58            $this->logger->info("Scanning for files for package {packageName}", ['packageName' => $dependency->getPackageName()]);
59            $dependencyPackageAbsolutePath = $dependency->getPackageAbsolutePath();
60            // Meta packages.
61            if (is_null($dependencyPackageAbsolutePath)) {
62                continue;
63            }
64            $this->compileFileListForPaths([$dependencyPackageAbsolutePath], $dependency);
65//            $absoluteFilePaths = $this->filesystem->findAllFilesAbsolutePaths([$dependencyPackageAbsolutePath]);
66//
67//            foreach ($absoluteFilePaths as $sourceAbsolutePath) {
68//                $this->addFile($sourceAbsolutePath, $dependency);
69//            }
70        }
71
72        $this->discoveredFiles->sort();
73        return $this->discoveredFiles;
74    }
75
76    /**
77     * @param string[] $paths
78     * @throws FilesystemException
79     */
80    public function compileFileListForPaths(
81        array $paths,
82        ?ComposerPackage $dependency = null
83    ): DiscoveredFiles {
84        // First, shallowly list each path's top-level entries (files and directories, non-recursive).
85        $directoryListingByPath = [];
86        foreach ($paths as $path) {
87            $directoryListingByPath[$path] = $this->filesystem->findAllFilesAbsolutePaths([$path], false, false);
88        }
89
90        if ($this->config->isExcludeGitFiles()) {
91            // Apply the Git exclusion rules to each path's top-level entries before recursing, so we
92            // never deep-list (descend into) directories that Git would exclude, e.g. `.git`.
93            foreach ($directoryListingByPath as $path => $files) {
94                $directoryListingByPath[$path] = $this->excludeGitFiles([$path], $files);
95            }
96        }
97
98        $absoluteFilePaths = $this->filesystem->findAllFilesAbsolutePaths(array_merge(...array_values($directoryListingByPath)));
99
100        if ($this->config->isExcludeGitFiles()) {
101            $absoluteFilePaths = $this->excludeGitFiles($paths, $absoluteFilePaths);
102        }
103        foreach ($absoluteFilePaths as $sourceAbsolutePath) {
104            $this->addFile($sourceAbsolutePath, $dependency);
105        }
106
107        $this->discoveredFiles->sort();
108        return $this->discoveredFiles;
109    }
110
111    /**
112     * Remove files which Git would not include in the package's distributed archive:
113     * the `.git` directory, files matched by `.gitignore`, and files marked `export-ignore`
114     * in `.gitattributes`. Each base path is treated as its own repository root.
115     *
116     * @param string[] $basePaths
117     * @param string[] $absoluteFilePaths
118     *
119     * @return string[]
120     * @throws FilesystemException
121     */
122    protected function excludeGitFiles(array $basePaths, array $absoluteFilePaths): array
123    {
124        /** @var array<string, array{gitignore?:GitIgnoreChecker, gitattributes?:GitAttributes}> $repositories */
125        $repositories = [];
126        foreach ($basePaths as $basePath) {
127            if (!$this->filesystem->directoryExists($basePath)) {
128                continue;
129            }
130
131            $normalizedBasePath = rtrim(FileSystem::normalizeDirSeparator($basePath), '/');
132
133            // A `.git` directory is never part of the distributed package, so its presence alone is
134            // enough to enable pruning – even without a `.gitignore`/`.gitattributes`. Registering the
135            // base path here ensures `isGitExcluded()` runs its `.git` check for it.
136            if ($this->filesystem->directoryExists($normalizedBasePath . '/.git')) {
137                $repositories[$normalizedBasePath] ??= [];
138            }
139
140            if ($this->filesystem->fileExists($normalizedBasePath . '/.gitignore')) {
141                try {
142                    /**
143                     * TODO: use {@see FileSystem::prefixPath()} when #278 is merged.
144                     */
145                    $gitIgnoreChecker = new GitIgnoreChecker('/' . $normalizedBasePath);
146                    $repositories[$normalizedBasePath][ 'gitignore'] = $gitIgnoreChecker;
147                } catch (GitIgnoreCherkerException $e) {
148                    // e.g. when the path is not on the local filesystem (in-memory tests).
149                    // The user explicitly enabled Git exclusion, so surface the failure to honour it.
150                    $this->logger->warning("Could not read .gitignore at {path}: {message}", [
151                        'path' => $normalizedBasePath,
152                        'message' => $e->getMessage(),
153                    ]);
154                }
155            }
156
157            if ($this->filesystem->fileExists($normalizedBasePath . '/.gitattributes')) {
158                $repositories[$normalizedBasePath][ 'gitattributes'] = new GitAttributes($this->filesystem, $normalizedBasePath);
159            }
160        }
161
162        if (empty($repositories)) {
163            return $absoluteFilePaths;
164        }
165
166        $this->logger->info('Processing .gitignore/.gitattributes – checking ' . count($absoluteFilePaths) . ' files.');
167
168        return array_values(array_filter(
169            $absoluteFilePaths,
170            fn(string $sourceAbsolutePath): bool => !$this->isGitExcluded($sourceAbsolutePath, $repositories)
171        ));
172    }
173
174    /**
175     * @param array<string, array{gitignore?:GitIgnoreChecker, gitattributes?:GitAttributes}> $repositories
176     *
177     * @throws FilesystemException
178     */
179    protected function isGitExcluded(string $sourceAbsolutePath, array $repositories): bool
180    {
181        foreach ($repositories as $basePath => $checkers) {
182            $relativePath = $this->filesystem->getRelativePath($basePath, $sourceAbsolutePath);
183
184            // Not located within this repository root.
185            if ($relativePath === '' || strpos($relativePath, '../') === 0) {
186                continue;
187            }
188
189            // The .git directory is never part of the distributed package.
190            if ($relativePath === '.git' || strpos($relativePath, '.git/') === 0) {
191                $this->logger->debug("Skipping .git file {path}", ['path' => $sourceAbsolutePath]);
192                return true;
193            }
194
195            if (isset($checkers['gitignore'])) {
196                try {
197                    if ($checkers['gitignore']->isPathIgnored('/' . $relativePath)) {
198                        $this->logger->debug("Skipping .gitignore'd file {path}", ['path' => $sourceAbsolutePath]);
199                        return true;
200                    }
201                } catch (GitIgnoreCherkerException $e) {
202                    $this->logger->warning("Could not check .gitignore for {path}: {message}", [
203                        'path' => $sourceAbsolutePath,
204                        'message' => $e->getMessage(),
205                    ]);
206                }
207            }
208
209            if (isset($checkers['gitattributes']) && $checkers['gitattributes']->isExportIgnored($relativePath)) {
210                $this->logger->debug("Skipping export-ignore file {path}", ['path' => $sourceAbsolutePath]);
211                return true;
212            }
213        }
214
215        return false;
216    }
217
218    /**
219     * @param string $sourceAbsoluteFilepath
220     * @param ?ComposerPackage $dependency
221     * @param ?string $autoloaderType
222     *
223     * @throws FilesystemException
224     * @uses DiscoveredFiles::add
225     *
226     */
227    protected function addFile(
228        string $sourceAbsoluteFilepath,
229        ?ComposerPackage $dependency = null,
230        ?string $autoloaderType = null
231    ): void {
232
233        if ($this->filesystem->directoryExists($sourceAbsoluteFilepath)) {
234            $this->logger->debug("Skipping directory at {sourcePath}", ['sourcePath' => $sourceAbsoluteFilepath]);
235            return;
236        }
237
238        // Do not add a file if its source does not exist!
239        if (!$this->filesystem->fileExists($sourceAbsoluteFilepath)) {
240            $this->logger->warning("File does not exist: {sourcePath}", ['sourcePath' => $sourceAbsoluteFilepath]);
241            return;
242        }
243
244        if ($dependency) {
245            $vendorRelativePath = $this->filesystem->getRelativePath(
246                $this->config->getAbsoluteVendorDirectory(),
247                $sourceAbsoluteFilepath
248            );
249
250            /** @var string $dependencyPackageAbsolutePath */
251            $dependencyPackageAbsolutePath = $dependency->getPackageAbsolutePath();
252            if ($vendorRelativePath === $sourceAbsoluteFilepath) {
253                $vendorRelativePath = $dependency->getRelativePath() . str_replace(
254                    FileSystem::normalizeDirSeparator($dependencyPackageAbsolutePath),
255                    '',
256                    FileSystem::normalizeDirSeparator($sourceAbsoluteFilepath)
257                );
258            }
259
260            /** @var FileWithDependency $f */
261            $f = $this->discoveredFiles->getFile($sourceAbsoluteFilepath)
262                ?? new FileWithDependency(
263                    $dependency,
264                    FileSystem::normalizeDirSeparator($vendorRelativePath),
265                    $this->filesystem->normalizePath($sourceAbsoluteFilepath),
266                    $this->config->getAbsoluteTargetDirectory(). '/' . $vendorRelativePath
267                );
268
269//            $f->setTargetAbsolutePath($this->config->getAbsoluteTargetDirectory() . '/' . $vendorRelativePath);
270
271            $autoloaderType && $f->addAutoloader($autoloaderType);
272
273//            if ($isOutsideProjectDir) {
274//                $f->setDoDelete(false);
275//            }
276        } else {
277            $vendorRelativePath = $this->filesystem->getRelativePath(
278                str_starts_with($sourceAbsoluteFilepath, $this->config->getAbsoluteVendorDirectory()) ? $this->config->getAbsoluteVendorDirectory() : $this->config->getAbsoluteTargetDirectory(),
279                $sourceAbsoluteFilepath,
280            );
281
282            $targetAbsolutePath = $this->config->getAbsoluteTargetDirectory() . '/' . $vendorRelativePath;
283
284            $f = $this->discoveredFiles->getFile($sourceAbsoluteFilepath)
285                 ?? new File(
286                     FileSystem::normalizeDirSeparator($sourceAbsoluteFilepath),
287                     $vendorRelativePath,
288                     $targetAbsolutePath
289                 );
290        }
291
292        $this->discoveredFiles->add($f);
293
294        $vendorRelativeFilePath =
295            $this->filesystem->getRelativePath(
296                $this->config->getProjectAbsolutePath(),
297                $f->getSourcePath()
298            );
299        $this->logger->info("Found file " . $vendorRelativeFilePath);
300    }
301}