diff --git a/internal/appupdate/appupdate.go b/internal/appupdate/appupdate.go index 3d97cef..65fb3e4 100644 --- a/internal/appupdate/appupdate.go +++ b/internal/appupdate/appupdate.go @@ -26,6 +26,7 @@ func HandleSelfUpdate( resultChannel := make(chan string) isHomebrewInstall := isHomebrewInstall(logger) + isNixInstall := isNixInstall(logger) currentSemVer, err := semver.NewVersion(currentVersion) if err != nil { @@ -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) @@ -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 { @@ -91,6 +116,7 @@ func updateToLatestVersion( fs filesystem.FileSystem, updater Updater, isHomebrewInstall bool, + isNixInstall bool, ) { latestVersion := readLatestVersion(fs) if latestVersion == "" { @@ -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", diff --git a/internal/appupdate/appupdate_test.go b/internal/appupdate/appupdate_test.go index 19792f3..d462290 100644 --- a/internal/appupdate/appupdate_test.go +++ b/internal/appupdate/appupdate_test.go @@ -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 @@ -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) +}