Repository: docker/compose

Issue:
Watch: Support Path Pattern Matching
# Request

It would be nice if the [watch](https://docs.docker.com/reference/compose-file/develop/#watch) `path` attribute behaved like the `ignore` attribute. For example, `path` could accept wildcards such as `*`.

At the very least it would help to have some more detail in the API documentation adding that (at least for the time being) pattern matching does not work on `path`.

Reference: https://docs.docker.com/reference/compose-file/develop/

# Example

I would like the convenience of being able to do this:

```yaml
services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "5173:5173"
    develop:
        watch:
        - action: rebuild
          path: frontend/*.json
        - action: rebuild
          path: frontend/*.js
```

instead of the verbose (yet explicit):

```yaml
services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "5173:5173"
    develop:
        watch:
        - action: rebuild
          path: frontend/jsconfig.json
        - action: rebuild
          path: frontend/package-lock.json
        - action: rebuild
          path: frontend/package.json
        - action: rebuild
          path: frontend/eslint.config.js
        - action: rebuild
          path: frontend/svelte.config.js
        - action: rebuild
          path: frontend/tailwind.config.js
        - action: rebuild
          path: frontend/vite.config.js
```

## Nice To Have

Bonus points for allowing multiple `path` entries in a single `rebuild` action:

```yaml
services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "5173:5173"
    develop:
        watch:
        - action: rebuild
          path: frontend/*.json frontend/*.js
```

Currently, passing a space separated list is silently interpreted as a single path. So, the below example (misleadingly) does not match `frontend/package.json` nor `frontend/vite.config.js`

```yaml
        - action: rebuild
          path: frontend/package.json frontend/vite.config.js
```



Hints from issue comments:
@jhrotko: I would propose to introduce a new keyword `paths` for `rebuild` and `restart` actions, since the `path` has a 1 to 1 relation to `target` for `sync++` actions. 
Under this assumption, the new keyword `paths` you could also have the pattern matching safely as it would support multiple files (as the pattern matching might resolve to more than one file). As for `path`, we should be careful for the pattern matching; perhaps returning an error explaining the matched files if it resolves to multiple files.  

Or we can change `path` to be either a string or a list in the json schema and during validation make sure the above assumptions. However, I think usability becomes more complicated as there are different conditions for different actions@davidgfitzgerald: @jhrotko `paths` sounds reasonable, at the slight expense of the user having to understand that both `path` and `paths` exist. Which is not obscene by any stretch of the imagination, just slightly more mental overhead for the user.

I guess it's a tradeoff between making the API as smooth as possible for the user and an implementation that actually achieves the desired behaviour. I am guess that extending the behaviour of `path` has complications on the implementation I am unaware of. @jhrotko: I created a POC in these branches (feature-wild-star-watch-path):
- [compose](https://github.com/jhrotko/compose/tree/feature-wild-star-watch-path)
- [compose-go](https://github.com/jhrotko/compose-go/tree/feature-wild-star-watch-path)

in these branches the implementation is under the `path` which can now receive a string or a list of strings and also validates the `*` pattern matching. 

If you want to try, clone my [fork/branch](https://github.com/jhrotko/compose/tree/feature-wild-star-watch-path) and run make build.

in your `compose.yml` you can write something along the lines

```
...
develop:
  watch:
    - path: SOME/PATH/*.txt
      action: rebuild
    - path: 
        - FILE1
        - FILE2
      action: restart
```
you can run with the specific compose build (from the make build) by running 

```
PATH/TO/JHROTKO/FORK/compose/bin/build/docker-compose up --watch
```@ndeloof: On https://github.com/docker/compose/pull/12584 I proposed to keep using a single `path` as base to monitor filesystem for changes, and introduce a dedicated `include` attribute so one can declare patterns to restrict which actual file changes should be considered by action:
```yaml
    develop:
        watch:
        - action: rebuild
          path: frontend
          include:
            - *.json
            - *.js
```

Solution patch:
diff --git a/go.mod b/go.mod
index f28e626a1..28b14cb5c 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/Microsoft/go-winio v0.6.2
 	github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
 	github.com/buger/goterm v1.0.4
-	github.com/compose-spec/compose-go/v2 v2.4.9-0.20250225151507-331db8fefcb7
+	github.com/compose-spec/compose-go/v2 v2.4.9-0.20250302154753-e508c724a35f
 	github.com/containerd/containerd/v2 v2.0.2
 	github.com/containerd/platforms v1.0.0-rc.1
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
diff --git a/go.sum b/go.sum
index 8acb7edde..bde32edf7 100644
--- a/go.sum
+++ b/go.sum
@@ -81,8 +81,8 @@ github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e
 github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
 github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
 github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
-github.com/compose-spec/compose-go/v2 v2.4.9-0.20250225151507-331db8fefcb7 h1:7NlxAsQcWvLpFlEHsBo80sJ1UMMs84kkf0yXGs6de2k=
-github.com/compose-spec/compose-go/v2 v2.4.9-0.20250225151507-331db8fefcb7/go.mod h1:6k5l/0TxCg0/2uLEhRVEsoBWBprS2uvZi32J7xub3lo=
+github.com/compose-spec/compose-go/v2 v2.4.9-0.20250302154753-e508c724a35f h1:kbmTPhf7d9kTnmH0ghAQTqxs1zenKnwKczJlC4z5WSc=
+github.com/compose-spec/compose-go/v2 v2.4.9-0.20250302154753-e508c724a35f/go.mod h1:6k5l/0TxCg0/2uLEhRVEsoBWBprS2uvZi32J7xub3lo=
 github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo=
 github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins=
 github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go
index b5fdee6d8..459a5f2f5 100644
--- a/pkg/compose/publish.go
+++ b/pkg/compose/publish.go
@@ -18,6 +18,7 @@ package compose
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"os"
 
@@ -136,6 +137,9 @@ func (s *composeService) generateImageDigestsOverride(ctx context.Context, proje
 }
 
 func (s *composeService) preChecks(project *types.Project, options api.PublishOptions) (bool, error) {
+	if ok, err := s.checkOnlyBuildSection(project); !ok {
+		return false, err
+	}
 	envVariables, err := s.checkEnvironmentVariables(project, options)
 	if err != nil {
 		return false, err
@@ -214,3 +218,20 @@ func envFileLayers(project *types.Project) []ocipush.Pushable {
 	}
 	return layers
 }
+
+func (s *composeService) checkOnlyBuildSection(project *types.Project) (bool, error) {
+	errorList := []string{}
+	for _, service := range project.Services {
+		if service.Image == "" && service.Build != nil {
+			errorList = append(errorList, service.Name)
+		}
+	}
+	if len(errorList) > 0 {
+		errMsg := "your Compose stack cannot be published as it only contains a build section for service(s):\n"
+		for _, serviceInError := range errorList {
+			errMsg += fmt.Sprintf("- %q\n", serviceInError)
+		}
+		return false, errors.New(errMsg)
+	}
+	return true, nil
+}
diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go
index cff1d4f0b..72f329a62 100644
--- a/pkg/compose/watch.go
+++ b/pkg/compose/watch.go
@@ -81,6 +81,7 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
 
 type watchRule struct {
 	types.Trigger
+	include watch.PathMatcher
 	ignore  watch.PathMatcher
 	service string
 }
@@ -90,6 +91,15 @@ func (r watchRule) Matches(event watch.FileEvent) *sync.PathMapping {
 	if !pathutil.IsChild(r.Path, hostPath) {
 		return nil
 	}
+	included, err := r.include.Matches(hostPath)
+	if err != nil {
+		logrus.Warnf("error include matching %q: %v", hostPath, err)
+		return nil
+	}
+	if !included {
+		logrus.Debugf("%s is not matching include pattern", hostPath)
+		return nil
+	}
 	isIgnored, err := r.ignore.Matches(hostPath)
 	if err != nil {
 		logrus.Warnf("error ignore matching %q: %v", hostPath, err)
@@ -244,8 +254,19 @@ func getWatchRules(config *types.DevelopConfig, service types.ServiceConfig) ([]
 			return nil, err
 		}
 
+		var include watch.PathMatcher
+		if len(trigger.Include) == 0 {
+			include = watch.AnyMatcher{}
+		} else {
+			include, err = watch.NewDockerPatternMatcher(trigger.Path, trigger.Include)
+			if err != nil {
+				return nil, err
+			}
+		}
+
 		rules = append(rules, watchRule{
 			Trigger: trigger,
+			include: include,
 			ignore: watch.NewCompositeMatcher(
 				dockerIgnores,
 				watch.EphemeralPathMatcher(),
diff --git a/pkg/e2e/fixtures/publish/Dockerfile b/pkg/e2e/fixtures/publish/Dockerfile
new file mode 100644
index 000000000..b17b6b061
--- /dev/null
+++ b/pkg/e2e/fixtures/publish/Dockerfile
@@ -0,0 +1,15 @@
+#   Copyright 2020 Docker Compose CLI authors
+
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+
+#       http://www.apache.org/licenses/LICENSE-2.0
+
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+
+FROM alpine:latest
\ No newline at end of file
diff --git a/pkg/e2e/fixtures/publish/compose-build-only.yml b/pkg/e2e/fixtures/publish/compose-build-only.yml
new file mode 100644
index 000000000..e4736d983
--- /dev/null
+++ b/pkg/e2e/fixtures/publish/compose-build-only.yml
@@ -0,0 +1,9 @@
+services:
+  serviceA:
+    build:
+      context: .
+      dockerfile: Dockerfile
+  serviceB:
+    build:
+      context: .
+      dockerfile: Dockerfile
diff --git a/pkg/e2e/fixtures/watch/include.yaml b/pkg/e2e/fixtures/watch/include.yaml
new file mode 100644
index 000000000..ccd9d4504
--- /dev/null
+++ b/pkg/e2e/fixtures/watch/include.yaml
@@ -0,0 +1,12 @@
+services:
+  a:
+    build:
+      dockerfile_inline: |
+        FROM nginx
+        RUN mkdir /data/
+    develop:
+      watch:
+        - path: .
+          include: A.*
+          target: /data/
+          action: sync
diff --git a/pkg/watch/notify.go b/pkg/watch/notify.go
index b76292106..d09a6be15 100644
--- a/pkg/watch/notify.go
+++ b/pkg/watch/notify.go
@@ -68,6 +68,15 @@ type PathMatcher interface {
 	MatchesEntireDir(file string) (bool, error)
 }
 
+// AnyMatcher is a PathMatcher to match any path
+type AnyMatcher struct{}
+
+func (AnyMatcher) Matches(f string) (bool, error)          { return true, nil }
+func (AnyMatcher) MatchesEntireDir(f string) (bool, error) { return true, nil }
+
+var _ PathMatcher = AnyMatcher{}
+
+// EmptyMatcher is a PathMatcher to match no path
 type EmptyMatcher struct{}
 
 func (EmptyMatcher) Matches(f string) (bool, error)          { return false, nil }



Test patch:
diff --git a/pkg/e2e/publish_test.go b/pkg/e2e/publish_test.go
index 52625cfd0..3a9415343 100644
--- a/pkg/e2e/publish_test.go
+++ b/pkg/e2e/publish_test.go
@@ -107,4 +107,13 @@ FOO=bar`), res.Combined())
 		assert.Assert(t, strings.Contains(res.Combined(), `BAR=baz`), res.Combined())
 		assert.Assert(t, strings.Contains(res.Combined(), `QUIX=`), res.Combined())
 	})
+
+	t.Run("refuse to publish with build section only", func(t *testing.T) {
+		res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/publish/compose-build-only.yml",
+			"-p", projectName, "alpha", "publish", "test/test", "--with-env", "-y", "--dry-run")
+		res.Assert(t, icmd.Expected{ExitCode: 1})
+		assert.Assert(t, strings.Contains(res.Combined(), "your Compose stack cannot be published as it only contains a build section for service(s):"), res.Combined())
+		assert.Assert(t, strings.Contains(res.Combined(), "serviceA"), res.Combined())
+		assert.Assert(t, strings.Contains(res.Combined(), "serviceB"), res.Combined())
+	})
 }
diff --git a/pkg/e2e/watch_test.go b/pkg/e2e/watch_test.go
index 1a8c92cc5..5c4db4765 100644
--- a/pkg/e2e/watch_test.go
+++ b/pkg/e2e/watch_test.go
@@ -368,3 +368,40 @@ func TestWatchMultiServices(t *testing.T) {
 
 	c.RunDockerComposeCmdNoCheck(t, "-p", projectName, "kill", "-s", "9")
 }
+
+func TestWatchIncludes(t *testing.T) {
+	c := NewCLI(t)
+	const projectName = "test_watch_includes"
+
+	defer c.cleanupWithDown(t, projectName)
+
+	tmpdir := t.TempDir()
+	composeFilePath := filepath.Join(tmpdir, "compose.yaml")
+	CopyFile(t, filepath.Join("fixtures", "watch", "include.yaml"), composeFilePath)
+
+	cmd := c.NewDockerComposeCmd(t, "-p", projectName, "-f", composeFilePath, "up", "--watch")
+	buffer := bytes.NewBuffer(nil)
+	cmd.Stdout = buffer
+	watch := icmd.StartCmd(cmd)
+
+	poll.WaitOn(t, func(l poll.LogT) poll.Result {
+		if strings.Contains(watch.Stdout(), "Attaching to ") {
+			return poll.Success()
+		}
+		return poll.Continue("%v", watch.Stdout())
+	})
+
+	require.NoError(t, os.WriteFile(filepath.Join(tmpdir, "B.test"), []byte("test"), 0o600))
+	require.NoError(t, os.WriteFile(filepath.Join(tmpdir, "A.test"), []byte("test"), 0o600))
+
+	poll.WaitOn(t, func(l poll.LogT) poll.Result {
+		cat := c.RunDockerComposeCmdNoCheck(t, "-p", projectName, "exec", "a", "ls", "/data/")
+		if strings.Contains(cat.Stdout(), "A.test") {
+			assert.Check(t, !strings.Contains(cat.Stdout(), "B.test"))
+			return poll.Success()
+		}
+		return poll.Continue("%v", cat.Combined())
+	})
+
+	c.RunDockerComposeCmdNoCheck(t, "-p", projectName, "kill", "-s", "9")
+}