Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.20% covered (success)
93.20%
96 / 103
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Merger
93.20% covered (success)
93.20%
96 / 103
50.00% covered (danger)
50.00%
5 / 10
43.58
0.00% covered (danger)
0.00%
0 / 1
 merge
94.34% covered (success)
94.34%
50 / 53
0.00% covered (danger)
0.00%
0 / 1
15.04
 parseExistingLines
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 keepInExportLookup
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 generatedDirectoryLookup
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 normalizeLine
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 extractExportIgnorePathSpec
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 sortKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizePathSpec
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 normalizePathKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isLiteralPathSpec
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of fast-forward/dev-tools.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2026 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/dev-tools
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\DevTools\GitAttributes;
20
21use function Safe\preg_split;
22use function Safe\preg_replace;
23use function Safe\preg_match;
24
25/**
26 * Merges .gitattributes content with generated export-ignore rules.
27 *
28 * This class preserves existing custom entries while adding missing
29 * export-ignore rules for known candidate paths, deduplicates semantically
30 * equivalent entries, and sorts export-ignore rules with directories before
31 * files.
32 */
33 class Merger implements MergerInterface
34{
35    /**
36     * Merges generated export-ignore entries with existing .gitattributes content.
37     *
38     * This method:
39     * 1. Preserves custom user-defined entries in their original order
40     * 2. Adds missing generated export-ignore entries for existing paths
41     * 3. Deduplicates entries using normalized path comparison
42     * 4. Sorts export-ignore entries with directories before files
43     *
44     * @param string $existingContent The raw .gitattributes content currently stored.
45     * @param list<string> $exportIgnoreEntries the export-ignore entries to manage
46     * @param list<string> $keepInExportPaths the paths that MUST remain exported
47     *
48     * @return string The merged .gitattributes content
49     */
50    public function merge(string $existingContent, array $exportIgnoreEntries, array $keepInExportPaths = []): string
51    {
52        $nonExportIgnoreLines = [];
53        $seenNonExportIgnoreLines = [];
54        $exportIgnoreLines = [];
55        $keptExportLookup = $this->keepInExportLookup($keepInExportPaths);
56        $generatedDirectoryLookup = $this->generatedDirectoryLookup($exportIgnoreEntries);
57
58        foreach ($this->parseExistingLines($existingContent) as $line) {
59            $normalizedLine = $this->normalizeLine($line);
60
61            if ('' === $normalizedLine) {
62                continue;
63            }
64
65            $pathSpec = $this->extractExportIgnorePathSpec($normalizedLine);
66
67            if (null === $pathSpec) {
68                if (isset($seenNonExportIgnoreLines[$normalizedLine])) {
69                    continue;
70                }
71
72                $nonExportIgnoreLines[] = $normalizedLine;
73                $seenNonExportIgnoreLines[$normalizedLine] = true;
74
75                continue;
76            }
77
78            $pathKey = $this->normalizePathKey($pathSpec);
79            if (isset($keptExportLookup[$pathKey])) {
80                continue;
81            }
82            if (isset($exportIgnoreLines[$pathKey])) {
83                continue;
84            }
85
86            $exportIgnoreLines[$pathKey] = [
87                'line' => $normalizedLine,
88                'sort_key' => $this->sortKey($pathSpec),
89                'is_directory' => str_ends_with($pathSpec, '/') || isset($generatedDirectoryLookup[$pathKey]),
90            ];
91        }
92
93        foreach ($exportIgnoreEntries as $entry) {
94            $trimmedEntry = trim($entry);
95            $pathKey = $this->normalizePathKey($trimmedEntry);
96
97            if (isset($keptExportLookup[$pathKey])) {
98                continue;
99            }
100
101            if (! isset($exportIgnoreLines[$pathKey])) {
102                $exportIgnoreLines[$pathKey] = [
103                    'line' => $trimmedEntry . ' export-ignore',
104                    'sort_key' => $this->sortKey($trimmedEntry),
105                    'is_directory' => str_ends_with($trimmedEntry, '/'),
106                ];
107
108                continue;
109            }
110
111            $exportIgnoreLines[$pathKey]['is_directory'] = $exportIgnoreLines[$pathKey]['is_directory']
112                || str_ends_with($trimmedEntry, '/');
113        }
114
115        $sortedExportIgnoreLines = array_values($exportIgnoreLines);
116
117        usort(
118            $sortedExportIgnoreLines,
119            static function (array $left, array $right): int {
120                if ($left['is_directory'] !== $right['is_directory']) {
121                    return $left['is_directory'] ? -1 : 1;
122                }
123
124                $naturalOrder = strnatcasecmp($left['sort_key'], $right['sort_key']);
125
126                if (0 !== $naturalOrder) {
127                    return $naturalOrder;
128                }
129
130                return strcmp($left['sort_key'], $right['sort_key']);
131            }
132        );
133
134        return implode("\n", [...$nonExportIgnoreLines, ...array_column($sortedExportIgnoreLines, 'line')]);
135    }
136
137    /**
138     * Parses the raw .gitattributes content into trimmed non-empty lines.
139     *
140     * @param string $content The full .gitattributes content.
141     *
142     * @return list<string> the non-empty lines from the file
143     */
144    private function parseExistingLines(string $content): array
145    {
146        if ('' === $content) {
147            return [];
148        }
149
150        $lines = [];
151
152        foreach (preg_split('/\R/', $content) ?: [] as $line) {
153            $trimmedLine = trim($line);
154
155            if ('' === $trimmedLine) {
156                continue;
157            }
158
159            $lines[] = $trimmedLine;
160        }
161
162        return $lines;
163    }
164
165    /**
166     * Builds a lookup table for paths that MUST stay in the exported archive.
167     *
168     * @param list<string> $keepInExportPaths the configured keep-in-export paths
169     *
170     * @return array<string, true> the normalized path lookup
171     */
172    private function keepInExportLookup(array $keepInExportPaths): array
173    {
174        $lookup = [];
175
176        foreach ($keepInExportPaths as $path) {
177            $normalizedPath = $this->normalizePathKey($path);
178
179            if ('' === $normalizedPath) {
180                continue;
181            }
182
183            $lookup[$normalizedPath] = true;
184        }
185
186        return $lookup;
187    }
188
189    /**
190     * Builds a lookup table of generated directory candidates.
191     *
192     * @param list<string> $exportIgnoreEntries the generated export-ignore path list
193     *
194     * @return array<string, true> the normalized directory lookup
195     */
196    private function generatedDirectoryLookup(array $exportIgnoreEntries): array
197    {
198        $lookup = [];
199
200        foreach ($exportIgnoreEntries as $entry) {
201            $trimmedEntry = trim($entry);
202
203            if (! str_ends_with($trimmedEntry, '/')) {
204                continue;
205            }
206
207            $lookup[$this->normalizePathKey($trimmedEntry)] = true;
208        }
209
210        return $lookup;
211    }
212
213    /**
214     * Normalizes a .gitattributes line for deterministic comparison and output.
215     *
216     * @param string $line the raw line to normalize
217     *
218     * @return string the normalized line
219     */
220    private function normalizeLine(string $line): string
221    {
222        $trimmedLine = trim($line);
223
224        if ('' === $trimmedLine) {
225            return '';
226        }
227
228        if (str_starts_with($trimmedLine, '#')) {
229            return $trimmedLine;
230        }
231
232        return preg_replace('/(?<!\\\\)[ \t]+/', ' ', $trimmedLine) ?? $trimmedLine;
233    }
234
235    /**
236     * Extracts the path spec from a simple export-ignore line.
237     *
238     * @param string $line the line to inspect
239     *
240     * @return string|null the extracted path spec when the line is a simple export-ignore rule
241     */
242    private function extractExportIgnorePathSpec(string $line): ?string
243    {
244        if (1 !== preg_match('/^(\S+)\s+export-ignore$/', $line, $matches)) {
245            return null;
246        }
247
248        return $matches[1];
249    }
250
251    /**
252     * Builds the natural sort key for a path spec.
253     *
254     * @param string $pathSpec the raw path spec to normalize for sorting
255     *
256     * @return string the natural sort key
257     */
258    private function sortKey(string $pathSpec): string
259    {
260        return ltrim($this->normalizePathSpec($pathSpec), '/');
261    }
262
263    /**
264     * Normalizes a gitattributes path spec for sorting.
265     *
266     * @param string $pathSpec the raw path spec to normalize
267     *
268     * @return string the normalized path spec
269     */
270    private function normalizePathSpec(string $pathSpec): string
271    {
272        $trimmedPathSpec = trim($pathSpec);
273
274        if ('' === $trimmedPathSpec) {
275            return '';
276        }
277
278        $isDirectory = str_ends_with($trimmedPathSpec, '/');
279        $normalizedPathSpec = preg_replace('#/+#', '/', '/' . ltrim($trimmedPathSpec, '/')) ?? $trimmedPathSpec;
280        $normalizedPathSpec = '/' === $normalizedPathSpec ? $normalizedPathSpec : rtrim($normalizedPathSpec, '/');
281
282        if ($isDirectory && '/' !== $normalizedPathSpec) {
283            $normalizedPathSpec .= '/';
284        }
285
286        return $normalizedPathSpec;
287    }
288
289    /**
290     * Normalizes a path spec for deduplication and keep-in-export matching.
291     *
292     * Literal root paths are compared without leading slash differences, while
293     * pattern-based specs preserve their original anchoring semantics.
294     *
295     * @param string $pathSpec the raw path spec to normalize
296     *
297     * @return string the normalized deduplication key
298     */
299    private function normalizePathKey(string $pathSpec): string
300    {
301        $normalizedPathSpec = $this->normalizePathSpec($pathSpec);
302
303        if ($this->isLiteralPathSpec($normalizedPathSpec)) {
304            return ltrim(rtrim($normalizedPathSpec, '/'), '/');
305        }
306
307        return $normalizedPathSpec;
308    }
309
310    /**
311     * Determines whether a path spec is a literal path and not a glob pattern.
312     *
313     * @param string $pathSpec the normalized path spec to inspect
314     *
315     * @return bool true when the path spec is a literal path
316     */
317    private function isLiteralPathSpec(string $pathSpec): bool
318    {
319        return ! str_contains($pathSpec, '*')
320            && ! str_contains($pathSpec, '?')
321            && ! str_contains($pathSpec, '[')
322            && ! str_contains($pathSpec, '{');
323    }
324}