Repository: docker/compose
Pull request #11525 (2024-02-19 14:49:40)

introduce --watch
**What I did**
This introduces a new `--watch` flag to `docker compose up` so watch feature can be enabled on up, with all the logs attachement features. 
Watch events are reported mixed with application logs, but use of a reserved log stream name allows to offer a distinguished rendering

demo: https://asciinema.org/a/S3I2cyLUa0xomePNACgvo75Cg

Long term, I expect this also to allow dynamically enabling watch mode on a running `up` command.

**Related issue**
fixes https://github.com/docker/compose/issues/11089

**(not mandatory) A picture of a cute animal, if possible in relation to what you did**


jhrotko: Could you add an e2e test for this new use case?
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/docker/compose/pull/11525?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) Report
Attention: Patch coverage is `59.09091%` with `27 lines` in your changes are missing coverage. Please review.
> Project coverage is 58.33%. Comparing base [(`e2762b1`)](https://app.codecov.io/gh/docker/compose/commit/e2762b144eaba5769cbb767ed588fa4ac9b7acdd?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) to head [(`dd3324b`)](https://app.codecov.io/gh/docker/compose/pull/11525?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker).
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/docker/compose/pull/11525?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) | Patch % | Lines |
|---|---|---|
| [pkg/compose/watch.go](https://app.codecov.io/gh/docker/compose/pull/11525?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker#diff-cGtnL2NvbXBvc2Uvd2F0Y2guZ28=) | 67.50% | [9 Missing and 4 partials :warning: ](https://app.codecov.io/gh/docker/compose/pull/11525?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) |
| [cmd/formatter/logs.go](https://app.codecov.io/gh/docker/compose/pull/11525?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker#diff-Y21kL2Zvcm1hdHRlci9sb2dzLmdv) | 22.22% | [4 Missing and 3 partials :warning: ](https://app.codecov.io/gh/docker/compose/pull/11525?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) |
| [pkg/compose/up.go](https://app.codecov.io/gh/docker/compose/pull/11525?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker#diff-cGtnL2NvbXBvc2UvdXAuZ28=) | 0.00% | [6 Missing and 1 partial :warning: ](https://app.codecov.io/gh/docker/compose/pull/11525?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main   #11525      +/-   ##
==========================================
+ Coverage   58.29%   58.33%   +0.04%     
==========================================
  Files         135      135              
  Lines       11555    11576      +21     
==========================================
+ Hits         6736     6753      +17     
+ Misses       4155     4154       -1     
- Partials      664      669       +5     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/docker/compose/pull/11525?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=docker).

ndeloof: @jhrotko watch e2e tests are disabled "_until we can figure out why they are flaky/failing_" 🥲

jhrotko: > @jhrotko watch e2e tests are disabled "_until we can figure out why they are flaky/failing_" 🥲

@ndeloof do you think it's going to be flaky in up as well?
ndeloof: as long as we don't know the root cause, I don't see any reason this would be better with up. I plan to revisit the watch test suite as a follow-up PR

Patch:
diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go
index fc29014c4..8e9d52599 100644
--- a/cmd/compose/compose.go
+++ b/cmd/compose/compose.go
@@ -201,6 +201,24 @@ func (o *ProjectOptions) toProjectName(ctx context.Context, dockerCli command.Cl
 	return project.Name, nil
 }
 
+func (o *ProjectOptions) ToModel(ctx context.Context, dockerCli command.Cli, services []string, po ...cli.ProjectOptionsFn) (map[string]any, error) {
+	remotes := o.remoteLoaders(dockerCli)
+	for _, r := range remotes {
+		po = append(po, cli.WithResourceLoader(r))
+	}
+
+	options, err := o.toProjectOptions(po...)
+	if err != nil {
+		return nil, err
+	}
+
+	if o.Compatibility || utils.StringToBool(options.Environment[ComposeCompatibility]) {
+		api.Separator = "_"
+	}
+
+	return options.LoadModel(ctx)
+}
+
 func (o *ProjectOptions) ToProject(ctx context.Context, dockerCli command.Cli, services []string, po ...cli.ProjectOptionsFn) (*types.Project, tracing.Metrics, error) {
 	var metrics tracing.Metrics
 
@@ -241,7 +259,7 @@ func (o *ProjectOptions) ToProject(ctx context.Context, dockerCli command.Cli, s
 		api.Separator = "_"
 	}
 
-	project, err := cli.ProjectFromOptions(ctx, options)
+	project, err := options.LoadProject(ctx)
 	if err != nil {
 		return nil, metrics, compose.WrapComposeError(err)
 	}
@@ -468,7 +486,7 @@ func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //
 		psCommand(&opts, dockerCli, backend),
 		listCommand(dockerCli, backend),
 		logsCommand(&opts, dockerCli, backend),
-		configCommand(&opts, dockerCli, backend),
+		configCommand(&opts, dockerCli),
 		killCommand(&opts, dockerCli, backend),
 		runCommand(&opts, dockerCli, backend),
 		removeCommand(&opts, dockerCli, backend),
diff --git a/cmd/compose/config.go b/cmd/compose/config.go
index 481ad776d..8631956ce 100644
--- a/cmd/compose/config.go
+++ b/cmd/compose/config.go
@@ -19,6 +19,7 @@ package compose
 import (
 	"bytes"
 	"context"
+	"encoding/json"
 	"fmt"
 	"os"
 	"sort"
@@ -28,8 +29,8 @@ import (
 	"github.com/compose-spec/compose-go/v2/types"
 	"github.com/docker/cli/cli/command"
 	"github.com/spf13/cobra"
+	"gopkg.in/yaml.v3"
 
-	"github.com/docker/compose/v2/internal/tracing"
 	"github.com/docker/compose/v2/pkg/api"
 	"github.com/docker/compose/v2/pkg/compose"
 )
@@ -51,18 +52,29 @@ type configOptions struct {
 	noConsistency       bool
 }
 
-func (o *configOptions) ToProject(ctx context.Context, dockerCli command.Cli, services []string, po ...cli.ProjectOptionsFn) (*types.Project, tracing.Metrics, error) {
-	po = append(po,
+func (o *configOptions) ToProject(ctx context.Context, dockerCli command.Cli, services []string, po ...cli.ProjectOptionsFn) (*types.Project, error) {
+	po = append(po, o.ToProjectOptions()...)
+	project, _, err := o.ProjectOptions.ToProject(ctx, dockerCli, services, po...)
+	return project, err
+}
+
+func (o *configOptions) ToModel(ctx context.Context, dockerCli command.Cli, services []string, po ...cli.ProjectOptionsFn) (map[string]any, error) {
+	po = append(po, o.ToProjectOptions()...)
+	return o.ProjectOptions.ToModel(ctx, dockerCli, services, po...)
+}
+
+func (o *configOptions) ToProjectOptions() []cli.ProjectOptionsFn {
+	return []cli.ProjectOptionsFn{
 		cli.WithInterpolation(!o.noInterpolate),
 		cli.WithResolvedPaths(!o.noResolvePath),
 		cli.WithNormalization(!o.noNormalize),
 		cli.WithConsistency(!o.noConsistency),
 		cli.WithDefaultProfiles(o.Profiles...),
-		cli.WithDiscardEnvFile)
-	return o.ProjectOptions.ToProject(ctx, dockerCli, services, po...)
+		cli.WithDiscardEnvFile,
+	}
 }
 
-func configCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *cobra.Command {
+func configCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command {
 	opts := configOptions{
 		ProjectOptions: p,
 	}
@@ -100,7 +112,7 @@ func configCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service
 				return runConfigImages(ctx, dockerCli, opts, args)
 			}
 
-			return runConfig(ctx, dockerCli, backend, opts, args)
+			return runConfig(ctx, dockerCli, opts, args)
 		}),
 		ValidArgsFunction: completeServiceNames(dockerCli, p),
 	}
@@ -123,18 +135,20 @@ func configCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service
 	return cmd
 }
 
-func runConfig(ctx context.Context, dockerCli command.Cli, backend api.Service, opts configOptions, services []string) error {
-	var content []byte
-	project, _, err := opts.ToProject(ctx, dockerCli, services)
+func runConfig(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) error {
+	model, err := opts.ToModel(ctx, dockerCli, services)
 	if err != nil {
 		return err
 	}
 
-	content, err = backend.Config(ctx, project, api.ConfigOptions{
-		Format:              opts.Format,
-		Output:              opts.Output,
-		ResolveImageDigests: opts.resolveImageDigests,
-	})
+	if opts.resolveImageDigests {
+		err = resolveImageDigests(ctx, dockerCli, model)
+		if err != nil {
+			return err
+		}
+	}
+
+	content, err := formatModel(model, opts.Format)
 	if err != nil {
 		return err
 	}
@@ -154,8 +168,57 @@ func runConfig(ctx context.Context, dockerCli command.Cli, backend api.Service,
 	return err
 }
 
+func resolveImageDigests(ctx context.Context, dockerCli command.Cli, model map[string]any) (err error) {
+	// create a pseudo-project so we can rely on WithImagesResolved to resolve images
+	p := &types.Project{
+		Services: types.Services{},
+	}
+	services := model["services"].(map[string]any)
+	for name, s := range services {
+		service := s.(map[string]any)
+		if image, ok := service["image"]; ok {
+			p.Services[name] = types.ServiceConfig{
+				Image: image.(string),
+			}
+		}
+	}
+
+	p, err = p.WithImagesResolved(compose.ImageDigestResolver(ctx, dockerCli.ConfigFile(), dockerCli.Client()))
+	if err != nil {
+		return err
+	}
+
+	// Collect image resolved with digest and update model accordingly
+	for name, s := range services {
+		service := s.(map[string]any)
+		config := p.Services[name]
+		if config.Image != "" {
+			service["image"] = config.Image
+		}
+		services[name] = service
+	}
+	model["services"] = services
+	return nil
+}
+
+func formatModel(model map[string]any, format string) (content []byte, err error) {
+	switch format {
+	case "json":
+		content, err = json.MarshalIndent(model, "", "  ")
+	case "yaml":
+		buf := bytes.NewBuffer([]byte{})
+		encoder := yaml.NewEncoder(buf)
+		encoder.SetIndent(2)
+		err = encoder.Encode(model)
+		content = buf.Bytes()
+	default:
+		return nil, fmt.Errorf("unsupported format %q", format)
+	}
+	return
+}
+
 func runServices(ctx context.Context, dockerCli command.Cli, opts configOptions) error {
-	project, _, err := opts.ToProject(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution)
+	project, err := opts.ToProject(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution)
 	if err != nil {
 		return err
 	}
@@ -167,7 +230,7 @@ func runServices(ctx context.Context, dockerCli command.Cli, opts configOptions)
 }
 
 func runVolumes(ctx context.Context, dockerCli command.Cli, opts configOptions) error {
-	project, _, err := opts.ToProject(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution)
+	project, err := opts.ToProject(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution)
 	if err != nil {
 		return err
 	}
@@ -182,7 +245,7 @@ func runHash(ctx context.Context, dockerCli command.Cli, opts configOptions) err
 	if opts.hash != "*" {
 		services = append(services, strings.Split(opts.hash, ",")...)
 	}
-	project, _, err := opts.ToProject(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution)
+	project, err := opts.ToProject(ctx, dockerCli, nil, cli.WithoutEnvironmentResolution)
 	if err != nil {
 		return err
 	}
@@ -218,7 +281,7 @@ func runHash(ctx context.Context, dockerCli command.Cli, opts configOptions) err
 
 func runProfiles(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) error {
 	set := map[string]struct{}{}
-	project, _, err := opts.ToProject(ctx, dockerCli, services, cli.WithoutEnvironmentResolution)
+	project, err := opts.ToProject(ctx, dockerCli, services, cli.WithoutEnvironmentResolution)
 	if err != nil {
 		return err
 	}
@@ -239,7 +302,7 @@ func runProfiles(ctx context.Context, dockerCli command.Cli, opts configOptions,
 }
 
 func runConfigImages(ctx context.Context, dockerCli command.Cli, opts configOptions, services []string) error {
-	project, _, err := opts.ToProject(ctx, dockerCli, services, cli.WithoutEnvironmentResolution)
+	project, err := opts.ToProject(ctx, dockerCli, services, cli.WithoutEnvironmentResolution)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/compose/up.go b/cmd/compose/up.go
index 9fed9951a..084e010fa 100644
--- a/cmd/compose/up.go
+++ b/cmd/compose/up.go
@@ -24,11 +24,10 @@ import (
 	"strings"
 	"time"
 
-	xprogress "github.com/moby/buildkit/util/progress/progressui"
-
 	"github.com/compose-spec/compose-go/v2/types"
 	"github.com/docker/cli/cli/command"
 	"github.com/docker/compose/v2/cmd/formatter"
+	xprogress "github.com/moby/buildkit/util/progress/progressui"
 	"github.com/spf13/cobra"
 
 	"github.com/docker/compose/v2/pkg/api"
@@ -55,6 +54,7 @@ type upOptions struct {
 	timestamp          bool
 	wait               bool
 	waitTimeout        int
+	watch              bool
 }
 
 func (opts upOptions) apply(project *types.Project, services []string) (*types.Project, error) {
@@ -126,6 +126,7 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *c
 	flags.BoolVar(&up.attachDependencies, "attach-dependencies", false, "Automatically attach to log output of dependent services")
 	flags.BoolVar(&up.wait, "wait", false, "Wait for services to be running|healthy. Implies detached mode.")
 	flags.IntVar(&up.waitTimeout, "wait-timeout", 0, "Maximum duration to wait for the project to be running|healthy")
+	flags.BoolVarP(&up.watch, "watch", "w", false, "Watch source code and rebuild/refresh containers when files are updated.")
 
 	return upCmd
 }
@@ -257,6 +258,7 @@ func runUp(
 			CascadeStop:  upOptions.cascadeStop,
 			Wait:         upOptions.wait,
 			WaitTimeout:  timeout,
+			Watch:        upOptions.watch,
 			Services:     services,
 		},
 	})
diff --git a/cmd/compose/watch.go b/cmd/compose/watch.go
index e4f942726..880711a20 100644
--- a/cmd/compose/watch.go
+++ b/cmd/compose/watch.go
@@ -21,6 +21,7 @@ import (
 	"fmt"
 
 	"github.com/compose-spec/compose-go/v2/types"
+	"github.com/docker/compose/v2/cmd/formatter"
 
 	"github.com/docker/cli/cli/command"
 	"github.com/docker/compose/v2/internal/locker"
@@ -31,8 +32,7 @@ import (
 
 type watchOptions struct {
 	*ProjectOptions
-	quiet bool
-	noUp  bool
+	noUp bool
 }
 
 func watchCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *cobra.Command {
@@ -57,7 +57,7 @@ func watchCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service)
 		ValidArgsFunction: completeServiceNames(dockerCli, p),
 	}
 
-	cmd.Flags().BoolVar(&watchOpts.quiet, "quiet", false, "hide build output")
+	cmd.Flags().BoolVar(&buildOpts.quiet, "quiet", false, "hide build output")
 	cmd.Flags().BoolVar(&watchOpts.noUp, "no-up", false, "Do not build & start services before watching")
 	return cmd
 }
@@ -101,7 +101,7 @@ func runWatch(ctx context.Context, dockerCli command.Cli, backend api.Service, w
 				Recreate:             api.RecreateDiverged,
 				RecreateDependencies: api.RecreateNever,
 				Inherit:              true,
-				QuietPull:            watchOpts.quiet,
+				QuietPull:            buildOpts.quiet,
 			},
 			Start: api.StartOptions{
 				Project:     project,
@@ -114,7 +114,10 @@ func runWatch(ctx context.Context, dockerCli command.Cli, backend api.Service, w
 			return err
 		}
 	}
+
+	consumer := formatter.NewLogConsumer(ctx, dockerCli.Out(), dockerCli.Err(), false, false, false)
 	return backend.Watch(ctx, project, services, api.WatchOptions{
-		Build: build,
+		Build: &build,
+		LogTo: consumer,
 	})
 }
diff --git a/cmd/formatter/logs.go b/cmd/formatter/logs.go
index c6b8baa32..465aa229a 100644
--- a/cmd/formatter/logs.go
+++ b/cmd/formatter/logs.go
@@ -62,7 +62,11 @@ func (l *logConsumer) Register(name string) {
 func (l *logConsumer) register(name string) *presenter {
 	cf := monochrome
 	if l.color {
-		cf = nextColor()
+		if name == api.WatchLogger {
+			cf = makeColorFunc("92")
+		} else {
+			cf = nextColor()
+		}
 	}
 	p := &presenter{
 		colors: cf,
@@ -138,5 +142,9 @@ type presenter struct {
 }
 
 func (p *presenter) setPrefix(width int) {
+	if p.name == api.WatchLogger {
+		p.prefix = p.colors(strings.Repeat(" ", width) + " ⦿ ")
+		return
+	}
 	p.prefix = p.colors(fmt.Sprintf("%-"+strconv.Itoa(width)+"s | ", p.name))
 }
diff --git a/docs/reference/compose_up.md b/docs/reference/compose_up.md
index 38d678d00..a34766c48 100644
--- a/docs/reference/compose_up.md
+++ b/docs/reference/compose_up.md
@@ -32,6 +32,7 @@ Create and start containers
 | `--timestamps`               |               |          | Show timestamps                                                                                         |
 | `--wait`                     |               |          | Wait for services to be running\|healthy. Implies detached mode.                                        |
 | `--wait-timeout`             | `int`         | `0`      | Maximum duration to wait for the project to be running\|healthy                                         |
+| `-w`, `--watch`              |               |          | Watch source code and rebuild/refresh containers when files are updated.                                |
 
 
 <!---MARKER_GEN_END-->
diff --git a/docs/reference/docker_compose_up.yaml b/docs/reference/docker_compose_up.yaml
index 0ed06a09b..ec269c8b8 100644
--- a/docs/reference/docker_compose_up.yaml
+++ b/docs/reference/docker_compose_up.yaml
@@ -274,6 +274,18 @@ options:
       experimentalcli: false
       kubernetes: false
       swarm: false
+    - option: watch
+      shorthand: w
+      value_type: bool
+      default_value: "false"
+      description: |
+        Watch source code and rebuild/refresh containers when files are updated.
+      deprecated: false
+      hidden: false
+      experimental: false
+      experimentalcli: false
+      kubernetes: false
+      swarm: false
 inherited_options:
     - option: dry-run
       value_type: bool
diff --git a/go.mod b/go.mod
index 05fa0fbfa..65d2ed7a2 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
 	github.com/AlecAivazis/survey/v2 v2.3.7
 	github.com/Microsoft/go-winio v0.6.1
 	github.com/buger/goterm v1.0.4
-	github.com/compose-spec/compose-go/v2 v2.0.0-rc.8
+	github.com/compose-spec/compose-go/v2 v2.0.0-rc.8.0.20240228111658-a0507e98fe60
 	github.com/containerd/console v1.0.3
 	github.com/containerd/containerd v1.7.12
 	github.com/davecgh/go-spew v1.1.1
@@ -49,6 +49,7 @@ require (
 	golang.org/x/sync v0.6.0
 	golang.org/x/sys v0.16.0
 	google.golang.org/grpc v1.59.0
+	gopkg.in/yaml.v3 v3.0.1
 	gotest.tools/v3 v3.5.1
 )
 
@@ -170,7 +171,6 @@ require (
 	google.golang.org/protobuf v1.31.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
 	k8s.io/api v0.26.7 // indirect
 	k8s.io/apimachinery v0.26.7 // indirect
 	k8s.io/apiserver v0.26.7 // indirect
diff --git a/go.sum b/go.sum
index 22d96bb9b..adabccef1 100644
--- a/go.sum
+++ b/go.sum
@@ -86,8 +86,8 @@ github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 h1:/inchEIKaYC1Akx+H+g
 github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 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.0.0-rc.8 h1:b7l+GqFF+2W4M4kLQUDRTGhqmTiRwT3bYd9X7xrxp5Q=
-github.com/compose-spec/compose-go/v2 v2.0.0-rc.8/go.mod h1:bEPizBkIojlQ20pi2vNluBa58tevvj0Y18oUSHPyfdc=
+github.com/compose-spec/compose-go/v2 v2.0.0-rc.8.0.20240228111658-a0507e98fe60 h1:NlkpaLBPFr05mNJWVMH7PP4L30gFG6k4z1QpypLUSh8=
+github.com/compose-spec/compose-go/v2 v2.0.0-rc.8.0.20240228111658-a0507e98fe60/go.mod h1:bEPizBkIojlQ20pi2vNluBa58tevvj0Y18oUSHPyfdc=
 github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
 github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 3b8f09161..53fce924e 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -52,8 +52,6 @@ type Service interface {
 	Ps(ctx context.Context, projectName string, options PsOptions) ([]ContainerSummary, error)
 	// List executes the equivalent to a `docker stack ls`
 	List(ctx context.Context, options ListOptions) ([]Stack, error)
-	// Config executes the equivalent to a `compose config`
-	Config(ctx context.Context, project *types.Project, options ConfigOptions) ([]byte, error)
 	// Kill executes the equivalent to a `compose kill`
 	Kill(ctx context.Context, projectName string, options KillOptions) error
 	// RunOneOffContainer creates a service oneoff container and starts its dependencies
@@ -116,9 +114,13 @@ type VizOptions struct {
 	Indentation string
 }
 
+// WatchLogger is a reserved name to log watch events
+const WatchLogger = "#watch"
+
 // WatchOptions group options of the Watch API
 type WatchOptions struct {
-	Build BuildOptions
+	Build *BuildOptions
+	LogTo LogConsumer
 }
 
 // BuildOptions group options of the Build API
@@ -216,6 +218,7 @@ type StartOptions struct {
 	WaitTimeout time.Duration
 	// Services passed in the command line to be started
 	Services []string
+	Watch    bool
 }
 
 // RestartOptions group options of the Restart API
diff --git a/pkg/compose/build.go b/pkg/compose/build.go
index 41a5920d2..8dacba1ba 100644
--- a/pkg/compose/build.go
+++ b/pkg/compose/build.go
@@ -127,6 +127,9 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
 		progressCtx, cancel := context.WithCancel(context.Background())
 		defer cancel()
 
+		if options.Quiet {
+			options.Progress = progress.ModeQuiet
+		}
 		w, err = xprogress.NewPrinter(progressCtx, os.Stdout, progressui.DisplayMode(options.Progress),
 			xprogress.WithDesc(
 				fmt.Sprintf("building with %q instance using %s driver", b.Name, b.Driver),
diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go
index f1db51123..a025b9d90 100644
--- a/pkg/compose/compose.go
+++ b/pkg/compose/compose.go
@@ -25,12 +25,10 @@ import (
 	"strings"
 	"sync"
 
-	"github.com/jonboulle/clockwork"
-
 	"github.com/docker/docker/api/types/volume"
+	"github.com/jonboulle/clockwork"
 
 	"github.com/compose-spec/compose-go/v2/types"
-	"github.com/distribution/reference"
 	"github.com/docker/cli/cli/command"
 	"github.com/docker/cli/cli/config/configfile"
 	"github.com/docker/cli/cli/flags"
@@ -40,7 +38,6 @@ import (
 	"github.com/docker/docker/api/types/filters"
 	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/docker/client"
-	"github.com/opencontainers/go-digest"
 )
 
 var stdioToStdout bool
@@ -147,35 +144,6 @@ func getContainerNameWithoutProject(c moby.Container) string {
 	return name[len(project)+1:]
 }
 
-func (s *composeService) Config(ctx context.Context, project *types.Project, options api.ConfigOptions) ([]byte, error) {
-	if options.ResolveImageDigests {
-		var err error
-		project, err = project.WithImagesResolved(func(named reference.Named) (digest.Digest, error) {
-			auth, err := encodedAuth(named, s.configFile())
-			if err != nil {
-				return "", err
-			}
-			inspect, err := s.apiClient().DistributionInspect(ctx, named.String(), auth)
-			if err != nil {
-				return "", err
-			}
-			return inspect.Descriptor.Digest, nil
-		})
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	switch options.Format {
-	case "json":
-		return project.MarshalJSON()
-	case "yaml":
-		return project.MarshalYAML()
-	default:
-		return nil, fmt.Errorf("unsupported format %q", options.Format)
-	}
-}
-
 // projectFromName builds a types.Project based on actual resources with compose labels set
 func (s *composeService) projectFromName(containers Containers, projectName string, services ...string) (*types.Project, error) {
 	project := &types.Project{
diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go
index 406da5ec2..a01d7be58 100644
--- a/pkg/compose/publish.go
+++ b/pkg/compose/publish.go
@@ -26,7 +26,6 @@ import (
 	"github.com/docker/compose/v2/internal/ocipush"
 	"github.com/docker/compose/v2/pkg/api"
 	"github.com/docker/compose/v2/pkg/progress"
-	"github.com/opencontainers/go-digest"
 )
 
 func (s *composeService) Publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error {
@@ -111,17 +110,7 @@ func (s *composeService) generateImageDigestsOverride(ctx context.Context, proje
 	if err != nil {
 		return nil, err
 	}
-	project, err = project.WithImagesResolved(func(named reference.Named) (digest.Digest, error) {
-		auth, err := encodedAuth(named, s.configFile())
-		if err != nil {
-			return "", err
-		}
-		inspect, err := s.apiClient().DistributionInspect(ctx, named.String(), auth)
-		if err != nil {
-			return "", err
-		}
-		return inspect.Descriptor.Digest, nil
-	})
+	project, err = project.WithImagesResolved(ImageDigestResolver(ctx, s.configFile(), s.apiClient()))
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go
index 977397bd0..222876d65 100644
--- a/pkg/compose/pull.go
+++ b/pkg/compose/pull.go
@@ -28,10 +28,13 @@ import (
 	"github.com/compose-spec/compose-go/v2/types"
 	"github.com/distribution/reference"
 	"github.com/docker/buildx/driver"
+	"github.com/docker/cli/cli/config/configfile"
 	moby "github.com/docker/docker/api/types"
+	"github.com/docker/docker/client"
 	"github.com/docker/docker/pkg/jsonmessage"
 	"github.com/docker/docker/registry"
 	"github.com/hashicorp/go-multierror"
+	"github.com/opencontainers/go-digest"
 	"golang.org/x/sync/errgroup"
 
 	"github.com/docker/compose/v2/pkg/api"
@@ -242,6 +245,21 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser
 	return inspected.ID, nil
 }
 
+// ImageDigestResolver creates a func able to resolve image digest from a docker ref,
+func ImageDigestResolver(ctx context.Context, file *configfile.ConfigFile, apiClient client.APIClient) func(named reference.Named) (digest.Digest, error) {
+	return func(named reference.Named) (digest.Digest, error) {
+		auth, err := encodedAuth(named, file)
+		if err != nil {
+			return "", err
+		}
+		inspect, err := apiClient.DistributionInspect(ctx, named.String(), auth)
+		if err != nil {
+			return "", err
+		}
+		return inspect.Descriptor.Digest, nil
+	}
+}
+
 func encodedAuth(ref reference.Named, configFile driver.Auth) (string, error) {
 	repoInfo, err := registry.ParseRepositoryInfo(ref)
 	if err != nil {
diff --git a/pkg/compose/up.go b/pkg/compose/up.go
index c1dafb594..b91a4ef73 100644
--- a/pkg/compose/up.go
+++ b/pkg/compose/up.go
@@ -125,6 +125,15 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 		return err
 	})
 
+	if options.Start.Watch {
+		eg.Go(func() error {
+			return s.Watch(ctx, project, options.Start.Services, api.WatchOptions{
+				Build: options.Create.Build,
+				LogTo: options.Start.Attach,
+			})
+		})
+	}
+
 	// We don't use parent (cancelable) context as we manage sigterm to stop the stack
 	err = s.start(context.Background(), project.Name, options.Start, printer.HandleEvent)
 	if err != nil && !isTerminated { // Ignore error if the process is terminated
diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go
index 2e783026e..dca4d7c15 100644
--- a/pkg/compose/watch.go
+++ b/pkg/compose/watch.go
@@ -76,6 +76,7 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
 	}
 	eg, ctx := errgroup.WithContext(ctx)
 	watching := false
+	options.LogTo.Register(api.WatchLogger)
 	for i := range project.Services {
 		service := project.Services[i]
 		config, err := loadDevelopmentConfig(service, project)
@@ -91,9 +92,15 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
 			continue
 		}
 
-		if len(config.Watch) > 0 && service.Build == nil {
-			// service configured with watchers but no build section
-			return fmt.Errorf("can't watch service %q without a build context", service.Name)
+		for _, trigger := range config.Watch {
+			if trigger.Action == types.WatchActionRebuild {
+				if service.Build == nil {
+					return fmt.Errorf("can't watch service %q with action %s without a build context", service.Name, types.WatchActionRebuild)
+				}
+				if options.Build == nil {
+					return fmt.Errorf("--no-build is incompatible with watch action %s in service %s", types.WatchActionRebuild, service.Name)
+				}
+			}
 		}
 
 		if len(services) > 0 && service.Build == nil {
@@ -142,9 +149,7 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
 			return err
 		}
 
-		fmt.Fprintf(
-			s.stdinfo(),
-			"Watch configuration for service %q:%s\n",
+		logrus.Debugf("Watch configuration for service %q:%s\n",
 			service.Name,
 			strings.Join(append([]string{""}, pathLogs...), "\n  - "),
 		)
@@ -163,6 +168,7 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv
 	if !watching {
 		return fmt.Errorf("none of the selected services is configured for watch, consider setting an 'develop' section")
 	}
+	options.LogTo.Log(api.WatchLogger, "watch enabled")
 
 	return eg.Wait()
 }
@@ -190,7 +196,7 @@ func (s *composeService) watch(ctx context.Context, project *types.Project, name
 			case batch := <-batchEvents:
 				start := time.Now()
 				logrus.Debugf("batch start: service[%s] count[%d]", name, len(batch))
-				if err := s.handleWatchBatch(ctx, project, name, options.Build, batch, syncer); err != nil {
+				if err := s.handleWatchBatch(ctx, project, name, options, batch, syncer); err != nil {
 					logrus.Warnf("Error handling changed files for service %s: %v", name, err)
 				}
 				logrus.Debugf("batch complete: service[%s] duration[%s] count[%d]",
@@ -431,32 +437,38 @@ func (t tarDockerClient) Untar(ctx context.Context, id string, archive io.ReadCl
 	})
 }
 
-func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Project, serviceName string, build api.BuildOptions, batch []fileEvent, syncer sync.Syncer) error {
+func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Project, serviceName string, options api.WatchOptions, batch []fileEvent, syncer sync.Syncer) error {
 	pathMappings := make([]sync.PathMapping, len(batch))
 	restartService := false
 	for i := range batch {
 		if batch[i].Action == types.WatchActionRebuild {
-			fmt.Fprintf(
-				s.stdinfo(),
-				"Rebuilding service %q after changes were detected:%s\n",
-				serviceName,
-				strings.Join(append([]string{""}, batch[i].HostPath), "\n  - "),
-			)
+			options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Rebuilding service %q after changes were detected...", serviceName))
 			// restrict the build to ONLY this service, not any of its dependencies
-			build.Services = []string{serviceName}
-			err := s.Up(ctx, project, api.UpOptions{
-				Create: api.CreateOptions{
-					Build:    &build,
-					Services: []string{serviceName},
-					Inherit:  true,
-				},
-				Start: api.StartOptions{
-					Services: []string{serviceName},
-					Project:  project,
-				},
+			options.Build.Services = []string{serviceName}
+			options.Build.Quiet = true
+			_, err := s.build(ctx, project, *options.Build, nil)
+			if err != nil {
+				options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Build failed. Error: %v", err))
+				return err
+			}
+			options.LogTo.Log(api.WatchLogger, fmt.Sprintf("service %q successfully built", serviceName))
+
+			err = s.create(ctx, project, api.CreateOptions{
+				Services: []string{serviceName},
+				Inherit:  true,
+				Recreate: api.RecreateForce,
 			})
 			if err != nil {
-				fmt.Fprintf(s.stderr(), "Application failed to start after update. Error: %v\n", err)
+				options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Failed to recreate service after update. Error: %v", err))
+				return err
+			}
+
+			err = s.start(ctx, project.Name, api.StartOptions{
+				Project:  project,
+				Services: []string{serviceName},
+			}, nil)
+			if err != nil {
+				options.LogTo.Log(api.WatchLogger, fmt.Sprintf("Application failed to start after update. Error: %v", err))
 			}
 			return nil
 		}
@@ -466,7 +478,7 @@ func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Pr
 		pathMappings[i] = batch[i].PathMapping
 	}
 
-	writeWatchSyncMessage(s.stdinfo(), serviceName, pathMappings)
+	writeWatchSyncMessage(options.LogTo, serviceName, pathMappings)
 
 	service, err := project.GetService(serviceName)
 	if err != nil {
@@ -486,29 +498,19 @@ func (s *composeService) handleWatchBatch(ctx context.Context, project *types.Pr
 }
 
 // writeWatchSyncMessage prints out a message about the sync for the changed paths.
-func writeWatchSyncMessage(w io.Writer, serviceName string, pathMappings []sync.PathMapping) {
+func writeWatchSyncMessage(log api.LogConsumer, serviceName string, pathMappings []sync.PathMapping) {
 	const maxPathsToShow = 10
 	if len(pathMappings) <= maxPathsToShow || logrus.IsLevelEnabled(logrus.DebugLevel) {
 		hostPathsToSync := make([]string, len(pathMappings))
 		for i := range pathMappings {
 			hostPathsToSync[i] = pathMappings[i].HostPath
 		}
-		fmt.Fprintf(
-			w,
-			"Syncing %q after changes were detected:%s\n",
-			serviceName,
-			strings.Join(append([]string{""}, hostPathsToSync...), "\n  - "),
-		)
+		log.Log(api.WatchLogger, fmt.Sprintf("Syncing %q after changes were detected", serviceName))
 	} else {
 		hostPathsToSync := make([]string, len(pathMappings))
 		for i := range pathMappings {
 			hostPathsToSync[i] = pathMappings[i].HostPath
 		}
-		fmt.Fprintf(
-			w,
-			"Syncing service %q after %d changes were detected\n",
-			serviceName,
-			len(pathMappings),
-		)
+		log.Log(api.WatchLogger, fmt.Sprintf("Syncing service %q after %d changes were detected", serviceName, len(pathMappings)))
 	}
 }


Test patch:
diff --git a/pkg/compose/watch_test.go b/pkg/compose/watch_test.go
index fc39ee7d3..39fbf2bcb 100644
--- a/pkg/compose/watch_test.go
+++ b/pkg/compose/watch_test.go
@@ -16,6 +16,7 @@ package compose
 
 import (
 	"context"
+	"fmt"
 	"os"
 	"testing"
 	"time"
@@ -91,10 +92,29 @@ func (t testWatcher) Errors() chan error {
 	return t.errors
 }
 
+type stdLogger struct{}
+
+func (s stdLogger) Log(containerName, message string) {
+	fmt.Printf("%s: %s\n", containerName, message)
+}
+
+func (s stdLogger) Err(containerName, message string) {
+	fmt.Fprintf(os.Stderr, "%s: %s\n", containerName, message)
+}
+
+func (s stdLogger) Status(container, msg string) {
+	fmt.Printf("%s: %s\n", container, msg)
+}
+
+func (s stdLogger) Register(container string) {
+
+}
+
 func TestWatch_Sync(t *testing.T) {
 	mockCtrl := gomock.NewController(t)
 	cli := mocks.NewMockCli(mockCtrl)
 	cli.EXPECT().Err().Return(os.Stderr).AnyTimes()
+	cli.EXPECT().BuildKitEnabled().Return(true, nil)
 	apiClient := mocks.NewMockAPIClient(mockCtrl)
 	apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return([]moby.Container{
 		testContainer("test", "123", false),
@@ -124,7 +144,10 @@ func TestWatch_Sync(t *testing.T) {
 			dockerCli: cli,
 			clock:     clock,
 		}
-		err := service.watch(ctx, &proj, "test", api.WatchOptions{}, watcher, syncer, []types.Trigger{
+		err := service.watch(ctx, &proj, "test", api.WatchOptions{
+			Build: &api.BuildOptions{},
+			LogTo: stdLogger{},
+		}, watcher, syncer, []types.Trigger{
 			{
 				Path:   "/sync",
 				Action: "sync",
diff --git a/pkg/e2e/compose_test.go b/pkg/e2e/compose_test.go
index b37ec0f48..28d8b0eb7 100644
--- a/pkg/e2e/compose_test.go
+++ b/pkg/e2e/compose_test.go
@@ -235,7 +235,7 @@ func TestCompatibility(t *testing.T) {
 	})
 }
 
-func TestConvert(t *testing.T) {
+func TestConfig(t *testing.T) {
 	const projectName = "compose-e2e-convert"
 	c := NewParallelCLI(t)
 
@@ -244,20 +244,22 @@ func TestConvert(t *testing.T) {
 
 	t.Run("up", func(t *testing.T) {
 		res := c.RunDockerComposeCmd(t, "-f", "./fixtures/simple-build-test/compose.yaml", "-p", projectName, "convert")
-		res.Assert(t, icmd.Expected{Out: fmt.Sprintf(`services:
+		res.Assert(t, icmd.Expected{Out: fmt.Sprintf(`name: %s
+networks:
+  default:
+    name: compose-e2e-convert_default
+services:
   nginx:
     build:
       context: %s
       dockerfile: Dockerfile
     networks:
       default: null
-networks:
-  default:
-    name: compose-e2e-convert_default`, filepath.Join(wd, "fixtures", "simple-build-test", "nginx-build")), ExitCode: 0})
+`, projectName, filepath.Join(wd, "fixtures", "simple-build-test", "nginx-build")), ExitCode: 0})
 	})
 }
 
-func TestConvertInterpolate(t *testing.T) {
+func TestConfigInterpolate(t *testing.T) {
 	const projectName = "compose-e2e-convert-interpolate"
 	c := NewParallelCLI(t)
 
@@ -266,16 +268,18 @@ func TestConvertInterpolate(t *testing.T) {
 
 	t.Run("convert", func(t *testing.T) {
 		res := c.RunDockerComposeCmd(t, "-f", "./fixtures/simple-build-test/compose-interpolate.yaml", "-p", projectName, "convert", "--no-interpolate")
-		res.Assert(t, icmd.Expected{Out: fmt.Sprintf(`services:
+		res.Assert(t, icmd.Expected{Out: fmt.Sprintf(`name: %s
+networks:
+  default:
+    name: compose-e2e-convert-interpolate_default
+services:
   nginx:
     build:
       context: %s
       dockerfile: ${MYVAR}
     networks:
       default: null
-networks:
-  default:
-    name: compose-e2e-convert-interpolate_default`, filepath.Join(wd, "fixtures", "simple-build-test", "nginx-build")), ExitCode: 0})
+`, projectName, filepath.Join(wd, "fixtures", "simple-build-test", "nginx-build")), ExitCode: 0})
 	})
 }