diff --git a/Makefile b/Makefile index 4c846d5..cd3abbb 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ BIN_DIR=bin # Apple's Make uses the parent's PATH to spawn bare recipe commands. On POSIX # shells, exec forces lookup through the shell with our exported PATH instead. # Native Windows shells must keep plain tool invocations. +# An explicit GOPATH_BIN (including an empty value) skips Go path discovery. RUN_TOOL := ifeq ($(wildcard /dev/null),/dev/null) DEVNULL := /dev/null @@ -38,7 +39,9 @@ ifeq ($(OS),Windows_NT) # Windows + POSIX Shell (Git Bash / MSYS2 / Cygwin) RUN_TOOL := exec # cygpath converts drive-letter paths to the shell's mount layout. - GOPATH_BIN := $(shell if command -v cygpath >/dev/null 2>&1; then cygpath -u "$$(go env GOPATH)/bin"; else printf '%s/bin' "$$(go env GOPATH)"; fi) + ifeq ($(origin GOPATH_BIN),undefined) + GOPATH_BIN := $(shell if command -v cygpath >/dev/null 2>&1; then cygpath -u "$$(go env GOPATH)/bin"; else printf '%s/bin' "$$(go env GOPATH)"; fi) + endif ifneq ($(shell test -d "$(GOPATH_BIN)" && echo yes),) export PATH := $(GOPATH_BIN):$(PATH) endif @@ -52,7 +55,9 @@ ifeq ($(OS),Windows_NT) else # Windows 原生环境 (cmd.exe / PowerShell) # 自动将 Go bin 目录加入 PATH (Windows 环境变量使用分号分隔) - GOPATH_BIN := $(shell go env GOPATH 2>$(DEVNULL))\bin + ifeq ($(origin GOPATH_BIN),undefined) + GOPATH_BIN := $(shell go env GOPATH 2>$(DEVNULL))\bin + endif ifneq ($(wildcard $(GOPATH_BIN)),) export PATH := $(GOPATH_BIN);$(PATH) endif @@ -67,7 +72,9 @@ else # Linux / macOS 环境 (POSIX) RUN_TOOL := exec SHELL_EXT := - GOPATH_BIN := $(shell go env GOPATH 2>$(DEVNULL))/bin + ifeq ($(origin GOPATH_BIN),undefined) + GOPATH_BIN := $(shell go env GOPATH 2>$(DEVNULL))/bin + endif ifneq ($(shell test -d "$(GOPATH_BIN)" && echo yes),) export PATH := $(GOPATH_BIN):$(PATH) endif diff --git a/cmd/makefile_test.go b/cmd/makefile_test.go index bade326..3f8ac69 100644 --- a/cmd/makefile_test.go +++ b/cmd/makefile_test.go @@ -11,27 +11,17 @@ import ( "time" ) +// Allow subprocess startup on loaded native runners while keeping every fixture bounded. +const makefileTestTimeout = 30 * time.Second + func TestMakefile_LinuxDryRun(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("requires a POSIX host") } - if _, err := exec.LookPath("make"); err != nil { - t.Skip("make command not available in environment") - } - - targets := []string{"build", "clean", "help", "windows", "linux"} + targets := []string{"build", "clean", "windows", "linux"} for _, target := range targets { t.Run(target, func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "make", "-n", target) - cmd.Dir = ".." - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("make -n %s failed: %v, output: %s", target, err, string(out)) - } - outStr := string(out) + outStr := makefileDryRun(t, target) switch target { case "build": @@ -55,21 +45,8 @@ func TestMakefile_LinuxDryRun(t *testing.T) { } func TestMakefile_WindowsDryRun(t *testing.T) { - if _, err := exec.LookPath("make"); err != nil { - t.Skip("make command not available in environment") - } - t.Run("Windows_NT_build", func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "make", "-n", "OS=Windows_NT", "build") - cmd.Dir = ".." - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("make -n OS=Windows_NT build failed: %v, output: %s", err, string(out)) - } - outStr := string(out) + outStr := makefileDryRun(t, "OS=Windows_NT", "build") if !strings.Contains(outStr, "bin/xops.exe") { t.Fatalf("expected bin/xops.exe with OS=Windows_NT, got: %s", outStr) } @@ -84,34 +61,14 @@ func TestMakefile_WindowsDryRun(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("requires a POSIX host; native Windows clean is tested separately") } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "make", "-n", "OS=Windows_NT", "SHELL=/bin/bash", "clean") - cmd.Dir = ".." - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("make -n OS=Windows_NT SHELL=/bin/bash clean failed: %v, output: %s", err, string(out)) - } - outStr := string(out) + outStr := makefileDryRun(t, "OS=Windows_NT", "SHELL=/bin/bash", "clean") if !strings.Contains(outStr, "rm -rf bin coverage.out") { t.Fatalf("expected rm -rf for POSIX shell on Windows_NT, got: %s", outStr) } }) t.Run("Windows_NT_CMD_clean", func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - // Avoid metadata subprocesses so the CMD dry run also works on POSIX hosts. - cmd := exec.CommandContext(ctx, "make", "-n", "OS=Windows_NT", "SHELL=cmd.exe", - "GOPATH_BIN=", "VERSION=test", "COMMIT=test", "DATE=test", "clean") - cmd.Dir = ".." - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("native Windows clean dry run failed: %v, output: %s", err, out) - } - outStr := string(out) + outStr := makefileDryRun(t, "OS=Windows_NT", "SHELL=cmd.exe", "clean") if !strings.Contains(outStr, "rmdir /s /q bin") { t.Fatalf("expected rmdir /s /q bin for CMD shell on Windows_NT, got: %s", outStr) } @@ -122,21 +79,7 @@ func TestMakefile_WindowsDryRun(t *testing.T) { } func TestMakefile_CrossCompilationTargets(t *testing.T) { - if _, err := exec.LookPath("make"); err != nil { - t.Skip("make command not available in environment") - } - - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "make", "-n", "windows", "windows-arm64", "linux", "linux-arm64", "darwin-amd64", "darwin-arm64") - cmd.Dir = ".." - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("make -n cross compilation targets failed: %v, output: %s", err, string(out)) - } - - outStr := string(out) + outStr := makefileDryRun(t, "windows", "windows-arm64", "linux", "linux-arm64", "darwin-amd64", "darwin-arm64") expectedArtifacts := []string{ "bin/xops.exe", "bin/xops-arm64.exe", @@ -153,22 +96,10 @@ func TestMakefile_CrossCompilationTargets(t *testing.T) { } } -func TestMakefile_TimeoutContextSafety(t *testing.T) { - if _, err := exec.LookPath("make"); err != nil { - t.Skip("make command not available in environment") - } - // Bound make execution even when the suite has no timeout. - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - start := time.Now() - cmd := exec.CommandContext(ctx, "make", "-n", "help") - cmd.Dir = ".." - if err := cmd.Run(); err != nil { - t.Fatalf("make -n help failed: %v", err) - } - if time.Since(start) > 5*time.Second { - t.Fatalf("make -n help took unexpectedly long: %v", time.Since(start)) +func TestMakefile_HelpDryRun(t *testing.T) { + out := makefileDryRun(t, "help") + if !strings.Contains(out, "make [target]") || !strings.Contains(out, "Targets:") { + t.Fatalf("help dry run omitted usage or targets: %s", out) } } @@ -199,7 +130,7 @@ func TestMakefile_InstallSkillPOSIX(t *testing.T) { t.Fatal(err) } homeDir := filepath.Join(root, "home with spaces") - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout) defer cancel() cmd := exec.CommandContext(ctx, "make", "OS="+platform, "SHELL=/bin/sh", "GOPATH_BIN=", "HOME="+homeDir, "install-skill") cmd.Dir = root @@ -218,21 +149,72 @@ func TestMakefile_InstallSkillPOSIX(t *testing.T) { } func TestMakefile_InstallSkillWindowsDryRun(t *testing.T) { - if _, err := exec.LookPath("make"); err != nil { + out := makefileDryRun(t, "OS=Windows_NT", "SHELL=cmd.exe", "install-skill") + if strings.Count(out, "(Join-Path $HOME '.gemini/skills/xops-agent')") != 2 { + t.Fatalf("expected expandable home paths for both installation commands, got: %s", out) + } +} + +// Dry runs check recipes with fixed metadata, without measuring Go/Git startup +// on a loaded native runner. Real tool execution and PATH discovery have separate +// fixture tests below. Bound both the command and inherited output pipes. +func makefileDryRun(t *testing.T, args ...string) string { + t.Helper() + makePath, err := exec.LookPath("make") + if err != nil { t.Skip("make command not available in environment") } - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout) defer cancel() - // Override shell-evaluated metadata so this dry run also works on POSIX hosts. - cmd := exec.CommandContext(ctx, "make", "-n", "OS=Windows_NT", "SHELL=cmd.exe", - "GOPATH_BIN=", "VERSION=test", "COMMIT=test", "DATE=test", "install-skill") + options := []string{"-n", "GOPATH_BIN=", "VERSION=test", "COMMIT=test", "DATE=test"} + cmd := exec.CommandContext(ctx, makePath, append(options, args...)...) cmd.Dir = ".." + cmd.WaitDelay = time.Second out, err := cmd.CombinedOutput() if err != nil { - t.Fatalf("Windows install-skill dry run failed: %v, output: %s", err, out) + t.Fatalf("make dry run %v failed: %v (context: %v), output: %s", args, err, ctx.Err(), out) } - if strings.Count(string(out), "(Join-Path $HOME '.gemini/skills/xops-agent')") != 2 { - t.Fatalf("expected expandable home paths for both installation commands, got: %s", out) + return string(out) +} + +func TestMakefile_GOPATHOverrideSkipsDiscovery(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX host for shell fixtures") + } + makePath, err := exec.LookPath("make") + if err != nil { + t.Skip("make command not available in environment") + } + for _, platform := range []struct { + name, os, shellMode string + }{ + {"POSIX", "Darwin", "yes"}, + {"Windows_POSIX", "Windows_NT", "yes"}, + {"Windows_CMD", "Windows_NT", ""}, + } { + for _, bin := range []string{"", "/explicit go/bin"} { + t.Run(platform.name+"/"+bin, func(t *testing.T) { + root := makefileFixture(t) + marker := filepath.Join(root, "discovery") + writeMakefileFixture(t, root, "tools/go", "#!/bin/sh\nprintf 'called' >> \"$XOPS_MAKE_DISCOVERY\"\nprintf '/unused'\n") + t.Setenv("PATH", filepath.Join(root, "tools")) + t.Setenv("XOPS_MAKE_DISCOVERY", marker) + ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, makePath, "-n", "OS="+platform.os, "SHELL=/bin/sh", + "POSIX_SHELL="+platform.shellMode, "GOPATH_BIN="+bin, "VERSION=test", "COMMIT=test", "DATE=test", "help") + cmd.Dir = root + cmd.WaitDelay = time.Second + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("help with explicit GOPATH_BIN failed: %v, output: %s", err, out) + } + if _, err := os.Stat(marker); err == nil { + t.Fatal("explicit GOPATH_BIN still invoked Go discovery") + } else if !os.IsNotExist(err) { + t.Fatalf("check Go discovery marker: %v", err) + } + }) + } } } @@ -302,7 +284,7 @@ func TestMakefile_WindowsPOSIXPath(t *testing.T) { gopath = `C:\Go Home` } t.Setenv("FIXTURE_GOPATH", gopath) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout) defer cancel() cmd := exec.CommandContext(ctx, makePath, "--no-print-directory", "-f", "probe.mk", "OS=Windows_NT", "SHELL=/bin/sh", "VERSION=test", "COMMIT=test", "DATE=test", "probe") @@ -343,7 +325,7 @@ func TestMakefile_LintUsesExportedPOSIXPath(t *testing.T) { t.Setenv("FIXTURE_EXIT", "7") } t.Setenv("MAKELEVEL", "1") - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout) defer cancel() cmd := exec.CommandContext(ctx, makePath, "--no-print-directory", "OS="+platform, "SHELL=/bin/sh", "VERSION=test", "COMMIT=test", "DATE=test", "lint") cmd.Dir = root @@ -397,7 +379,7 @@ case "$2" in *) printf 'unexpected shell command: %s\n' "$2" >&2; exit 1 ;; esac `) - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout) defer cancel() cmd := exec.CommandContext(ctx, "make", "-n", "OS=Windows_NT", "SHELL="+filepath.Join(root, "sh.exe"), "GOPATH_BIN=", diff --git a/docs/en/guide/getting-started.md b/docs/en/guide/getting-started.md index 6a20b1c..acaefd3 100644 --- a/docs/en/guide/getting-started.md +++ b/docs/en/guide/getting-started.md @@ -15,6 +15,8 @@ make build On Windows, `make build` automatically produces `bin/xops.exe`. You can also cross-build Windows binaries on any platform with `make windows`. +Use `make GOPATH_BIN=/path/to/bin build` to specify the Go tool directory. Setting `GOPATH_BIN=` skips automatic Go tool directory discovery and preserves the existing `PATH`. + ## Initialize configuration ```bash diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 539d88c..d9d903c 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -15,6 +15,8 @@ make build 在 Windows 系统上,`make build` 会自动生成 `bin/xops.exe`;亦可在任意平台上通过 `make windows` 交叉编译 Windows 产物。 +可用 `make GOPATH_BIN=/path/to/bin build` 指定 Go 工具目录;设置 `GOPATH_BIN=` 会跳过 Go 工具目录的自动探测,并保留现有 `PATH`。 + ## 初始化配置 ```bash diff --git a/go.mod b/go.mod index d0e0367..d4f44ca 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( charm.land/lipgloss/v2 v2.0.5 github.com/charmbracelet/colorprofile v0.4.3 github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 - github.com/charmbracelet/x/ansi v0.11.7 + github.com/charmbracelet/x/ansi v0.11.8 github.com/charmbracelet/x/conpty v0.2.0 github.com/charmbracelet/x/term v0.2.2 github.com/charmbracelet/x/termios v0.1.1 diff --git a/go.sum b/go.sum index 60cd50f..9ee9744 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= -github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= -github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY= github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=