Skip to content

Commit c732a5b

Browse files
authored
Merge pull request #297 from BrianHenryIE/ignore-git-gitignore-gitattributes
Skip .git, .gitignore and .gitattributes export-ignore files when enumerating
2 parents 49dc363 + 151bbf8 commit c732a5b

11 files changed

Lines changed: 602 additions & 1 deletion

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ Strauss potentially requires zero configuration, but likely you'll want to custo
183183
"namespace_replacement_patterns" : {
184184
},
185185
"delete_vendor_packages": false,
186-
"delete_vendor_files": false
186+
"delete_vendor_files": false,
187+
"exclude_git_files": true
187188
}
188189
},
189190
```
@@ -205,6 +206,7 @@ The following configuration is default:
205206
- `update_call_sites`: `false`. This can be `true`, `false` or an `array` of directories/filepaths. When set to `true` it defaults to the directories and files in the project's `autoload` key. The PHP files and directories' PHP files will be updated where they call the prefixed classes.
206207
- `include_root_autoload`: `false` is a boolean flag to indicate whether Strauss should include the root autoload section of your project when creating its autoloader. It is false by default. Enabling this option will allow you to require only the Strauss autoloader in your project. Note that conflicts may occur if your project enables this option, requires both the Composer and Strauss autoloaders, and uses `files` autoloading.
207208
- `optimize_autoloader`: `true` is a boolean flag to indicate whether Strauss should force optimized/classmap-authoritative autoload generation. Set it to `false` to still regenerate autoload files without authoritative mode.
209+
- `exclude_git_files`: `true` is a boolean flag to indicate whether Strauss should skip files that would not be part of a package's distributed archive when enumerating files to copy: the `.git` directory, files matched by the package's `.gitignore`, and files marked `export-ignore` in the package's `.gitattributes` (mirroring `git archive` / Composer dist behaviour). Set it to `false` to copy every file found in the package directory. This is mostly useful for symlinked packages during local development.
208210

209211
To disable optimized/classmap-authoritative Composer autoload generation:
210212

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"composer/class-map-generator": "^1.6.0",
2323
"composer/composer": "^2.10",
2424
"elazar/flystream": "^0.6.0 || ^1.4.0",
25+
"inmarelibero/gitignore-checker": "^1.0",
2526
"json-mapper/json-mapper": "^2.0.0",
2627
"league/flysystem": "^2.5.0 || ^3.33.0",
2728
"league/flysystem-memory": "*",

src/Composer/Extra/StraussConfig.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,12 @@ class StraussConfig implements
208208
*/
209209
protected bool $optimizeAutoloader = true;
210210

211+
/**
212+
* Should `.git`, `.gitignore`-matched and `.gitattributes export-ignore` files be skipped when
213+
* enumerating each package's files (mimicking `git archive` / Composer dist behaviour)?
214+
*/
215+
protected bool $excludeGitFiles = true;
216+
211217
/**
212218
* Read any existing Mozart config.
213219
* Overwrite it with any Strauss config.
@@ -700,6 +706,22 @@ public function setDeleteVendorPackages(bool $deleteVendorPackages): void
700706
$this->deleteVendorPackages = $deleteVendorPackages;
701707
}
702708

709+
/**
710+
* Should exclude `.git` directory and read and follow `.gitignore` and `.gitattributes` files. Mostly relevant for local symlinked packages.
711+
*/
712+
public function getExcludeGitFiles(): bool
713+
{
714+
return $this->excludeGitFiles;
715+
}
716+
717+
/**
718+
* Should Strauss read and respect `.gitignore` and `.gitattributes` files and exclude `.git` directory. Packagist packages have already respected these.
719+
*/
720+
public function setExcludeGitFiles(bool $excludeGitFiles): void
721+
{
722+
$this->excludeGitFiles = $excludeGitFiles;
723+
}
724+
703725
/**
704726
* @return string[]
705727
*/

src/Config/FileEnumeratorConfig.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,10 @@ public function getExcludePackagesFromCopy(): array;
1919

2020
/** @return string[] */
2121
public function getExcludeFilePatternsFromCopy(): array;
22+
23+
/**
24+
* Whether to skip `.git`, `.gitignore`-matched and `.gitattributes`.`[].export-ignore` files when
25+
* enumerating each package's files.
26+
*/
27+
public function getExcludeGitFiles(): bool;
2228
}

src/Helpers/GitAttributes.php

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

src/Pipeline/FileEnumerator.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
use BrianHenryIE\Strauss\Files\File;
1212
use BrianHenryIE\Strauss\Files\FileWithDependency;
1313
use BrianHenryIE\Strauss\Helpers\FileSystem;
14+
use BrianHenryIE\Strauss\Helpers\GitAttributes;
15+
use Inmarelibero\GitIgnoreChecker\Exception\GitIgnoreCherkerException;
16+
use Inmarelibero\GitIgnoreChecker\GitIgnoreChecker;
1417
use League\Flysystem\FilesystemException;
1518
use Psr\Log\LoggerAwareTrait;
1619
use Psr\Log\LoggerInterface;
@@ -67,6 +70,10 @@ public function compileFileListForPaths(array $paths, ?ComposerPackage $dependen
6770
{
6871
$absoluteFilePaths = $this->filesystem->findAllFilesAbsolutePaths($paths);
6972

73+
if ($this->config->getExcludeGitFiles()) {
74+
$absoluteFilePaths = $this->excludeGitFiles($paths, $absoluteFilePaths);
75+
}
76+
7077
foreach ($absoluteFilePaths as $sourceAbsolutePath) {
7178
$this->addFile($sourceAbsolutePath, $dependency);
7279
}
@@ -75,6 +82,105 @@ public function compileFileListForPaths(array $paths, ?ComposerPackage $dependen
7582
return $this->discoveredFiles;
7683
}
7784

85+
/**
86+
* Remove files which Git would not include in the package's distributed archive:
87+
* the `.git` directory, files matched by `.gitignore`, and files marked `export-ignore`
88+
* in `.gitattributes`. Each base path is treated as its own repository root.
89+
*
90+
* @param string[] $basePaths
91+
* @param string[] $absoluteFilePaths
92+
*
93+
* @return string[]
94+
* @throws FilesystemException
95+
*/
96+
protected function excludeGitFiles(array $basePaths, array $absoluteFilePaths): array
97+
{
98+
/** @var array<string, array{gitignore?:GitIgnoreChecker, gitattributes?:GitAttributes}> $repositories */
99+
$repositories = [];
100+
foreach ($basePaths as $basePath) {
101+
if (!$this->filesystem->directoryExists($basePath)) {
102+
continue;
103+
}
104+
105+
$normalizedBasePath = rtrim(FileSystem::normalizeDirSeparator($basePath), '/');
106+
107+
if ($this->filesystem->fileExists($normalizedBasePath . '/.gitignore')) {
108+
try {
109+
/**
110+
* TODO: use {@see FileSystem::prefixPath()} when #278 is merged.
111+
*/
112+
$gitIgnoreChecker = new GitIgnoreChecker('/' . $normalizedBasePath);
113+
$repositories[$normalizedBasePath][ 'gitignore'] = $gitIgnoreChecker;
114+
} catch (GitIgnoreCherkerException $e) {
115+
// e.g. when the path is not on the local filesystem (in-memory tests).
116+
$this->logger->debug("Could not read .gitignore at {path}: {message}", [
117+
'path' => $normalizedBasePath,
118+
'message' => $e->getMessage(),
119+
]);
120+
}
121+
}
122+
123+
if ($this->filesystem->fileExists($normalizedBasePath . '/.gitattributes')) {
124+
$repositories[$normalizedBasePath][ 'gitattributes'] = new GitAttributes($this->filesystem, $normalizedBasePath);
125+
}
126+
}
127+
128+
if (empty($repositories)) {
129+
return $absoluteFilePaths;
130+
}
131+
132+
$this->logger->info('Processing .gitignore/.gitattributes – checking ' . count($absoluteFilePaths) . ' files.');
133+
134+
return array_values(array_filter(
135+
$absoluteFilePaths,
136+
fn(string $sourceAbsolutePath): bool => !$this->isGitExcluded($sourceAbsolutePath, $repositories)
137+
));
138+
}
139+
140+
/**
141+
* @param array<string, array{gitignore?:GitIgnoreChecker, gitattributes?:GitAttributes}> $repositories
142+
*
143+
* @throws FilesystemException
144+
*/
145+
protected function isGitExcluded(string $sourceAbsolutePath, array $repositories): bool
146+
{
147+
foreach ($repositories as $basePath => $checkers) {
148+
$relativePath = $this->filesystem->getRelativePath($basePath, $sourceAbsolutePath);
149+
150+
// Not located within this repository root.
151+
if ($relativePath === '' || strpos($relativePath, '../') === 0) {
152+
continue;
153+
}
154+
155+
// The .git directory is never part of the distributed package.
156+
if ($relativePath === '.git' || strpos($relativePath, '.git/') === 0) {
157+
$this->logger->debug("Skipping .git file {path}", ['path' => $sourceAbsolutePath]);
158+
return true;
159+
}
160+
161+
if (isset($checkers['gitignore'])) {
162+
try {
163+
if ($checkers['gitignore']->isPathIgnored('/' . $relativePath)) {
164+
$this->logger->debug("Skipping .gitignore'd file {path}", ['path' => $sourceAbsolutePath]);
165+
return true;
166+
}
167+
} catch (GitIgnoreCherkerException $e) {
168+
$this->logger->debug("Could not check .gitignore for {path}: {message}", [
169+
'path' => $sourceAbsolutePath,
170+
'message' => $e->getMessage(),
171+
]);
172+
}
173+
}
174+
175+
if (isset($checkers['gitattributes']) && $checkers['gitattributes']->isExportIgnored($relativePath)) {
176+
$this->logger->debug("Skipping export-ignore file {path}", ['path' => $sourceAbsolutePath]);
177+
return true;
178+
}
179+
}
180+
181+
return false;
182+
}
183+
78184
/**
79185
* @param string $sourceAbsoluteFilepath
80186
* @param ?ComposerPackage $dependency

tests/Integration/FileEnumeratorIntegrationTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public function testBuildFileList(): void
6060

6161
$config = $this->createStub(StraussConfig::class);
6262
$config->method('getAbsoluteVendorDirectory')->willReturn($vendorDir);
63+
$config->method('getExcludeGitFiles')->willReturn(false);
6364

6465
$fileEnumerator = new FileEnumerator(
6566
$config,

0 commit comments

Comments
 (0)