Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions internal/cli/gga_available_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,64 @@ func TestGGAAvailableDetectsViaLocalBin(t *testing.T) {
}
}

// TestGGAAvailableDetectsViaWindowsPS1 verifies that ggaAvailable returns true
// when the Windows PowerShell shim exists in ~/bin/gga.ps1.
func TestGGAAvailableDetectsViaWindowsPS1(t *testing.T) {
tmpHome := t.TempDir()
binDir := filepath.Join(tmpHome, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(binDir, "gga.ps1"), []byte("fake"), 0o644); err != nil {
t.Fatal(err)
}

origLookPath := cmdLookPath
origHomeDir := osUserHomeDir
origStat := osStat
cmdLookPath = func(file string) (string, error) { return "", os.ErrNotExist }
osUserHomeDir = func() (string, error) { return tmpHome, nil }
osStat = os.Stat
t.Cleanup(func() {
cmdLookPath = origLookPath
osUserHomeDir = origHomeDir
osStat = origStat
})

if !ggaAvailable(system.PlatformProfile{OS: "windows", PackageManager: "winget"}) {
t.Fatal("ggaAvailable() = false, want true when gga.ps1 exists on Windows")
}
}

// TestGGAAvailableDetectsViaWindowsExe verifies that ggaAvailable returns true
// when a native Windows executable exists in ~/bin/gga.exe.
func TestGGAAvailableDetectsViaWindowsExe(t *testing.T) {
tmpHome := t.TempDir()
binDir := filepath.Join(tmpHome, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(binDir, "gga.exe"), []byte("fake"), 0o755); err != nil {
t.Fatal(err)
}

origLookPath := cmdLookPath
origHomeDir := osUserHomeDir
origStat := osStat
cmdLookPath = func(file string) (string, error) { return "", os.ErrNotExist }
osUserHomeDir = func() (string, error) { return tmpHome, nil }
osStat = os.Stat
t.Cleanup(func() {
cmdLookPath = origLookPath
osUserHomeDir = origHomeDir
osStat = origStat
})

if !ggaAvailable(system.PlatformProfile{OS: "windows", PackageManager: "winget"}) {
t.Fatal("ggaAvailable() = false, want true when gga.exe exists on Windows")
}
}

// TestGGAAvailableDetectsViaHomebrewOptPrefix verifies that ggaAvailable returns
// true when gga exists at /opt/homebrew/bin/gga (Apple Silicon Homebrew default).
func TestGGAAvailableDetectsViaHomebrewOptPrefix(t *testing.T) {
Expand Down
6 changes: 4 additions & 2 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -715,8 +715,10 @@ func ggaAvailable(profile system.PlatformProfile) bool {
}
}
if profile.OS == "windows" {
if _, err := osStat(filepath.Join(homeDir, "bin", "gga")); err == nil {
return true
for _, name := range []string{"gga.ps1", "gga.exe", "gga"} {
if _, err := osStat(filepath.Join(homeDir, "bin", name)); err == nil {
return true
}
}
}
return false
Expand Down
93 changes: 93 additions & 0 deletions internal/update/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/gentleman-programming/gentle-ai/internal/system"
Expand Down Expand Up @@ -118,6 +120,97 @@ func TestDetectInstalledVersion(t *testing.T) {
}
}

func TestDetectInstalledVersionWindowsShim(t *testing.T) {
tmpHome := t.TempDir()
binDir := filepath.Join(tmpHome, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(binDir, "gga.ps1"), []byte("fake"), 0o644); err != nil {
t.Fatal(err)
}

origLookPath := lookPath
origExecCommand := execCommand
origRuntimeGOOS := runtimeGOOS
origHomeDir := osUserHomeDir
origStat := osStat
t.Cleanup(func() {
lookPath = origLookPath
execCommand = origExecCommand
runtimeGOOS = origRuntimeGOOS
osUserHomeDir = origHomeDir
osStat = origStat
})

lookPath = func(string) (string, error) { return "", os.ErrNotExist }
runtimeGOOS = "windows"
osUserHomeDir = func() (string, error) { return tmpHome, nil }
osStat = os.Stat

got := detectInstalledVersion(context.Background(), ToolInfo{Name: "gga", DetectCmd: []string{"gga", "--version"}}, "dev")
if got != "unknown" {
t.Fatalf("detectInstalledVersion() = %q, want unknown when windows shim exists", got)
}
}

// TestCheckFilteredWindowsShimKeepsGGAVisible verifies the integrated update
// flow keeps GGA visible on Windows when the PowerShell shim exists.
func TestCheckFilteredWindowsShimKeepsGGAVisible(t *testing.T) {
tmpHome := t.TempDir()
binDir := filepath.Join(tmpHome, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(binDir, "gga.ps1"), []byte("fake"), 0o644); err != nil {
t.Fatal(err)
}

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(githubRelease{TagName: "v2.0.0", HTMLURL: "https://github.com/Gentleman-Programming/gentleman-guardian-angel/releases/tag/v2.0.0"})
}))
defer server.Close()

origClient := httpClient
origLookPath := lookPath
origExecCommand := execCommand
origRuntimeGOOS := runtimeGOOS
origHomeDir := osUserHomeDir
origStat := osStat
t.Cleanup(func() {
httpClient = origClient
lookPath = origLookPath
execCommand = origExecCommand
runtimeGOOS = origRuntimeGOOS
osUserHomeDir = origHomeDir
osStat = origStat
})

httpClient = server.Client()
httpClient.Transport = &testTransport{server: server}
lookPath = func(string) (string, error) { return "", os.ErrNotExist }
execCommand = func(name string, args ...string) *exec.Cmd { return exec.Command("false") }
runtimeGOOS = "windows"
osUserHomeDir = func() (string, error) { return tmpHome, nil }
osStat = os.Stat

results := CheckFiltered(context.Background(), "1.0.0", system.PlatformProfile{OS: "windows", PackageManager: "winget", Supported: true}, []string{"gga"})
if len(results) != 1 {
Comment on lines +198 to +200
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The final assertion if results[0].Status == NotInstalled is redundant because the test already asserts results[0].Status != VersionUnknown / == VersionUnknown just above. Consider removing the redundant check to keep the test focused on a single expectation.

Suggested change
results := CheckFiltered(context.Background(), "1.0.0", system.PlatformProfile{OS: "windows", PackageManager: "winget", Supported: true}, []string{"gga"})
if len(results) != 1 {

Copilot uses AI. Check for mistakes.
t.Fatalf("CheckFiltered() len = %d, want 1", len(results))
}
if results[0].Tool.Name != "gga" {
t.Fatalf("CheckFiltered() tool = %q, want gga", results[0].Tool.Name)
}
if results[0].Status != VersionUnknown {
t.Fatalf("CheckFiltered() status = %q, want %q for Windows shim visibility", results[0].Status, VersionUnknown)
}
if results[0].Status == NotInstalled {
t.Fatal("CheckFiltered() unexpectedly reported GGA as NotInstalled on Windows shim path")
}
}

func TestParseVersionFromOutput_DevSentinel(t *testing.T) {
if got := parseVersionFromOutput("engram dev"); got != "dev" {
t.Fatalf("parseVersionFromOutput(engram dev) = %q, want %q", got, "dev")
Expand Down
32 changes: 30 additions & 2 deletions internal/update/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@ package update

import (
"context"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
)

// Package-level vars for testability (swap in tests via t.Cleanup).
var (
execCommand = exec.Command
lookPath = exec.LookPath
execCommand = exec.Command
lookPath = exec.LookPath
osUserHomeDir = os.UserHomeDir
osStat = os.Stat
runtimeGOOS = runtime.GOOS
)

// versionRegexp extracts a semver-like version from command output.
Expand All @@ -36,6 +42,9 @@ func detectInstalledVersion(ctx context.Context, tool ToolInfo, currentBuildVers

binary := tool.DetectCmd[0]
if _, err := lookPath(binary); err != nil {
if binary == "gga" && ggaShimInstalledOnWindows() {
return "unknown"
}
return "" // binary not found
}

Expand Down Expand Up @@ -68,6 +77,25 @@ func detectInstalledVersion(ctx context.Context, tool ToolInfo, currentBuildVers
return parseVersionFromOutput(strings.TrimSpace(string(out)))
}

func ggaShimInstalledOnWindows() bool {
if runtimeGOOS != "windows" {
return false
}

homeDir, err := osUserHomeDir()
if err != nil {
return false
}

for _, name := range []string{"gga.ps1", "gga.exe", "gga"} {
if _, err := osStat(filepath.Join(homeDir, "bin", name)); err == nil {
return true
}
}

return false
}

// parseVersionFromOutput extracts the first semver-like pattern from raw output.
func parseVersionFromOutput(output string) string {
if output == "" {
Expand Down
Loading