Repository: docker/compose

Issue:
[BUG] `docker compose exec` selects a one-off container instead of the service container
### Description

I'm running a deployment where the primary container is being created using docker compose up -d. The main container of the deployment is a service called `app`. I'm spinning up temporary containers on a per-need basis using e.g `docker compose run app bash`.

When a temporary container has been created using `run` and is still running, `docker compose exec app bash` will enter the `run` container instead of the `app` container.

I have found various issues and PRs referencing this issue, and I've been able to narrow it down to being most likely connected with the way Docker Compose assigns the `container-number` label and the `oneoff` label across various versions.

### Related issues / PRs and history of changes:

* In 2.15.1, run containers are being incorrectly selected by exec (#10359)
* In v2.27.1, run containers are being incorrectly selected by exec (#11992)
* Starting with 2.29.1, run containers are supposed to be sorted at the end of the selection list so that they have lower priority than the app container (#11995). However, this sorting logic relies on the run container number being set.
	* This seems to have stemmed from a decision to [maintain the incorrect behavior in case users started relying on it](https://github.com/docker/compose/issues/11992#issuecomment-2242244640) - while still giving a higher priority to the managed service container(s) if they are running
	* I unfortunately haven't been able to test that the bug is NOT present when `2.29.1 < docker compose version < 2.30.0`
* In v2.29.7, a user reports that run containers are being incorrectly selected by the `--index`  option, which leads to realizing that run containers should probably not have a container number label to begin with (#12219).
* Starting in 2.30.0, run containers no longer have a container number (#12224)
  * this might be breaking the sorting logic introduced in #12219: in my understanding, if the container number is not set, the run container will be sorted before the app container (assuming `strconv.Atoi(containers[i].Labels[api.ContainerNumberLabel])` will return 0 - and possibly an error which is being ignored in the sorting logic from #12219 - but I'm not familiar enough with Go to be 100% sure of this hypothesis).

End result: the regression is present again, and run containers are being selected by `exec`.

### Steps To Reproduce

```
services:
  app:
    container_name: app
    image: ubuntu
    restart: always
    command: tail -f /dev/null
```

```
docker compose up -d # creates `app` container
docker compose run app bash
```

In another terminal window:
```
docker compose exec app bash
```

Expected behavior: the above command enters the `app` container.
Actual behavior: the above command enters the one-off "run" container.

### Compose Version

Tried across three different Docker Compose versions:

#### Docker Compose version v2.18.1

* app container has a container number (set to 1)
* run container has a container number (set to 1)
* docker compose exec app bash enters the run container

#### Docker Compose version v2.21.0-desktop.1

* app container has a container number (set to 1)
* run container has a container number (set to 1)
* docker compose exec app bash enters the run container

#### Docker Compose version v2.39.1

* app container has a container number (set to 1)
* run container **does not** have a container number
* docker compose exec app bash enters the run container


### Potential fix proposals:

(Trying to help with brainstorming a possible fix now that I'm this deep into the investigation 😂)

#### 1. Update sorting logic?

Update the sorting logic from #12219 to always sort one-off containers which don't have a label after non-one-off containers which have a container number label?

The final sorting order used by `exec` would then be:
* managed containers sorted by container number (if any are present)
* one-off containers sorted in an undetermined order (if any are present)

#### 2. Bring back container numbers on one-off containers and fix `--index` option?

This would mean essentially reverting #12224. This would enable the sorting logic to correctly consider the one-off container with `container-number=1` as having the same number as the first service container, BUT in this case, the `--index 1` selection logic would need to be fixed in order to skip one-off containers.

#### 3. Something else?


Discussion:
@aurelienshz: Interesting add-on: I was able to find a legacy deployment where `docker-compose` (v1) was still installed.

```
$ docker-compose version
docker-compose version 1.24.1, build 4667896b
```

On this deployment, containers created using `docker-compose run`:
* have the `one-off` label set to `"True"`
* **do not** have the `container-number` label

So, as mentioned in #12224, one-off containers were probably never meant to have a container number, and the fact that they received a container number starting somewhere before Compose 2.15.1 might be what caused this bug in the first place?@aurelienshz: Expanding on the above if that's of any value:

## `run`:

In Compose v2, original implementation of the `run` command adds 1 as the container number when using `run` (in https://github.com/docker/compose/commit/412385c49549a48e55df45c99c40665a8eb0c36e)

```go
err = s.createContainer(ctx, project, service, service.ContainerName, 1)
```

Compose v1 implementation of the `run` command did not add a container number (in https://github.com/docker/compose/blob/v1.25.2/compose/container.py#L92-L94)

```python
    @property
    def number(self):
        if self.one_off:
            # One-off containers are no longer assigned numbers and use slugs instead.
            return None
```

## `exec`:

In compose v2, as discussed above:
* containers are sorted by container number and the lowest container number is being used
* one-off containers no longer have a container number, (possibly) causing the sorting logic used by `exec` to treat one-off containers as having container number `0`, and therefore being higher priority than service containers running with a `container-number` label >= 1.

In compose v1, if `--index` is not provided to `exec`, it falls back to the default, which is 1 (?), then uses this index to select the service container - naturally excluding one-off containers which do not have a number (in https://github.com/docker/compose/blob/v1.25.2/compose/cli/main.py#L469-L482)

```python
  container = service.get_container(number=index)
```

@ndeloof: one-off container indeed do not have a container number label, and can be distinguished from application containers by `com.docker.compose.oneoff=True`.
@aurelienshz: Thank you so much for the quick fix! Highly appreciated!

Solution patch:
diff --git a/go.mod b/go.mod
index 1f2140a9..783e7f91 100644
--- a/go.mod
+++ b/go.mod
@@ -14,22 +14,21 @@ require (
 	github.com/containerd/platforms v1.0.0-rc.1
 	github.com/davecgh/go-spew v1.1.1
 	github.com/distribution/reference v0.6.0
-	github.com/docker/buildx v0.27.0
-	github.com/docker/cli v28.3.3+incompatible
+	github.com/docker/buildx v0.28.0
+	github.com/docker/cli v28.4.0+incompatible
 	github.com/docker/cli-docs-tool v0.10.0
-	github.com/docker/docker v28.3.3+incompatible
+	github.com/docker/docker v28.4.0+incompatible
 	github.com/docker/go-connections v0.6.0
 	github.com/docker/go-units v0.5.0
 	github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203
 	github.com/fsnotify/fsevents v0.2.0
 	github.com/google/go-cmp v0.7.0
-	github.com/hashicorp/go-multierror v1.1.1
 	github.com/hashicorp/go-version v1.7.0
 	github.com/jonboulle/clockwork v0.5.0
 	github.com/mattn/go-shellwords v1.0.12
 	github.com/mitchellh/go-ps v1.0.0
 	github.com/mitchellh/mapstructure v1.5.0
-	github.com/moby/buildkit v0.23.0-rc1.0.20250806140246-955c2b2f7d01 // master
+	github.com/moby/buildkit v0.24.0
 	github.com/moby/go-archive v0.1.0
 	github.com/moby/patternmatcher v0.6.0
 	github.com/moby/sys/atomicwriter v0.1.0
@@ -40,9 +39,9 @@ require (
 	github.com/otiai10/copy v1.14.1
 	github.com/sirupsen/logrus v1.9.3
 	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
-	github.com/spf13/cobra v1.9.1
-	github.com/spf13/pflag v1.0.7
-	github.com/stretchr/testify v1.10.0
+	github.com/spf13/cobra v1.10.1
+	github.com/spf13/pflag v1.0.10
+	github.com/stretchr/testify v1.11.1
 	github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0
 	go.opentelemetry.io/otel v1.36.0
@@ -62,7 +61,6 @@ require (
 )
 
 require (
-	dario.cat/mergo v1.0.1 // indirect
 	github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
 	github.com/Masterminds/semver/v3 v3.4.0 // indirect
 	github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
@@ -89,7 +87,7 @@ require (
 	github.com/containerd/log v0.1.0 // indirect
 	github.com/containerd/ttrpc v1.2.7 // indirect
 	github.com/containerd/typeurl/v2 v2.2.3 // indirect
-	github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
+	github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
 	github.com/docker/distribution v2.8.3+incompatible // indirect
 	github.com/docker/docker-credential-helpers v0.9.3 // indirect
 	github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
@@ -117,6 +115,7 @@ require (
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
 	github.com/hashicorp/errwrap v1.1.0 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+	github.com/hashicorp/go-multierror v1.1.1 // indirect
 	github.com/in-toto/in-toto-golang v0.9.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect
@@ -168,9 +167,6 @@ require (
 	github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
 	github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
 	github.com/x448/float16 v0.8.4 // indirect
-	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
-	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
-	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
 	github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
 	github.com/zclconf/go-cty v1.16.2 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
diff --git a/go.sum b/go.sum
index 66515680..2187febd 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,3 @@
-dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
-dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
 github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
 github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
@@ -113,8 +111,9 @@ github.com/containerd/ttrpc v1.2.7 h1:qIrroQvuOL9HQ1X6KHe2ohc7p+HP/0VE6XPU7elJRq
 github.com/containerd/ttrpc v1.2.7/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o=
 github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40=
 github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk=
-github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
+github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
@@ -127,17 +126,17 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-github.com/docker/buildx v0.27.0 h1:8QQOqIAMpDItzlmYO5ua/AR2Qttu07hHeOeOxPSbUR8=
-github.com/docker/buildx v0.27.0/go.mod h1:omZ9N6owYkRoAN79fq+Dfa0RwfR3iblCGuUyThovCpM=
-github.com/docker/cli v28.3.3+incompatible h1:fp9ZHAr1WWPGdIWBM1b3zLtgCF+83gRdVMTJsUeiyAo=
-github.com/docker/cli v28.3.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/buildx v0.28.0 h1:ZnrVsZ/qQwSOQ4Fx3IgXjiurAwvocaF1YUaPbIXD89E=
+github.com/docker/buildx v0.28.0/go.mod h1:nLwx58w7xrQbLVSXiWiHpkVhY4ou4ci/hYomc139Vjk=
+github.com/docker/cli v28.4.0+incompatible h1:RBcf3Kjw2pMtwui5V0DIMdyeab8glEw5QY0UUU4C9kY=
+github.com/docker/cli v28.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli-docs-tool v0.10.0 h1:bOD6mKynPQgojQi3s2jgcUWGp/Ebqy1SeCr9VfKQLLU=
 github.com/docker/cli-docs-tool v0.10.0/go.mod h1:5EM5zPnT2E7yCLERZmrDA234Vwn09fzRHP4aX1qwp1U=
 github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
-github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v28.4.0+incompatible h1:KVC7bz5zJY/4AZe/78BIvCnPsLaC9T/zh72xnlrTTOk=
+github.com/docker/docker v28.4.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
 github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
 github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0=
@@ -317,8 +316,8 @@ github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/z
 github.com/mitchellh/mapstructure v0.0.0-20150613213606-2caf8efc9366/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/moby/buildkit v0.23.0-rc1.0.20250806140246-955c2b2f7d01 h1:ua0f++eVvjBLs77tBmg5Ko2x6BTgjULihEu03ogLBC8=
-github.com/moby/buildkit v0.23.0-rc1.0.20250806140246-955c2b2f7d01/go.mod h1:8qy9VSHDZuin0BnQzxbRGSXFKQiT0cgeKLWiNRw4/5Q=
+github.com/moby/buildkit v0.24.0 h1:qYfTl7W1SIJzWDIDCcPT8FboHIZCYfi++wvySi3eyFE=
+github.com/moby/buildkit v0.24.0/go.mod h1:4qovICAdR2H4C7+EGMRva5zgHW1gyhT4/flHI7F5F9k=
 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
 github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
@@ -445,14 +444,14 @@ github.com/spdx/tools-golang v0.5.5/go.mod h1:MVIsXx8ZZzaRWNQpUDhC4Dud34edUYJYec
 github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94 h1:JmfC365KywYwHB946TTiQWEb8kqPY+pybPLoGE9GgVk=
 github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
 github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
-github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
-github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
+github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
 github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431 h1:XTHrT015sxHyJ5FnQ0AeemSspZWaDq7DoTRW0EVsDCE=
 github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
-github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c h1:2EejZtjFjKJGk71ANb+wtFK5EjUzUkEM3R0xnp559xg=
 github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -469,8 +468,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c=
 github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw=
 github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 h1:QB54BJwA6x8QU9nHY3xJSZR2kX9bgpZekRKGkLTmEXA=
@@ -489,13 +488,6 @@ github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnn
 github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
-github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
-github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
-github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
 github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
diff --git a/pkg/api/dryrunclient.go b/pkg/api/dryrunclient.go
index b5ffc8ae..83d214b5 100644
--- a/pkg/api/dryrunclient.go
+++ b/pkg/api/dryrunclient.go
@@ -130,7 +130,7 @@ func (d *DryRunClient) ContainerInspect(ctx context.Context, container string) (
 				ID:   id,
 				Name: container,
 				State: &containerType.State{
-					Status: "running", // needed for --wait option
+					Status: containerType.StateRunning, // needed for --wait option
 					Health: &containerType.Health{
 						Status: containerType.Healthy, // needed for healthcheck control
 					},
diff --git a/pkg/compose/build_bake.go b/pkg/compose/build_bake.go
index e9241c1b..e8c68e38 100644
--- a/pkg/compose/build_bake.go
+++ b/pkg/compose/build_bake.go
@@ -42,7 +42,7 @@ import (
 	"github.com/docker/docker/api/types/versions"
 	"github.com/docker/docker/builder/remotecontext/urlutil"
 	"github.com/moby/buildkit/client"
-	"github.com/moby/buildkit/util/gitutil"
+	gitutil "github.com/moby/buildkit/frontend/dockerfile/dfgitutil"
 	"github.com/moby/buildkit/util/progress/progressui"
 	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
@@ -218,7 +218,7 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
 
 		read = append(read, build.Context)
 		for _, path := range build.AdditionalContexts {
-			_, err := gitutil.ParseGitRef(path)
+			_, _, err := gitutil.ParseGitRef(path)
 			if !strings.Contains(path, "://") && err != nil {
 				read = append(read, path)
 			}
diff --git a/pkg/compose/containers.go b/pkg/compose/containers.go
index 598cc2a2..a2c4c99b 100644
--- a/pkg/compose/containers.go
+++ b/pkg/compose/containers.go
@@ -100,7 +100,7 @@ func (s *composeService) getSpecifiedContainer(ctx context.Context, projectName
 		IsOneOffLabelTrueX := containers[i].Labels[api.OneoffLabel] == "True"
 		IsOneOffLabelTrueY := containers[j].Labels[api.OneoffLabel] == "True"
 
-		if numberLabelX == numberLabelY {
+		if IsOneOffLabelTrueX || IsOneOffLabelTrueY {
 			return !IsOneOffLabelTrueX && IsOneOffLabelTrueY
 		}
 
diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go
index ec90ae54..1fd6ec89 100644
--- a/pkg/compose/convergence.go
+++ b/pkg/compose/convergence.go
@@ -828,25 +828,25 @@ func (s *composeService) getLinks(ctx context.Context, projectName string, servi
 
 func (s *composeService) isServiceHealthy(ctx context.Context, containers Containers, fallbackRunning bool) (bool, error) {
 	for _, c := range containers {
-		container, err := s.apiClient().ContainerInspect(ctx, c.ID)
+		ctr, err := s.apiClient().ContainerInspect(ctx, c.ID)
 		if err != nil {
 			return false, err
 		}
-		name := container.Name[1:]
+		name := ctr.Name[1:]
 
-		if container.State.Status == "exited" {
-			return false, fmt.Errorf("container %s exited (%d)", name, container.State.ExitCode)
+		if ctr.State.Status == containerType.StateExited {
+			return false, fmt.Errorf("container %s exited (%d)", name, ctr.State.ExitCode)
 		}
 
-		if container.Config.Healthcheck == nil && fallbackRunning {
+		if ctr.Config.Healthcheck == nil && fallbackRunning {
 			// Container does not define a health check, but we can fall back to "running" state
-			return container.State != nil && container.State.Status == "running", nil
+			return ctr.State != nil && ctr.State.Status == containerType.StateRunning, nil
 		}
 
-		if container.State == nil || container.State.Health == nil {
+		if ctr.State == nil || ctr.State.Health == nil {
 			return false, fmt.Errorf("container %s has no healthcheck configured", name)
 		}
-		switch container.State.Health.Status {
+		switch ctr.State.Health.Status {
 		case containerType.Healthy:
 			// Continue by checking the next container.
 		case containerType.Unhealthy:
@@ -854,7 +854,7 @@ func (s *composeService) isServiceHealthy(ctx context.Context, containers Contai
 		case containerType.Starting:
 			return false, nil
 		default:
-			return false, fmt.Errorf("container %s had unexpected health status %q", name, container.State.Health.Status)
+			return false, fmt.Errorf("container %s had unexpected health status %q", name, ctr.State.Health.Status)
 		}
 	}
 	return true, nil
@@ -862,12 +862,12 @@ func (s *composeService) isServiceHealthy(ctx context.Context, containers Contai
 
 func (s *composeService) isServiceCompleted(ctx context.Context, containers Containers) (bool, int, error) {
 	for _, c := range containers {
-		container, err := s.apiClient().ContainerInspect(ctx, c.ID)
+		ctr, err := s.apiClient().ContainerInspect(ctx, c.ID)
 		if err != nil {
 			return false, 0, err
 		}
-		if container.State != nil && container.State.Status == "exited" {
-			return true, container.State.ExitCode, nil
+		if ctr.State != nil && ctr.State.Status == containerType.StateExited {
+			return true, ctr.State.ExitCode, nil
 		}
 	}
 	return false, 0, nil
diff --git a/pkg/compose/create.go b/pkg/compose/create.go
index 04c887a7..5ff84f39 100644
--- a/pkg/compose/create.go
+++ b/pkg/compose/create.go
@@ -1623,7 +1623,7 @@ func (s *composeService) removeDivergedVolume(ctx context.Context, name string,
 }
 
 func (s *composeService) createVolume(ctx context.Context, volume types.VolumeConfig) error {
-	eventName := fmt.Sprintf("Volume %q", volume.Name)
+	eventName := fmt.Sprintf("Volume %s", volume.Name)
 	w := progress.ContextWriter(ctx)
 	w.Event(progress.CreatingEvent(eventName))
 	hash, err := VolumeHash(volume)
diff --git a/pkg/compose/logs.go b/pkg/compose/logs.go
index b3b44d53..b4479d75 100644
--- a/pkg/compose/logs.go
+++ b/pkg/compose/logs.go
@@ -104,6 +104,7 @@ func (s *composeService) Logs(
 			}
 		})
 		eg.Go(func() error {
+			// pass ctx so monitor will immediately stop on SIGINT
 			return monitor.Start(ctx)
 		})
 	}
diff --git a/pkg/compose/monitor.go b/pkg/compose/monitor.go
index 6952b4e6..b0f9cc0a 100644
--- a/pkg/compose/monitor.go
+++ b/pkg/compose/monitor.go
@@ -79,7 +79,7 @@ func (c *monitor) Start(ctx context.Context) error {
 	}
 	restarting := utils.Set[string]{}
 
-	evtCh, errCh := c.api.Events(context.Background(), events.ListOptions{
+	evtCh, errCh := c.api.Events(ctx, events.ListOptions{
 		Filters: filters.NewArgs(
 			filters.Arg("type", "container"),
 			projectFilter(c.project)),
@@ -89,6 +89,8 @@ func (c *monitor) Start(ctx context.Context) error {
 			return nil
 		}
 		select {
+		case <-ctx.Done():
+			return nil
 		case err := <-errCh:
 			return err
 		case event := <-evtCh:
diff --git a/pkg/compose/ps.go b/pkg/compose/ps.go
index 72b47e30..42dfd272 100644
--- a/pkg/compose/ps.go
+++ b/pkg/compose/ps.go
@@ -21,6 +21,7 @@ import (
 	"sort"
 	"strings"
 
+	"github.com/docker/docker/api/types/container"
 	"golang.org/x/sync/errgroup"
 
 	"github.com/docker/compose/v2/pkg/api"
@@ -42,13 +43,13 @@ func (s *composeService) Ps(ctx context.Context, projectName string, options api
 	}
 	summary := make([]api.ContainerSummary, len(containers))
 	eg, ctx := errgroup.WithContext(ctx)
-	for i, container := range containers {
+	for i, ctr := range containers {
 		eg.Go(func() error {
-			publishers := make([]api.PortPublisher, len(container.Ports))
-			sort.Slice(container.Ports, func(i, j int) bool {
-				return container.Ports[i].PrivatePort < container.Ports[j].PrivatePort
+			publishers := make([]api.PortPublisher, len(ctr.Ports))
+			sort.Slice(ctr.Ports, func(i, j int) bool {
+				return ctr.Ports[i].PrivatePort < ctr.Ports[j].PrivatePort
 			})
-			for i, p := range container.Ports {
+			for i, p := range ctr.Ports {
 				publishers[i] = api.PortPublisher{
 					URL:           p.IP,
 					TargetPort:    int(p.PrivatePort),
@@ -57,22 +58,22 @@ func (s *composeService) Ps(ctx context.Context, projectName string, options api
 				}
 			}
 
-			inspect, err := s.apiClient().ContainerInspect(ctx, container.ID)
+			inspect, err := s.apiClient().ContainerInspect(ctx, ctr.ID)
 			if err != nil {
 				return err
 			}
 
 			var (
-				health   string
+				health   container.HealthStatus
 				exitCode int
 			)
 			if inspect.State != nil {
 				switch inspect.State.Status {
-				case "running":
+				case container.StateRunning:
 					if inspect.State.Health != nil {
 						health = inspect.State.Health.Status
 					}
-				case "exited", "dead":
+				case container.StateExited, container.StateDead:
 					exitCode = inspect.State.ExitCode
 				}
 			}
@@ -81,7 +82,7 @@ func (s *composeService) Ps(ctx context.Context, projectName string, options api
 				local  int
 				mounts []string
 			)
-			for _, m := range container.Mounts {
+			for _, m := range ctr.Mounts {
 				name := m.Name
 				if name == "" {
 					name = m.Source
@@ -93,26 +94,26 @@ func (s *composeService) Ps(ctx context.Context, projectName string, options api
 			}
 
 			var networks []string
-			if container.NetworkSettings != nil {
-				for k := range container.NetworkSettings.Networks {
+			if ctr.NetworkSettings != nil {
+				for k := range ctr.NetworkSettings.Networks {
 					networks = append(networks, k)
 				}
 			}
 
 			summary[i] = api.ContainerSummary{
-				ID:           container.ID,
-				Name:         getCanonicalContainerName(container),
-				Names:        container.Names,
-				Image:        container.Image,
-				Project:      container.Labels[api.ProjectLabel],
-				Service:      container.Labels[api.ServiceLabel],
-				Command:      container.Command,
-				State:        container.State,
-				Status:       container.Status,
-				Created:      container.Created,
-				Labels:       container.Labels,
-				SizeRw:       container.SizeRw,
-				SizeRootFs:   container.SizeRootFs,
+				ID:           ctr.ID,
+				Name:         getCanonicalContainerName(ctr),
+				Names:        ctr.Names,
+				Image:        ctr.Image,
+				Project:      ctr.Labels[api.ProjectLabel],
+				Service:      ctr.Labels[api.ServiceLabel],
+				Command:      ctr.Command,
+				State:        ctr.State,
+				Status:       ctr.Status,
+				Created:      ctr.Created,
+				Labels:       ctr.Labels,
+				SizeRw:       ctr.SizeRw,
+				SizeRootFs:   ctr.SizeRootFs,
 				Mounts:       mounts,
 				LocalVolumes: local,
 				Networks:     networks,
diff --git a/pkg/compose/up.go b/pkg/compose/up.go
index 9cbba83d..b3c4373e 100644
--- a/pkg/compose/up.go
+++ b/pkg/compose/up.go
@@ -18,10 +18,12 @@ package compose
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"os"
 	"os/signal"
 	"slices"
+	"sync"
 	"sync/atomic"
 	"syscall"
 
@@ -33,7 +35,6 @@ import (
 	"github.com/docker/compose/v2/pkg/api"
 	"github.com/docker/compose/v2/pkg/progress"
 	"github.com/eiannone/keyboard"
-	"github.com/hashicorp/go-multierror"
 	"github.com/sirupsen/logrus"
 	"golang.org/x/sync/errgroup"
 )
@@ -61,14 +62,11 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 		return err
 	}
 
-	var eg multierror.Group
-
 	// if we get a second signal during shutdown, we kill the services
 	// immediately, so the channel needs to have sufficient capacity or
 	// we might miss a signal while setting up the second channel read
 	// (this is also why signal.Notify is used vs signal.NotifyContext)
 	signalChan := make(chan os.Signal, 2)
-	defer close(signalChan)
 	signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
 	defer signal.Stop(signalChan)
 	var isTerminated atomic.Bool
@@ -103,26 +101,45 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 
 	printer := newLogPrinter(logConsumer)
 
-	doneCh := make(chan bool)
+	// global context to handle canceling goroutines
+	globalCtx, cancel := context.WithCancel(ctx)
+	defer cancel()
+
+	var (
+		eg   errgroup.Group
+		mu   sync.Mutex
+		errs []error
+	)
+
+	appendErr := func(err error) {
+		if err != nil {
+			mu.Lock()
+			errs = append(errs, err)
+			mu.Unlock()
+		}
+	}
+
 	eg.Go(func() error {
 		first := true
 		gracefulTeardown := func() {
 			first = false
 			fmt.Println("Gracefully Stopping... press Ctrl+C again to force")
 			eg.Go(func() error {
-				return progress.RunWithLog(context.WithoutCancel(ctx), func(ctx context.Context) error {
-					return s.stop(ctx, project.Name, api.StopOptions{
+				err := progress.RunWithLog(context.WithoutCancel(globalCtx), func(c context.Context) error {
+					return s.stop(c, project.Name, api.StopOptions{
 						Services: options.Create.Services,
 						Project:  project,
 					}, printer.HandleEvent)
 				}, s.stdinfo(), logConsumer)
+				appendErr(err)
+				return nil
 			})
 			isTerminated.Store(true)
 		}
 
 		for {
 			select {
-			case <-doneCh:
+			case <-globalCtx.Done():
 				if watcher != nil {
 					return watcher.Stop()
 				}
@@ -133,12 +150,12 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 				}
 			case <-signalChan:
 				if first {
-					keyboard.Close() //nolint:errcheck
+					_ = keyboard.Close()
 					gracefulTeardown()
 					break
 				}
 				eg.Go(func() error {
-					err := s.kill(context.WithoutCancel(ctx), project.Name, api.KillOptions{
+					err := s.kill(context.WithoutCancel(globalCtx), project.Name, api.KillOptions{
 						Services: options.Create.Services,
 						Project:  project,
 						All:      true,
@@ -148,18 +165,21 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 						return nil
 					}
 
-					return err
+					appendErr(err)
+					return nil
 				})
 				return nil
 			case event := <-kEvents:
-				navigationMenu.HandleKeyEvents(ctx, event, project, options)
+				navigationMenu.HandleKeyEvents(globalCtx, event, project, options)
 			}
 		}
 	})
 
 	if options.Start.Watch && watcher != nil {
-		err = watcher.Start(ctx)
-		if err != nil {
+		if err := watcher.Start(globalCtx); err != nil {
+			// cancel the global context to terminate background goroutines
+			cancel()
+			_ = eg.Wait()
 			return err
 		}
 	}
@@ -186,12 +206,14 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 				exitCode = event.ExitCode
 				_, _ = fmt.Fprintln(s.stdinfo(), progress.ErrorColor("Aborting on container exit..."))
 				eg.Go(func() error {
-					return progress.RunWithLog(context.WithoutCancel(ctx), func(ctx context.Context) error {
-						return s.stop(ctx, project.Name, api.StopOptions{
+					err := progress.RunWithLog(context.WithoutCancel(globalCtx), func(c context.Context) error {
+						return s.stop(c, project.Name, api.StopOptions{
 							Services: options.Create.Services,
 							Project:  project,
 						}, printer.HandleEvent)
 					}, s.stdinfo(), logConsumer)
+					appendErr(err)
+					return nil
 				})
 			}
 		})
@@ -208,13 +230,10 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 		})
 	}
 
-	// use an independent context tied to the errgroup for background attach operations
-	// the primary context is still used for other operations
-	// this means that once any attach operation fails, all other attaches are cancelled,
-	// but an attach failing won't interfere with the rest of the start
-	_, attachCtx := errgroup.WithContext(ctx)
-	containers, err := s.attach(attachCtx, project, printer.HandleEvent, options.Start.AttachTo)
+	containers, err := s.attach(globalCtx, project, printer.HandleEvent, options.Start.AttachTo)
 	if err != nil {
+		cancel()
+		_ = eg.Wait()
 		return err
 	}
 	attached := make([]string, len(containers))
@@ -230,38 +249,46 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 			return
 		}
 		eg.Go(func() error {
-			ctr, err := s.apiClient().ContainerInspect(ctx, event.ID)
+			ctr, err := s.apiClient().ContainerInspect(globalCtx, event.ID)
 			if err != nil {
-				return err
+				appendErr(err)
+				return nil
 			}
 
-			err = s.doLogContainer(ctx, options.Start.Attach, event.Source, ctr, api.LogOptions{
+			err = s.doLogContainer(globalCtx, options.Start.Attach, event.Source, ctr, api.LogOptions{
 				Follow: true,
 				Since:  ctr.State.StartedAt,
 			})
 			if errdefs.IsNotImplemented(err) {
 				// container may be configured with logging_driver: none
 				// as container already started, we might miss the very first logs. But still better than none
-				return s.doAttachContainer(ctx, event.Service, event.ID, event.Source, printer.HandleEvent)
+				err := s.doAttachContainer(globalCtx, event.Service, event.ID, event.Source, printer.HandleEvent)
+				appendErr(err)
+				return nil
 			}
-			return err
+			appendErr(err)
+			return nil
 		})
 	})
 
 	eg.Go(func() error {
-		err := monitor.Start(context.Background())
-		// Signal for the signal-handler goroutines to stop
-		close(doneCh)
-		return err
+		err := monitor.Start(globalCtx)
+		// cancel the global context to terminate signal-handler goroutines
+		cancel()
+		appendErr(err)
+		return nil
 	})
 
 	// We use the parent context without cancellation as we manage sigterm to stop the stack
 	err = s.start(context.WithoutCancel(ctx), project.Name, options.Start, printer.HandleEvent)
 	if err != nil && !isTerminated.Load() { // Ignore error if the process is terminated
+		cancel()
+		_ = eg.Wait()
 		return err
 	}
 
-	err = eg.Wait().ErrorOrNil()
+	_ = eg.Wait()
+	err = errors.Join(errs...)
 	if exitCode != 0 {
 		errMsg := ""
 		if err != nil {
diff --git a/pkg/e2e/fixtures/exec/compose.yaml b/pkg/e2e/fixtures/exec/compose.yaml
new file mode 100644
index 00000000..8920173b
--- /dev/null
+++ b/pkg/e2e/fixtures/exec/compose.yaml
@@ -0,0 +1,4 @@
+services:
+  test:
+    image: alpine
+    command: tail -f /dev/null
\ No newline at end of file
diff --git a/pkg/remote/git.go b/pkg/remote/git.go
index a7d27afb..5d85345e 100644
--- a/pkg/remote/git.go
+++ b/pkg/remote/git.go
@@ -32,7 +32,7 @@ import (
 	"github.com/compose-spec/compose-go/v2/types"
 	"github.com/docker/cli/cli/command"
 	"github.com/docker/compose/v2/pkg/api"
-	"github.com/moby/buildkit/util/gitutil"
+	gitutil "github.com/moby/buildkit/frontend/dockerfile/dfgitutil"
 	"github.com/sirupsen/logrus"
 )
 
@@ -64,7 +64,7 @@ type gitRemoteLoader struct {
 }
 
 func (g gitRemoteLoader) Accept(path string) bool {
-	_, err := gitutil.ParseGitRef(path)
+	_, _, err := gitutil.ParseGitRef(path)
 	return err == nil
 }
 
@@ -79,15 +79,15 @@ func (g gitRemoteLoader) Load(ctx context.Context, path string) (string, error)
 		return "", fmt.Errorf("git remote resource is disabled by %q", GIT_REMOTE_ENABLED)
 	}
 
-	ref, err := gitutil.ParseGitRef(path)
+	ref, _, err := gitutil.ParseGitRef(path)
 	if err != nil {
 		return "", err
 	}
 
 	local, ok := g.known[path]
 	if !ok {
-		if ref.Commit == "" {
-			ref.Commit = "HEAD" // default branch
+		if ref.Ref == "" {
+			ref.Ref = "HEAD" // default branch
 		}
 
 		err = g.resolveGitRef(ctx, path, ref)
@@ -100,7 +100,7 @@ func (g gitRemoteLoader) Load(ctx context.Context, path string) (string, error)
 			return "", fmt.Errorf("initializing remote resource cache: %w", err)
 		}
 
-		local = filepath.Join(cache, ref.Commit)
+		local = filepath.Join(cache, ref.Ref)
 		if _, err := os.Stat(local); os.IsNotExist(err) {
 			if g.offline {
 				return "", nil
@@ -130,8 +130,8 @@ func (g gitRemoteLoader) Dir(path string) string {
 }
 
 func (g gitRemoteLoader) resolveGitRef(ctx context.Context, path string, ref *gitutil.GitRef) error {
-	if !commitSHA.MatchString(ref.Commit) {
-		cmd := exec.CommandContext(ctx, "git", "ls-remote", "--exit-code", ref.Remote, ref.Commit)
+	if !commitSHA.MatchString(ref.Ref) {
+		cmd := exec.CommandContext(ctx, "git", "ls-remote", "--exit-code", ref.Remote, ref.Ref)
 		cmd.Env = g.gitCommandEnv()
 		out, err := cmd.CombinedOutput()
 		if err != nil {
@@ -147,7 +147,7 @@ func (g gitRemoteLoader) resolveGitRef(ctx context.Context, path string, ref *gi
 		if !commitSHA.MatchString(sha) {
 			return fmt.Errorf("invalid commit sha %q", sha)
 		}
-		ref.Commit = sha
+		ref.Ref = sha
 	}
 	return nil
 }
@@ -169,7 +169,7 @@ func (g gitRemoteLoader) checkout(ctx context.Context, path string, ref *gitutil
 		return err
 	}
 
-	cmd = exec.CommandContext(ctx, "git", "fetch", "--depth=1", "origin", ref.Commit)
+	cmd = exec.CommandContext(ctx, "git", "fetch", "--depth=1", "origin", ref.Ref)
 	cmd.Env = g.gitCommandEnv()
 	cmd.Dir = path
 
@@ -178,7 +178,7 @@ func (g gitRemoteLoader) checkout(ctx context.Context, path string, ref *gitutil
 		return err
 	}
 
-	cmd = exec.CommandContext(ctx, "git", "checkout", ref.Commit)
+	cmd = exec.CommandContext(ctx, "git", "checkout", ref.Ref)
 	cmd.Dir = path
 	err = cmd.Run()
 	if err != nil {



Test patch:
diff --git a/pkg/compose/ps_test.go b/pkg/compose/ps_test.go
index 41cd807e..ac2230e4 100644
--- a/pkg/compose/ps_test.go
+++ b/pkg/compose/ps_test.go
@@ -22,11 +22,11 @@ import (
 	"testing"
 
 	containerType "github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/filters"
 	"go.uber.org/mock/gomock"
 	"gotest.tools/v3/assert"
 
 	compose "github.com/docker/compose/v2/pkg/api"
-	"github.com/docker/docker/api/types/filters"
 )
 
 func TestPs(t *testing.T) {
@@ -42,10 +42,10 @@ func TestPs(t *testing.T) {
 	args := filters.NewArgs(projectFilter(strings.ToLower(testProject)), hasConfigHashLabel())
 	args.Add("label", "com.docker.compose.oneoff=False")
 	listOpts := containerType.ListOptions{Filters: args, All: false}
-	c1, inspect1 := containerDetails("service1", "123", "running", "healthy", 0)
-	c2, inspect2 := containerDetails("service1", "456", "running", "", 0)
+	c1, inspect1 := containerDetails("service1", "123", containerType.StateRunning, containerType.Healthy, 0)
+	c2, inspect2 := containerDetails("service1", "456", containerType.StateRunning, "", 0)
 	c2.Ports = []containerType.Port{{PublicPort: 80, PrivatePort: 90, IP: "localhost"}}
-	c3, inspect3 := containerDetails("service2", "789", "exited", "", 130)
+	c3, inspect3 := containerDetails("service2", "789", containerType.StateExited, "", 130)
 	api.EXPECT().ContainerList(ctx, listOpts).Return([]containerType.Summary{c1, c2, c3}, nil)
 	api.EXPECT().ContainerInspect(anyCancellableContext(), "123").Return(inspect1, nil)
 	api.EXPECT().ContainerInspect(anyCancellableContext(), "456").Return(inspect2, nil)
@@ -56,7 +56,9 @@ func TestPs(t *testing.T) {
 	expected := []compose.ContainerSummary{
 		{
 			ID: "123", Name: "123", Names: []string{"/123"}, Image: "foo", Project: strings.ToLower(testProject), Service: "service1",
-			State: "running", Health: "healthy", Publishers: []compose.PortPublisher{},
+			State:      containerType.StateRunning,
+			Health:     containerType.Healthy,
+			Publishers: []compose.PortPublisher{},
 			Labels: map[string]string{
 				compose.ProjectLabel:     strings.ToLower(testProject),
 				compose.ConfigFilesLabel: "/src/pkg/compose/testdata/compose.yaml",
@@ -66,7 +68,8 @@ func TestPs(t *testing.T) {
 		},
 		{
 			ID: "456", Name: "456", Names: []string{"/456"}, Image: "foo", Project: strings.ToLower(testProject), Service: "service1",
-			State: "running", Health: "",
+			State:      containerType.StateRunning,
+			Health:     "",
 			Publishers: []compose.PortPublisher{{URL: "localhost", TargetPort: 90, PublishedPort: 80}},
 			Labels: map[string]string{
 				compose.ProjectLabel:     strings.ToLower(testProject),
@@ -77,7 +80,10 @@ func TestPs(t *testing.T) {
 		},
 		{
 			ID: "789", Name: "789", Names: []string{"/789"}, Image: "foo", Project: strings.ToLower(testProject), Service: "service2",
-			State: "exited", Health: "", ExitCode: 130, Publishers: []compose.PortPublisher{},
+			State:      containerType.StateExited,
+			Health:     "",
+			ExitCode:   130,
+			Publishers: []compose.PortPublisher{},
 			Labels: map[string]string{
 				compose.ProjectLabel:     strings.ToLower(testProject),
 				compose.ConfigFilesLabel: "/src/pkg/compose/testdata/compose.yaml",
@@ -90,8 +96,8 @@ func TestPs(t *testing.T) {
 	assert.DeepEqual(t, containers, expected)
 }
 
-func containerDetails(service string, id string, status string, health string, exitCode int) (containerType.Summary, containerType.InspectResponse) {
-	container := containerType.Summary{
+func containerDetails(service string, id string, status containerType.ContainerState, health containerType.HealthStatus, exitCode int) (containerType.Summary, containerType.InspectResponse) {
+	ctr := containerType.Summary{
 		ID:     id,
 		Names:  []string{"/" + id},
 		Image:  "foo",
@@ -107,5 +113,5 @@ func containerDetails(service string, id string, status string, health string, e
 			},
 		},
 	}
-	return container, inspect
+	return ctr, inspect
 }
diff --git a/pkg/e2e/exec_test.go b/pkg/e2e/exec_test.go
new file mode 100644
index 00000000..16643a0f
--- /dev/null
+++ b/pkg/e2e/exec_test.go
@@ -0,0 +1,47 @@
+/*
+   Copyright 2023 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.
+*/
+
+package e2e
+
+import (
+	"testing"
+
+	"gotest.tools/v3/icmd"
+)
+
+func TestExec(t *testing.T) {
+	const projectName = "e2e-exec"
+	c := NewParallelCLI(t)
+
+	cleanup := func() {
+		c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--timeout=0", "--remove-orphans")
+	}
+	t.Cleanup(cleanup)
+	cleanup()
+
+	c.RunDockerComposeCmd(t, "-f", "./fixtures/exec/compose.yaml", "--project-name", projectName, "run", "-d", "test", "cat")
+
+	res := c.RunDockerComposeCmdNoCheck(t, "--project-name", projectName, "exec", "--index=1", "test", "ps")
+	res.Assert(t, icmd.Expected{Err: "service \"test\" is not running container #1", ExitCode: 1})
+
+	res = c.RunDockerComposeCmd(t, "--project-name", projectName, "exec", "test", "ps")
+	res.Assert(t, icmd.Expected{Out: "cat"}) // one-off container was selected
+
+	c.RunDockerComposeCmd(t, "-f", "./fixtures/exec/compose.yaml", "--project-name", projectName, "up", "-d")
+
+	res = c.RunDockerComposeCmd(t, "--project-name", projectName, "exec", "test", "ps")
+	res.Assert(t, icmd.Expected{Out: "tail"}) // service container was selected
+}
diff --git a/pkg/e2e/up_test.go b/pkg/e2e/up_test.go
index 0d3e3db0..395a1633 100644
--- a/pkg/e2e/up_test.go
+++ b/pkg/e2e/up_test.go
@@ -175,7 +175,7 @@ func TestUpWithAllResources(t *testing.T) {
 	})
 
 	res := c.RunDockerComposeCmd(t, "-f", "./fixtures/resources/compose.yaml", "--all-resources", "--project-name", projectName, "up")
-	assert.Assert(t, strings.Contains(res.Combined(), fmt.Sprintf(`Volume "%s_my_vol"  Created`, projectName)), res.Combined())
+	assert.Assert(t, strings.Contains(res.Combined(), fmt.Sprintf(`Volume %s_my_vol  Created`, projectName)), res.Combined())
 	assert.Assert(t, strings.Contains(res.Combined(), fmt.Sprintf(`Network %s_my_net  Created`, projectName)), res.Combined())
 }