Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
3.92% covered (danger)
3.92%
4 / 102
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileEnumerator
3.92% covered (danger)
3.92%
4 / 102
0.00% covered (danger)
0.00%
0 / 6
883.32
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
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 compileFileListForPaths
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 excludeGitFiles
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 isGitExcluded
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
132
 addFile
9.30% covered (danger)
9.30%
4 / 43
0.00% covered (danger)
0.00%
0 / 1
43.56
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\Config\FileEnumeratorConfig;
10use BrianHenryIE\Strauss\Files\DiscoveredFiles;
11use BrianHenryIE\Strauss\Files\File;
12use BrianHenryIE\Strauss\Files\FileWithDependency;
13use BrianHenryIE\Strauss\Helpers\FileSystem;
14use BrianHenryIE\Strauss\Helpers\GitAttributes;
15use Inmarelibero\GitIgnoreChecker\Exception\GitIgnoreCherkerException;
16use Inmarelibero\GitIgnoreChecker\GitIgnoreChecker;
17use League\Flysystem\FilesystemException;
18use Psr\Log\LoggerAwareTrait;
19use Psr\Log\LoggerInterface;
20
21class FileEnumerator
22{
23    use LoggerAwareTrait;
24
25    protected FileEnumeratorConfig $config;
26
27    protected Filesystem $filesystem;
28
29    protected DiscoveredFiles $discoveredFiles;
30
31    /**
32     * Copier constructor.
33     */
34    public function __construct(
35        FileEnumeratorConfig $config,
36        FileSystem $filesystem,
37        LoggerInterface $logger
38    ) {
39        $this->discoveredFiles = new DiscoveredFiles();
40
41        $this->config = $config;
42
43        $this->filesystem = $filesystem;
44
45        $this->logger = $logger;
46    }
47
48    /**
49     * @param ComposerPackage[] $dependencies
50     * @throws FilesystemException
51     */
52    public function compileFileListForDependencies(array $dependencies): DiscoveredFiles
53    {
54        foreach ($dependencies as $dependency) {
55            $this->logger->info("Scanning for files for package {packageName}", ['packageName' => $dependency->getPackageName()]);
56            /** @var string $dependencyPackageAbsolutePath */
57            $dependencyPackageAbsolutePath = $dependency->getPackageAbsolutePath();
58            $this->compileFileListForPaths([$dependencyPackageAbsolutePath], $dependency);
59        }
60
61        $this->discoveredFiles->sort();
62        return $this->discoveredFiles;
63    }
64
65    /**
66     * @param string[] $paths
67     * @throws FilesystemException
68     */
69    public function compileFileListForPaths(array $paths, ?ComposerPackage $dependency = null): DiscoveredFiles
70    {
71        $absoluteFilePaths = $this->filesystem->findAllFilesAbsolutePaths($paths);
72
73        if ($this->config->getExcludeGitFiles()) {
74            $absoluteFilePaths = $this->excludeGitFiles($paths, $absoluteFilePaths);
75        }
76
77        foreach ($absoluteFilePaths as $sourceAbsolutePath) {
78            $this->addFile($sourceAbsolutePath, $dependency);
79        }
80
81        $this->discoveredFiles->sort();
82        return $this->discoveredFiles;
83    }
84
85    /**
86     * Remove files which Git would not include in the package's distributed archive:
87     * the `.git` directory, files matched by `.gitignore`, and files marked `export-ignore`
88     * in `.gitattributes`. Each base path is treated as its own repository root.
89     *
90     * @param string[] $basePaths
91     * @param string[] $absoluteFilePaths
92     *
93     * @return string[]
94     * @throws FilesystemException
95     */
96    protected function excludeGitFiles(array $basePaths, array $absoluteFilePaths): array
97    {
98        /** @var array<string, array{gitignore?:GitIgnoreChecker, gitattributes?:GitAttributes}> $repositories */
99        $repositories = [];
100        foreach ($basePaths as $basePath) {
101            if (!$this->filesystem->directoryExists($basePath)) {
102                continue;
103            }
104
105            $normalizedBasePath = rtrim(FileSystem::normalizeDirSeparator($basePath), '/');
106
107            if ($this->filesystem->fileExists($normalizedBasePath . '/.gitignore')) {
108                try {
109                    /**
110                     * TODO: use {@see FileSystem::prefixPath()} when #278 is merged.
111                     */
112                    $gitIgnoreChecker = new GitIgnoreChecker('/' . $normalizedBasePath);
113                    $repositories[$normalizedBasePath][ 'gitignore'] = $gitIgnoreChecker;
114                } catch (GitIgnoreCherkerException $e) {
115                    // e.g. when the path is not on the local filesystem (in-memory tests).
116                    $this->logger->debug("Could not read .gitignore at {path}: {message}", [
117                        'path' => $normalizedBasePath,
118                        'message' => $e->getMessage(),
119                    ]);
120                }
121            }
122
123            if ($this->filesystem->fileExists($normalizedBasePath . '/.gitattributes')) {
124                $repositories[$normalizedBasePath][ 'gitattributes'] = new GitAttributes($this->filesystem, $normalizedBasePath);
125            }
126        }
127
128        if (empty($repositories)) {
129            return $absoluteFilePaths;
130        }
131
132        $this->logger->info('Processing .gitignore/.gitattributes – checking ' . count($absoluteFilePaths) . ' files.');
133
134        return array_values(array_filter(
135            $absoluteFilePaths,
136            fn(string $sourceAbsolutePath): bool => !$this->isGitExcluded($sourceAbsolutePath, $repositories)
137        ));
138    }
139
140    /**
141     * @param array<string, array{gitignore?:GitIgnoreChecker, gitattributes?:GitAttributes}> $repositories
142     *
143     * @throws FilesystemException
144     */
145    protected function isGitExcluded(string $sourceAbsolutePath, array $repositories): bool
146    {
147        foreach ($repositories as $basePath => $checkers) {
148            $relativePath = $this->filesystem->getRelativePath($basePath, $sourceAbsolutePath);
149
150            // Not located within this repository root.
151            if ($relativePath === '' || strpos($relativePath, '../') === 0) {
152                continue;
153            }
154
155            // The .git directory is never part of the distributed package.
156            if ($relativePath === '.git' || strpos($relativePath, '.git/') === 0) {
157                $this->logger->debug("Skipping .git file {path}", ['path' => $sourceAbsolutePath]);
158                return true;
159            }
160
161            if (isset($checkers['gitignore'])) {
162                try {
163                    if ($checkers['gitignore']->isPathIgnored('/' . $relativePath)) {
164                        $this->logger->debug("Skipping .gitignore'd file {path}", ['path' => $sourceAbsolutePath]);
165                        return true;
166                    }
167                } catch (GitIgnoreCherkerException $e) {
168                    $this->logger->debug("Could not check .gitignore for {path}: {message}", [
169                        'path' => $sourceAbsolutePath,
170                        'message' => $e->getMessage(),
171                    ]);
172                }
173            }
174
175            if (isset($checkers['gitattributes']) && $checkers['gitattributes']->isExportIgnored($relativePath)) {
176                $this->logger->debug("Skipping export-ignore file {path}", ['path' => $sourceAbsolutePath]);
177                return true;
178            }
179        }
180
181        return false;
182    }
183
184    /**
185     * @param string $sourceAbsoluteFilepath
186     * @param ?ComposerPackage $dependency
187     * @param ?string $autoloaderType
188     *
189     * @throws FilesystemException
190     * @uses DiscoveredFiles::add
191     *
192     */
193    protected function addFile(
194        string $sourceAbsoluteFilepath,
195        ?ComposerPackage $dependency = null,
196        ?string $autoloaderType = null
197    ): void {
198
199        if ($this->filesystem->directoryExists($sourceAbsoluteFilepath)) {
200            $this->logger->debug("Skipping directory at {sourcePath}", ['sourcePath' => $sourceAbsoluteFilepath]);
201            return;
202        }
203
204        // Do not add a file if its source does not exist!
205        if (!$this->filesystem->fileExists($sourceAbsoluteFilepath)) {
206            $this->logger->warning("File does not exist: {sourcePath}", ['sourcePath' => $sourceAbsoluteFilepath]);
207            return;
208        }
209
210        $isOutsideProjectDir = 0 !== strpos($sourceAbsoluteFilepath, $this->config->getAbsoluteVendorDirectory());
211
212        if ($dependency) {
213            $vendorRelativePath = $this->filesystem->getRelativePath(
214                $this->config->getAbsoluteVendorDirectory(),
215                $sourceAbsoluteFilepath
216            );
217
218            /** @var string $dependencyPackageAbsolutePath */
219            $dependencyPackageAbsolutePath = $dependency->getPackageAbsolutePath();
220            if ($vendorRelativePath === $sourceAbsoluteFilepath) {
221                $vendorRelativePath = $dependency->getRelativePath() . str_replace(
222                    FileSystem::normalizeDirSeparator($dependencyPackageAbsolutePath),
223                    '',
224                    FileSystem::normalizeDirSeparator($sourceAbsoluteFilepath)
225                );
226            }
227
228            /** @var FileWithDependency $f */
229            $f = $this->discoveredFiles->getFile($sourceAbsoluteFilepath)
230                ?? new FileWithDependency(
231                    $dependency,
232                    FileSystem::normalizeDirSeparator($vendorRelativePath),
233                    FileSystem::normalizeDirSeparator($sourceAbsoluteFilepath)
234                );
235
236            $autoloaderType && $f->addAutoloader($autoloaderType);
237            $f->setDoDelete($isOutsideProjectDir);
238        } else {
239            $vendorRelativePath = $this->filesystem->getRelativePath(
240                str_starts_with($sourceAbsoluteFilepath, $this->config->getAbsoluteVendorDirectory()) ? $this->config->getAbsoluteVendorDirectory() : $this->config->getAbsoluteTargetDirectory(),
241                $sourceAbsoluteFilepath,
242            );
243
244            $f = $this->discoveredFiles->getFile($sourceAbsoluteFilepath)
245                 ?? new File(
246                     FileSystem::normalizeDirSeparator($sourceAbsoluteFilepath),
247                     $vendorRelativePath
248                 );
249        }
250
251        $this->discoveredFiles->add($f);
252
253        $relativeFilePath =
254            $this->filesystem->getRelativePath(
255                dirname($this->config->getAbsoluteVendorDirectory()),
256                $f->getAbsoluteTargetPath()
257            );
258        $this->logger->info("Found file " . $relativeFilePath);
259    }
260}