Skip to content

Commit 1bbafa7

Browse files
committed
Validates negated export-ignore directives
1 parent 9083849 commit 1bbafa7

5 files changed

Lines changed: 256 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
77

88
## [Unreleased]
99

10+
## [v5.10.0] - 2026-05-18
11+
12+
### Added
13+
- Expanded the `validation` to also validate __negated__ export-ignore directives. Closes [#70](https://github.com/raphaelstolt/lean-package-validator/issues/70).
14+
1015
## [v5.9.1] - 2026-05-17
1116

1217
### Changed

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,11 @@ brew install lean-package-validator
7373

7474
Run the lean package validator CLI within or against a project/micro-package
7575
directory, and it will validate the [export-ignore](https://git-scm.com/book/en/v2/Customizing-Git-Git-Attributes#Exporting-Your-Repository) entries present in
76-
a `.gitattributes` file against a set of common repository artefacts. If no
77-
`.gitattributes` file is present it will suggest creating one.
76+
a `.gitattributes` file against a set of common repository artefacts.
77+
78+
It can handle __"normal"__ export-ignore directives as well as __negated__ export-ignore directives.
79+
80+
If no `.gitattributes` file is present it will suggest creating one.
7881

7982
``` bash
8083
lean-package-validator validate [<directory>]

src/Analyser.php

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,7 @@ public function getPresentExportIgnoresToPreserve(array $globPatternMatchingExpo
668668
&$globPatternMatchingExportIgnores,
669669
&$basenamedGlobPatternMatchingExportIgnores
670670
) {
671-
if (\strstr($line, 'export-ignore') && \strpos($line, '#') === false) {
671+
if (\strstr($line, 'export-ignore') && !\str_contains($line, '-export-ignore') && \strpos($line, '#') === false) {
672672
list($pattern, $void) = \explode('export-ignore', $line);
673673
if (\substr($pattern, 0, 1) === '/') {
674674
$pattern = \substr($pattern, 1);
@@ -1164,7 +1164,8 @@ public function getPresentExportIgnores(bool $applyGlob = true, string $gitattri
11641164

11651165
$exportIgnores = [];
11661166
\array_filter($gitattributesLines, function (string $line) use (&$exportIgnores, &$applyGlob) {
1167-
if (\strstr($line, 'export-ignore', true)) {
1167+
$before = \strstr($line, 'export-ignore', true);
1168+
if ($before !== false && $before !== '' && !\str_ends_with(\rtrim((string) $before), '-')) {
11681169
list($line, $void) = \explode('export-ignore', $line);
11691170
if ($applyGlob) {
11701171
if ($this->patternHasMatch(\trim($line))) {
@@ -1231,8 +1232,112 @@ private function getByDirectoriesToFilesExportIgnoreArtifacts(array $artifacts):
12311232
return \array_merge($directories, $files);
12321233
}
12331234

1235+
public function usesNegatedExportIgnoreStrategy(string $gitattributesContent = ''): bool
1236+
{
1237+
if ($gitattributesContent === '') {
1238+
if ($this->hasGitattributesFile() === false) {
1239+
return false;
1240+
}
1241+
$gitattributesContent = (string) \file_get_contents($this->gitattributesFile);
1242+
}
1243+
1244+
$lines = \preg_split('/\\r\\n|\\r|\\n/', $gitattributesContent) ?: [];
1245+
1246+
foreach ($lines as $line) {
1247+
if (\trim($line) === '* export-ignore') {
1248+
return true;
1249+
}
1250+
}
1251+
1252+
return false;
1253+
}
1254+
1255+
/**
1256+
* @param bool $applyGlob
1257+
* @param string $gitattributesContent
1258+
* @return array<int, string>
1259+
*/
1260+
public function getPresentNegatedExportIgnores(bool $applyGlob = true, string $gitattributesContent = ''): array
1261+
{
1262+
if ($this->hasGitattributesFile() === false && $gitattributesContent === '') {
1263+
return [];
1264+
}
1265+
1266+
if ($gitattributesContent === '') {
1267+
$gitattributesContent = (string) \file_get_contents($this->gitattributesFile);
1268+
}
1269+
1270+
$lines = \preg_split('/\\r\\n|\\r|\\n/', $gitattributesContent) ?: [];
1271+
1272+
$negatedIgnores = [];
1273+
1274+
foreach ($lines as $line) {
1275+
if (!\str_contains($line, '-export-ignore') || \str_starts_with(\ltrim($line), '#')) {
1276+
continue;
1277+
}
1278+
1279+
[$pattern] = \explode('-export-ignore', $line, 2);
1280+
$pattern = \ltrim(\trim($pattern), '/');
1281+
1282+
if ($pattern === '') {
1283+
continue;
1284+
}
1285+
1286+
if ($applyGlob) {
1287+
if ($this->patternHasMatch($pattern)) {
1288+
$negatedIgnores[] = $pattern;
1289+
}
1290+
} else {
1291+
$negatedIgnores[] = $pattern;
1292+
}
1293+
}
1294+
1295+
if ($this->isStrictOrderComparisonEnabled() === false) {
1296+
\sort($negatedIgnores, SORT_STRING | SORT_FLAG_CASE);
1297+
}
1298+
1299+
return \array_unique($negatedIgnores);
1300+
}
1301+
1302+
public function hasCompleteNegatedExportIgnores(): bool
1303+
{
1304+
if ($this->hasGitattributesFile() === false) {
1305+
return false;
1306+
}
1307+
1308+
if ($this->usesNegatedExportIgnoreStrategy() === false) {
1309+
return false;
1310+
}
1311+
1312+
$content = (string) \file_get_contents($this->gitattributesFile);
1313+
1314+
if (\preg_match("/(\*\h*)(text\h*)(=\h*auto)/", $content)) {
1315+
$this->hasTextAutoConfiguration = true;
1316+
}
1317+
1318+
$matchingNegatedIgnores = $this->getPresentNegatedExportIgnores(true);
1319+
1320+
if ($matchingNegatedIgnores === []) {
1321+
return false;
1322+
}
1323+
1324+
if ($this->isStaleExportIgnoresComparisonEnabled()) {
1325+
$allNegatedIgnores = $this->getPresentNegatedExportIgnores(false);
1326+
$staleNegatedIgnores = \array_diff($allNegatedIgnores, $matchingNegatedIgnores);
1327+
if ($staleNegatedIgnores !== []) {
1328+
return false;
1329+
}
1330+
}
1331+
1332+
return true;
1333+
}
1334+
12341335
public function hasCompleteExportIgnoresFromString(string $gitattributesContent): bool
12351336
{
1337+
if ($this->usesNegatedExportIgnoreStrategy($gitattributesContent)) {
1338+
return $this->getPresentNegatedExportIgnores(true, $gitattributesContent) !== [];
1339+
}
1340+
12361341
$expectedExportIgnores = $this->collectExpectedExportIgnores();
12371342
$presentExportIgnores = $this->getPresentExportIgnores(true, $gitattributesContent);
12381343

@@ -1245,6 +1350,10 @@ public function hasCompleteExportIgnoresFromString(string $gitattributesContent)
12451350
*/
12461351
public function hasCompleteExportIgnores(): bool
12471352
{
1353+
if ($this->usesNegatedExportIgnoreStrategy()) {
1354+
return $this->hasCompleteNegatedExportIgnores();
1355+
}
1356+
12481357
$expectedExportIgnores = $this->collectExpectedExportIgnores();
12491358

12501359
if ($expectedExportIgnores === [] || $this->hasGitattributesFile() === false) {

src/Commands/ValidateCommand.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int
587587
$output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE);
588588

589589
if ($this->analyser->hasCompleteExportIgnores() === false) {
590+
if ($this->analyser->usesNegatedExportIgnoreStrategy()) {
591+
$message = 'The present .gitattributes file is considered <error>invalid</error>: '
592+
. 'It uses the negated export-ignore strategy but has no or only stale -export-ignore re-inclusions.';
593+
if ($isAgenticRun) {
594+
$this->writeAgenticOutput($output, $this->getName(), false, \strip_tags($message), ['valid' => false]);
595+
} else {
596+
$output->writeln($message);
597+
}
598+
return Command::FAILURE;
599+
}
600+
590601
$verboseOutput = "+ Gathering expected .gitattribute content.";
591602
$output->writeln($verboseOutput, OutputInterface::VERBOSITY_VERBOSE);
592603

tests/Commands/ValidateCommandTest.php

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2558,4 +2558,128 @@ public function outputsJsonOnMissingGitattributesFileWhenAgenticRunOptionIsSet()
25582558
$this->assertFalse($json['valid']);
25592559
$this->assertArrayHasKey('expected_gitattributes_content', $json);
25602560
}
2561+
2562+
#[Test]
2563+
public function validatesNegatedExportIgnoreDirectivesAsValid(): void
2564+
{
2565+
$this->createTemporaryFiles(['composer.json', 'LICENSE.md'], ['src', 'bin']);
2566+
2567+
$gitattributesContent = <<<CONTENT
2568+
# Line-ending normalization for text files.
2569+
* text=auto
2570+
2571+
# PHP-aware hunk headers in `git diff`.
2572+
*.php diff=php
2573+
2574+
# Binary files keep their bytes.
2575+
*.gpg binary
2576+
*.phar binary
2577+
2578+
# Default: nothing is included in the dist archive of the Composer package.
2579+
# Re-include only the files that actually belong to the distributed package below.
2580+
* export-ignore
2581+
2582+
# Top-level metadata.
2583+
composer.json -export-ignore
2584+
LICENSE.md -export-ignore
2585+
2586+
# Source code and CLI entry point.
2587+
/src -export-ignore
2588+
/bin -export-ignore
2589+
CONTENT;
2590+
2591+
$this->createTemporaryGitattributesFile($gitattributesContent);
2592+
2593+
$command = $this->application->find('validate');
2594+
2595+
TestCommand::for($command)
2596+
->addArgument(WORKING_DIRECTORY)
2597+
->execute()
2598+
->assertOutputContains('The present .gitattributes file is considered valid.')
2599+
->assertSuccessful();
2600+
}
2601+
2602+
#[Test]
2603+
public function validatesIncompleteNegatedExportIgnoreDirectivesAsInvalid(): void
2604+
{
2605+
$this->createTemporaryFiles(['composer.json'], ['src']);
2606+
2607+
$gitattributesContent = <<<CONTENT
2608+
# Default: nothing is included in the dist archive of the Composer package.
2609+
* export-ignore
2610+
CONTENT;
2611+
2612+
$this->createTemporaryGitattributesFile($gitattributesContent);
2613+
2614+
$command = $this->application->find('validate');
2615+
2616+
TestCommand::for($command)
2617+
->addArgument(WORKING_DIRECTORY)
2618+
->execute()
2619+
->assertOutputContains('It uses the negated export-ignore strategy but has no or only stale -export-ignore re-inclusions.')
2620+
->assertFaulty();
2621+
}
2622+
2623+
#[Test]
2624+
#[RunInSeparateProcess]
2625+
public function validatesNegatedExportIgnoreDirectivesFromStdinAsValid(): void
2626+
{
2627+
$this->createTemporaryFiles(['composer.json', 'LICENSE.md'], ['src']);
2628+
2629+
$gitattributesContent = <<<CONTENT
2630+
* export-ignore
2631+
2632+
/composer.json -export-ignore
2633+
/LICENSE.md -export-ignore
2634+
/src -export-ignore
2635+
CONTENT;
2636+
2637+
$this->createTemporaryGitattributesFile($gitattributesContent);
2638+
2639+
$application = new Application();
2640+
$fakeInputReader = new FakeInputReader();
2641+
$fakeInputReader->set($gitattributesContent);
2642+
2643+
$analyserCommand = new ValidateCommand(
2644+
new Analyser(new Finder(new PhpPreset())),
2645+
new Validator(new Archive($this->temporaryDirectory)),
2646+
$fakeInputReader
2647+
);
2648+
2649+
$application->addCommand($analyserCommand);
2650+
$command = $application->find('validate');
2651+
2652+
TestCommand::for($command)
2653+
->addOption('stdin-input')
2654+
->execute()
2655+
->assertOutputContains('The provided .gitattributes content is considered valid.')
2656+
->assertSuccessful();
2657+
}
2658+
2659+
#[Test]
2660+
public function detectsStaleNegatedExportIgnoreDirectivesAsInvalid(): void
2661+
{
2662+
$this->createTemporaryFiles(['composer.json', 'LICENSE.md'], ['src']);
2663+
2664+
$gitattributesContent = <<<CONTENT
2665+
* export-ignore
2666+
2667+
composer.json -export-ignore
2668+
LICENSE.md -export-ignore
2669+
NON_EXISTENT.md -export-ignore
2670+
/src -export-ignore
2671+
/stale-non-existent-dir -export-ignore
2672+
CONTENT;
2673+
2674+
$this->createTemporaryGitattributesFile($gitattributesContent);
2675+
2676+
$command = $this->application->find('validate');
2677+
2678+
TestCommand::for($command)
2679+
->addArgument(WORKING_DIRECTORY)
2680+
->addOption('report-stale-export-ignores')
2681+
->execute()
2682+
->assertOutputContains('It uses the negated export-ignore strategy but has no or only stale -export-ignore re-inclusions.')
2683+
->assertFaulty();
2684+
}
25612685
}

0 commit comments

Comments
 (0)