Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changelog/git-rule-precedence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: fixed
type: patch
---

Preserve consumer rule ordering and overrides when synchronizing `.gitignore` and `.gitattributes`, including repeated patterns, export exceptions, and significant ignore-pattern whitespace. Add generated defaults before existing rules without reordering consumer exceptions.
7 changes: 6 additions & 1 deletion docs/commands/gitattributes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ Behavior
paths in exported archives.
- Respects ``extra.gitattributes.no-export-ignore`` as an alias.
- Preserves existing custom ``.gitattributes`` rules.
- Deduplicates equivalent entries and sorts them (directories before files, then alphabetically).
- Missing generated exclusions are prepended as defaults. Existing rules retain
order and repetitions, so later consumer ``-export-ignore`` or
``!export-ignore`` overrides remain effective. No-op and repeated merges retain
the same effective attributes.
- Explicit keep-in-export paths still remove matching simple positive exclusions;
they do not rewrite arbitrary compound attribute rules or glob exceptions.
- Uses CandidateProvider, ExistenceChecker, ExportIgnoreFilter, Merger,
Reader, and Writer components from the GitAttributes namespace.
- ``--dry-run`` and ``--check`` render a diff against the normalized managed
Expand Down
7 changes: 6 additions & 1 deletion docs/commands/gitignore.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,12 @@ Behavior

- Reads the canonical .gitignore from dev-tools and merges with the project's.
- By default, the source is the packaged .gitignore and the target is the project's root.
- Duplicates are removed and entries are sorted alphabetically.
- Canonical defaults precede consumer rules, so the last matching consumer rule
keeps Git precedence. Existing order, repetitions and pattern whitespace are
preserved; patterns are not sorted or globally deduplicated.
- An already present canonical prefix is not prepended again, making repeated
synchronization idempotent. Changed defaults are added before existing rules;
synchronization does not silently remove historical consumer rules.
- Uses the Reader, Merger, and Writer components from the GitIgnore namespace.
- ``--dry-run`` and ``--check`` render a diff against the normalized managed
result before deciding whether to write.
2 changes: 1 addition & 1 deletion src/Console/Command/GitIgnoreCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
* Provides functionality to merge and synchronize .gitignore files.
*
* This command merges the canonical .gitignore from dev-tools with the project's
* existing .gitignore, removing duplicates and sorting entries.
* existing .gitignore, preserving ordered consumer overrides.
*
* The command accepts two options: --source and --target to specify the paths
* to the canonical and project .gitignore files respectively.
Expand Down
152 changes: 22 additions & 130 deletions src/GitAttributes/Merger.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@
* Merges .gitattributes content with generated export-ignore rules.
*
* This class preserves existing custom entries while adding missing
* export-ignore rules for known candidate paths, deduplicates semantically
* equivalent entries, and sorts export-ignore rules with directories before
* files.
* export-ignore defaults before consumer rules. Existing rules MUST retain their
* order and repetitions so later unset and unspecified attributes keep precedence.
*/
final class Merger implements MergerInterface
{
Expand All @@ -38,9 +37,9 @@ final class Merger implements MergerInterface
*
* This method:
* 1. Preserves custom user-defined entries in their original order
* 2. Adds missing generated export-ignore entries for existing paths
* 3. Deduplicates entries using normalized path comparison
* 4. Sorts export-ignore entries with directories before files
* 2. Prepends missing generated defaults before consumer overrides
* 3. Omits generated duplicates without deduplicating existing rules
* 4. Removes simple positive rules for explicitly kept paths
*
* @param string $existingContent The raw .gitattributes content currently stored.
* @param list<string> $exportIgnoreEntries the export-ignore entries to manage
Expand All @@ -50,90 +49,41 @@ final class Merger implements MergerInterface
*/
public function merge(string $existingContent, array $exportIgnoreEntries, array $keepInExportPaths = []): string
{
$nonExportIgnoreLines = [];
$seenNonExportIgnoreLines = [];
$exportIgnoreLines = [];
$existingLines = [];
$existingRules = [];
$keptExportLookup = $this->keepInExportLookup($keepInExportPaths);
$generatedDirectoryLookup = $this->generatedDirectoryLookup($exportIgnoreEntries);

foreach ($this->parseExistingLines($existingContent) as $line) {
$normalizedLine = $this->normalizeLine($line);
$pathSpec = $this->extractExportIgnorePathSpec($line);

if ('' === $normalizedLine) {
continue;
}
if (null !== $pathSpec) {
$pathKey = $this->normalizePathKey($pathSpec);

$pathSpec = $this->extractExportIgnorePathSpec($normalizedLine);

if (null === $pathSpec) {
if (isset($seenNonExportIgnoreLines[$normalizedLine])) {
if ($this->isKeptPath($pathKey, $keptExportLookup)) {
continue;
}

$nonExportIgnoreLines[] = $normalizedLine;
$seenNonExportIgnoreLines[$normalizedLine] = true;

continue;
}

$pathKey = $this->normalizePathKey($pathSpec);
if ($this->isKeptPath($pathKey, $keptExportLookup)) {
continue;
}

if (isset($exportIgnoreLines[$pathKey])) {
continue;
$existingRules[$pathKey] = true;
}

$exportIgnoreLines[$pathKey] = [
'line' => $normalizedLine,
'sort_key' => $this->sortKey($pathSpec),
'is_directory' => str_ends_with($pathSpec, '/') || isset($generatedDirectoryLookup[$pathKey]),
];
$existingLines[] = $line;
}

foreach ($exportIgnoreEntries as $entry) {
$trimmedEntry = trim($entry);
$pathKey = $this->normalizePathKey($trimmedEntry);

if ($this->isKeptPath($pathKey, $keptExportLookup)) {
continue;
}
$generatedLines = [];

if (! isset($exportIgnoreLines[$pathKey])) {
$exportIgnoreLines[$pathKey] = [
'line' => $trimmedEntry . ' export-ignore',
'sort_key' => $this->sortKey($trimmedEntry),
'is_directory' => str_ends_with($trimmedEntry, '/'),
];
foreach ($exportIgnoreEntries as $entry) {
$entry = trim($entry);
$pathKey = $this->normalizePathKey($entry);

if ('' === $entry || $this->isKeptPath($pathKey, $keptExportLookup) || isset($existingRules[$pathKey])) {
continue;
}

$exportIgnoreLines[$pathKey]['is_directory'] = $exportIgnoreLines[$pathKey]['is_directory']
|| str_ends_with($trimmedEntry, '/');
$generatedLines[] = $entry . ' export-ignore';
$existingRules[$pathKey] = true;
}

$sortedExportIgnoreLines = array_values($exportIgnoreLines);

usort(
$sortedExportIgnoreLines,
static function (array $left, array $right): int {
if ($left['is_directory'] !== $right['is_directory']) {
return $left['is_directory'] ? -1 : 1;
}

$naturalOrder = strnatcasecmp($left['sort_key'], $right['sort_key']);

if (0 !== $naturalOrder) {
return $naturalOrder;
}

return strcmp($left['sort_key'], $right['sort_key']);
}
);

return implode("\n", [...$nonExportIgnoreLines, ...array_column($sortedExportIgnoreLines, 'line')]);
return implode("\n", [...$generatedLines, ...$existingLines]);
}

/**
Expand Down Expand Up @@ -203,52 +153,6 @@ private function isKeptPath(string $pathKey, array $keptExportLookup): bool
return false;
}

/**
* Builds a lookup table of generated directory candidates.
*
* @param list<string> $exportIgnoreEntries the generated export-ignore path list
*
* @return array<string, true> the normalized directory lookup
*/
private function generatedDirectoryLookup(array $exportIgnoreEntries): array
{
$lookup = [];

foreach ($exportIgnoreEntries as $entry) {
$trimmedEntry = trim($entry);

if (! str_ends_with($trimmedEntry, '/')) {
continue;
}

$lookup[$this->normalizePathKey($trimmedEntry)] = true;
}

return $lookup;
}

/**
* Normalizes a .gitattributes line for deterministic comparison and output.
*
* @param string $line the raw line to normalize
*
* @return string the normalized line
*/
private function normalizeLine(string $line): string
{
$trimmedLine = trim($line);

if ('' === $trimmedLine) {
return '';
}

if (str_starts_with($trimmedLine, '#')) {
return $trimmedLine;
}

return preg_replace('/(?<!\\\\)[ \t]+/', ' ', $trimmedLine) ?? $trimmedLine;
}

/**
* Extracts the path spec from a simple export-ignore line.
*
Expand All @@ -265,18 +169,6 @@ private function extractExportIgnorePathSpec(string $line): ?string
return $matches[1];
}

/**
* Builds the natural sort key for a path spec.
*
* @param string $pathSpec the raw path spec to normalize for sorting
*
* @return string the natural sort key
*/
private function sortKey(string $pathSpec): string
{
return ltrim($this->normalizePathSpec($pathSpec), '/');
}

/**
* Normalizes a gitattributes path spec for sorting.
*
Expand Down Expand Up @@ -321,7 +213,7 @@ private function normalizePathKey(string $pathSpec): string
return ltrim(rtrim($normalizedPathSpec, '/'), '/');
}

return $normalizedPathSpec;
return trim($pathSpec);
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/GitAttributes/MergerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
*
* This interface defines the contract for managing .gitattributes files,
* specifically handling the merging of canonical export-ignore rules with
* existing custom entries while removing obsolete generated markers and
* duplicate lines.
* existing custom entries. New defaults MUST precede consumer overrides;
* existing order and repeated rules MUST be retained.
*/
interface MergerInterface
{
Expand Down
6 changes: 3 additions & 3 deletions src/GitIgnore/GitIgnore.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,10 @@ public static function fromFile(string $gitignorePath): self

$entries = [];
foreach ($file as $line) {
$trimmed = trim($line ?: '');
$entry = false === $line ? '' : $line;

if ('' !== $trimmed) {
$entries[] = $trimmed;
if ('' !== trim($entry)) {
$entries[] = $entry;
}
}

Expand Down
83 changes: 25 additions & 58 deletions src/GitIgnore/Merger.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,82 +20,49 @@
namespace FastForward\DevTools\GitIgnore;

/**
* Merges, deduplicates, and sorts .gitignore entries.
* Merges ordered .gitignore rules with canonical defaults before consumer overrides.
*
* This service SHALL combine canonical and project-specific .gitignore
* definitions into a single normalized result. The resulting entry list MUST
* exclude blank lines and comment lines from the merged output, MUST remove
* duplicate entries, and MUST group directory entries before file entries.
* Directory and file groups SHALL be sorted independently in ascending string
* order to provide deterministic output.
* Existing patterns MUST retain their order, whitespace and repeated occurrences.
*/
final readonly class Merger implements MergerInterface
{
/**
* Initializes the merger with a classifier implementation.
*
* The classifier MUST be capable of determining whether a normalized
* .gitignore entry represents a directory or a file pattern. When no
* classifier is provided, a default Classifier instance SHALL be used.
*
* @param ClassifierInterface $classifier the classifier responsible for
* distinguishing directory entries
* from file entries during merging
* Retains the classifier constructor parameter for compatibility with consumers.
*/
public function __construct(
private ClassifierInterface $classifier = new Classifier()
) {}

/**
* Merges canonical and project .gitignore entries into a normalized result.
*
* The implementation MUST combine entries from both sources, MUST remove
* duplicates, and MUST ignore blank or commented entries after trimming.
* Entries identified as directories SHALL be collected separately from file
* entries. Each group MUST be sorted using string comparison, and directory
* entries MUST appear before file entries in the final result.
*
* The returned GitIgnore instance SHALL preserve the project path provided by
* the $project argument.
* Prepends canonical defaults once, allowing later consumer rules to override them.
*
* @param GitIgnoreInterface $canonical The canonical .gitignore source whose
* entries provide the shared baseline.
* @param GitIgnoreInterface $project The project-specific .gitignore source
* whose path MUST be preserved in the
* merged result.
*
* @return GitIgnoreInterface A new merged .gitignore representation containing
* normalized, deduplicated, and ordered entries.
* @param GitIgnoreInterface $canonical the canonical baseline
* @param GitIgnoreInterface $project the consumer rules and destination path
*/
public function merge(GitIgnoreInterface $canonical, GitIgnoreInterface $project): GitIgnoreInterface
{
$entries = array_unique(array_merge($canonical->entries(), $project->entries()));

$directories = [];
$files = [];

foreach ($entries as $entry) {
$trimmed = trim($entry);
if ('' === $trimmed) {
continue;
}

if (str_starts_with($trimmed, '#')) {
continue;
}
$canonicalEntries = $this->patterns($canonical->entries());
$projectEntries = $this->patterns($project->entries());

if ($this->classifier->isDirectory($trimmed)) {
$directories[] = $trimmed;
} else {
$files[] = $trimmed;
}
if (array_slice($projectEntries, 0, count($canonicalEntries)) === $canonicalEntries) {
return new GitIgnore($project->path(), $projectEntries);
}

sort($directories, \SORT_STRING);
sort($files, \SORT_STRING);

$mergedEntries = array_merge($directories, $files);
return new GitIgnore($project->path(), [...$canonicalEntries, ...$projectEntries]);
}

return new GitIgnore($project->path(), array_values($mergedEntries));
/**
* Removes only blank lines and comments, never normalizing pattern whitespace.
*
* @param list<string> $entries the raw rules
*
* @return list<string>
*/
private function patterns(array $entries): array
{
return array_values(array_filter(
$entries,
static fn(string $entry): bool => '' !== trim($entry) && ! str_starts_with($entry, '#'),
));
}
}
Loading
Loading