Skip to content
Merged
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
35 changes: 34 additions & 1 deletion internal/appupdate/appupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func HandleSelfUpdate(
resultChannel := make(chan string)

isHomebrewInstall := isHomebrewInstall(logger)
isNixInstall := isNixInstall(logger)

currentSemVer, err := semver.NewVersion(currentVersion)
if err != nil {
Expand All @@ -35,7 +36,7 @@ func HandleSelfUpdate(
}

// Check if we have previously detected a newer version
updateToLatestVersion(currentSemVer, logger, fs, updater, isHomebrewInstall)
updateToLatestVersion(currentSemVer, logger, fs, updater, isHomebrewInstall, isNixInstall)

// Check for newer versions from remote repository
go fetchAndSaveLatestVersion(resultChannel, logger, fs, updater)
Expand Down Expand Up @@ -69,6 +70,30 @@ func isHomebrewInstall(logger *zap.Logger) bool {
return false
}

func isNixInstall(logger *zap.Logger) bool {
executable, err := executablePath()
if err != nil {
logger.Debug("failed to determine executable path for update strategy", zap.Error(err))
return false
}

resolvedExecutable := executable
if resolved, err := filepath.EvalSymlinks(executable); err == nil {
resolvedExecutable = resolved
} else {
logger.Debug("failed to resolve symlinks for executable", zap.Error(err))
}

nixStorePrefix := "/nix/store/"
if strings.HasPrefix(resolvedExecutable, nixStorePrefix) ||
strings.HasPrefix(executable, nixStorePrefix) {
logger.Info("detected Nix installation; skipping self-update")
return true
}

return false
}

func readLatestVersion(fs filesystem.FileSystem) string {
file, err := fs.Open(core.LatestVersionFile())
if err != nil {
Expand All @@ -91,6 +116,7 @@ func updateToLatestVersion(
fs filesystem.FileSystem,
updater Updater,
isHomebrewInstall bool,
isNixInstall bool,
) {
latestVersion := readLatestVersion(fs)
if latestVersion == "" {
Expand All @@ -113,6 +139,13 @@ func updateToLatestVersion(
return
}

// Nix installations shouldn't self-update; just notify the user.
if isNixInstall {
fmt.Printf("\nNew version of gsh available: %s (current: %s)\n", latestVersion, currentSemVer.String())
fmt.Println("gsh was installed via Nix. Update your flake input to install the latest version.")
return
}

// Check for major version boundary - don't auto-update across major versions
if latestSemVer.Major() > currentSemVer.Major() {
logger.Info("major version update available",
Expand Down
74 changes: 74 additions & 0 deletions internal/appupdate/appupdate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,30 @@ func TestIsHomebrewInstall(t *testing.T) {
}
}

func TestIsNixInstall(t *testing.T) {
logger := zap.NewNop()
originalExecutablePath := executablePath
defer func() { executablePath = originalExecutablePath }()

testCases := []struct {
name string
path string
expected bool
}{
{"nix store path", "/nix/store/abc123-gsh-1.2.3/bin/gsh", true},
{"nix store path with long hash", "/nix/store/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-gsh-1.10.0/bin/gsh", true},
{"non-nix path", "/usr/local/bin/gsh", false},
{"homebrew path not nix", filepath.Join("/opt", "homebrew", "Cellar", "gsh", "1.2.3", "bin", "gsh"), false},
}

for _, tc := range testCases {
executablePath = func() (string, error) {
return tc.path, nil
}
assert.Equal(t, tc.expected, isNixInstall(logger), tc.name)
}
}

func TestHandleSelfUpdate_HomebrewSkipsBinaryUpdate(t *testing.T) {
logger := zap.NewNop()
originalExecutablePath := executablePath
Expand Down Expand Up @@ -267,3 +291,53 @@ func TestHandleSelfUpdate_HomebrewSkipsBinaryUpdate(t *testing.T) {
mockRemoteRelease.AssertExpectations(t)
mockUpdater.AssertExpectations(t)
}

func TestHandleSelfUpdate_NixSkipsBinaryUpdate(t *testing.T) {
logger := zap.NewNop()
originalExecutablePath := executablePath
defer func() { executablePath = originalExecutablePath }()

executablePath = func() (string, error) {
return "/nix/store/abc123-gsh-1.0.0/bin/gsh", nil
}

mockFS := new(MockFileSystem)
mockUpdater := new(MockUpdater)
mockRemoteRelease := new(MockRelease)

mockFileForRead, _ := os.CreateTemp("", "test-latest-version-read")
defer os.Remove(mockFileForRead.Name())
mockFileForRead.Write([]byte("1.2.0"))
mockFileForRead.Seek(0, 0)

mockFileForWrite, _ := os.CreateTemp("", "test-latest-version-write")
defer os.Remove(mockFileForWrite.Name())

mockFS.On("Open", core.LatestVersionFile()).Return(mockFileForRead, nil)
mockFS.On("Create", core.LatestVersionFile()).Return(mockFileForWrite, nil)

mockRemoteRelease.On("Version").Return("1.2.0")
mockUpdater.On("DetectLatest", mock.Anything, "kunchenguid/gsh").Return(mockRemoteRelease, true, nil)

originalStdout := os.Stdout
readPipe, writePipe, _ := os.Pipe()
os.Stdout = writePipe

resultChannel := HandleSelfUpdate("1.0.0", logger, mockFS, mockUpdater)

remoteVersion, ok := <-resultChannel
writePipe.Close()
os.Stdout = originalStdout

outputBytes, _ := io.ReadAll(readPipe)
output := string(outputBytes)

assert.Equal(t, true, ok)
assert.Equal(t, "1.2.0", remoteVersion)
assert.Contains(t, output, "Nix")

mockUpdater.AssertNotCalled(t, "UpdateTo")
mockFS.AssertExpectations(t)
mockRemoteRelease.AssertExpectations(t)
mockUpdater.AssertExpectations(t)
}
Loading