From 5fc5bd0250984a0a6e8ea077647e6478d0265646 Mon Sep 17 00:00:00 2001 From: maxpetrusenkoagent Date: Tue, 23 Jun 2026 10:18:51 -0400 Subject: [PATCH] watch: resolve event path before matching trigger path When loadDevelopmentConfig processes the develop.watch trigger path, it resolves it via filepath.EvalSymlinks. However, when an inotify event fires on Linux, the reported path may differ from the original trigger path in two ways: 1. Symlinks: the event path may go through a symlink that points to the same directory as the trigger path. 2. Case sensitivity: on Linux's case-sensitive filesystem, inotify reports the actual filesystem path which may differ in case from the path as originally specified in the compose file. This causes watchRule.Matches to fail the IsChild check and silently skip the sync action, as reported in issue #13743. The fix: resolve the event path via filepath.EvalSymlinks and clean it before comparing with the trigger path, mirroring what loadDevelopmentConfig does during config loading. The original event path is preserved as HostPath so the sync operation uses the correct source path. Fixes #13743 Signed-off-by: maxpetrusenkoagent --- pkg/compose/watch.go | 15 ++++++++++- pkg/compose/watch_test.go | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index c485267c0c1..ff0c8c42d44 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -147,6 +147,19 @@ type watchRule struct { func (r watchRule) Matches(event watch.FileEvent) *sync.PathMapping { hostPath := string(event) + // Keep the original path for the HostPath field of the returned PathMapping. + originalHostPath := hostPath + + // Resolve the event path to its real path, mirroring the resolution + // done in loadDevelopmentConfig when processing the trigger path. + // This is necessary because inotify on Linux reports the actual filesystem + // path which may differ in case or symlink resolution from the path + // as originally specified in the compose file. + if realPath, err := filepath.EvalSymlinks(hostPath); err == nil { + hostPath = realPath + } + hostPath = filepath.Clean(hostPath) + if !pathutil.IsChild(r.Path, hostPath) { return nil } @@ -181,7 +194,7 @@ func (r watchRule) Matches(event watch.FileEvent) *sync.PathMapping { containerPath = path.Join(r.Target, filepath.ToSlash(rel)) } return &sync.PathMapping{ - HostPath: hostPath, + HostPath: originalHostPath, ContainerPath: containerPath, } } diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go index 0c59b884ba6..170ec92ff3b 100644 --- a/pkg/compose/watch_test.go +++ b/pkg/compose/watch_test.go @@ -19,6 +19,7 @@ import ( "context" "fmt" "os" + "path/filepath" "slices" "testing" "time" @@ -194,3 +195,57 @@ func (f *fakeSyncer) Sync(ctx context.Context, service string, paths []*sync.Pat f.synced <- paths return nil } + +func TestWatchRule_Matches_SymlinkPath(t *testing.T) { + // Regression test: when the trigger path is resolved via filepath.EvalSymlinks + // during config loading, events from inotify (Linux) may come in through a + // symlink path. The event path must be resolved to its real path before + // comparison with the trigger path. + // + // NOTE: On macOS this test cannot reproduce the original case-sensitivity + // bug (Issue #13743) because macOS filesystem is case-insensitive. The test + // below validates the symlink resolution behavior, which is the same + // mechanism that fixes the case-sensitivity bug on Linux. + tmpDir := t.TempDir() + realDir := tmpDir + "/MyProject/src" + err := os.MkdirAll(realDir, 0o755) + assert.NilError(t, err) + + err = os.WriteFile(realDir+"/app.js", []byte("console.log('hello')"), 0o644) + assert.NilError(t, err) + + // Symlink: link_to_src -> MyProject/src + symlinkPath := tmpDir + "/link_to_src" + err = os.Symlink(realDir, symlinkPath) + assert.NilError(t, err) + + // Resolve the symlink to get the real path — this mirrors what + // loadDevelopmentConfig does for the trigger path. + resolvedRulePath, err := filepath.EvalSymlinks(symlinkPath) + assert.NilError(t, err) + resolvedRulePath = filepath.Clean(resolvedRulePath) + + rule := watchRule{ + Trigger: types.Trigger{ + Path: resolvedRulePath, + Target: "/app/src", + Action: "sync", + }, + include: watch.AnyMatcher{}, + ignore: watch.EmptyMatcher{}, + service: "test", + } + + // Event path goes through the symlink; the Matches function should + // resolve it to the same real path as the trigger path. + eventPathViaSymlink := symlinkPath + "/app.js" + + event := watch.NewFileEvent(eventPathViaSymlink) + result := rule.Matches(event) + assert.Check(t, result != nil, + "event via symlink %s should match resolved rule path %s", eventPathViaSymlink, resolvedRulePath) + assert.Check(t, result.HostPath == eventPathViaSymlink, + "HostPath should be original event path %s, got %s", eventPathViaSymlink, result.HostPath) + assert.Check(t, result.ContainerPath == "/app/src/app.js", + "ContainerPath should be /app/src/app.js, got %s", result.ContainerPath) +}