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