Skip to content
Closed
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
15 changes: 13 additions & 2 deletions cmd/entire/cli/strategy/content_overlap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]),
Expand Down Expand Up @@ -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 {
Expand Down
53 changes: 53 additions & 0 deletions cmd/entire/cli/strategy/content_overlap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package strategy
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
Expand Down Expand Up @@ -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()
Expand Down
103 changes: 103 additions & 0 deletions cmd/entire/cli/versioncheck/autoupdate.go
Original file line number Diff line number Diff line change
@@ -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
}
142 changes: 142 additions & 0 deletions cmd/entire/cli/versioncheck/autoupdate_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading
Loading