diff --git a/cmd/entire/cli/strategy/content_overlap.go b/cmd/entire/cli/strategy/content_overlap.go index f8d693887..83577ff5a 100644 --- a/cmd/entire/cli/strategy/content_overlap.go +++ b/cmd/entire/cli/strategy/content_overlap.go @@ -2,10 +2,13 @@ package strategy import ( "context" + "errors" "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 +496,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 +524,22 @@ 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) + err := cmd.Run() + if err == nil { + return true + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false // git says file is dirty + } + // git itself failed (128+, timeout, etc.) — fall back to raw blob hash + 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 65c149dd9..1bb6c6517 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()