From 9f6a738b3cb66b5179661b362afc7d7902c1643b Mon Sep 17 00:00:00 2001 From: bunnysayzz Date: Fri, 2 Oct 2026 00:16:48 +0530 Subject: [PATCH] fix: resolve compose project name from compose config Fixes #842 --- pkg/commands/docker.go | 83 ++++++++++++++++++++++++------------- pkg/commands/docker_test.go | 43 +++++++++++++++++++ 2 files changed, 97 insertions(+), 29 deletions(-) diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index b055441a8..6e3cdafbe 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -243,29 +243,14 @@ func (c *DockerCommand) RefreshContainersAndServices(currentContainers []*Contai } } - // Determine the local project name before merging services, since - // mergeServices needs it. We match compose service names against container - // labels to handle cases where the project name differs from the directory - // name (e.g. a `name:` directive in the compose file). - if c.LocalProjectName == "" && c.InDockerComposeProject && composeServices != nil { - for _, ctr := range containers { - if ctr.ProjectName == "" || ctr.ServiceName == "" { - continue - } - for _, svc := range composeServices { - if ctr.ServiceName == svc.Name { - c.LocalProjectName = ctr.ProjectName - break - } - } - if c.LocalProjectName != "" { - break - } - } - // Fall back to directory name - if c.LocalProjectName == "" && c.Config.ProjectDir != "" { - c.LocalProjectName = filepath.Base(c.Config.ProjectDir) - } + // The local project name is resolved by GetServices from compose itself + // (see below). If compose did not report one, fall back to the directory + // name, which is compose's own last-resort default. We deliberately do NOT + // guess it from the daemon-wide container list: when another project runs + // a service with the same name, the first container in list order wins and + // poisons every local service binding (see #842). + if c.LocalProjectName == "" && c.InDockerComposeProject && composeServices != nil && c.Config.ProjectDir != "" { + c.LocalProjectName = filepath.Base(c.Config.ProjectDir) } // Merge compose services (which include stopped services) with @@ -436,7 +421,25 @@ func (c *DockerCommand) GetServices() ([]*Service, error) { } composeCommand := c.Config.UserConfig.CommandTemplates.DockerCompose - output, err := c.OSCommand.RunCommandWithOutput(fmt.Sprintf("%s config --services", composeCommand)) + + // Ask compose itself for the resolved project name. `config --format json` + // carries the project name at the top level, resolved exactly the way + // compose resolves it at runtime: -p/--project-name, COMPOSE_PROJECT_NAME, + // the `name:` directive, then the directory name. + output, err := c.OSCommand.RunCommandWithOutput(fmt.Sprintf("%s config --format json", composeCommand)) + if err == nil { + if projectName, names, jsonErr := parseComposeConfigJSON(output); jsonErr == nil { + if projectName != "" { + c.LocalProjectName = projectName + } + return c.buildServices(names), nil + } + } + + // Fall back to the plain service list (e.g. docker-compose v1, which has + // no --format json). The project name stays unset and is resolved by the + // caller. + output, err = c.OSCommand.RunCommandWithOutput(fmt.Sprintf("%s config --services", composeCommand)) if err != nil { return nil, err } @@ -445,9 +448,32 @@ func (c *DockerCommand) GetServices() ([]*Service, error) { // service1 // service2 - lines := utils.SplitLines(output) - services := make([]*Service, len(lines)) - for i, str := range lines { + return c.buildServices(utils.SplitLines(output)), nil +} + +// parseComposeConfigJSON extracts the resolved project name and the sorted +// service names from `docker compose config --format json` output. +func parseComposeConfigJSON(output string) (string, []string, error) { + var parsed struct { + Name string `json:"name"` + Services map[string]json.RawMessage `json:"services"` + } + if err := json.Unmarshal([]byte(output), &parsed); err != nil { + return "", nil, err + } + names := make([]string, 0, len(parsed.Services)) + for name := range parsed.Services { + names = append(names, name) + } + sort.Strings(names) + return parsed.Name, names, nil +} + +// buildServices builds the service list, stamped with the current +// LocalProjectName. +func (c *DockerCommand) buildServices(names []string) []*Service { + services := make([]*Service, len(names)) + for i, str := range names { services[i] = &Service{ Name: str, ID: c.LocalProjectName + "-" + str, @@ -457,8 +483,7 @@ func (c *DockerCommand) GetServices() ([]*Service, error) { DockerCommand: c, } } - - return services, nil + return services } func (c *DockerCommand) RefreshContainerDetails(containers []*Container) error { diff --git a/pkg/commands/docker_test.go b/pkg/commands/docker_test.go index db45a06ff..3933f0da6 100644 --- a/pkg/commands/docker_test.go +++ b/pkg/commands/docker_test.go @@ -89,3 +89,46 @@ func TestIsProjectScoped(t *testing.T) { }) } } + +// TestParseComposeConfigJSON verifies we pull the resolved project name and +// service list out of `docker compose config --format json` output. +// Regression context: https://github.com/jesseduffield/lazydocker/issues/842 +func TestParseComposeConfigJSON(t *testing.T) { + output := `{"name":"matomo","services":{"db":{"image":"mariadb"},"app":{"image":"matomo"}},"networks":{"default":{}},"volumes":{}}` + name, services, err := parseComposeConfigJSON(output) + assert.NoError(t, err) + assert.Equal(t, "matomo", name) + assert.Equal(t, []string{"app", "db"}, services) +} + +func TestParseComposeConfigJSONInvalid(t *testing.T) { + _, _, err := parseComposeConfigJSON("not json at all") + assert.Error(t, err) +} + +func TestParseComposeConfigJSONEmptyServices(t *testing.T) { + name, services, err := parseComposeConfigJSON(`{"name":"lonely","services":{}}`) + assert.NoError(t, err) + assert.Equal(t, "lonely", name) + assert.Empty(t, services) +} + +// TestAssignContainersToServicesPicksOwnProject is a regression test for +// https://github.com/jesseduffield/lazydocker/issues/842: two compose projects +// running a service with the same name must not get each other's containers. +func TestAssignContainersToServicesPicksOwnProject(t *testing.T) { + c := &DockerCommand{LocalProjectName: "matomo"} + + services := []*Service{ + {Name: "app", ProjectName: "matomo"}, + } + foreign := &Container{ID: "foreign-id", ServiceName: "app", ProjectName: "env2"} + local := &Container{ID: "local-id", ServiceName: "app", ProjectName: "matomo"} + // foreign container first in daemon list order, like the real bug + containers := []*Container{foreign, local} + + c.assignContainersToServices(containers, services) + + assert.NotNil(t, services[0].Container) + assert.Equal(t, "local-id", services[0].Container.ID) +}