diff --git a/app/Models/Application.php b/app/Models/Application.php index 9f6dcb125..cfe4ba8db 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1556,40 +1556,185 @@ protected function buildGitCheckoutCommand($target): string return $command; } + private function parseWatchPaths($value) + { + if ($value) { + $watch_paths = collect(explode("\n", $value)) + ->map(function (string $path): string { + // Trim whitespace and remove leading slashes to normalize paths + $path = trim($path); + + return ltrim($path, '/'); + }) + ->filter(function (string $path): bool { + return strlen($path) > 0; + }); + + return trim($watch_paths->implode("\n")); + } + } + public function watchPaths(): Attribute { return Attribute::make( set: function ($value) { if ($value) { - return trim($value); + return $this->parseWatchPaths($value); } } ); } + public function matchWatchPaths(Collection $modified_files, ?Collection $watch_paths): Collection + { + return self::matchPaths($modified_files, $watch_paths); + } + + /** + * Static method to match paths against watch patterns with negation support + * Uses order-based matching: last matching pattern wins + */ + public static function matchPaths(Collection $modified_files, ?Collection $watch_paths): Collection + { + if (is_null($watch_paths) || $watch_paths->isEmpty()) { + return collect([]); + } + + return $modified_files->filter(function ($file) use ($watch_paths) { + $shouldInclude = null; // null means no patterns matched + + // Process patterns in order - last match wins + foreach ($watch_paths as $pattern) { + $pattern = trim($pattern); + if (empty($pattern)) { + continue; + } + + $isExclusion = str_starts_with($pattern, '!'); + $matchPattern = $isExclusion ? substr($pattern, 1) : $pattern; + + if (self::globMatch($matchPattern, $file)) { + // This pattern matches - it determines the current state + $shouldInclude = ! $isExclusion; + } + } + + // If no patterns matched and we only have exclusion patterns, include by default + if ($shouldInclude === null) { + // Check if we only have exclusion patterns + $hasInclusionPatterns = $watch_paths->contains(fn ($p) => ! str_starts_with(trim($p), '!')); + + return ! $hasInclusionPatterns; + } + + return $shouldInclude; + })->values(); + } + + /** + * Check if a path matches a glob pattern + * Supports: *, **, ?, [abc], [!abc] + */ + public static function globMatch(string $pattern, string $path): bool + { + $regex = self::globToRegex($pattern); + + return preg_match($regex, $path) === 1; + } + + /** + * Convert a glob pattern to a regular expression + */ + public static function globToRegex(string $pattern): string + { + $regex = ''; + $inGroup = false; + $chars = str_split($pattern); + $len = count($chars); + + for ($i = 0; $i < $len; $i++) { + $c = $chars[$i]; + + switch ($c) { + case '*': + // Check for ** + if ($i + 1 < $len && $chars[$i + 1] === '*') { + // ** matches any number of directories + $regex .= '.*'; + $i++; // Skip next * + // Skip optional / + if ($i + 1 < $len && $chars[$i + 1] === '/') { + $i++; + } + } else { + // * matches anything except / + $regex .= '[^/]*'; + } + break; + + case '?': + // ? matches any single character except / + $regex .= '[^/]'; + break; + + case '[': + // Character class + $inGroup = true; + $regex .= '['; + // Check for negation + if ($i + 1 < $len && ($chars[$i + 1] === '!' || $chars[$i + 1] === '^')) { + $regex .= '^'; + $i++; + } + break; + + case ']': + if ($inGroup) { + $inGroup = false; + $regex .= ']'; + } else { + $regex .= preg_quote($c, '#'); + } + break; + + case '.': + case '(': + case ')': + case '+': + case '{': + case '}': + case '$': + case '^': + case '|': + case '\\': + // Escape regex special characters + $regex .= '\\'.$c; + break; + + default: + $regex .= $c; + break; + } + } + + // Wrap in delimiters and anchors + return '#^'.$regex.'$#'; + } + public function isWatchPathsTriggered(Collection $modified_files): bool { if (is_null($this->watch_paths)) { return false; } - $watch_paths = collect(explode("\n", $this->watch_paths)) - ->map(function (string $path): string { - return trim($path); - }) - ->filter(function (string $path): bool { - return strlen($path) > 0; - }); + $this->watch_paths = $this->parseWatchPaths($this->watch_paths); + $this->save(); + $watch_paths = collect(explode("\n", $this->watch_paths)); // If no valid patterns after filtering, don't trigger if ($watch_paths->isEmpty()) { return false; } - - $matches = $modified_files->filter(function ($file) use ($watch_paths) { - return $watch_paths->contains(function ($glob) use ($file) { - return fnmatch($glob, $file); - }); - }); + $matches = $this->matchWatchPaths($modified_files, $watch_paths); return $matches->count() > 0; } diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 41ce78222..ba7d2edb0 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -271,7 +271,7 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @if ($this->application->is_github_based() && !$this->application->is_public_repository())
@@ -310,7 +310,7 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @if ($this->application->is_github_based() && !$this->application->is_public_repository())
diff --git a/tests/Unit/ApplicationWatchPathsTest.php b/tests/Unit/ApplicationWatchPathsTest.php index c90105b78..f675db43a 100644 --- a/tests/Unit/ApplicationWatchPathsTest.php +++ b/tests/Unit/ApplicationWatchPathsTest.php @@ -3,163 +3,377 @@ namespace Tests\Unit; use App\Models\Application; -use Illuminate\Support\Collection; -use Tests\TestCase; +use PHPUnit\Framework\TestCase; class ApplicationWatchPathsTest extends TestCase { + /** + * This matches the CURRENT (broken) behavior without negation support + * which is what the old Application.php had + */ + private function matchWatchPathsCurrentBehavior(array $changed_files, ?array $watch_paths): array + { + if (is_null($watch_paths) || empty($watch_paths)) { + return []; + } + + $matches = []; + foreach ($changed_files as $file) { + foreach ($watch_paths as $pattern) { + $pattern = trim($pattern); + if (empty($pattern)) { + continue; + } + // Old implementation just uses fnmatch directly + // This means !patterns are treated as literal strings + if (fnmatch($pattern, $file)) { + $matches[] = $file; + break; + } + } + } + + return $matches; + } + + /** + * Use the shared implementation from Application model + */ + private function matchWatchPaths(array $changed_files, ?array $watch_paths): array + { + $modifiedFiles = collect($changed_files); + $watchPaths = is_null($watch_paths) ? null : collect($watch_paths); + + $result = Application::matchPaths($modifiedFiles, $watchPaths); + + return $result->toArray(); + } + public function test_is_watch_paths_triggered_returns_false_when_watch_paths_is_null() { - $application = new Application(); - $application->watch_paths = null; + $changed_files = ['docker-compose.yml', 'README.md']; + $watch_paths = null; - $modified_files = collect(['docker-compose.yml', 'README.md']); - - $this->assertFalse($application->isWatchPathsTriggered($modified_files)); + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + $this->assertEmpty($matches); } public function test_is_watch_paths_triggered_with_exact_match() { - $application = new Application(); - $application->watch_paths = "docker-compose.yml\nDockerfile"; + $watch_paths = ['docker-compose.yml', 'Dockerfile']; - // Exact match should return true - $this->assertTrue($application->isWatchPathsTriggered(collect(['docker-compose.yml']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['Dockerfile']))); + // Exact match should return matches + $matches = $this->matchWatchPaths(['docker-compose.yml'], $watch_paths); + $this->assertCount(1, $matches); + $this->assertEquals(['docker-compose.yml'], $matches); - // Non-matching file should return false - $this->assertFalse($application->isWatchPathsTriggered(collect(['README.md']))); + $matches = $this->matchWatchPaths(['Dockerfile'], $watch_paths); + $this->assertCount(1, $matches); + $this->assertEquals(['Dockerfile'], $matches); + + // Non-matching file should return empty + $matches = $this->matchWatchPaths(['README.md'], $watch_paths); + $this->assertEmpty($matches); } public function test_is_watch_paths_triggered_with_wildcard_patterns() { - $application = new Application(); - $application->watch_paths = "*.yml\nsrc/**/*.php\nconfig/*"; + $watch_paths = ['*.yml', 'src/**/*.php', 'config/*']; // Wildcard matches - $this->assertTrue($application->isWatchPathsTriggered(collect(['docker-compose.yml']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['production.yml']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['src/Controllers/UserController.php']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['src/Models/User.php']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['config/app.php']))); + $this->assertNotEmpty($this->matchWatchPaths(['docker-compose.yml'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['production.yml'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['src/Controllers/UserController.php'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['src/Models/User.php'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['config/app.php'], $watch_paths)); // Non-matching files - $this->assertFalse($application->isWatchPathsTriggered(collect(['README.md']))); - $this->assertFalse($application->isWatchPathsTriggered(collect(['src/index.js']))); - $this->assertFalse($application->isWatchPathsTriggered(collect(['configurations/deep/file.php']))); + $this->assertEmpty($this->matchWatchPaths(['README.md'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['src/index.js'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['configurations/deep/file.php'], $watch_paths)); } public function test_is_watch_paths_triggered_with_multiple_files() { - $application = new Application(); - $application->watch_paths = "docker-compose.yml\n*.env"; + $watch_paths = ['docker-compose.yml', '*.env']; // At least one file matches - $modified_files = collect(['README.md', 'docker-compose.yml', 'package.json']); - $this->assertTrue($application->isWatchPathsTriggered($modified_files)); + $changed_files = ['README.md', 'docker-compose.yml', 'package.json']; + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + $this->assertNotEmpty($matches); + $this->assertContains('docker-compose.yml', $matches); // No files match - $modified_files = collect(['README.md', 'package.json', 'src/index.js']); - $this->assertFalse($application->isWatchPathsTriggered($modified_files)); + $changed_files = ['README.md', 'package.json', 'src/index.js']; + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + $this->assertEmpty($matches); } public function test_is_watch_paths_triggered_with_complex_patterns() { - $application = new Application(); // fnmatch doesn't support {a,b} syntax, so we need to use separate patterns - $application->watch_paths = "**/*.js\n**/*.jsx\n**/*.ts\n**/*.tsx"; + $watch_paths = ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx']; // JavaScript/TypeScript files should match - $this->assertTrue($application->isWatchPathsTriggered(collect(['src/index.js']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['components/Button.jsx']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['types/user.ts']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['pages/Home.tsx']))); + $this->assertNotEmpty($this->matchWatchPaths(['src/index.js'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['components/Button.jsx'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['types/user.ts'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['pages/Home.tsx'], $watch_paths)); // Deeply nested files should match - $this->assertTrue($application->isWatchPathsTriggered(collect(['src/components/ui/Button.tsx']))); + $this->assertNotEmpty($this->matchWatchPaths(['src/components/ui/Button.tsx'], $watch_paths)); // Non-matching files - $this->assertFalse($application->isWatchPathsTriggered(collect(['README.md']))); - $this->assertFalse($application->isWatchPathsTriggered(collect(['package.json']))); + $this->assertEmpty($this->matchWatchPaths(['README.md'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['package.json'], $watch_paths)); } public function test_is_watch_paths_triggered_with_question_mark_pattern() { - $application = new Application(); - $application->watch_paths = "test?.txt\nfile-?.yml"; + $watch_paths = ['test?.txt', 'file-?.yml']; // Single character wildcard matches - $this->assertTrue($application->isWatchPathsTriggered(collect(['test1.txt']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['testA.txt']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['file-1.yml']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['file-B.yml']))); + $this->assertNotEmpty($this->matchWatchPaths(['test1.txt'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['testA.txt'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['file-1.yml'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['file-B.yml'], $watch_paths)); // Non-matching files - $this->assertFalse($application->isWatchPathsTriggered(collect(['test.txt']))); - $this->assertFalse($application->isWatchPathsTriggered(collect(['test12.txt']))); - $this->assertFalse($application->isWatchPathsTriggered(collect(['file.yml']))); + $this->assertEmpty($this->matchWatchPaths(['test.txt'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['test12.txt'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['file.yml'], $watch_paths)); } public function test_is_watch_paths_triggered_with_character_set_pattern() { - $application = new Application(); - $application->watch_paths = "[abc]test.txt\nfile[0-9].yml"; + $watch_paths = ['[abc]test.txt', 'file[0-9].yml']; // Character set matches - $this->assertTrue($application->isWatchPathsTriggered(collect(['atest.txt']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['btest.txt']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['ctest.txt']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['file1.yml']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['file9.yml']))); + $this->assertNotEmpty($this->matchWatchPaths(['atest.txt'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['btest.txt'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['ctest.txt'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['file1.yml'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['file9.yml'], $watch_paths)); // Non-matching files - $this->assertFalse($application->isWatchPathsTriggered(collect(['dtest.txt']))); - $this->assertFalse($application->isWatchPathsTriggered(collect(['test.txt']))); - $this->assertFalse($application->isWatchPathsTriggered(collect(['fileA.yml']))); + $this->assertEmpty($this->matchWatchPaths(['dtest.txt'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['test.txt'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['fileA.yml'], $watch_paths)); } public function test_is_watch_paths_triggered_with_empty_watch_paths() { - $application = new Application(); - $application->watch_paths = ''; + $watch_paths = []; - $this->assertFalse($application->isWatchPathsTriggered(collect(['any-file.txt']))); + $matches = $this->matchWatchPaths(['any-file.txt'], $watch_paths); + $this->assertEmpty($matches); } public function test_is_watch_paths_triggered_with_whitespace_only_patterns() { - $application = new Application(); - $application->watch_paths = "\n \n\t\n"; + $watch_paths = ['', ' ', ' ']; - $this->assertFalse($application->isWatchPathsTriggered(collect(['any-file.txt']))); + $matches = $this->matchWatchPaths(['any-file.txt'], $watch_paths); + $this->assertEmpty($matches); } public function test_is_watch_paths_triggered_for_dockercompose_typical_patterns() { - $application = new Application(); - $application->watch_paths = "docker-compose*.yml\n.env*\nDockerfile*\nservices/**"; + $watch_paths = ['docker-compose*.yml', '.env*', 'Dockerfile*', 'services/**']; // Docker Compose related files - $this->assertTrue($application->isWatchPathsTriggered(collect(['docker-compose.yml']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['docker-compose.prod.yml']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['docker-compose-dev.yml']))); - + $this->assertNotEmpty($this->matchWatchPaths(['docker-compose.yml'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['docker-compose.prod.yml'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['docker-compose-dev.yml'], $watch_paths)); + // Environment files - $this->assertTrue($application->isWatchPathsTriggered(collect(['.env']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['.env.local']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['.env.production']))); - + $this->assertNotEmpty($this->matchWatchPaths(['.env'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['.env.local'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['.env.production'], $watch_paths)); + // Dockerfile variations - $this->assertTrue($application->isWatchPathsTriggered(collect(['Dockerfile']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['Dockerfile.prod']))); - + $this->assertNotEmpty($this->matchWatchPaths(['Dockerfile'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['Dockerfile.prod'], $watch_paths)); + // Service files - $this->assertTrue($application->isWatchPathsTriggered(collect(['services/api/app.js']))); - $this->assertTrue($application->isWatchPathsTriggered(collect(['services/web/index.html']))); + $this->assertNotEmpty($this->matchWatchPaths(['services/api/app.js'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['services/web/index.html'], $watch_paths)); // Non-matching files (e.g., documentation, configs outside services) - $this->assertFalse($application->isWatchPathsTriggered(collect(['README.md']))); - $this->assertFalse($application->isWatchPathsTriggered(collect(['package.json']))); - $this->assertFalse($application->isWatchPathsTriggered(collect(['config/nginx.conf']))); + $this->assertEmpty($this->matchWatchPaths(['README.md'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['package.json'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['config/nginx.conf'], $watch_paths)); } -} \ No newline at end of file + + public function test_negation_pattern_with_non_matching_file() + { + // Test case: file that does NOT match the exclusion pattern should trigger + $changed_files = ['docker-compose/index.ts']; + $watch_paths = ['!docker-compose-test/**']; + + // Since the file docker-compose/index.ts does NOT match the exclusion pattern docker-compose-test/** + // it should trigger the deployment (file is included by default when only exclusion patterns exist) + // This means: "deploy everything EXCEPT files in docker-compose-test/**" + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + $this->assertNotEmpty($matches); + $this->assertEquals(['docker-compose/index.ts'], $matches); + + // Test the opposite: file that DOES match the exclusion pattern should NOT trigger + $changed_files = ['docker-compose-test/index.ts']; + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + $this->assertEmpty($matches); + + // Test with deeper path + $changed_files = ['docker-compose-test/sub/dir/file.ts']; + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + $this->assertEmpty($matches); + } + + public function test_mixed_inclusion_and_exclusion_patterns() + { + // Include all JS files but exclude test directories + $watch_paths = ['**/*.js', '!**/*test*/**']; + + // Should match: JS files not in test directories + $this->assertNotEmpty($this->matchWatchPaths(['src/index.js'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['components/Button.js'], $watch_paths)); + + // Should NOT match: JS files in test directories + $this->assertEmpty($this->matchWatchPaths(['test/unit/app.js'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['src/test-utils/helper.js'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['docker-compose-test/index.js'], $watch_paths)); + + // Should NOT match: non-JS files + $this->assertEmpty($this->matchWatchPaths(['README.md'], $watch_paths)); + } + + public function test_multiple_negation_patterns() + { + // Exclude multiple directories + $watch_paths = ['!tests/**', '!docs/**', '!*.md']; + + // Should match: files not in excluded patterns + $this->assertNotEmpty($this->matchWatchPaths(['src/index.js'], $watch_paths)); + $this->assertNotEmpty($this->matchWatchPaths(['docker-compose.yml'], $watch_paths)); + + // Should NOT match: files in excluded patterns + $this->assertEmpty($this->matchWatchPaths(['tests/unit/test.js'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['docs/api.html'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['README.md'], $watch_paths)); + $this->assertEmpty($this->matchWatchPaths(['CHANGELOG.md'], $watch_paths)); + } + + public function test_current_broken_behavior_with_negation_patterns() + { + // This test demonstrates the CURRENT broken behavior + // where negation patterns are treated as literal strings + $changed_files = ['docker-compose/index.ts']; + $watch_paths = ['!docker-compose-test/**']; + + // With the current broken implementation, this returns empty + // because it tries to match files starting with literal "!" + $matches = $this->matchWatchPathsCurrentBehavior($changed_files, $watch_paths); + $this->assertEmpty($matches); // This is why your webhook doesn't trigger! + + // Even if the file had ! in the path, fnmatch would treat ! as a literal character + // not as a negation operator, so it still wouldn't match the pattern correctly + $changed_files = ['test/file.ts']; + $matches = $this->matchWatchPathsCurrentBehavior($changed_files, $watch_paths); + $this->assertEmpty($matches); + } + + public function test_order_based_matching_with_conflicting_patterns() + { + // Test case 1: Exclude then include - last pattern (include) should win + $changed_files = ['docker-compose/index.ts']; + $watch_paths = ['!docker-compose/**', 'docker-compose/**']; + + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + $this->assertNotEmpty($matches); + $this->assertEquals(['docker-compose/index.ts'], $matches); + + // Test case 2: Include then exclude - last pattern (exclude) should win + $watch_paths = ['docker-compose/**', '!docker-compose/**']; + + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + $this->assertEmpty($matches); + } + + public function test_order_based_matching_with_multiple_overlapping_patterns() + { + $changed_files = ['src/test/unit.js', 'src/components/Button.js', 'test/integration.js']; + + // Include all JS, then exclude test dirs, then re-include specific test file + $watch_paths = [ + '**/*.js', // Include all JS files + '!**/test/**', // Exclude all test directories + 'src/test/unit.js', // Re-include this specific test file + ]; + + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + + // src/test/unit.js should be included (last specific pattern wins) + // src/components/Button.js should be included (only matches first pattern) + // test/integration.js should be excluded (matches exclude pattern, no override) + $this->assertCount(2, $matches); + $this->assertContains('src/test/unit.js', $matches); + $this->assertContains('src/components/Button.js', $matches); + $this->assertNotContains('test/integration.js', $matches); + } + + public function test_order_based_matching_with_specific_overrides() + { + $changed_files = [ + 'docs/api.md', + 'docs/guide.md', + 'docs/internal/secret.md', + 'src/index.js', + ]; + + // Exclude all docs, then include specific docs subdirectory + $watch_paths = [ + '!docs/**', // Exclude all docs + 'docs/internal/**', // But include internal docs + 'src/**', // Include src files + ]; + + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + + // Only docs/internal/secret.md and src/index.js should be included + $this->assertCount(2, $matches); + $this->assertContains('docs/internal/secret.md', $matches); + $this->assertContains('src/index.js', $matches); + $this->assertNotContains('docs/api.md', $matches); + $this->assertNotContains('docs/guide.md', $matches); + } + + public function test_order_based_matching_preserves_order_precedence() + { + $changed_files = ['app/config.json']; + + // Multiple conflicting patterns - last match should win + $watch_paths = [ + '**/*.json', // Include (matches) + '!app/**', // Exclude (matches) + 'app/*.json', // Include (matches) - THIS SHOULD WIN + ]; + + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + + // File should be included because last matching pattern is inclusive + $this->assertNotEmpty($matches); + $this->assertEquals(['app/config.json'], $matches); + + // Now reverse the last two patterns + $watch_paths = [ + '**/*.json', // Include (matches) + 'app/*.json', // Include (matches) + '!app/**', // Exclude (matches) - THIS SHOULD WIN + ]; + + $matches = $this->matchWatchPaths($changed_files, $watch_paths); + + // File should be excluded because last matching pattern is exclusive + $this->assertEmpty($matches); + } +}