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
10 changes: 10 additions & 0 deletions features/distignore.feature
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,16 @@ Feature: Generate a distribution archive of a project with .distignore
When I run `sh -c 'i=1; while [ $i -le 50 ]; do touch foo/node_modules/package3/file$i.js; i=$((i+1)); done'`
Then STDERR should be empty

When I try `wp dist-archive foo foo-debug.zip --debug=dist-archive`
Then STDERR should contain:
"""
Skipping descent into ignored directory: /node_modules
"""
And STDERR should not contain:
"""
/node_modules/package1
"""

When I run `wp dist-archive foo`
Then STDOUT should match /^Success: Created foo\.[^ ]+ \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/
And STDERR should be empty
Expand Down
87 changes: 55 additions & 32 deletions src/Distignore_Filter_Iterator.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ class Distignore_Filter_Iterator extends RecursiveFilterIterator {
*/
private $visited_paths = [];

/**
* Negation rules (lines with a leading `!`) from the `.distignore` file, without the `!`.
* Null until first read.
*
* @var string[]|null
*/
private $negation_rules;

/**
* Constructor.
*
Expand Down Expand Up @@ -140,12 +148,12 @@ public function isPathIgnoredCached( $relative_filepath ) {

/**
* Check whether the current element has children that should be recursed into.
* We return false for certain ignored directories to prevent descending into them.
* We return false for ignored directories to prevent descending into them.
*
* This optimization only applies to directories that appear to be "leaf" ignore
* patterns (simple directory names without wildcards), to safely handle cases
* like `node_modules` while still correctly processing complex patterns with
* negations like `frontend/*` with `!/frontend/build/`.
* An ignored directory's contents cannot appear in the archive, so there is no need
* to traverse them — except when a negation rule (leading `!`) might re-include a
* path inside it, e.g. `frontend/*` with `!/frontend/build/`, where the checker
* reports `/frontend` itself as ignored but `/frontend/build` is not.
*
* @return bool True if we should descend into this directory, false otherwise.
*/
Expand Down Expand Up @@ -181,44 +189,58 @@ public function hasChildren() {
$relative_filepath = $this->getRelativeFilePath( $item );

try {
$is_ignored = $this->isPathIgnoredCached( $relative_filepath );

if ( ! $is_ignored ) {
// Not ignored, so descend.
return true;
}

// Directory is ignored. Check if it's safe to skip descent.
// We only skip for single-level directories (no slashes except leading/trailing)
// to avoid issues with wildcard patterns and negations.
$path_parts = explode( '/', trim( $relative_filepath, '/' ) );
if ( count( $path_parts ) === 1 ) {
// This is a top-level ignored directory like "/node_modules" or "/.git".
// It's likely safe to skip descent as these are typically simple patterns.
// However, we still need to be conservative. Let's check if a child would be ignored.
// We use 'test' as a probe filename to check if children would be ignored.
// The actual name doesn't matter; we just need to verify the pattern applies to children.
$test_child = $relative_filepath . '/test';
try {
$child_ignored = $this->isPathIgnoredCached( $test_child );
if ( $child_ignored ) {
// Child is also ignored, safe to skip descent.
return false;
}
} catch ( \Inmarelibero\GitIgnoreChecker\Exception\InvalidArgumentException $exception ) {
// On error, descend to be safe.
if ( $this->isPathIgnoredCached( $relative_filepath ) ) {
if ( $this->mightContainNegatedPath( $relative_filepath ) ) {
return true;
}
WP_CLI::debug( "Skipping descent into ignored directory: {$relative_filepath}", 'dist-archive' );
return false;
}

// For nested directories or if test shows children might not be ignored, descend.
return true;
} catch ( \Inmarelibero\GitIgnoreChecker\Exception\InvalidArgumentException $exception ) {
// If there's an error checking, allow descending (error will be handled in get_file_list).
WP_CLI::debug( "Error checking is path ignored for {$relative_filepath}: " . $exception->getMessage(), 'dist-archive' );
return true;
}
}

/**
* Check whether a `.distignore` negation rule (leading `!`) might re-include a path
* inside the given ignored directory, meaning it must still be descended into.
*
* Anchored negation patterns without wildcards are compared by path prefix; unanchored
* or wildcard patterns could match anywhere, so they conservatively require descent.
*
* @param string $relative_dirpath Relative path of the ignored directory.
*/
private function mightContainNegatedPath( string $relative_dirpath ): bool {
if ( null === $this->negation_rules ) {
$this->negation_rules = [];
$distignore_filepath = $this->source_dir_path . '/.distignore';
if ( file_exists( $distignore_filepath ) ) {
foreach ( explode( "\n", (string) file_get_contents( $distignore_filepath ) ) as $line ) {
$line = trim( $line );
if ( '' !== $line && '!' === $line[0] ) {
$this->negation_rules[] = substr( $line, 1 );
}
}
}
}

foreach ( $this->negation_rules as $rule ) {
$rule = rtrim( $rule, '/' );
if ( '' === $rule || '/' !== $rule[0] || false !== strpbrk( $rule, '*?[' ) ) {
return true;
}
if ( 0 === strpos( $rule, $relative_dirpath . '/' ) ) {
return true;
}
}

return false;
}

/**
* Return the inner iterator's children wrapped in this filter.
*
Expand All @@ -243,6 +265,7 @@ public function getChildren() {
$child->ignored_cache = &$this->ignored_cache;
$child->error_items = &$this->error_items;
$child->visited_paths = &$this->visited_paths;
$child->negation_rules = &$this->negation_rules;
return $child;
}

Expand Down
41 changes: 41 additions & 0 deletions tests/Distignore_Filter_Iterator_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
use WP_CLI\Tests\TestCase;
use Inmarelibero\GitIgnoreChecker\GitIgnoreChecker;

require_once __DIR__ . '/Recording_Distignore_Filter_Iterator.php';

class Distignore_Filter_Iterator_Test extends TestCase {

/**
Expand Down Expand Up @@ -239,6 +241,45 @@ public function test_nested_directory_filtering() {
$this->assertContains( '/src/components/widget.php', $files );
}

/**
* Test that iteration never descends into ignored directories.
*
* The yielded-items assertions in test_has_children_prevents_descent pass even when
* descent happens (accept() filters each child individually), so this test records
* every path checked against the ignore rules to prove the contents of ignored
* directories are never visited at all.
*
* @see https://github.com/wp-cli/dist-archive-command/issues/134
*/
public function test_does_not_descend_into_ignored_directories(): void {
mkdir( $this->temp_dir . '/node_modules' );
mkdir( $this->temp_dir . '/node_modules/package1' );
file_put_contents( $this->temp_dir . '/node_modules/package1/file1.js', 'test' );
file_put_contents( $this->temp_dir . '/node_modules/file2.js', 'test' );
mkdir( $this->temp_dir . '/sub' );
mkdir( $this->temp_dir . '/sub/node_modules' );
file_put_contents( $this->temp_dir . '/sub/node_modules/deep.js', 'test' );
file_put_contents( $this->temp_dir . '/index.php', '<?php' );
file_put_contents( $this->temp_dir . '/.distignore', "node_modules\n" );

Recording_Distignore_Filter_Iterator::$checked_paths = [];

$checker = new GitIgnoreChecker( $this->temp_dir, '.distignore' );
$directory_iter = new RecursiveDirectoryIterator( $this->temp_dir, RecursiveDirectoryIterator::SKIP_DOTS );
$filter_iter = new Recording_Distignore_Filter_Iterator( $directory_iter, $checker, $this->temp_dir );
$recursive_iter = new RecursiveIteratorIterator( $filter_iter, RecursiveIteratorIterator::SELF_FIRST );

iterator_to_array( $recursive_iter );

$checked_paths = Recording_Distignore_Filter_Iterator::$checked_paths;

$this->assertContains( '/node_modules', $checked_paths );
$this->assertContains( '/sub/node_modules', $checked_paths );
foreach ( $checked_paths as $checked_path ) {
$this->assertStringNotContainsString( 'node_modules/', $checked_path, 'Paths inside an ignored directory should never be checked' );
}
}

/**
* Test that children share the same cache and excluded files arrays.
*/
Expand Down
24 changes: 24 additions & 0 deletions tests/Recording_Distignore_Filter_Iterator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/**
* Records every path checked against the ignore rules, including by the child iterators
* that getChildren() creates via `new static`, since the property is static.
*/
class Recording_Distignore_Filter_Iterator extends Distignore_Filter_Iterator {

/**
* Relative paths passed to isPathIgnoredCached, in order.
*
* @var string[]
*/
public static $checked_paths = [];

/**
* @param string $relative_filepath Relative file path to check.
* @return bool True if the path is ignored, false otherwise.
*/
public function isPathIgnoredCached( $relative_filepath ): bool {
self::$checked_paths[] = $relative_filepath;
return parent::isPathIgnoredCached( $relative_filepath );
}
}