fix: adjust build defaults when tmp_dir is customized (#888)

* fix: adjust build defaults when tmp_dir is customized

When a user sets tmp_dir to a non-default value (e.g. ".tmp"),
Build.Cmd, Build.Bin, and Build.ExcludeDir still referenced the
default "tmp" directory, causing the build to create a "tmp/" folder
alongside the intended custom directory.

Add adjustDefaultsForTmpDir() to update these fields when they still
hold their default values and TmpDir has been changed. Explicitly set
values are preserved.

Fixes #780

Signed-off-by: majiayu000 <1835304752@qq.com>

* test: add coverage for Windows branch in adjustDefaultsForTmpDir

Extract OS parameter to allow testing the Windows code path on any
platform, covering the lines flagged by Codecov.

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: support absolute tmp_dir defaults and preserve custom exclude_dir

* fix: make tmp_dir absolute path handling OS-agnostic

---------

Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: xiantang <zhujingdi1998@gmail.com>
This commit is contained in:
lif
2026-04-03 17:53:22 +08:00
committed by GitHub
parent 0d9e5e1344
commit 47d11f6a71
2 changed files with 283 additions and 0 deletions
+88
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/exec"
pathpkg "path"
"path/filepath"
"reflect"
"regexp"
@@ -392,6 +393,7 @@ func (c *Config) preprocess(args map[string]TomlInfo) error {
if c.TestDataDir == "" {
c.TestDataDir = "testdata"
}
c.adjustDefaultsForTmpDir()
ed := c.Build.ExcludeDir
for i := range ed {
ed[i] = cleanPath(ed[i])
@@ -446,6 +448,92 @@ func (c *Config) preprocess(args map[string]TomlInfo) error {
return err
}
// adjustDefaultsForTmpDir updates Build.Cmd, Build.Bin, and Build.ExcludeDir
// when they still hold their default values but TmpDir has been changed.
func (c *Config) adjustDefaultsForTmpDir() {
c.adjustDefaultsForTmpDirWithOS(runtime.GOOS)
}
func (c *Config) adjustDefaultsForTmpDirWithOS(goos string) {
const defaultTmpDir = "tmp"
if c.TmpDir == defaultTmpDir {
return
}
defaultCmd := "go build -o ./tmp/main ."
defaultBin := "./tmp/main"
mainBinary := "main"
if goos == PlatformWindows {
defaultCmd = "go build -o ./tmp/main.exe ."
defaultBin = `tmp\main.exe`
mainBinary = "main.exe"
}
newBinPath := filepath.Join(c.TmpDir, mainBinary)
normalizedBinPath := strings.ReplaceAll(newBinPath, `\`, "/")
newBin := "./" + normalizedBinPath
cmdOut := newBin
if isAbsPathForOS(goos, c.TmpDir) {
newBin = newBinPath
cmdOut = normalizedBinPath
}
if goos == PlatformWindows {
if isAbsPathForOS(goos, c.TmpDir) {
newBin = strings.ReplaceAll(newBinPath, "/", "\\")
cmdOut = normalizedBinPath
} else {
newBin = strings.ReplaceAll(newBinPath, "/", "\\")
cmdOut = "./" + normalizedBinPath
}
}
newCmd := "go build -o " + cmdOut + " ."
if c.Build.Cmd == defaultCmd {
c.Build.Cmd = newCmd
}
if c.Build.Bin == defaultBin {
c.Build.Bin = newBin
}
if isDefaultExcludeDir(c.Build.ExcludeDir) {
for i, dir := range c.Build.ExcludeDir {
if dir == defaultTmpDir {
c.Build.ExcludeDir[i] = c.TmpDir
}
}
}
}
func isDefaultExcludeDir(dirs []string) bool {
defaultDirs := []string{"assets", "tmp", "vendor", "testdata"}
if len(dirs) != len(defaultDirs) {
return false
}
for i := range dirs {
if dirs[i] != defaultDirs[i] {
return false
}
}
return true
}
func isAbsPathForOS(goos, path string) bool {
if goos != PlatformWindows {
return pathpkg.IsAbs(path)
}
if strings.HasPrefix(path, `\\`) {
return true
}
if len(path) < 3 {
return false
}
drive := path[0]
if ((drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z')) && path[1] == ':' {
return path[2] == '\\' || path[2] == '/'
}
return false
}
func (c *Config) colorInfo() map[string]string {
return map[string]string{
"main": c.Color.Main,
+195
View File
@@ -360,6 +360,201 @@ cmd = "go build -o ./tmp/main ."
}
}
func TestTmpDirAdjustsDefaults(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, ".air.toml")
cfgContent := `tmp_dir = ".tmp"
`
if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := InitConfig(cfgPath, nil)
if err != nil {
t.Fatalf("InitConfig error: %v", err)
}
if !strings.Contains(cfg.Build.Cmd, ".tmp") {
t.Fatalf("expected Build.Cmd to reference .tmp, got %s", cfg.Build.Cmd)
}
if strings.Contains(cfg.Build.Cmd, "./tmp/") {
t.Fatalf("expected Build.Cmd to not reference ./tmp/, got %s", cfg.Build.Cmd)
}
binBase := filepath.Base(cfg.Build.Bin)
if runtime.GOOS == "windows" {
if binBase != "main.exe" {
t.Fatalf("unexpected bin base: %s", binBase)
}
} else {
if binBase != "main" {
t.Fatalf("unexpected bin base: %s", binBase)
}
}
if !strings.Contains(cfg.Build.Bin, ".tmp") {
t.Fatalf("expected Build.Bin to reference .tmp, got %s", cfg.Build.Bin)
}
foundTmpInExclude := false
foundDotTmpInExclude := false
for _, dir := range cfg.Build.ExcludeDir {
if dir == "tmp" {
foundTmpInExclude = true
}
if dir == ".tmp" {
foundDotTmpInExclude = true
}
}
if foundTmpInExclude {
t.Fatal("expected ExcludeDir to not contain 'tmp'")
}
if !foundDotTmpInExclude {
t.Fatal("expected ExcludeDir to contain '.tmp'")
}
}
func TestTmpDirAdjustsDefaultsWindows(t *testing.T) {
t.Parallel()
cfg := &Config{
TmpDir: ".tmp",
Build: cfgBuild{
Cmd: "go build -o ./tmp/main.exe .",
Bin: `tmp\main.exe`,
ExcludeDir: []string{"assets", "tmp", "vendor", "testdata"},
},
}
cfg.adjustDefaultsForTmpDirWithOS("windows")
expectedCmd := "go build -o ./.tmp/main.exe ."
if cfg.Build.Cmd != expectedCmd {
t.Fatalf("expected Build.Cmd %q, got %q", expectedCmd, cfg.Build.Cmd)
}
expectedBin := `.tmp\main.exe`
if cfg.Build.Bin != expectedBin {
t.Fatalf("expected Build.Bin %q, got %q", expectedBin, cfg.Build.Bin)
}
foundDotTmp := false
for _, dir := range cfg.Build.ExcludeDir {
if dir == "tmp" {
t.Fatal("expected ExcludeDir to not contain 'tmp'")
}
if dir == ".tmp" {
foundDotTmp = true
}
}
if !foundDotTmp {
t.Fatal("expected ExcludeDir to contain '.tmp'")
}
}
func TestTmpDirDoesNotOverrideExplicitCmd(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, ".air.toml")
cfgContent := `tmp_dir = ".tmp"
[build]
cmd = "make build"
bin = "./bin/myapp"
`
if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
cfg, err := InitConfig(cfgPath, nil)
if err != nil {
t.Fatalf("InitConfig error: %v", err)
}
if cfg.Build.Cmd != "make build" {
t.Fatalf("expected Build.Cmd to remain 'make build', got %s", cfg.Build.Cmd)
}
if !strings.Contains(cfg.Build.Bin, "myapp") {
t.Fatalf("expected Build.Bin to contain 'myapp', got %s", cfg.Build.Bin)
}
}
func TestTmpDirAdjustsDefaultsWithAbsolutePath(t *testing.T) {
t.Parallel()
if runtime.GOOS == PlatformWindows {
t.Skip("POSIX absolute path test only runs on linux/macos")
}
cfg := &Config{
TmpDir: "/tmp/air-build",
Build: cfgBuild{
Cmd: "go build -o ./tmp/main .",
Bin: "./tmp/main",
ExcludeDir: []string{"assets", "tmp", "vendor", "testdata"},
},
}
cfg.adjustDefaultsForTmpDirWithOS("linux")
expectedCmd := "go build -o /tmp/air-build/main ."
if cfg.Build.Cmd != expectedCmd {
t.Fatalf("expected Build.Cmd %q, got %q", expectedCmd, cfg.Build.Cmd)
}
expectedBin := "/tmp/air-build/main"
if cfg.Build.Bin != expectedBin {
t.Fatalf("expected Build.Bin %q, got %q", expectedBin, cfg.Build.Bin)
}
}
func TestTmpDirAdjustsDefaultsWithWindowsAbsolutePath(t *testing.T) {
t.Parallel()
if runtime.GOOS != PlatformWindows {
t.Skip("Windows absolute path test only runs on windows")
}
cfg := &Config{
TmpDir: `C:\tmp\air-build`,
Build: cfgBuild{
Cmd: "go build -o ./tmp/main.exe .",
Bin: `tmp\main.exe`,
ExcludeDir: []string{"assets", "tmp", "vendor", "testdata"},
},
}
cfg.adjustDefaultsForTmpDirWithOS("windows")
expectedCmd := "go build -o C:/tmp/air-build/main.exe ."
if cfg.Build.Cmd != expectedCmd {
t.Fatalf("expected Build.Cmd %q, got %q", expectedCmd, cfg.Build.Cmd)
}
expectedBin := `C:\tmp\air-build\main.exe`
if cfg.Build.Bin != expectedBin {
t.Fatalf("expected Build.Bin %q, got %q", expectedBin, cfg.Build.Bin)
}
}
func TestTmpDirDoesNotOverrideExplicitExcludeDir(t *testing.T) {
t.Parallel()
if runtime.GOOS == PlatformWindows {
t.Skip("POSIX absolute path test only runs on linux/macos")
}
cfg := &Config{
TmpDir: ".tmp",
Build: cfgBuild{
Cmd: "go build -o ./tmp/main .",
Bin: "./tmp/main",
ExcludeDir: []string{"tmp", "node_modules"},
},
}
cfg.adjustDefaultsForTmpDirWithOS("linux")
if cfg.Build.ExcludeDir[0] != "tmp" {
t.Fatalf("expected first exclude_dir value to stay 'tmp', got %q", cfg.Build.ExcludeDir[0])
}
if cfg.Build.ExcludeDir[1] != "node_modules" {
t.Fatalf("expected second exclude_dir value to stay 'node_modules', got %q", cfg.Build.ExcludeDir[1])
}
}
func TestWarnIgnoreDangerousRootDirProtection(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("root dir protection uses Unix root path")