diff --git a/cmd/entire/cli/strategy/content_overlap.go b/cmd/entire/cli/strategy/content_overlap.go index f8d693887..1a4078e51 100644 --- a/cmd/entire/cli/strategy/content_overlap.go +++ b/cmd/entire/cli/strategy/content_overlap.go @@ -5,7 +5,9 @@ import ( "io" "log/slog" "os" + "os/exec" "path/filepath" + "time" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/go-git/go-git/v6" @@ -493,7 +495,7 @@ func filesWithRemainingAgentChanges( // Committed content differs from shadow. Check whether the working tree // still has changes — if clean, the user intentionally replaced the content // and there's nothing left to carry forward. - if worktreeRoot != "" && workingTreeMatchesCommit(worktreeRoot, filePath, commitFile.Hash) { + if worktreeRoot != "" && workingTreeMatchesCommit(ctx, worktreeRoot, filePath, commitFile.Hash) { logging.Debug(logCtx, "filesWithRemainingAgentChanges: content differs from shadow but working tree is clean, skipping", slog.String("file", filePath), slog.String("commit_hash", commitFile.Hash.String()[:7]), @@ -521,7 +523,16 @@ func filesWithRemainingAgentChanges( // workingTreeMatchesCommit checks if the file on disk matches the committed blob hash. // Returns true if the working tree is clean for this file (no remaining changes). -func workingTreeMatchesCommit(worktreeRoot, filePath string, commitHash plumbing.Hash) bool { +func workingTreeMatchesCommit(ctx context.Context, worktreeRoot, filePath string, commitHash plumbing.Hash) bool { + // Ask Git first so clean/smudge filters like core.autocrlf don't create + // phantom differences between the working tree bytes and the committed blob. + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "-C", worktreeRoot, "diff", "--exit-code", "--quiet", "--", filePath) + if err := cmd.Run(); err == nil { + return true + } + absPath := filepath.Join(worktreeRoot, filePath) diskContent, err := os.ReadFile(absPath) //nolint:gosec // filePath is from git status, not user input if err != nil { diff --git a/cmd/entire/cli/strategy/content_overlap_test.go b/cmd/entire/cli/strategy/content_overlap_test.go index ce69ccd80..259de7122 100644 --- a/cmd/entire/cli/strategy/content_overlap_test.go +++ b/cmd/entire/cli/strategy/content_overlap_test.go @@ -3,6 +3,7 @@ package strategy import ( "context" "os" + "os/exec" "path/filepath" "testing" "time" @@ -415,6 +416,58 @@ func TestFilesWithRemainingAgentChanges_ReplacedContent(t *testing.T) { assert.Empty(t, remaining, "Replaced content with clean working tree should not be in remaining") } +// TestFilesWithRemainingAgentChanges_AutocrlfNormalizedWorkingTree verifies that +// line-ending normalization does not create phantom carry-forward files. +func TestFilesWithRemainingAgentChanges_AutocrlfNormalizedWorkingTree(t *testing.T) { + t.Parallel() + dir := setupGitRepo(t) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + runGit := func(args ...string) { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v failed: %s", args, string(out)) + } + + runGit("config", "core.autocrlf", "true") + + shadowContent := []byte("package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"hello world\")\n\tfmt.Println(\"goodbye world\")\n}\n") + createShadowBranchWithContent(t, repo, "crlf123", "e3b0c4", map[string][]byte{ + "src/main.go": shadowContent, + }) + + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src"), 0o755)) + workingTreeContent := []byte("package main\r\n\r\nimport \"fmt\"\r\n\r\nfunc main() {\r\n\tfmt.Println(\"hello world\")\r\n\tfmt.Println(\"goodbye world\")\r\n}\r\n") + testFile := filepath.Join(dir, "src", "main.go") + require.NoError(t, os.WriteFile(testFile, workingTreeContent, 0o644)) + + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("src/main.go") + require.NoError(t, err) + headCommit, err := wt.Commit("Commit normalized content", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + require.NoError(t, err) + + commit, err := repo.CommitObject(headCommit) + require.NoError(t, err) + + shadowBranch := checkpoint.ShadowBranchNameForCommit("crlf123", "e3b0c4") + committedFiles := map[string]struct{}{"src/main.go": {}} + + // Git reports no diff here even though the on-disk bytes are CRLF and the + // committed blob is LF-normalized under core.autocrlf=true. + runGit("diff", "--exit-code", "--", "src/main.go") + + remaining := filesWithRemainingAgentChanges(context.Background(), repo, shadowBranch, commit, []string{"src/main.go"}, committedFiles) + assert.Empty(t, remaining, "autocrlf-only working tree differences should not be carried forward") +} + // TestFilesWithRemainingAgentChanges_NoShadowBranch tests fallback to file-level subtraction. func TestFilesWithRemainingAgentChanges_NoShadowBranch(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/versioncheck/autoupdate.go b/cmd/entire/cli/versioncheck/autoupdate.go new file mode 100644 index 000000000..497595729 --- /dev/null +++ b/cmd/entire/cli/versioncheck/autoupdate.go @@ -0,0 +1,103 @@ +package versioncheck + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + + "github.com/charmbracelet/huh" + "golang.org/x/term" + + "github.com/entireio/cli/cmd/entire/cli/logging" +) + +// envKillSwitch disables the interactive update prompt regardless of TTY. +const envKillSwitch = "ENTIRE_NO_AUTO_UPDATE" + +// Test seams. +var ( + runInstaller = realRunInstaller + stdoutIsTerminal = defaultStdoutIsTerminal + confirmUpdate = realConfirmUpdate +) + +func defaultStdoutIsTerminal() bool { + return term.IsTerminal(int(os.Stdout.Fd())) //nolint:gosec // G115: uintptr->int is safe for fd +} + +// MaybeAutoUpdate offers an interactive upgrade after the standard +// "version available" notification has been printed. Silent on every +// failure path — it must never interrupt the CLI. +func MaybeAutoUpdate(ctx context.Context, w io.Writer, currentVersion string) { + if os.Getenv(envKillSwitch) != "" { + return + } + if os.Getenv("CI") != "" { + return + } + if !stdoutIsTerminal() { + return + } + + confirmed, err := confirmUpdate() + if err != nil { + logging.Debug(ctx, "auto-update: prompt failed", "error", err.Error()) + return + } + if !confirmed { + return + } + + cmdStr := updateCommand(currentVersion) + fmt.Fprintf(w, "\nUpdating Entire CLI: %s\n", cmdStr) + if err := runInstaller(ctx, cmdStr); err != nil { + fmt.Fprintf(w, "Update failed: %v\n", err) + return + } + fmt.Fprintln(w, "Update complete. Re-run entire to use the new version.") +} + +func realConfirmUpdate() (bool, error) { + var confirmed bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Install the new version now?"). + Affirmative("Yes"). + Negative("No"). + Value(&confirmed), + ), + ).WithTheme(huh.ThemeDracula()) + if os.Getenv("ACCESSIBLE") != "" { + form = form.WithAccessible(true) + } + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, huh.ErrTimeout) { + return false, nil + } + return false, fmt.Errorf("confirm form: %w", err) + } + return confirmed, nil +} + +// realRunInstaller shells out to the installer command, streaming stdin/stdout/stderr +// so password prompts and progress output reach the user. +func realRunInstaller(ctx context.Context, cmdStr string) error { + var c *exec.Cmd + if runtime.GOOS == "windows" { + c = exec.CommandContext(ctx, "cmd", "/C", cmdStr) + } else { + c = exec.CommandContext(ctx, "sh", "-c", cmdStr) + } + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + return fmt.Errorf("installer exited: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/versioncheck/autoupdate_test.go b/cmd/entire/cli/versioncheck/autoupdate_test.go new file mode 100644 index 000000000..c5dbee4b2 --- /dev/null +++ b/cmd/entire/cli/versioncheck/autoupdate_test.go @@ -0,0 +1,142 @@ +package versioncheck + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" +) + +// autoUpdateFixture wires the test seams for MaybeAutoUpdate. +type autoUpdateFixture struct { + installCalls int + installErr error + lastCommand string + confirmValue bool + confirmErr error +} + +func newAutoUpdateFixture(t *testing.T) *autoUpdateFixture { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("CI", "") + t.Setenv(envKillSwitch, "") + t.Setenv("ACCESSIBLE", "") + + f := &autoUpdateFixture{confirmValue: true} + + origRun := runInstaller + runInstaller = func(_ context.Context, cmd string) error { + f.installCalls++ + f.lastCommand = cmd + return f.installErr + } + origTerm := stdoutIsTerminal + stdoutIsTerminal = func() bool { return true } + origConfirm := confirmUpdate + confirmUpdate = func() (bool, error) { return f.confirmValue, f.confirmErr } + + t.Cleanup(func() { + runInstaller = origRun + stdoutIsTerminal = origTerm + confirmUpdate = origConfirm + }) + return f +} + +// useBrewExecutable points the install-manager detector at a brew cellar path. +func useBrewExecutable(t *testing.T) { + t.Helper() + orig := executablePath + executablePath = func() (string, error) { + return "/opt/homebrew/Cellar/entire/1.0.0/bin/entire", nil + } + t.Cleanup(func() { executablePath = orig }) +} + +func TestMaybeAutoUpdate_KillSwitch(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + t.Setenv(envKillSwitch, "1") + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called with kill-switch set") + } +} + +func TestMaybeAutoUpdate_CI(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + t.Setenv("CI", "true") + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called in CI") + } +} + +func TestMaybeAutoUpdate_NoTTY(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + stdoutIsTerminal = func() bool { return false } + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called without TTY") + } +} + +func TestMaybeAutoUpdate_UserDeclines(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + f.confirmValue = false + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 0 { + t.Errorf("installer called after user declined") + } +} + +func TestMaybeAutoUpdate_HappyPath(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 1 { + t.Fatalf("installer called %d times, want 1", f.installCalls) + } + if f.lastCommand != "brew upgrade --cask entire" { + t.Errorf("installer got %q, want brew upgrade --cask entire", f.lastCommand) + } + if !strings.Contains(buf.String(), "Update complete") { + t.Errorf("missing success message: %q", buf.String()) + } +} + +func TestMaybeAutoUpdate_InstallerFailurePrintedToUser(t *testing.T) { + f := newAutoUpdateFixture(t) + useBrewExecutable(t) + f.installErr = errors.New("boom") + + var buf bytes.Buffer + MaybeAutoUpdate(context.Background(), &buf, "1.0.0") + + if f.installCalls != 1 { + t.Fatalf("installer called %d times, want 1", f.installCalls) + } + if !strings.Contains(buf.String(), "Update failed") { + t.Errorf("missing failure message: %q", buf.String()) + } +} diff --git a/cmd/entire/cli/versioncheck/versioncheck.go b/cmd/entire/cli/versioncheck/versioncheck.go index 76391d525..935676c02 100644 --- a/cmd/entire/cli/versioncheck/versioncheck.go +++ b/cmd/entire/cli/versioncheck/versioncheck.go @@ -73,9 +73,10 @@ func CheckAndNotify(ctx context.Context, w io.Writer, currentVersion string) { return } - // Show notification if outdated + // Show notification and offer an interactive upgrade when outdated if isOutdated(currentVersion, latestVersion) { printNotification(w, currentVersion, latestVersion) + MaybeAutoUpdate(ctx, w, currentVersion) } } @@ -173,10 +174,8 @@ func saveCache(cache *VersionCache) error { return nil } -// fetchLatestVersion fetches the latest version from the GitHub API. -// Returns a timeout-safe version check using the configured HTTP timeout. +// fetchLatestVersion fetches the latest stable version tag from the GitHub API. func fetchLatestVersion(ctx context.Context) (string, error) { - // Create a context with timeout for the HTTP request ctx, cancel := context.WithTimeout(ctx, httpTimeout) defer cancel() @@ -185,7 +184,6 @@ func fetchLatestVersion(ctx context.Context) (string, error) { return "", fmt.Errorf("creating request: %w", err) } - // Set headers to identify as Entire CLI req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "entire-cli") @@ -200,18 +198,15 @@ func fetchLatestVersion(ctx context.Context) (string, error) { return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - // Read response body (limit to 1MB to prevent memory exhaustion) body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", fmt.Errorf("reading response: %w", err) } - // Parse GitHub release response version, err := parseGitHubRelease(body) if err != nil { return "", fmt.Errorf("parsing release: %w", err) } - return version, nil } @@ -223,7 +218,7 @@ func isNightly(version string) bool { return strings.Contains(semver.Prerelease(version), "nightly") } -// fetchLatestNightlyVersion fetches the latest nightly version from the GitHub releases list. +// fetchLatestNightlyVersion fetches the latest nightly version tag from the GitHub releases list. func fetchLatestNightlyVersion(ctx context.Context) (string, error) { ctx, cancel := context.WithTimeout(ctx, httpTimeout) defer cancel() @@ -266,7 +261,7 @@ func fetchLatestNightlyVersion(ctx context.Context) (string, error) { return "", errors.New("no nightly release found") } -// parseGitHubRelease parses the GitHub API response and extracts the latest stable version. +// parseGitHubRelease parses the GitHub API response and returns the latest stable version tag. // Filters out prerelease versions. func parseGitHubRelease(body []byte) (string, error) { var release GitHubRelease @@ -274,12 +269,10 @@ func parseGitHubRelease(body []byte) (string, error) { return "", fmt.Errorf("parsing JSON: %w", err) } - // Skip prerelease versions if release.Prerelease { return "", errors.New("only prerelease versions available") } - // Ensure we have a tag name if release.TagName == "" { return "", errors.New("empty tag name") } diff --git a/cmd/entire/cli/versioncheck/versioncheck_test.go b/cmd/entire/cli/versioncheck/versioncheck_test.go index 19b19eaa1..03a93734d 100644 --- a/cmd/entire/cli/versioncheck/versioncheck_test.go +++ b/cmd/entire/cli/versioncheck/versioncheck_test.go @@ -310,6 +310,7 @@ func TestParseGitHubRelease(t *testing.T) { } func TestUpdateCommand(t *testing.T) { + const plainBinPath = "/usr/local/bin/entire" tests := []struct { name string currentVersion string @@ -355,13 +356,13 @@ func TestUpdateCommand(t *testing.T) { { name: "unknown path stable falls back to stable curl command", currentVersion: "1.0.0", - execPath: func() (string, error) { return "/usr/local/bin/entire", nil }, + execPath: func() (string, error) { return plainBinPath, nil }, want: "curl -fsSL https://entire.io/install.sh | bash", }, { name: "unknown path nightly falls back to nightly curl command", currentVersion: "1.0.1-nightly.202604101200.abc1234", - execPath: func() (string, error) { return "/usr/local/bin/entire", nil }, + execPath: func() (string, error) { return plainBinPath, nil }, want: "curl -fsSL https://entire.io/install.sh | bash -s -- --channel nightly", }, { diff --git a/docs/architecture/auto-update.md b/docs/architecture/auto-update.md new file mode 100644 index 000000000..6f162ef06 --- /dev/null +++ b/docs/architecture/auto-update.md @@ -0,0 +1,41 @@ +# Auto-Update + +After the Entire CLI's daily version check detects a newer release, the +standard notification is followed by an interactive Y/N prompt to run the +installer. + +## UX + +``` +A newer version of Entire CLI is available: v1.2.3 (current: v1.0.0) +Run 'brew upgrade --cask entire' to update. + +? Install the new version now? (Y/n) +``` + +- Declining simply skips the upgrade. The 24-hour version-check cache means + the prompt will not reappear until the next day. +- The installer command is whatever `versioncheck.updateCommand(current)` + returns — `brew upgrade --cask ...`, `mise upgrade entire`, + `scoop update entire/cli`, or the curl-pipe-bash fallback — including the + `--channel nightly` variant for nightly builds. +- stdin, stdout and stderr are wired through so the user sees installer + output and can answer any password prompt. + +## Guardrails + +The prompt is skipped silently when any of the following holds: + +- stdout is not a terminal. +- `CI` environment variable is set. +- `ENTIRE_NO_AUTO_UPDATE` environment variable is set (kill switch). + +In those cases the user still sees the existing notification line pointing +to the installer command. + +## Not in scope + +- No "silent auto-install" mode — the prompt is always interactive. +- No persisted preference — the kill-switch env is the escape hatch. +- No dedicated `entire auto-update` / `entire update` subcommands; the + notification + prompt replaces them.