Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.33% covered (success)
98.33%
59 / 60
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Merger
98.33% covered (success)
98.33%
59 / 60
87.50% covered (warning)
87.50%
7 / 8
32
0.00% covered (danger)
0.00%
0 / 1
 merge
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
8
 parseExistingLines
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 keepInExportLookup
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 isKeptPath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 extractExportIgnorePathSpec
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 normalizePathSpec
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 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 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\GitAttributes;
21
22use function Safe\preg_split;
23use function Safe\preg_replace;
24use function Safe\preg_match;
25
26/**
27 * Merges .gitattributes content with generated export-ignore rules.
28 *
29 * This class preserves existing custom entries while adding missing
30 * export-ignore defaults before consumer rules. Existing rules MUST retain their
31 * order and repetitions so later unset and unspecified attributes keep precedence.
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. Prepends missing generated defaults before consumer overrides
41     * 3. Omits generated duplicates without deduplicating existing rules
42     * 4. Removes simple positive rules for explicitly kept paths
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        $existingLines = [];
53        $existingRules = [];
54        $keptExportLookup = $this->keepInExportLookup($keepInExportPaths);
55
56        foreach ($this->parseExistingLines($existingContent) as $line) {
57            $pathSpec = $this->extractExportIgnorePathSpec($line);
58
59            if (null !== $pathSpec) {
60                $pathKey = $this->normalizePathKey($pathSpec);
61
62                if ($this->isKeptPath($pathKey, $keptExportLookup)) {
63                    continue;
64                }
65
66                $existingRules[$pathKey] = true;
67            }
68
69            $existingLines[] = $line;
70        }
71
72        $generatedLines = [];
73
74        foreach ($exportIgnoreEntries as $entry) {
75            $entry = trim($entry);
76            $pathKey = $this->normalizePathKey($entry);
77
78            if ('' === $entry || $this->isKeptPath($pathKey, $keptExportLookup) || isset($existingRules[$pathKey])) {
79                continue;
80            }
81
82            $generatedLines[] = $entry . ' export-ignore';
83            $existingRules[$pathKey] = true;
84        }
85
86        return implode("\n", [...$generatedLines, ...$existingLines]);
87    }
88
89    /**
90     * Parses the raw .gitattributes content into trimmed non-empty lines.
91     *
92     * @param string $content The full .gitattributes content.
93     *
94     * @return list<string> the non-empty lines from the file
95     */
96    private function parseExistingLines(string $content): array
97    {
98        if ('' === $content) {
99            return [];
100        }
101
102        $lines = [];
103
104        foreach (preg_split('/\R/', $content) as $line) {
105            $trimmedLine = trim((string) $line);
106
107            if ('' === $trimmedLine) {
108                continue;
109            }
110
111            $lines[] = $trimmedLine;
112        }
113
114        return $lines;
115    }
116
117    /**
118     * Builds a lookup table for paths that MUST stay in the exported archive.
119     *
120     * @param list<string> $keepInExportPaths the configured keep-in-export paths
121     *
122     * @return array<string, true> the normalized path lookup
123     */
124    private function keepInExportLookup(array $keepInExportPaths): array
125    {
126        $lookup = [];
127
128        foreach ($keepInExportPaths as $path) {
129            $normalizedPath = $this->normalizePathKey($path);
130
131            if ('' === $normalizedPath) {
132                continue;
133            }
134
135            $lookup[$normalizedPath] = true;
136        }
137
138        return $lookup;
139    }
140
141    /**
142     * @param string $pathKey
143     * @param array<string, true> $keptExportLookup
144     */
145    private function isKeptPath(string $pathKey, array $keptExportLookup): bool
146    {
147        foreach (array_keys($keptExportLookup) as $keptPath) {
148            if ($pathKey === $keptPath || str_starts_with($pathKey . '/', $keptPath . '/')) {
149                return true;
150            }
151        }
152
153        return false;
154    }
155
156    /**
157     * Extracts the path spec from a simple export-ignore line.
158     *
159     * @param string $line the line to inspect
160     *
161     * @return string|null the extracted path spec when the line is a simple export-ignore rule
162     */
163    private function extractExportIgnorePathSpec(string $line): ?string
164    {
165        if (1 !== preg_match('/^(\S+)\s+export-ignore$/', $line, $matches)) {
166            return null;
167        }
168
169        return $matches[1];
170    }
171
172    /**
173     * Normalizes a gitattributes path spec for sorting.
174     *
175     * @param string $pathSpec the raw path spec to normalize
176     *
177     * @return string the normalized path spec
178     */
179    private function normalizePathSpec(string $pathSpec): string
180    {
181        $trimmedPathSpec = trim($pathSpec);
182
183        if ('' === $trimmedPathSpec) {
184            return '';
185        }
186
187        $isDirectory = str_ends_with($trimmedPathSpec, '/');
188        $normalizedPathSpec = preg_replace('#/+#', '/', '/' . ltrim($trimmedPathSpec, '/')) ?? $trimmedPathSpec;
189        $normalizedPathSpec = '/' === $normalizedPathSpec ? $normalizedPathSpec : rtrim($normalizedPathSpec, '/');
190
191        if ($isDirectory && '/' !== $normalizedPathSpec) {
192            $normalizedPathSpec .= '/';
193        }
194
195        return $normalizedPathSpec;
196    }
197
198    /**
199     * Normalizes a path spec for deduplication and keep-in-export matching.
200     *
201     * Literal root paths are compared without leading slash differences, while
202     * pattern-based specs preserve their original anchoring semantics.
203     *
204     * @param string $pathSpec the raw path spec to normalize
205     *
206     * @return string the normalized deduplication key
207     */
208    private function normalizePathKey(string $pathSpec): string
209    {
210        $normalizedPathSpec = $this->normalizePathSpec($pathSpec);
211
212        if ($this->isLiteralPathSpec($normalizedPathSpec)) {
213            return ltrim(rtrim($normalizedPathSpec, '/'), '/');
214        }
215
216        return trim($pathSpec);
217    }
218
219    /**
220     * Determines whether a path spec is a literal path and not a glob pattern.
221     *
222     * @param string $pathSpec the normalized path spec to inspect
223     *
224     * @return bool true when the path spec is a literal path
225     */
226    private function isLiteralPathSpec(string $pathSpec): bool
227    {
228        return ! str_contains($pathSpec, '*')
229            && ! str_contains($pathSpec, '?')
230            && ! str_contains($pathSpec, '[')
231            && ! str_contains($pathSpec, '{');
232    }
233}