mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-16 03:47:22 +08:00
🐛 fix(ai): 修复 Windows CodeBuddy Git Bash 解析
This commit is contained in:
@@ -732,17 +732,22 @@ func resolveClaudeCodeGitBashPath(env []string, goos string, lookPath func(strin
|
||||
|
||||
if configured := strings.TrimSpace(envValue(env, "CLAUDE_CODE_GIT_BASH_PATH")); configured != "" {
|
||||
if exists(configured) {
|
||||
if isWindowsWSLBashLauncher(configured) {
|
||||
return "", fmt.Errorf("Claude Code CLI requires Git Bash on Windows, but CLAUDE_CODE_GIT_BASH_PATH points to a WSL launcher: %s", configured)
|
||||
}
|
||||
return configured, nil
|
||||
}
|
||||
return "", fmt.Errorf("Claude Code CLI requires git-bash on Windows, but CLAUDE_CODE_GIT_BASH_PATH points to a missing bash.exe: %s", configured)
|
||||
}
|
||||
|
||||
for _, command := range []string{"bash.exe", "bash"} {
|
||||
if bashPath, err := lookPath(command); err == nil && exists(bashPath) {
|
||||
return bashPath, nil
|
||||
}
|
||||
if detected := detectWindowsGitBashPath(env, lookPath, exists); detected != "" {
|
||||
return detected, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Claude Code CLI requires git-bash on Windows. Install Git for Windows (https://git-scm.com/downloads/win); if Git is already installed but not on PATH, set CLAUDE_CODE_GIT_BASH_PATH to bash.exe, for example C:\\Program Files\\Git\\bin\\bash.exe")
|
||||
}
|
||||
|
||||
func detectWindowsGitBashPath(env []string, lookPath func(string) (string, error), exists func(string) bool) string {
|
||||
if gitPath, err := lookPath("git.exe"); err == nil {
|
||||
gitDir := parentWindowsPath(gitPath)
|
||||
for _, candidate := range []string{
|
||||
@@ -750,18 +755,33 @@ func resolveClaudeCodeGitBashPath(env []string, goos string, lookPath func(strin
|
||||
joinWindowsPath(gitDir, "bash.exe"),
|
||||
} {
|
||||
if candidate != "" && exists(candidate) {
|
||||
return candidate, nil
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range windowsGitBashCandidates(env) {
|
||||
if exists(candidate) {
|
||||
return candidate, nil
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Claude Code CLI requires git-bash on Windows. Install Git for Windows (https://git-scm.com/downloads/win); if Git is already installed but not on PATH, set CLAUDE_CODE_GIT_BASH_PATH to bash.exe, for example C:\\Program Files\\Git\\bin\\bash.exe")
|
||||
for _, command := range []string{"bash.exe", "bash"} {
|
||||
if bashPath, err := lookPath(command); err == nil && exists(bashPath) && !isWindowsWSLBashLauncher(bashPath) {
|
||||
return bashPath
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isWindowsWSLBashLauncher(path string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `"`))
|
||||
if !strings.HasSuffix(normalized, `\bash.exe`) && !strings.HasSuffix(normalized, `\bash`) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(normalized, `\windows\system32\`) ||
|
||||
strings.Contains(normalized, `\windows\sysnative\`) ||
|
||||
strings.Contains(normalized, `\microsoft\windowsapps\`)
|
||||
}
|
||||
|
||||
func windowsGitBashCandidates(env []string) []string {
|
||||
|
||||
@@ -403,6 +403,59 @@ func TestBuildClaudeCLIEnv_UsesDetectedGitBashOnWindows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClaudeCodeGitBashPathPrefersGitForWindowsOverWSLLauncher(t *testing.T) {
|
||||
const (
|
||||
wslBash = `C:\Windows\System32\bash.exe`
|
||||
gitExe = `C:\Program Files\Git\cmd\git.exe`
|
||||
gitBash = `C:\Program Files\Git\bin\bash.exe`
|
||||
)
|
||||
|
||||
got, err := resolveClaudeCodeGitBashPath(nil, "windows", func(name string) (string, error) {
|
||||
switch name {
|
||||
case "bash.exe", "bash":
|
||||
return wslBash, nil
|
||||
case "git.exe":
|
||||
return gitExe, nil
|
||||
default:
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
}, func(path string) bool {
|
||||
return path == wslBash || path == gitExe || path == gitBash
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve Claude Code Git Bash: %v", err)
|
||||
}
|
||||
if got != gitBash {
|
||||
t.Fatalf("expected Git for Windows bash %q, got %q", gitBash, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClaudeCodeGitBashPathRejectsConfiguredWSLLauncher(t *testing.T) {
|
||||
const wslBash = `C:\Windows\System32\bash.exe`
|
||||
_, err := resolveClaudeCodeGitBashPath(
|
||||
[]string{"CLAUDE_CODE_GIT_BASH_PATH=" + wslBash},
|
||||
"windows",
|
||||
func(name string) (string, error) { return "", errors.New("not found") },
|
||||
func(path string) bool { return path == wslBash },
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "WSL launcher") {
|
||||
t.Fatalf("expected actionable WSL launcher error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClaudeCodeGitBashPathIgnoresPATHWSLLauncher(t *testing.T) {
|
||||
const wslBash = `C:\Windows\System32\bash.exe`
|
||||
_, err := resolveClaudeCodeGitBashPath(nil, "windows", func(name string) (string, error) {
|
||||
if name == "bash.exe" || name == "bash" {
|
||||
return wslBash, nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
}, func(path string) bool { return path == wslBash })
|
||||
if err == nil || !strings.Contains(err.Error(), "Install Git for Windows") {
|
||||
t.Fatalf("expected PATH WSL launcher to be ignored with an install hint, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeCLIEnv_ReturnsActionableErrorWhenGitBashMissingOnWindows(t *testing.T) {
|
||||
_, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) {
|
||||
return "", errors.New("not found")
|
||||
|
||||
@@ -98,11 +98,22 @@ func (p *CodeBuddyCLIProvider) ChatWithState(ctx context.Context, state json.Raw
|
||||
requestErr = fmt.Errorf("CodeBuddy CLI timed out (%s); the current login session, Base URL, or API Key may not be returning a valid response", codebuddyCLIRequestTimeout)
|
||||
return nil, nil, requestErr
|
||||
}
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
requestErr = fmt.Errorf("CodeBuddy CLI execution failed: %s", string(exitErr.Stderr))
|
||||
exitErr, exited := err.(*exec.ExitError)
|
||||
if !exited && len(output) == 0 {
|
||||
requestErr = fmt.Errorf("CodeBuddy CLI execution failed: %w", err)
|
||||
return nil, nil, requestErr
|
||||
}
|
||||
requestErr = fmt.Errorf("CodeBuddy CLI execution failed: %w", err)
|
||||
details := make([]string, 0, 3)
|
||||
if exited {
|
||||
if stderr := strings.TrimSpace(string(exitErr.Stderr)); stderr != "" {
|
||||
details = append(details, "stderr: "+stderr)
|
||||
}
|
||||
}
|
||||
if len(output) > 0 {
|
||||
details = append(details, fmt.Sprintf("stdout: %d bytes omitted", len(output)))
|
||||
}
|
||||
details = append(details, "error: "+err.Error())
|
||||
requestErr = fmt.Errorf("CodeBuddy CLI execution failed: %s", strings.Join(details, "; "))
|
||||
return nil, nil, requestErr
|
||||
}
|
||||
|
||||
@@ -449,33 +460,16 @@ func resolveCodeBuddyGitBashPath(env []string, goos string, lookPath func(string
|
||||
|
||||
if configured := strings.TrimSpace(envValue(env, "CODEBUDDY_CODE_GIT_BASH_PATH")); configured != "" {
|
||||
if exists(configured) {
|
||||
if isWindowsWSLBashLauncher(configured) {
|
||||
return "", fmt.Errorf("Configured CODEBUDDY_CODE_GIT_BASH_PATH points to a WSL launcher instead of Git Bash on Windows: %s", configured)
|
||||
}
|
||||
return configured, nil
|
||||
}
|
||||
return "", fmt.Errorf("Configured CODEBUDDY_CODE_GIT_BASH_PATH does not exist on Windows: %s", configured)
|
||||
}
|
||||
|
||||
for _, command := range []string{"bash.exe", "bash"} {
|
||||
if bashPath, err := lookPath(command); err == nil && exists(bashPath) {
|
||||
return bashPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
if gitPath, err := lookPath("git.exe"); err == nil {
|
||||
gitDir := parentWindowsPath(gitPath)
|
||||
for _, candidate := range []string{
|
||||
joinWindowsPath(parentWindowsPath(gitDir), "bin", "bash.exe"),
|
||||
joinWindowsPath(gitDir, "bash.exe"),
|
||||
} {
|
||||
if candidate != "" && exists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range windowsGitBashCandidates(env) {
|
||||
if exists(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
if detected := detectWindowsGitBashPath(env, lookPath, exists); detected != "" {
|
||||
return detected, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
|
||||
@@ -57,6 +57,62 @@ func TestBuildCodeBuddyCLIEnv_AllowsMissingGitBashOnWindows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCodeBuddyGitBashPathPrefersGitForWindowsOverWSLLauncher(t *testing.T) {
|
||||
const (
|
||||
wslBash = `C:\Windows\System32\bash.exe`
|
||||
gitExe = `C:\Program Files\Git\cmd\git.exe`
|
||||
gitBash = `C:\Program Files\Git\bin\bash.exe`
|
||||
)
|
||||
|
||||
got, err := resolveCodeBuddyGitBashPath(nil, "windows", func(name string) (string, error) {
|
||||
switch name {
|
||||
case "bash.exe", "bash":
|
||||
return wslBash, nil
|
||||
case "git.exe":
|
||||
return gitExe, nil
|
||||
default:
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
}, func(path string) bool {
|
||||
return path == wslBash || path == gitExe || path == gitBash
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve CodeBuddy Git Bash: %v", err)
|
||||
}
|
||||
if got != gitBash {
|
||||
t.Fatalf("expected Git for Windows bash %q, got %q", gitBash, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCodeBuddyGitBashPathRejectsConfiguredWSLLauncher(t *testing.T) {
|
||||
const wslBash = `C:\Users\tester\AppData\Local\Microsoft\WindowsApps\bash.exe`
|
||||
_, err := resolveCodeBuddyGitBashPath(
|
||||
[]string{"CODEBUDDY_CODE_GIT_BASH_PATH=" + wslBash},
|
||||
"windows",
|
||||
func(name string) (string, error) { return "", errors.New("not found") },
|
||||
func(path string) bool { return path == wslBash },
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "WSL launcher") {
|
||||
t.Fatalf("expected actionable WSL launcher error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCodeBuddyGitBashPathIgnoresPATHWSLLauncher(t *testing.T) {
|
||||
const wslBash = `C:\Windows\System32\bash.exe`
|
||||
got, err := resolveCodeBuddyGitBashPath(nil, "windows", func(name string) (string, error) {
|
||||
if name == "bash.exe" || name == "bash" {
|
||||
return wslBash, nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
}, func(path string) bool { return path == wslBash })
|
||||
if err != nil {
|
||||
t.Fatalf("expected missing Git Bash to remain optional for CodeBuddy, got %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("expected PATH WSL launcher to be ignored, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeBuddyCLIProvider_ChatParsesJSONEventArray(t *testing.T) {
|
||||
fakeCodeBuddy := writeFakeCodeBuddyScript(t, "#!/bin/sh\necho '[{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello \"}]}},{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hello world\"}]'\n")
|
||||
restore := overrideCodeBuddyCLIForTest(t, fakeCodeBuddy)
|
||||
@@ -80,6 +136,32 @@ func TestCodeBuddyCLIProvider_ChatParsesJSONEventArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeBuddyCLIProvider_ChatPreservesExecutionFailureDetails(t *testing.T) {
|
||||
fakeCodeBuddy := writeFakeCodeBuddyScript(t, "#!/bin/sh\necho 'partial output'\necho 'specific failure' >&2\nexit 7\n")
|
||||
restore := overrideCodeBuddyCLIForTest(t, fakeCodeBuddy)
|
||||
defer restore()
|
||||
|
||||
provider, err := NewCodeBuddyCLIProvider(ai.ProviderConfig{APIKey: "cb-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected provider error: %v", err)
|
||||
}
|
||||
|
||||
_, err = provider.Chat(context.Background(), ai.ChatRequest{
|
||||
Messages: []ai.Message{{Role: "user", Content: "ping"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected CodeBuddy CLI execution error")
|
||||
}
|
||||
for _, want := range []string{"stderr: specific failure", "stdout: 15 bytes omitted", "error: exit status 7"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("expected execution error to contain %q, got %q", want, err.Error())
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "partial output") {
|
||||
t.Fatalf("expected execution error to omit raw stdout, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeBuddyCLIProviderChatWithState_StartsTrackedSession(t *testing.T) {
|
||||
fakeCodeBuddy := writeFakeCodeBuddyScript(t, "#!/bin/sh\necho '[{\"type\":\"assistant\",\"session_id\":\"session-new\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello \"}]}},{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hello world\",\"session_id\":\"session-new\"}]'\n")
|
||||
var capturedArgs []string
|
||||
@@ -267,7 +349,11 @@ func TestCodeBuddyCLIProviderChatStreamWithState_ResumesExistingSessionWithoutDr
|
||||
|
||||
func writeFakeCodeBuddyScript(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
tempRoot := t.TempDir()
|
||||
dir := filepath.Join(tempRoot, "包含 空格")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create fake CodeBuddy directory: %v", err)
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
bashPath, err := resolveClaudeCodeGitBashPath(os.Environ(), runtime.GOOS, exec.LookPath, fileExists)
|
||||
@@ -280,7 +366,7 @@ func writeFakeCodeBuddyScript(t *testing.T, content string) string {
|
||||
t.Fatalf("failed to write fake codebuddy shell script: %v", err)
|
||||
}
|
||||
|
||||
wrapperPath := filepath.Join(dir, "codebuddy.cmd")
|
||||
wrapperPath := filepath.Join(tempRoot, "codebuddy.cmd")
|
||||
wrapper := "@echo off\r\n\"" + bashPath + "\" \"" + scriptPath + "\" %*\r\n"
|
||||
if err := os.WriteFile(wrapperPath, []byte(wrapper), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake codebuddy wrapper: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user