Skip to content
Open
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
22 changes: 20 additions & 2 deletions cmd/entire/cli/strategy/content_overlap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]),
Expand Down Expand Up @@ -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 {
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: "[email protected]", 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
Loading