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);
+ }
+}