Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.74% covered (warning)
55.74%
34 / 61
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
GitAttributes
55.74% covered (warning)
55.74%
34 / 61
0.00% covered (danger)
0.00%
0 / 4
95.99
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 parse
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
14.20
 isExportIgnored
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 matchesPattern
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2/**
3 * Minimal `.gitattributes` parser, used to determine which files a package marks `export-ignore`
4 * (i.e. files `git archive` / Composer dist would strip from the distributed package).
5 *
6 * Only the subset of `.gitattributes` needed by Strauss is implemented: line parsing into
7 * pattern + attributes, and `export-ignore` path matching using gitignore-style globbing.
8 *
9 * @author Claude
10 *
11 * @package brianhenryie/strauss
12 */
13
14namespace BrianHenryIE\Strauss\Helpers;
15
16use League\Flysystem\FilesystemException;
17
18class GitAttributes
19{
20    protected FileSystem $filesystem;
21
22    protected string $repositoryPath;
23
24    protected string $gitAttributesFilename;
25
26    /**
27     * @var ?array<array{pattern:string, attributes:array<string, bool|string|null>}>
28     */
29    protected ?array $parsed = null;
30
31    public function __construct(
32        FileSystem $filesystem,
33        string $repositoryPath,
34        string $gitAttributesFilename = '.gitattributes'
35    ) {
36        $this->filesystem = $filesystem;
37        $this->repositoryPath = rtrim(FileSystem::normalizeDirSeparator($repositoryPath), '/');
38        $this->gitAttributesFilename = $gitAttributesFilename;
39    }
40
41    /**
42     * Read and parse the repository's `.gitattributes` file.
43     *
44     * Each returned entry is the pattern and its attributes, where an attribute is:
45     *  - `true`   for a set attribute, e.g. `export-ignore`
46     *  - `false`  for an unset attribute, e.g. `-export-ignore`
47     *  - `null`   for an unspecified attribute, e.g. `!export-ignore`
48     *  - `string` for a valued attribute, e.g. `eol=lf`
49     *
50     * @return array<array{pattern:string, attributes:array<string, bool|string|null>}>
51     * @throws FilesystemException
52     */
53    public function parse(): array
54    {
55        if ($this->parsed !== null) {
56            return $this->parsed;
57        }
58
59        $this->parsed = [];
60
61        $gitAttributesPath = $this->repositoryPath . '/' . $this->gitAttributesFilename;
62
63        if (!$this->filesystem->fileExists($gitAttributesPath)) {
64            return $this->parsed;
65        }
66
67        $contents = $this->filesystem->read($gitAttributesPath);
68
69        foreach (preg_split('/\R/', $contents) ?: [] as $line) {
70            $line = trim($line);
71
72            // Skip blank lines and comments.
73            if ($line === '' || strpos($line, '#') === 0) {
74                continue;
75            }
76
77            $tokens = preg_split('/\s+/', $line) ?: [];
78            $pattern = array_shift($tokens);
79
80            if ($pattern === null || $pattern === '') {
81                continue;
82            }
83
84            $attributes = [];
85            foreach ($tokens as $token) {
86                if (strpos($token, '-') === 0) {
87                    $attributes[substr($token, 1)] = false;
88                } elseif (strpos($token, '!') === 0) {
89                    $attributes[substr($token, 1)] = null;
90                } elseif (strpos($token, '=') !== false) {
91                    [$name, $value] = explode('=', $token, 2);
92                    $attributes[$name] = $value;
93                } else {
94                    $attributes[$token] = true;
95                }
96            }
97
98            $this->parsed[] = [
99                'pattern' => $pattern,
100                'attributes' => $attributes,
101            ];
102        }
103
104        return $this->parsed;
105    }
106
107    /**
108     * Whether the given repository-relative path is marked `export-ignore`.
109     *
110     * The last matching pattern wins, so a later `-export-ignore` rule can re-include a path.
111     *
112     * @throws FilesystemException
113     */
114    public function isExportIgnored(string $relativePath): bool
115    {
116        $relativePath = ltrim(FileSystem::normalizeDirSeparator($relativePath), '/');
117
118        $ignored = false;
119
120        foreach ($this->parse() as $entry) {
121            if (!array_key_exists('export-ignore', $entry['attributes'])) {
122                continue;
123            }
124
125            if ($this->matchesPattern($entry['pattern'], $relativePath)) {
126                $ignored = $entry['attributes']['export-ignore'] === true;
127            }
128        }
129
130        return $ignored;
131    }
132
133    /**
134     * Match a `.gitattributes`/`.gitignore`-style pattern against a repository-relative file path.
135     *
136     * A pattern matches when it matches the path itself or one of its ancestor directories
137     * (marking a directory `export-ignore` also excludes its contents).
138     */
139    protected function matchesPattern(string $pattern, string $relativePath): bool
140    {
141        $pattern = rtrim(FileSystem::normalizeDirSeparator($pattern), '/');
142        // A pattern containing a slash (other than a trailing one) is anchored to the repository root.
143        $isAnchored = strpos($pattern, '/') !== false;
144        $pattern = ltrim($pattern, '/');
145
146        if ($pattern === '') {
147            return false;
148        }
149
150        if (!$isAnchored) {
151            // An unanchored pattern matches any single path segment, e.g. `tests` or `*.dist`.
152            foreach (explode('/', $relativePath) as $segment) {
153                if (fnmatch($pattern, $segment)) {
154                    return true;
155                }
156            }
157            return false;
158        }
159
160        if (fnmatch($pattern, $relativePath, FNM_PATHNAME)) {
161            return true;
162        }
163
164        // Match against each ancestor directory so a directory pattern covers the files within it.
165        $segments = explode('/', $relativePath);
166        array_pop($segments);
167
168        $ancestor = '';
169        foreach ($segments as $segment) {
170            $ancestor = $ancestor === '' ? $segment : $ancestor . '/' . $segment;
171            if (fnmatch($pattern, $ancestor, FNM_PATHNAME)) {
172                return true;
173            }
174        }
175
176        return false;
177    }
178}