Skip to content

Commit 6b2b30f

Browse files
fentasclaude
andauthored
fix: fallback to CWD/.bin when no git repo or envs (#108)
## Summary - `GetBinaryPath()` now falls back to `$PWD/.bin` when `PATH_BIN`, `PATH_BASE`, and git root are all unavailable - Improved `ErrNoBinaryPath` error message with actionable hint - Added 3 unit tests for `GetBinaryPath` (CWD fallback, env override, git root) Closes #86 ## Test plan - [x] `TestGetBinaryPath_FallbackCWD` — verifies CWD/.bin fallback in non-git directory - [x] `TestGetBinaryPath_EnvOverride` — verifies PATH_BIN takes priority - [x] `TestGetBinaryPath_GitRoot` — verifies git root detection still works - [x] Existing test suite passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 940edd5 commit 6b2b30f

File tree

3 files changed

+164
-12
lines changed

3 files changed

+164
-12
lines changed

pkg/cli/errors.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "errors"
44

55
var (
66
// ErrNoBinaryPath indicates that no suitable binary installation path was found
7-
ErrNoBinaryPath = errors.New("could not find a suitable path to install binaries")
7+
ErrNoBinaryPath = errors.New("could not determine a suitable path to install binaries (current working directory could not be determined)")
88

99
// ErrUnknownBinary indicates that the specified binary is not available
1010
ErrUnknownBinary = errors.New("unknown binary")

pkg/path/path.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,23 @@ func GetDefaultConfigPath() string {
1414
return filepath.Join(path, "b.yaml")
1515
}
1616

17-
// GetBinaryPath returns the binary path without importing pkg/binary to avoid import cycle
17+
// GetBinaryPath returns the binary path without importing pkg/binary to avoid import cycle.
18+
// Priority: PATH_BIN > PATH_BASE > <git-root>/.bin > <cwd>/.bin
1819
func GetBinaryPath() string {
19-
var path string
20-
21-
if os.Getenv("PATH_BIN") != "" {
22-
path = os.Getenv("PATH_BIN")
23-
} else if os.Getenv("PATH_BASE") != "" {
24-
path = os.Getenv("PATH_BASE")
25-
} else if gitRoot, err := GetGitRootDirectory(); err == nil {
26-
path = gitRoot + "/.bin"
20+
if p := os.Getenv("PATH_BIN"); p != "" {
21+
return p
2722
}
28-
29-
return path
23+
if p := os.Getenv("PATH_BASE"); p != "" {
24+
return p
25+
}
26+
if gitRoot, err := GetGitRootDirectory(); err == nil {
27+
return filepath.Join(gitRoot, ".bin")
28+
}
29+
// Fallback: use CWD/.bin so `b install` works outside a git repo.
30+
if cwd, err := os.Getwd(); err == nil {
31+
return filepath.Join(cwd, ".bin")
32+
}
33+
return ""
3034
}
3135

3236
// FindConfigFile searches for b.yaml file in current directory and parent directories

pkg/path/path_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package path
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// realDir resolves symlinks so tests pass on macOS where
10+
// /var is a symlink to /private/var.
11+
func realDir(t *testing.T, dir string) string {
12+
t.Helper()
13+
real, err := filepath.EvalSymlinks(dir)
14+
if err != nil {
15+
t.Fatal(err)
16+
}
17+
return real
18+
}
19+
20+
// clearPathEnvs uses t.Setenv to clear PATH_BIN and PATH_BASE,
21+
// automatically restoring (or unsetting) them when the test finishes.
22+
func clearPathEnvs(t *testing.T) {
23+
t.Helper()
24+
t.Setenv("PATH_BIN", "")
25+
t.Setenv("PATH_BASE", "")
26+
os.Unsetenv("PATH_BIN")
27+
os.Unsetenv("PATH_BASE")
28+
}
29+
30+
// TestGetBinaryPath_FallbackCWD verifies that GetBinaryPath falls back to
31+
// CWD/.bin when no env vars are set and no git root is found (issue #86).
32+
func TestGetBinaryPath_FallbackCWD(t *testing.T) {
33+
clearPathEnvs(t)
34+
35+
// Work in a temp dir that has no .git
36+
tmp := realDir(t, t.TempDir())
37+
origDir, err := os.Getwd()
38+
if err != nil {
39+
t.Fatal(err)
40+
}
41+
defer os.Chdir(origDir)
42+
43+
if err := os.Chdir(tmp); err != nil {
44+
t.Fatal(err)
45+
}
46+
47+
got := GetBinaryPath()
48+
want := filepath.Join(tmp, ".bin")
49+
if got != want {
50+
t.Errorf("GetBinaryPath() = %q, want %q (CWD fallback)", got, want)
51+
}
52+
}
53+
54+
// TestGetBinaryPath_EnvOverride verifies that PATH_BIN takes priority.
55+
func TestGetBinaryPath_EnvOverride(t *testing.T) {
56+
t.Setenv("PATH_BIN", "/custom/bin")
57+
got := GetBinaryPath()
58+
if got != "/custom/bin" {
59+
t.Errorf("GetBinaryPath() = %q, want %q", got, "/custom/bin")
60+
}
61+
}
62+
63+
// TestGetBinaryPath_GitRoot verifies that git root is used when available.
64+
func TestGetBinaryPath_GitRoot(t *testing.T) {
65+
clearPathEnvs(t)
66+
67+
// Create a temp dir with a .git directory
68+
tmp := realDir(t, t.TempDir())
69+
if err := os.Mkdir(filepath.Join(tmp, ".git"), 0755); err != nil {
70+
t.Fatal(err)
71+
}
72+
73+
origDir, err := os.Getwd()
74+
if err != nil {
75+
t.Fatal(err)
76+
}
77+
defer os.Chdir(origDir)
78+
79+
if err := os.Chdir(tmp); err != nil {
80+
t.Fatal(err)
81+
}
82+
83+
got := GetBinaryPath()
84+
want := filepath.Join(tmp, ".bin")
85+
if got != want {
86+
t.Errorf("GetBinaryPath() = %q, want %q (git root)", got, want)
87+
}
88+
}
89+
90+
// TestGetBinaryPath_PathBaseFallback verifies PATH_BASE is used when PATH_BIN is unset.
91+
func TestGetBinaryPath_PathBaseFallback(t *testing.T) {
92+
clearPathEnvs(t)
93+
t.Setenv("PATH_BASE", "/project/base")
94+
95+
got := GetBinaryPath()
96+
if got != "/project/base" {
97+
t.Errorf("GetBinaryPath() = %q, want %q (PATH_BASE)", got, "/project/base")
98+
}
99+
}
100+
101+
// TestGetBinaryPath_PathBinOverridesAll verifies PATH_BIN wins over PATH_BASE and git root.
102+
func TestGetBinaryPath_PathBinOverridesAll(t *testing.T) {
103+
// Set both — PATH_BIN should win
104+
t.Setenv("PATH_BIN", "/priority/bin")
105+
t.Setenv("PATH_BASE", "/base/bin")
106+
107+
// Even inside a git repo
108+
tmp := realDir(t, t.TempDir())
109+
if err := os.Mkdir(filepath.Join(tmp, ".git"), 0755); err != nil {
110+
t.Fatal(err)
111+
}
112+
origDir, err := os.Getwd()
113+
if err != nil {
114+
t.Fatal(err)
115+
}
116+
defer os.Chdir(origDir)
117+
118+
if err := os.Chdir(tmp); err != nil {
119+
t.Fatal(err)
120+
}
121+
122+
got := GetBinaryPath()
123+
if got != "/priority/bin" {
124+
t.Errorf("GetBinaryPath() = %q, want %q (PATH_BIN should override all)", got, "/priority/bin")
125+
}
126+
}
127+
128+
// TestGetDefaultConfigPath_FallbackCWD verifies config path uses CWD fallback.
129+
func TestGetDefaultConfigPath_FallbackCWD(t *testing.T) {
130+
clearPathEnvs(t)
131+
132+
tmp := realDir(t, t.TempDir())
133+
origDir, err := os.Getwd()
134+
if err != nil {
135+
t.Fatal(err)
136+
}
137+
defer os.Chdir(origDir)
138+
139+
if err := os.Chdir(tmp); err != nil {
140+
t.Fatal(err)
141+
}
142+
143+
got := GetDefaultConfigPath()
144+
want := filepath.Join(tmp, ".bin", "b.yaml")
145+
if got != want {
146+
t.Errorf("GetDefaultConfigPath() = %q, want %q", got, want)
147+
}
148+
}

0 commit comments

Comments
 (0)