diff --git a/packages/cli/internal/modules/dependencies/service.go b/packages/cli/internal/modules/dependencies/service.go index 6fa8b6f..d132d63 100644 --- a/packages/cli/internal/modules/dependencies/service.go +++ b/packages/cli/internal/modules/dependencies/service.go @@ -120,7 +120,13 @@ func (s Service) prepareGo(ctx context.Context, in Input, p workspace.ManifestPr if active != "" && active != "off" { want := filepath.Join(in.Root, "go.work") if filepath.Clean(active) != filepath.Clean(want) { - return fmt.Errorf("%s uses external GOWORK=%s; use %s or GOWORK=off explicitly", p.Name, active, want) + // Go may return the physical path while the workspace root uses + // a symlink (for example /var and /private/var on macOS). + activeInfo, activeErr := os.Stat(active) + wantInfo, wantErr := os.Stat(want) + if activeErr != nil || wantErr != nil || !os.SameFile(activeInfo, wantInfo) { + return fmt.Errorf("%s uses external GOWORK=%s; use %s or GOWORK=off explicitly", p.Name, active, want) + } } lockRoot = in.Root } diff --git a/packages/cli/internal/modules/dependencies/service_test.go b/packages/cli/internal/modules/dependencies/service_test.go index d4a48c5..bdc1ed2 100644 --- a/packages/cli/internal/modules/dependencies/service_test.go +++ b/packages/cli/internal/modules/dependencies/service_test.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -136,6 +137,57 @@ func TestGoUnresolvedImportFailsWithoutTidy(t *testing.T) { } } +func TestGoWorkspaceAcceptsSymlinkPaths(t *testing.T) { + for _, mode := range []string{"workspace-root", "gowork"} { + t.Run(mode, func(t *testing.T) { + root := setupGo(t) + write(t, root, "go.work", "go 1.25.0\nuse ./api\n") + write(t, root, "api/go.mod", "module example.com/api\ngo 1.25.0\n") + write(t, root, "api/main.go", "package main\nfunc main(){}\n") + physicalRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + linkTarget := filepath.Join(physicalRoot, "go.work") + alias := filepath.Join(physicalRoot, "alias.work") + if mode == "workspace-root" { + linkTarget = physicalRoot + alias = filepath.Join(t.TempDir(), "workspace-link") + } + if err := os.Symlink(linkTarget, alias); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlinks unavailable: %v", err) + } + t.Fatal(err) + } + active := alias + if mode == "workspace-root" { + root, active = alias, filepath.Join(physicalRoot, "go.work") + } + t.Setenv("GOWORK", active) + in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "api", "go")}}} + if err := (Service{}).Prepare(context.Background(), in); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestGoWorkspaceRejectsExternalFileWithSameContents(t *testing.T) { + root := setupGo(t) + work := "go 1.25.0\n" + write(t, root, "go.work", work) + write(t, root, "api/go.mod", "module example.com/api\ngo 1.25.0\n") + external := t.TempDir() + write(t, external, "go.work", work) + active := filepath.Join(external, "go.work") + t.Setenv("GOWORK", active) + in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "api", "go")}}} + if err := (Service{}).Prepare(context.Background(), in); err == nil || !strings.Contains(err.Error(), "uses external GOWORK="+active) { + t.Fatalf("expected external workspace rejection, got %v", err) + } +} + type fakeProvider struct{ dir string } func (p fakeProvider) Prepare(_ context.Context, c runtimeport.Command) (runtimeport.Command, error) { diff --git a/packages/cli/internal/transport/cobra/create/result.go b/packages/cli/internal/transport/cobra/create/result.go index caee511..aad58fa 100644 --- a/packages/cli/internal/transport/cobra/create/result.go +++ b/packages/cli/internal/transport/cobra/create/result.go @@ -3,6 +3,7 @@ package createcmd import ( "fmt" "io" + "os" "path/filepath" "strings" @@ -76,7 +77,7 @@ func compactHomePath(path string) string { } func relativeOrAbs(cwd, targetDir string, useCurrentDir bool) string { - rel, err := filepath.Rel(cwd, targetDir) + rel, err := filepath.Rel(resolveDisplayPath(cwd), resolveDisplayPath(targetDir)) if err == nil && rel != "" { return rel } @@ -86,6 +87,21 @@ func relativeOrAbs(cwd, targetDir string, useCurrentDir bool) string { return targetDir } +// resolveDisplayPath resolves symlinks in existing ancestors, even when the +// workspace directory (and some of its parents) has not been created yet. +// Other filesystem errors leave the original path available for display. +func resolveDisplayPath(path string) string { + resolved, err := filepath.EvalSymlinks(path) + if err == nil { + return resolved + } + parent := filepath.Dir(path) + if !os.IsNotExist(err) || parent == path { + return path + } + return filepath.Join(resolveDisplayPath(parent), filepath.Base(path)) +} + func parsePresetProjectNames(raw string, want int) ([]string, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/packages/cli/internal/transport/cobra/create/result_test.go b/packages/cli/internal/transport/cobra/create/result_test.go new file mode 100644 index 0000000..66319be --- /dev/null +++ b/packages/cli/internal/transport/cobra/create/result_test.go @@ -0,0 +1,44 @@ +package createcmd + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestRelativeOrAbsWithSymlinkPaths(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + alias := filepath.Join(t.TempDir(), "parent-link") + if err := os.Symlink(root, alias); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("directory symlinks unavailable: %v", err) + } + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "existing"), 0o755); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, cwd, target, want string + inPlace bool + }{ + {name: "plain", cwd: root, target: filepath.Join(root, "demo"), want: "demo"}, + {name: "target-alias", cwd: root, target: filepath.Join(alias, "demo"), want: "demo"}, + {name: "cwd-alias", cwd: alias, target: filepath.Join(root, "demo"), want: "demo"}, + {name: "both-aliases", cwd: alias, target: filepath.Join(alias, "demo"), want: "demo"}, + {name: "missing-parents", cwd: root, target: filepath.Join(alias, "new", "nested", "demo"), want: filepath.Join("new", "nested", "demo")}, + {name: "existing-target", cwd: root, target: filepath.Join(alias, "existing"), want: "existing"}, + {name: "in-place", cwd: root, target: alias, inPlace: true, want: "."}, + {name: "sibling", cwd: filepath.Join(root, "existing"), target: filepath.Join(alias, "demo"), want: filepath.Join("..", "demo")}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := relativeOrAbs(tc.cwd, tc.target, tc.inPlace); got != tc.want { + t.Errorf("relativeOrAbs(%q, %q, %v) = %q; want %q", tc.cwd, tc.target, tc.inPlace, got, tc.want) + } + }) + } +}