Resolve root directory if symlinked (#742)

This commit is contained in:
Caleb Fringer
2026-05-17 19:00:50 -07:00
committed by GitHub
parent 617e980a59
commit 31e6ebe9e7
3 changed files with 133 additions and 13 deletions
+9 -2
View File
@@ -172,6 +172,10 @@ func TestEntrypointResolvesAbsolutePath(t *testing.T) {
}
want := filepath.Join(rootWithSpace, "tmp", "main")
// expandPath resolves symlinks; t.TempDir() may return a symlinked path on some OSs
if resolved, err := filepath.EvalSymlinks(filepath.Dir(want)); err == nil {
want = filepath.Join(resolved, filepath.Base(want))
}
if got := cfg.Build.Entrypoint.binary(); got != want {
t.Fatalf("entrypoint is %s, but want %s", got, want)
}
@@ -231,6 +235,10 @@ func TestEntrypointPreservesArgs(t *testing.T) {
}
wantBin := filepath.Join(root, "tmp", "main")
// expandPath resolves symlinks; t.TempDir() may return a symlinked path on some OSs
if resolved, err := filepath.EvalSymlinks(root); err == nil {
wantBin = filepath.Join(resolved, "tmp", "main")
}
if cfg.Build.Entrypoint.binary() != wantBin {
t.Fatalf("entrypoint binary is %s, want %s", cfg.Build.Entrypoint.binary(), wantBin)
}
@@ -535,8 +543,7 @@ func TestBuildOverridesFromDiff(t *testing.T) {
got := buildOverridesFromDiff(base, target)
if got == nil {
t.Fatal("expected non-nil override for changed configs")
}
if !reflect.DeepEqual(got.PreCmd, target.PreCmd) {
} else if !reflect.DeepEqual(got.PreCmd, target.PreCmd) {
t.Fatalf("pre_cmd mismatch: got %v want %v", got.PreCmd, target.PreCmd)
}
if got.Cmd != target.Cmd {
+24 -9
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
@@ -258,23 +259,37 @@ func copyOutput(dst io.Writer, src io.Reader) {
}
}
// Expands a path, resolving any tilde prefixes, dereferencing symbolic links,
// and converting relative paths to absolute. The result will always be an
// absolute path.
// For paths that do not exist on the filesystem, the dereferencing step will
// be skipped.
// An error will be thrown if dereferencing fails due to something other than
// the path not existing on the filesystem.
func expandPath(path string) (string, error) {
expanded := path
if strings.HasPrefix(path, "~/") {
home := os.Getenv("HOME")
return home + path[1:], nil
expanded = filepath.Join(home, path[1:])
}
var err error
wd, err := os.Getwd()
expanded, err := filepath.Abs(expanded)
if err != nil {
return "", err
return "", fmt.Errorf("error getting absolute path to %v: %w", expanded, err)
}
if path == "." {
return wd, nil
// filepath.EvalSymlinks only works on real files
dereferenced, err := filepath.EvalSymlinks(expanded)
if err == nil {
return dereferenced, nil
}
if strings.HasPrefix(path, "./") {
return wd + path[1:], nil
if !errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("unexpected error while dereferencing %v: %w", expanded, err)
}
return path, nil
return expanded, nil
}
func isDir(path string) bool {
+100 -2
View File
@@ -40,6 +40,29 @@ func TestIsDirFileNot(t *testing.T) {
}
}
func TestExpandPathWithRelPath(t *testing.T) {
tmp := filepath.Join("_testdata", "tmp")
_, err := os.Create(tmp)
defer os.Remove(tmp)
if err != nil {
t.Fatalf("Error creating temp directory for testing: %v", err)
}
expandedPath, _ := expandPath("_testdata/tmp")
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Error getting cwd: %v", err)
}
expected := filepath.Join(wd, tmp)
// expandPath resolves symlinks, so resolve expected too
if resolved, err := filepath.EvalSymlinks(expected); err == nil {
expected = resolved
}
if expandedPath != expected {
t.Errorf("expected %s got %s", expected, expandedPath)
}
}
func TestExpandPathWithDot(t *testing.T) {
path, _ := expandPath(".")
wd, _ := os.Getwd()
@@ -49,15 +72,41 @@ func TestExpandPathWithDot(t *testing.T) {
}
func TestExpandPathWithHomePath(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("HOME-based tilde expansion is not reliable on Windows")
}
path := "~/.conf"
result, _ := expandPath(path)
result, err := expandPath(path)
if err != nil {
t.Errorf("expandPath returned an error: %v", err)
}
home := os.Getenv("HOME")
want := home + path[1:]
want := filepath.Join(home, path[1:])
if result != want {
t.Errorf("expected '%s' but got '%s'", want, result)
}
}
func TestExpandPathSymlinkError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("ENOTDIR behavior differs on Windows")
}
// Create a regular file, then try to expand a path *inside* it.
// filepath.EvalSymlinks will return ENOTDIR (not ErrNotExist), triggering
// the "unexpected error" branch in expandPath.
f, err := os.CreateTemp("", "expandpath-test-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
f.Close()
defer os.Remove(f.Name())
_, err = expandPath(filepath.Join(f.Name(), "subpath"))
if err == nil {
t.Fatal("expected an error but got nil")
}
}
func TestIsHiddenDirectory(t *testing.T) {
t.Parallel()
@@ -1021,3 +1070,52 @@ func TestIsDangerousRoot(t *testing.T) {
})
}
}
// Regression test for https://github.com/air-verse/air/issues/531. Makes sure
// that if the project's root directory is a symlink, it will be correctly
// expanded.
func TestExpandPathRootDirectoryIsSymlink(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
// Temp directories on some OSs (like macOS) are themselves symlinks.
// We need the fully resolved base directory to correctly construct our expected path.
realTempDir, err := filepath.EvalSymlinks(tempDir)
if err != nil {
t.Fatalf("Failed to resolve real temp dir: %v", err)
}
fooBarDir := filepath.Join(realTempDir, "foo", "bar")
if err := os.MkdirAll(fooBarDir, 0755); err != nil {
t.Fatalf("Failed to create nested directories: %v", err)
}
// Create a file <realTempDir>/foo/bar/baz
bazFile := filepath.Join(fooBarDir, "baz")
if err := os.WriteFile(bazFile, []byte("dummy content"), 0644); err != nil {
t.Fatalf("Failed to create regular file baz: %v", err)
}
// Symlink <realTempDir>/bar -> <realTempDir>/foo/bar
symlinkDir := filepath.Join(realTempDir, "bar")
if err := os.Symlink(fooBarDir, symlinkDir); err != nil {
t.Fatalf("Failed to create symlink: %v", err)
}
// The expected fully resolved absolute path is the real file location
expectedPath := bazFile
// Test expandPath by passing the absolute path via the symlink
// This removes the need to pretend our CWD is inside the symlink.
targetPath := filepath.Join(symlinkDir, "baz")
gotPath, err := expandPath(targetPath)
if err != nil {
t.Fatalf("expandPath returned an unexpected error: %v", err)
}
if gotPath != expectedPath {
t.Errorf("expandPath failed to correctly resolve the symlinked directory.\nGot: %s\nWant: %s", gotPath, expectedPath)
}
}