diff --git a/README.md b/README.md index f2ce83a..24eaef0 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,27 @@ Uninstall git-undo: git undo self uninstall ``` +### Auto-Update Feature + +git-undo includes an automatic update checker that runs in the background: + +- **Automatic checks**: Every 7 days when you run `git undo`, it checks for new releases +- **Non-intrusive**: Runs in the background and only shows a notification if an update is available +- **Smart timing**: Skips checks in verbose mode, dry-run mode, or for self-management commands +- **Per-repository tracking**: Maintains separate update check timers for each git repository +- **Fallback to global**: Uses global config when not in a git repository + +When an update is available, you'll see: +``` +🔄 Update available: v1.2.3 → v1.3.0 +Run 'git undo self-update' to update +``` + +The auto-update feature: +- Stores check timestamps in `.git/git-undo-autoupdate.json` (per-repo) or `~/.config/git-undo/autoupdate.json` (global) +- Only makes network requests once every 7 days +- Gracefully handles network failures without disrupting normal operation + ## Supported Git Commands * `commit` * `add` diff --git a/git-undo b/git-undo new file mode 100755 index 0000000..67271b8 Binary files /dev/null and b/git-undo differ diff --git a/internal/app/app.go b/internal/app/app.go index 781b4ab..8e34bbe 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -188,6 +188,9 @@ func (a *App) Run(args []string) error { return nil } + // Check for updates in the background (only for normal undo operations) + a.AutoUpdate() + // Get the last git command lastEntry, err := a.lgr.GetLastRegularEntry() if err != nil { diff --git a/internal/app/autoupdate.go b/internal/app/autoupdate.go new file mode 100644 index 0000000..fdda1ee --- /dev/null +++ b/internal/app/autoupdate.go @@ -0,0 +1,229 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + // AutoUpdateCheckInterval defines how often to check for updates (7 days) + AutoUpdateCheckInterval = 7 * 24 * time.Hour + + // GitHub API URL for checking latest release + GitHubAPIURL = "https://api.github.com/repos/amberpixels/git-undo/releases/latest" +) + +// AutoUpdateConfig stores the auto-update configuration and state +type AutoUpdateConfig struct { + LastCheckTime time.Time `json:"last_check_time"` + LastVersion string `json:"last_version"` +} + +// GitHubRelease represents the GitHub API response for a release +type GitHubRelease struct { + TagName string `json:"tag_name"` +} + +// getAutoUpdateConfigPath returns the path to the auto-update config file +func (a *App) getAutoUpdateConfigPath() (string, error) { + gitDir, err := a.git.GetRepoGitDir() + if err != nil { + // If we're not in a git repo, use a global config directory + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + configDir := filepath.Join(homeDir, ".config", "git-undo") + if err := os.MkdirAll(configDir, 0755); err != nil { + return "", fmt.Errorf("failed to create config directory: %w", err) + } + return filepath.Join(configDir, "autoupdate.json"), nil + } + + // Use git directory for repo-specific config + configPath := filepath.Join(gitDir, "git-undo-autoupdate.json") + return configPath, nil +} + +// loadAutoUpdateConfig loads the auto-update configuration +func (a *App) loadAutoUpdateConfig() (*AutoUpdateConfig, error) { + configPath, err := a.getAutoUpdateConfigPath() + if err != nil { + return nil, err + } + + config := &AutoUpdateConfig{} + + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + // Config doesn't exist, return default config + return config, nil + } + return nil, fmt.Errorf("failed to read auto-update config: %w", err) + } + + if err := json.Unmarshal(data, config); err != nil { + return nil, fmt.Errorf("failed to parse auto-update config: %w", err) + } + + return config, nil +} + +// saveAutoUpdateConfig saves the auto-update configuration +func (a *App) saveAutoUpdateConfig(config *AutoUpdateConfig) error { + configPath, err := a.getAutoUpdateConfigPath() + if err != nil { + return err + } + + data, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal auto-update config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write auto-update config: %w", err) + } + + return nil +} + +// getLatestVersion fetches the latest version from GitHub API +func (a *App) getLatestVersion() (string, error) { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get(GitHubAPIURL) + if err != nil { + return "", fmt.Errorf("failed to fetch latest version: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + var release GitHubRelease + if err := json.Unmarshal(body, &release); err != nil { + return "", fmt.Errorf("failed to parse GitHub API response: %w", err) + } + + return release.TagName, nil +} + +// compareVersions compares two version strings +// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 +func compareVersions(v1, v2 string) int { + // Remove 'v' prefix if present + v1 = strings.TrimPrefix(v1, "v") + v2 = strings.TrimPrefix(v2, "v") + + // Handle development versions + if v1 == "dev" && v2 != "dev" { + return -1 // dev is always older than any release + } + if v1 != "dev" && v2 == "dev" { + return 1 // any release is newer than dev + } + if v1 == "dev" && v2 == "dev" { + return 0 // dev == dev + } + + // Split versions into parts + parts1 := strings.Split(strings.Split(v1, "-")[0], ".") + parts2 := strings.Split(strings.Split(v2, "-")[0], ".") + + // Ensure both have at least 3 parts (major.minor.patch) + for len(parts1) < 3 { + parts1 = append(parts1, "0") + } + for len(parts2) < 3 { + parts2 = append(parts2, "0") + } + + // Compare each part + for i := 0; i < 3; i++ { + var n1, n2 int + fmt.Sscanf(parts1[i], "%d", &n1) + fmt.Sscanf(parts2[i], "%d", &n2) + + if n1 < n2 { + return -1 + } + if n1 > n2 { + return 1 + } + } + + return 0 +} + +// checkForUpdates checks if an update is available and prompts the user +func (a *App) checkForUpdates() { + // Load auto-update config + config, err := a.loadAutoUpdateConfig() + if err != nil { + a.logDebugf("Failed to load auto-update config: %v", err) + return + } + + // Check if enough time has passed since last check + if time.Since(config.LastCheckTime) < AutoUpdateCheckInterval { + a.logDebugf("Auto-update check skipped (last check: %v)", config.LastCheckTime.Format("2006-01-02 15:04:05")) + return + } + + a.logDebugf("Checking for updates...") + + // Get latest version from GitHub + latestVersion, err := a.getLatestVersion() + if err != nil { + a.logDebugf("Failed to check for updates: %v", err) + // Update last check time even if failed to avoid spamming + config.LastCheckTime = time.Now() + _ = a.saveAutoUpdateConfig(config) + return + } + + // Update last check time and version + config.LastCheckTime = time.Now() + config.LastVersion = latestVersion + if err := a.saveAutoUpdateConfig(config); err != nil { + a.logDebugf("Failed to save auto-update config: %v", err) + } + + // Compare with current version + currentVersion := a.buildVersion + if compareVersions(currentVersion, latestVersion) < 0 { + // Update available + fmt.Fprintf(os.Stderr, "\n"+yellowColor+"🔄 Update available: %s → %s"+resetColor+"\n", currentVersion, latestVersion) + fmt.Fprintf(os.Stderr, grayColor+"Run 'git undo self-update' to update"+resetColor+"\n\n") + } else { + a.logDebugf("No update available (current: %s, latest: %s)", currentVersion, latestVersion) + } +} + +// AutoUpdate performs the auto-update check if needed +func (a *App) AutoUpdate() { + // Only check for updates in normal operation, not for self-management commands + // and not in verbose/dry-run modes to avoid noise + if a.verbose || a.dryRun { + return + } + + // Run in background to avoid blocking the main operation + go a.checkForUpdates() +} diff --git a/internal/app/autoupdate_integration_test.go b/internal/app/autoupdate_integration_test.go new file mode 100644 index 0000000..f2a8459 --- /dev/null +++ b/internal/app/autoupdate_integration_test.go @@ -0,0 +1,116 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAutoUpdateIntegration(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "git-undo-integration-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a mock app with a temporary git directory + app := &App{ + buildVersion: "v1.0.0", // Simulate an older version + verbose: false, + dryRun: false, + git: &mockGitHelper{gitDir: tempDir}, + } + + // Test 1: First run should trigger update check (but won't show notification due to network call) + app.AutoUpdate() + + // Give the goroutine a moment to complete + time.Sleep(100 * time.Millisecond) + + // Verify config file was created + configPath := filepath.Join(tempDir, "git-undo-autoupdate.json") + _, err = os.Stat(configPath) + assert.NoError(t, err, "Auto-update config file should be created") + + // Test 2: Load the config and verify it was updated + config, err := app.loadAutoUpdateConfig() + require.NoError(t, err) + + // The last check time should be recent (within the last minute) + assert.True(t, time.Since(config.LastCheckTime) < time.Minute, + "Last check time should be recent, got: %v", config.LastCheckTime) + + // Test 3: Immediate second call should skip the check + oldCheckTime := config.LastCheckTime + app.AutoUpdate() + time.Sleep(100 * time.Millisecond) + + // Load config again + config, err = app.loadAutoUpdateConfig() + require.NoError(t, err) + + // Check time should be the same (no new check performed) + assert.Equal(t, oldCheckTime.Unix(), config.LastCheckTime.Unix(), + "Second check should be skipped due to recent check") + + // Test 4: Simulate old check time to trigger new check + config.LastCheckTime = time.Now().Add(-8 * 24 * time.Hour) // 8 days ago + err = app.saveAutoUpdateConfig(config) + require.NoError(t, err) + + app.AutoUpdate() + time.Sleep(100 * time.Millisecond) + + // Load config again + newConfig, err := app.loadAutoUpdateConfig() + require.NoError(t, err) + + // Check time should be updated + assert.True(t, newConfig.LastCheckTime.After(config.LastCheckTime), + "Check time should be updated after old timestamp") +} + +func TestAutoUpdateSkipsInVerboseMode(t *testing.T) { + tempDir, err := os.MkdirTemp("", "git-undo-verbose-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + app := &App{ + buildVersion: "v1.0.0", + verbose: true, // Verbose mode should skip auto-update + dryRun: false, + git: &mockGitHelper{gitDir: tempDir}, + } + + app.AutoUpdate() + time.Sleep(100 * time.Millisecond) + + // Config file should not be created in verbose mode + configPath := filepath.Join(tempDir, "git-undo-autoupdate.json") + _, err = os.Stat(configPath) + assert.True(t, os.IsNotExist(err), "Auto-update config should not be created in verbose mode") +} + +func TestAutoUpdateSkipsInDryRunMode(t *testing.T) { + tempDir, err := os.MkdirTemp("", "git-undo-dryrun-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + app := &App{ + buildVersion: "v1.0.0", + verbose: false, + dryRun: true, // Dry-run mode should skip auto-update + git: &mockGitHelper{gitDir: tempDir}, + } + + app.AutoUpdate() + time.Sleep(100 * time.Millisecond) + + // Config file should not be created in dry-run mode + configPath := filepath.Join(tempDir, "git-undo-autoupdate.json") + _, err = os.Stat(configPath) + assert.True(t, os.IsNotExist(err), "Auto-update config should not be created in dry-run mode") +} diff --git a/internal/app/autoupdate_test.go b/internal/app/autoupdate_test.go new file mode 100644 index 0000000..55329b3 --- /dev/null +++ b/internal/app/autoupdate_test.go @@ -0,0 +1,143 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompareVersions(t *testing.T) { + tests := []struct { + v1 string + v2 string + expected int + }{ + {"1.0.0", "1.0.1", -1}, + {"1.0.1", "1.0.0", 1}, + {"1.0.0", "1.0.0", 0}, + {"v1.0.0", "v1.0.1", -1}, + {"1.0.0", "v1.0.1", -1}, + {"v1.0.1", "1.0.0", 1}, + {"dev", "1.0.0", -1}, + {"1.0.0", "dev", 1}, + {"dev", "dev", 0}, + {"1.2.3", "1.2.4", -1}, + {"1.2.4", "1.2.3", 1}, + {"1.2.3", "1.3.0", -1}, + {"1.3.0", "1.2.3", 1}, + {"2.0.0", "1.9.9", 1}, + {"1.9.9", "2.0.0", -1}, + {"1.0.0-beta", "1.0.0", 0}, // Base versions are same + {"1.0", "1.0.0", 0}, // Missing patch version + } + + for _, test := range tests { + t.Run(test.v1+"_vs_"+test.v2, func(t *testing.T) { + result := compareVersions(test.v1, test.v2) + assert.Equal(t, test.expected, result, "compareVersions(%s, %s) = %d, expected %d", test.v1, test.v2, result, test.expected) + }) + } +} + +func TestAutoUpdateConfig(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "git-undo-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a mock app with a temporary git directory + app := &App{ + git: &mockGitHelper{gitDir: tempDir}, + } + + // Test loading non-existent config + config, err := app.loadAutoUpdateConfig() + require.NoError(t, err) + assert.Equal(t, time.Time{}, config.LastCheckTime) + assert.Equal(t, "", config.LastVersion) + + // Test saving and loading config + now := time.Now() + config.LastCheckTime = now + config.LastVersion = "v1.2.3" + + err = app.saveAutoUpdateConfig(config) + require.NoError(t, err) + + // Load the config back + loadedConfig, err := app.loadAutoUpdateConfig() + require.NoError(t, err) + assert.Equal(t, now.Unix(), loadedConfig.LastCheckTime.Unix()) // Compare Unix timestamps to avoid precision issues + assert.Equal(t, "v1.2.3", loadedConfig.LastVersion) + + // Verify the file was created in the correct location + configPath, err := app.getAutoUpdateConfigPath() + require.NoError(t, err) + assert.True(t, filepath.IsAbs(configPath)) + + // Check file exists and has correct content + data, err := os.ReadFile(configPath) + require.NoError(t, err) + + var fileConfig AutoUpdateConfig + err = json.Unmarshal(data, &fileConfig) + require.NoError(t, err) + assert.Equal(t, "v1.2.3", fileConfig.LastVersion) +} + +func TestAutoUpdateConfigPath(t *testing.T) { + // Test with git directory + tempGitDir, err := os.MkdirTemp("", "git-undo-test-git") + require.NoError(t, err) + defer os.RemoveAll(tempGitDir) + + app := &App{ + git: &mockGitHelper{gitDir: tempGitDir}, + } + + configPath, err := app.getAutoUpdateConfigPath() + require.NoError(t, err) + assert.Equal(t, filepath.Join(tempGitDir, "git-undo-autoupdate.json"), configPath) + + // Test without git directory (should use global config) + app = &App{ + git: &mockGitHelper{gitDirError: true}, + } + + configPath, err = app.getAutoUpdateConfigPath() + require.NoError(t, err) + + homeDir, _ := os.UserHomeDir() + expectedPath := filepath.Join(homeDir, ".config", "git-undo", "autoupdate.json") + assert.Equal(t, expectedPath, configPath) +} + +// mockGitHelper is a mock implementation of GitHelper for testing +type mockGitHelper struct { + gitDir string + gitDirError bool +} + +func (m *mockGitHelper) GetCurrentGitRef() (string, error) { + return "main", nil +} + +func (m *mockGitHelper) GetRepoGitDir() (string, error) { + if m.gitDirError { + return "", assert.AnError + } + return m.gitDir, nil +} + +func (m *mockGitHelper) GitRun(subCmd string, args ...string) error { + return nil +} + +func (m *mockGitHelper) GitOutput(subCmd string, args ...string) (string, error) { + return "", nil +}