From 61c103f643560269c2506a9a3ef040b09dba1cbc Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 11:58:58 -0700 Subject: [PATCH 01/17] fix: CRLF normalization in filterToUncommittedFiles and interactive test race Fix two E2E test failures from CI run #23889606598: 1. filterToUncommittedFiles compares working tree content with HEAD blob content using raw bytes. On Windows with core.autocrlf=true, the working tree has CRLF while git blobs store LF, causing the comparison to fail and committed files to be incorrectly treated as uncommitted. This created spurious shadow branches after subagent commits. Fix: normalize CRLF to LF before comparing. 2. TestInteractiveMultiStep reads the checkpoint branch immediately after WaitForCheckpoint, but the turn-end hook's finalize step writes a second commit to the same branch concurrently. Reading mid-update can see a broken ref. Fix: add WaitForSessionIdle before checkpoint assertions. Entire-Checkpoint: 8141fe489b5a --- cmd/entire/cli/state.go | 6 ++- cmd/entire/cli/state_test.go | 94 +++++++++++++++++++++++++++++++++++ e2e/tests/interactive_test.go | 5 ++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 545f6e0b6..4ce36839d 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -339,7 +339,11 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri continue } - if string(workingContent) != headContent { + // Normalize CRLF → LF so the comparison works on Windows where + // core.autocrlf converts line endings on checkout. Git blobs always + // store LF, but the working tree copy may have CRLF. + normalizedWorking := strings.ReplaceAll(string(workingContent), "\r\n", "\n") + if normalizedWorking != headContent { // Working tree differs from HEAD — uncommitted changes result = append(result, relPath) } diff --git a/cmd/entire/cli/state_test.go b/cmd/entire/cli/state_test.go index dc55bc8bd..fc51e0537 100644 --- a/cmd/entire/cli/state_test.go +++ b/cmd/entire/cli/state_test.go @@ -796,3 +796,97 @@ func TestMergeUnique(t *testing.T) { }) } } + +func TestFilterToUncommittedFiles_CRLFNormalization(t *testing.T) { + // Regression test: on Windows with core.autocrlf=true, the working tree + // has CRLF line endings while git blobs store LF. filterToUncommittedFiles + // must normalize line endings before comparing so committed files are + // correctly filtered out. + + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + // Commit file with LF content via go-git (blobs always store LF) + repo, err := git.PlainInit(tmpDir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + lfContent := "line one\nline two\nline three\n" + filePath := filepath.Join(tmpDir, "file.txt") + if err := os.WriteFile(filePath, []byte(lfContent), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + wt, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + if _, err := wt.Add("file.txt"); err != nil { + t.Fatalf("failed to add file: %v", err) + } + if _, err := wt.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + }, + }); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + // Overwrite on-disk file with CRLF content (simulating Windows checkout) + crlfContent := "line one\r\nline two\r\nline three\r\n" + if err := os.WriteFile(filePath, []byte(crlfContent), 0o644); err != nil { + t.Fatalf("failed to rewrite file with CRLF: %v", err) + } + + // filterToUncommittedFiles should treat the file as committed (same + // logical content despite CRLF vs LF difference) + result := filterToUncommittedFiles(context.Background(), []string{"file.txt"}, tmpDir) + if len(result) != 0 { + t.Errorf("filterToUncommittedFiles() = %v, want empty (file should be treated as committed)", result) + } +} + +func TestFilterToUncommittedFiles_ReallyModified(t *testing.T) { + // Verify that files with genuinely different content are kept. + + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + repo, err := git.PlainInit(tmpDir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + filePath := filepath.Join(tmpDir, "file.txt") + if err := os.WriteFile(filePath, []byte("original\n"), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + wt, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + if _, err := wt.Add("file.txt"); err != nil { + t.Fatalf("failed to add file: %v", err) + } + if _, err := wt.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + }, + }); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + // Modify the file with genuinely different content + if err := os.WriteFile(filePath, []byte("modified\n"), 0o644); err != nil { + t.Fatalf("failed to rewrite file: %v", err) + } + + result := filterToUncommittedFiles(context.Background(), []string{"file.txt"}, tmpDir) + if len(result) != 1 || result[0] != "file.txt" { + t.Errorf("filterToUncommittedFiles() = %v, want [file.txt]", result) + } +} diff --git a/e2e/tests/interactive_test.go b/e2e/tests/interactive_test.go index f40de506e..3d9ed8574 100644 --- a/e2e/tests/interactive_test.go +++ b/e2e/tests/interactive_test.go @@ -29,6 +29,11 @@ func TestInteractiveMultiStep(t *testing.T) { s.WaitFor(t, session, prompt, 60*time.Second) testutil.AssertNewCommits(t, s, 1) + // Wait for the turn-end hook (including finalize) to complete before + // reading the checkpoint branch. The finalize step writes a second + // commit to entire/checkpoints/v1 concurrently with the test, and + // reading the branch mid-update can see a broken ref. + testutil.WaitForSessionIdle(t, s.Dir, 10*time.Second) testutil.WaitForCheckpoint(t, s, 30*time.Second) testutil.AssertCommitLinkedToCheckpoint(t, s.Dir, "HEAD") testutil.WaitForNoShadowBranches(t, s.Dir, 10*time.Second) From ce88dbe993ba6c033fedbe3225d2874066123ee8 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 13:15:56 -0700 Subject: [PATCH 02/17] review: normalize CRLF on both sides per @Copilot and @cursor on filterToUncommittedFiles Entire-Checkpoint: 9a6f73c4cfe0 --- cmd/entire/cli/state.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 4ce36839d..a73c54311 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -339,11 +339,12 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri continue } - // Normalize CRLF → LF so the comparison works on Windows where - // core.autocrlf converts line endings on checkout. Git blobs always - // store LF, but the working tree copy may have CRLF. + // Normalize CRLF → LF on both sides so the comparison works on Windows + // where core.autocrlf may convert line endings on checkout. Git blobs + // store raw bytes and are not guaranteed to use LF line endings. normalizedWorking := strings.ReplaceAll(string(workingContent), "\r\n", "\n") - if normalizedWorking != headContent { + normalizedHead := strings.ReplaceAll(headContent, "\r\n", "\n") + if normalizedWorking != normalizedHead { // Working tree differs from HEAD — uncommitted changes result = append(result, relPath) } From 53310630d8e0298548efa0339bc84c10ec88baa7 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 13:16:37 -0700 Subject: [PATCH 03/17] review: fix misleading "blobs always store LF" comment per @Copilot Entire-Checkpoint: ee1c6a0cbbed --- cmd/entire/cli/state_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/state_test.go b/cmd/entire/cli/state_test.go index fc51e0537..d8bf7d13b 100644 --- a/cmd/entire/cli/state_test.go +++ b/cmd/entire/cli/state_test.go @@ -799,14 +799,15 @@ func TestMergeUnique(t *testing.T) { func TestFilterToUncommittedFiles_CRLFNormalization(t *testing.T) { // Regression test: on Windows with core.autocrlf=true, the working tree - // has CRLF line endings while git blobs store LF. filterToUncommittedFiles - // must normalize line endings before comparing so committed files are - // correctly filtered out. + // may have CRLF line endings while the committed blob can contain LF + // content. This test commits LF content, then rewrites the working-tree + // file with CRLF. filterToUncommittedFiles must normalize line endings + // before comparing so committed files are correctly filtered out. tmpDir := t.TempDir() t.Chdir(tmpDir) - // Commit file with LF content via go-git (blobs always store LF) + // Commit file with LF content via go-git so the committed blob is LF. repo, err := git.PlainInit(tmpDir, false) if err != nil { t.Fatalf("failed to init repo: %v", err) From 03d8395878809fedabc2e6c405d61ecfc469a1cb Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 13:16:57 -0700 Subject: [PATCH 04/17] review: use 15s WaitForSessionIdle timeout for consistency per @Copilot Entire-Checkpoint: 1e6a888663be --- e2e/tests/interactive_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/interactive_test.go b/e2e/tests/interactive_test.go index 3d9ed8574..b0d0ec764 100644 --- a/e2e/tests/interactive_test.go +++ b/e2e/tests/interactive_test.go @@ -33,7 +33,7 @@ func TestInteractiveMultiStep(t *testing.T) { // reading the checkpoint branch. The finalize step writes a second // commit to entire/checkpoints/v1 concurrently with the test, and // reading the branch mid-update can see a broken ref. - testutil.WaitForSessionIdle(t, s.Dir, 10*time.Second) + testutil.WaitForSessionIdle(t, s.Dir, 15*time.Second) testutil.WaitForCheckpoint(t, s, 30*time.Second) testutil.AssertCommitLinkedToCheckpoint(t, s.Dir, "HEAD") testutil.WaitForNoShadowBranches(t, s.Dir, 10*time.Second) From a19edebf49a11e5788e130805a8256d24488c3f5 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 13:28:53 -0700 Subject: [PATCH 05/17] fix: revert CRLF normalization, add diagnostic logging, fix cursor prompt - Revert CRLF normalization in filterToUncommittedFiles since it didn't fix the Windows failure. Add diagnostic logging instead to identify the actual root cause on the next CI run. - Fix TestMidTurnCommit prompt: replace "Do not include any trailers or metadata" with "Do not add Co-authored-by or Signed-off-by trailers" to prevent cursor-cli from stripping Entire's hook-added trailers. Entire-Checkpoint: 0e96c87393c4 --- cmd/entire/cli/state.go | 39 ++++++++++++++++++----- cmd/entire/cli/state_test.go | 52 ------------------------------- e2e/tests/mid_turn_commit_test.go | 2 +- 3 files changed, 33 insertions(+), 60 deletions(-) diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index a73c54311..0c6cbb014 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -291,35 +291,53 @@ func DetectFileChanges(ctx context.Context, previouslyUntracked []string) (*File // HEAD or with different content in the working tree are kept. Fails open: if any git // operation errors, returns the original list unchanged. func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot string) []string { + logCtx := logging.WithComponent(ctx, "filter-uncommitted") + if len(files) == 0 { return files } repo, err := openRepository(ctx) if err != nil { + logging.Debug(logCtx, "openRepository failed, returning all files", + slog.String("error", err.Error())) return files // fail open } head, err := repo.Head() if err != nil { + logging.Debug(logCtx, "repo.Head() failed, returning all files", + slog.String("error", err.Error())) return files // fail open (empty repo, detached HEAD, etc.) } commit, err := repo.CommitObject(head.Hash()) if err != nil { + logging.Debug(logCtx, "repo.CommitObject failed, returning all files", + slog.String("error", err.Error()), + slog.String("head", head.Hash().String())) return files // fail open } headTree, err := commit.Tree() if err != nil { + logging.Debug(logCtx, "commit.Tree() failed, returning all files", + slog.String("error", err.Error())) return files // fail open } + logging.Debug(logCtx, "checking files against HEAD", + slog.String("head", head.Hash().String()[:7]), + slog.Int("candidates", len(files))) + var result []string for _, relPath := range files { headFile, err := headTree.File(relPath) if err != nil { // File not in HEAD — it's uncommitted + logging.Debug(logCtx, "file not in HEAD tree, keeping", + slog.String("file", relPath), + slog.String("error", err.Error())) result = append(result, relPath) continue } @@ -329,26 +347,33 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri workingContent, err := os.ReadFile(absPath) //nolint:gosec // path from controlled source if err != nil { // Can't read working tree file (deleted?) — keep it + logging.Debug(logCtx, "cannot read working tree file, keeping", + slog.String("file", relPath), + slog.String("error", err.Error())) result = append(result, relPath) continue } headContent, err := headFile.Contents() if err != nil { + logging.Debug(logCtx, "cannot read HEAD blob, keeping", + slog.String("file", relPath), + slog.String("error", err.Error())) result = append(result, relPath) continue } - // Normalize CRLF → LF on both sides so the comparison works on Windows - // where core.autocrlf may convert line endings on checkout. Git blobs - // store raw bytes and are not guaranteed to use LF line endings. - normalizedWorking := strings.ReplaceAll(string(workingContent), "\r\n", "\n") - normalizedHead := strings.ReplaceAll(headContent, "\r\n", "\n") - if normalizedWorking != normalizedHead { + if string(workingContent) != headContent { // Working tree differs from HEAD — uncommitted changes + logging.Debug(logCtx, "content differs from HEAD, keeping", + slog.String("file", relPath), + slog.Int("working_len", len(workingContent)), + slog.Int("head_len", len(headContent))) result = append(result, relPath) + } else { + logging.Debug(logCtx, "content matches HEAD, filtering out", + slog.String("file", relPath)) } - // else: content matches HEAD — already committed, skip } return result diff --git a/cmd/entire/cli/state_test.go b/cmd/entire/cli/state_test.go index d8bf7d13b..f8d88bc4a 100644 --- a/cmd/entire/cli/state_test.go +++ b/cmd/entire/cli/state_test.go @@ -797,58 +797,6 @@ func TestMergeUnique(t *testing.T) { } } -func TestFilterToUncommittedFiles_CRLFNormalization(t *testing.T) { - // Regression test: on Windows with core.autocrlf=true, the working tree - // may have CRLF line endings while the committed blob can contain LF - // content. This test commits LF content, then rewrites the working-tree - // file with CRLF. filterToUncommittedFiles must normalize line endings - // before comparing so committed files are correctly filtered out. - - tmpDir := t.TempDir() - t.Chdir(tmpDir) - - // Commit file with LF content via go-git so the committed blob is LF. - repo, err := git.PlainInit(tmpDir, false) - if err != nil { - t.Fatalf("failed to init repo: %v", err) - } - - lfContent := "line one\nline two\nline three\n" - filePath := filepath.Join(tmpDir, "file.txt") - if err := os.WriteFile(filePath, []byte(lfContent), 0o644); err != nil { - t.Fatalf("failed to write file: %v", err) - } - - wt, err := repo.Worktree() - if err != nil { - t.Fatalf("failed to get worktree: %v", err) - } - if _, err := wt.Add("file.txt"); err != nil { - t.Fatalf("failed to add file: %v", err) - } - if _, err := wt.Commit("Initial commit", &git.CommitOptions{ - Author: &object.Signature{ - Name: "Test User", - Email: "test@example.com", - }, - }); err != nil { - t.Fatalf("failed to commit: %v", err) - } - - // Overwrite on-disk file with CRLF content (simulating Windows checkout) - crlfContent := "line one\r\nline two\r\nline three\r\n" - if err := os.WriteFile(filePath, []byte(crlfContent), 0o644); err != nil { - t.Fatalf("failed to rewrite file with CRLF: %v", err) - } - - // filterToUncommittedFiles should treat the file as committed (same - // logical content despite CRLF vs LF difference) - result := filterToUncommittedFiles(context.Background(), []string{"file.txt"}, tmpDir) - if len(result) != 0 { - t.Errorf("filterToUncommittedFiles() = %v, want empty (file should be treated as committed)", result) - } -} - func TestFilterToUncommittedFiles_ReallyModified(t *testing.T) { // Verify that files with genuinely different content are kept. diff --git a/e2e/tests/mid_turn_commit_test.go b/e2e/tests/mid_turn_commit_test.go index 19825e145..514b0d4e8 100644 --- a/e2e/tests/mid_turn_commit_test.go +++ b/e2e/tests/mid_turn_commit_test.go @@ -39,7 +39,7 @@ func TestMidTurnCommit_DifferentFilesThanPreviousTurn(t *testing.T) { // The committed files (turn2a.md, turn2b.md) do NOT overlap with // Turn 1's tracked files (turn1.md). _, err = s.RunPrompt(t, ctx, - "create two markdown files: docs/turn2a.md about bananas and docs/turn2b.md about cherries. Then git add and git commit both files with a short message. Do not commit any other files. Do not ask for confirmation, just make the changes. Do not include any trailers or metadata in the commit message. Do not use worktrees.") + "create two markdown files: docs/turn2a.md about bananas and docs/turn2b.md about cherries. Then git add and git commit both files with a short message. Do not commit any other files. Do not ask for confirmation, just make the changes. Do not add Co-authored-by or Signed-off-by trailers. Do not use worktrees.") if err != nil { t.Fatalf("agent prompt 2 (turn 2) failed: %v", err) } From b72f2a6587df8e6b1d9c95890a7e3291602c0371 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 14:34:41 -0700 Subject: [PATCH 06/17] fix: use git CLI instead of go-git in filterToUncommittedFiles Replace go-git content comparison with git status --porcelain in filterToUncommittedFiles. go-git's raw byte comparison doesn't handle line-ending normalization (core.autocrlf) on Windows, causing committed files to appear as modified. This created spurious shadow branches after subagent commits, failing TestSingleSessionSubagentCommitInTurn in 6/6 consecutive Windows CI runs. Uses git CLI which handles all normalization correctly, matching the existing pattern for go-git workarounds (HardResetWithProtection, HasUncommittedChanges). Also strengthen resume test prompts with "or approval" to prevent Claude Code from asking for file write permissions on Windows. Entire-Checkpoint: 3b132e6ccfdf --- cmd/entire/cli/state.go | 93 +++++++++++----------------------------- e2e/tests/resume_test.go | 8 ++-- 2 files changed, 30 insertions(+), 71 deletions(-) diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 0c6cbb014..af273dd06 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "os" + "os/exec" "path/filepath" "strings" "time" @@ -290,89 +291,47 @@ func DetectFileChanges(ctx context.Context, previouslyUntracked []string) (*File // (already condensed by PostCommit) back to FilesTouched via SaveStep. Files not in // HEAD or with different content in the working tree are kept. Fails open: if any git // operation errors, returns the original list unchanged. -func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot string) []string { +// +// Uses git CLI instead of go-git because go-git's content comparison doesn't +// handle line-ending normalization (core.autocrlf) correctly on Windows, +// causing committed files to appear as modified. See HardResetWithProtection +// and HasUncommittedChanges for similar go-git workarounds. +func filterToUncommittedFiles(ctx context.Context, files []string, _ string) []string { logCtx := logging.WithComponent(ctx, "filter-uncommitted") if len(files) == 0 { return files } - repo, err := openRepository(ctx) - if err != nil { - logging.Debug(logCtx, "openRepository failed, returning all files", - slog.String("error", err.Error())) - return files // fail open - } - - head, err := repo.Head() + // Use git status to find which candidate files have uncommitted changes. + // Files that are clean (committed with matching content) won't appear in + // the output and should be filtered out. + args := []string{"status", "--porcelain", "-z", "--"} + args = append(args, files...) + cmd := exec.CommandContext(ctx, "git", args...) + output, err := cmd.Output() if err != nil { - logging.Debug(logCtx, "repo.Head() failed, returning all files", + logging.Debug(logCtx, "git status failed, returning all files", slog.String("error", err.Error())) - return files // fail open (empty repo, detached HEAD, etc.) - } - - commit, err := repo.CommitObject(head.Hash()) - if err != nil { - logging.Debug(logCtx, "repo.CommitObject failed, returning all files", - slog.String("error", err.Error()), - slog.String("head", head.Hash().String())) return files // fail open } - headTree, err := commit.Tree() - if err != nil { - logging.Debug(logCtx, "commit.Tree() failed, returning all files", - slog.String("error", err.Error())) - return files // fail open + // Parse NUL-delimited output. Each entry is "XY path\0" where XY is the + // two-character status code. Files that are clean don't appear at all. + dirtyFiles := make(map[string]bool) + for _, entry := range strings.Split(string(output), "\x00") { + if len(entry) < 4 { + continue // skip empty entries and malformed lines + } + // Format: "XY " — status is first 2 chars, space, then path + path := filepath.ToSlash(entry[3:]) + dirtyFiles[path] = true } - logging.Debug(logCtx, "checking files against HEAD", - slog.String("head", head.Hash().String()[:7]), - slog.Int("candidates", len(files))) - var result []string for _, relPath := range files { - headFile, err := headTree.File(relPath) - if err != nil { - // File not in HEAD — it's uncommitted - logging.Debug(logCtx, "file not in HEAD tree, keeping", - slog.String("file", relPath), - slog.String("error", err.Error())) + if dirtyFiles[relPath] { result = append(result, relPath) - continue - } - - // File is in HEAD — compare content with working tree - absPath := filepath.Join(repoRoot, relPath) - workingContent, err := os.ReadFile(absPath) //nolint:gosec // path from controlled source - if err != nil { - // Can't read working tree file (deleted?) — keep it - logging.Debug(logCtx, "cannot read working tree file, keeping", - slog.String("file", relPath), - slog.String("error", err.Error())) - result = append(result, relPath) - continue - } - - headContent, err := headFile.Contents() - if err != nil { - logging.Debug(logCtx, "cannot read HEAD blob, keeping", - slog.String("file", relPath), - slog.String("error", err.Error())) - result = append(result, relPath) - continue - } - - if string(workingContent) != headContent { - // Working tree differs from HEAD — uncommitted changes - logging.Debug(logCtx, "content differs from HEAD, keeping", - slog.String("file", relPath), - slog.Int("working_len", len(workingContent)), - slog.Int("head_len", len(headContent))) - result = append(result, relPath) - } else { - logging.Debug(logCtx, "content matches HEAD, filtering out", - slog.String("file", relPath)) } } diff --git a/e2e/tests/resume_test.go b/e2e/tests/resume_test.go index 600e2df9e..8082e5a8b 100644 --- a/e2e/tests/resume_test.go +++ b/e2e/tests/resume_test.go @@ -33,7 +33,7 @@ func TestResumeFromFeatureBranch(t *testing.T) { s.Git(t, "checkout", "-b", "feature") _, err := s.RunPrompt(t, ctx, - "create a file at docs/hello.md with a paragraph about greetings. Do not ask for confirmation, just make the change.") + "create a file at docs/hello.md with a paragraph about greetings. Do not ask for confirmation or approval, just make the change.") if err != nil { t.Fatalf("agent failed: %v", err) } @@ -79,7 +79,7 @@ func TestResumeSquashMergeMultipleCheckpoints(t *testing.T) { s.Git(t, "checkout", "-b", "feature") _, err := s.RunPrompt(t, ctx, - "create a file at docs/red.md with a paragraph about the colour red. Do not ask for confirmation, just make the change.") + "create a file at docs/red.md with a paragraph about the colour red. Do not ask for confirmation or approval, just make the change.") if err != nil { t.Fatalf("prompt 1 failed: %v", err) } @@ -90,7 +90,7 @@ func TestResumeSquashMergeMultipleCheckpoints(t *testing.T) { cp1Ref := testutil.GitOutput(t, s.Dir, "rev-parse", "entire/checkpoints/v1") _, err = s.RunPrompt(t, ctx, - "create a file at docs/blue.md with a paragraph about the colour blue. Do not ask for confirmation, just make the change.") + "create a file at docs/blue.md with a paragraph about the colour blue. Do not ask for confirmation or approval, just make the change.") if err != nil { t.Fatalf("prompt 2 failed: %v", err) } @@ -207,7 +207,7 @@ func TestResumeOlderCheckpointWithNewerCommits(t *testing.T) { s.Git(t, "checkout", "-b", "feature") _, err := s.RunPrompt(t, ctx, - "create a file at docs/hello.md with a paragraph about greetings. Do not ask for confirmation, just make the change.") + "create a file at docs/hello.md with a paragraph about greetings. Do not ask for confirmation or approval, just make the change.") if err != nil { t.Fatalf("agent failed: %v", err) } From 316ce2bdaa11649407a1525b903614a998b5454e Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 14:51:54 -0700 Subject: [PATCH 07/17] fix: set cmd.Dir in filterToUncommittedFiles for subdirectory support git status needs to run from the repo root since the file paths are repo-root-relative. Without this, running the stop hook from a subdirectory causes path mismatch and skips the checkpoint. Entire-Checkpoint: c0ebbbd30d28 --- cmd/entire/cli/state.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index af273dd06..112630e41 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -296,7 +296,7 @@ func DetectFileChanges(ctx context.Context, previouslyUntracked []string) (*File // handle line-ending normalization (core.autocrlf) correctly on Windows, // causing committed files to appear as modified. See HardResetWithProtection // and HasUncommittedChanges for similar go-git workarounds. -func filterToUncommittedFiles(ctx context.Context, files []string, _ string) []string { +func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot string) []string { logCtx := logging.WithComponent(ctx, "filter-uncommitted") if len(files) == 0 { @@ -309,6 +309,7 @@ func filterToUncommittedFiles(ctx context.Context, files []string, _ string) []s args := []string{"status", "--porcelain", "-z", "--"} args = append(args, files...) cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = repoRoot // file paths are repo-root-relative output, err := cmd.Output() if err != nil { logging.Debug(logCtx, "git status failed, returning all files", From a8039800e3d2655c4f1c5967811fc724bc4bb113 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 15:37:04 -0700 Subject: [PATCH 08/17] fix: enable core.autocrlf in E2E and integration test repos Ensure test repos exercise CRLF line-ending normalization by setting core.autocrlf=true in both E2E (SetupRepo) and integration (InitRepo) test setup. go-git v6 supports autocrlf in worktree operations, and this makes the test environment consistent with Windows CI where autocrlf is typically enabled globally. Entire-Checkpoint: b5d9ef5ea680 --- cmd/entire/cli/testutil/testutil.go | 1 + e2e/testutil/repo.go | 1 + 2 files changed, 2 insertions(+) diff --git a/cmd/entire/cli/testutil/testutil.go b/cmd/entire/cli/testutil/testutil.go index cc5663b7b..483e72cc5 100644 --- a/cmd/entire/cli/testutil/testutil.go +++ b/cmd/entire/cli/testutil/testutil.go @@ -51,6 +51,7 @@ func InitRepo(t *testing.T, repoDir string) { cfg.Raw = config.New() } cfg.Raw.Section("commit").SetOption("gpgsign", "false") + cfg.Core.AutoCRLF = "true" if err := repo.SetConfig(cfg); err != nil { t.Fatalf("failed to set repo config: %v", err) diff --git a/e2e/testutil/repo.go b/e2e/testutil/repo.go index 1820549e5..ea7980e33 100644 --- a/e2e/testutil/repo.go +++ b/e2e/testutil/repo.go @@ -65,6 +65,7 @@ func SetupRepo(t *testing.T, agent agents.Agent) *RepoState { Git(t, dir, "config", "user.name", "E2E Test") Git(t, dir, "config", "user.email", "e2e@test.local") Git(t, dir, "config", "core.pager", "cat") + Git(t, dir, "config", "core.autocrlf", "true") Git(t, dir, "commit", "--allow-empty", "-m", "initial commit") // External agents need external_agents enabled in settings before enable, From 06e5545e4ea0497fd037d5f03b848b11833537a0 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 15:41:41 -0700 Subject: [PATCH 09/17] fix: revert git CLI replacement, keep original go-git filterToUncommittedFiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The git CLI replacement wasn't necessary. go-git v6 supports core.autocrlf — the real fix is setting core.autocrlf=true in the test repos (previous commit), which makes go-git handle line-ending normalization correctly. Entire-Checkpoint: 17f8316a512d --- cmd/entire/cli/state.go | 68 +++++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 112630e41..545f6e0b6 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -7,7 +7,6 @@ import ( "fmt" "log/slog" "os" - "os/exec" "path/filepath" "strings" "time" @@ -291,49 +290,60 @@ func DetectFileChanges(ctx context.Context, previouslyUntracked []string) (*File // (already condensed by PostCommit) back to FilesTouched via SaveStep. Files not in // HEAD or with different content in the working tree are kept. Fails open: if any git // operation errors, returns the original list unchanged. -// -// Uses git CLI instead of go-git because go-git's content comparison doesn't -// handle line-ending normalization (core.autocrlf) correctly on Windows, -// causing committed files to appear as modified. See HardResetWithProtection -// and HasUncommittedChanges for similar go-git workarounds. func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot string) []string { - logCtx := logging.WithComponent(ctx, "filter-uncommitted") - if len(files) == 0 { return files } - // Use git status to find which candidate files have uncommitted changes. - // Files that are clean (committed with matching content) won't appear in - // the output and should be filtered out. - args := []string{"status", "--porcelain", "-z", "--"} - args = append(args, files...) - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = repoRoot // file paths are repo-root-relative - output, err := cmd.Output() + repo, err := openRepository(ctx) if err != nil { - logging.Debug(logCtx, "git status failed, returning all files", - slog.String("error", err.Error())) return files // fail open } - // Parse NUL-delimited output. Each entry is "XY path\0" where XY is the - // two-character status code. Files that are clean don't appear at all. - dirtyFiles := make(map[string]bool) - for _, entry := range strings.Split(string(output), "\x00") { - if len(entry) < 4 { - continue // skip empty entries and malformed lines - } - // Format: "XY " — status is first 2 chars, space, then path - path := filepath.ToSlash(entry[3:]) - dirtyFiles[path] = true + head, err := repo.Head() + if err != nil { + return files // fail open (empty repo, detached HEAD, etc.) + } + + commit, err := repo.CommitObject(head.Hash()) + if err != nil { + return files // fail open + } + + headTree, err := commit.Tree() + if err != nil { + return files // fail open } var result []string for _, relPath := range files { - if dirtyFiles[relPath] { + headFile, err := headTree.File(relPath) + if err != nil { + // File not in HEAD — it's uncommitted + result = append(result, relPath) + continue + } + + // File is in HEAD — compare content with working tree + absPath := filepath.Join(repoRoot, relPath) + workingContent, err := os.ReadFile(absPath) //nolint:gosec // path from controlled source + if err != nil { + // Can't read working tree file (deleted?) — keep it + result = append(result, relPath) + continue + } + + headContent, err := headFile.Contents() + if err != nil { + result = append(result, relPath) + continue + } + + if string(workingContent) != headContent { + // Working tree differs from HEAD — uncommitted changes result = append(result, relPath) } + // else: content matches HEAD — already committed, skip } return result From 69d817f665418ebeddc0ccbf88beea23c075496b Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 16:40:03 -0700 Subject: [PATCH 10/17] fix: add diagnostic logging to filterToUncommittedFiles Add DEBUG-level logging to each decision point in filterToUncommittedFiles to diagnose why committed files are not filtered on Windows. Logs include: fail-open errors, tree lookup failures, content comparison results with lengths and first 20 bytes of each side (quoted). No behavior change. Entire-Checkpoint: d6d5c61790f9 --- cmd/entire/cli/state.go | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 545f6e0b6..40eeee19d 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -291,27 +291,38 @@ func DetectFileChanges(ctx context.Context, previouslyUntracked []string) (*File // HEAD or with different content in the working tree are kept. Fails open: if any git // operation errors, returns the original list unchanged. func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot string) []string { + logCtx := logging.WithComponent(ctx, "filter-uncommitted") + if len(files) == 0 { return files } repo, err := openRepository(ctx) if err != nil { + logging.Debug(logCtx, "openRepository failed, returning all files", + slog.String("error", err.Error())) return files // fail open } head, err := repo.Head() if err != nil { + logging.Debug(logCtx, "repo.Head() failed, returning all files", + slog.String("error", err.Error())) return files // fail open (empty repo, detached HEAD, etc.) } commit, err := repo.CommitObject(head.Hash()) if err != nil { + logging.Debug(logCtx, "repo.CommitObject failed, returning all files", + slog.String("error", err.Error()), + slog.String("head", head.Hash().String())) return files // fail open } headTree, err := commit.Tree() if err != nil { + logging.Debug(logCtx, "commit.Tree() failed, returning all files", + slog.String("error", err.Error())) return files // fail open } @@ -320,6 +331,9 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri headFile, err := headTree.File(relPath) if err != nil { // File not in HEAD — it's uncommitted + logging.Debug(logCtx, "file not in HEAD tree, keeping", + slog.String("file", relPath), + slog.String("error", err.Error())) result = append(result, relPath) continue } @@ -328,19 +342,30 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri absPath := filepath.Join(repoRoot, relPath) workingContent, err := os.ReadFile(absPath) //nolint:gosec // path from controlled source if err != nil { - // Can't read working tree file (deleted?) — keep it + logging.Debug(logCtx, "cannot read working tree file, keeping", + slog.String("file", relPath), + slog.String("error", err.Error())) result = append(result, relPath) continue } headContent, err := headFile.Contents() if err != nil { + logging.Debug(logCtx, "cannot read HEAD blob, keeping", + slog.String("file", relPath), + slog.String("error", err.Error())) result = append(result, relPath) continue } if string(workingContent) != headContent { // Working tree differs from HEAD — uncommitted changes + logging.Debug(logCtx, "content differs from HEAD, keeping", + slog.String("file", relPath), + slog.Int("working_len", len(workingContent)), + slog.Int("head_len", len(headContent)), + slog.String("working_first_20", safePrefix(workingContent, 20)), + slog.String("head_first_20", safePrefix([]byte(headContent), 20))) result = append(result, relPath) } // else: content matches HEAD — already committed, skip @@ -349,6 +374,15 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri return result } +// safePrefix returns the first n bytes as a string, replacing non-printable +// characters with their hex escape. Used for diagnostic logging only. +func safePrefix(b []byte, n int) string { + if len(b) < n { + n = len(b) + } + return fmt.Sprintf("%q", string(b[:n])) +} + // FilterAndNormalizePaths converts absolute paths to relative and filters out // infrastructure paths and paths outside the repo. func FilterAndNormalizePaths(files []string, cwd string) []string { From 91147d534540bd1c95f35e3820b1b4505dc3fa75 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Thu, 2 Apr 2026 17:51:00 -0700 Subject: [PATCH 11/17] fix: handle MSYS/Git Bash paths in ToRelativePath on Windows Claude Code on Windows outputs MSYS-style paths (/c/Users/...) in its transcript. Go's filepath.IsAbs doesn't recognize these as absolute on Windows, so ToRelativePath returned them unchanged. This caused filterToUncommittedFiles to pass the full MSYS path to headTree.File(), which failed with "file not found" since the git tree only has repo-relative paths like docs/red.md. Fix: normalize MSYS paths (/c/Users/... -> C:/Users/...) before processing in ToRelativePath. Confirmed via diagnostic logging that this was the root cause of TestSingleSessionSubagentCommitInTurn failing in 7/7 consecutive Windows CI runs. Entire-Checkpoint: abf133710085 --- cmd/entire/cli/paths/paths.go | 21 ++++++++++++++++++ cmd/entire/cli/paths/paths_test.go | 35 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index f22617249..b171d4db0 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -7,8 +7,10 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "strings" "sync" + "unicode" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" ) @@ -155,6 +157,9 @@ func IsSubpath(parent, child string) bool { // ToRelativePath converts an absolute path to relative. // Returns empty string if the path is outside the working directory. func ToRelativePath(absPath, cwd string) string { + absPath = normalizeMSYSPath(absPath) + cwd = normalizeMSYSPath(cwd) + if !filepath.IsAbs(absPath) { return absPath } @@ -166,6 +171,22 @@ func ToRelativePath(absPath, cwd string) string { return relPath } +// normalizeMSYSPath converts MSYS/Git Bash-style paths (e.g., /c/Users/...) +// to Windows-style paths (e.g., C:/Users/...) so that filepath.IsAbs and +// filepath.Rel work correctly. On non-Windows platforms this is a no-op. +// Claude Code on Windows outputs MSYS paths in its transcript, but Go's +// filepath package only recognizes Windows-style absolute paths. +func normalizeMSYSPath(p string) string { + if runtime.GOOS != "windows" { + return p + } + // MSYS paths look like /c/Users/... where the second char is a drive letter + if len(p) >= 3 && p[0] == '/' && unicode.IsLetter(rune(p[1])) && p[2] == '/' { + return string(unicode.ToUpper(rune(p[1]))) + ":" + p[2:] + } + return p +} + // nonAlphanumericRegex matches any non-alphanumeric character var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`) diff --git a/cmd/entire/cli/paths/paths_test.go b/cmd/entire/cli/paths/paths_test.go index 31c3bd195..372b35fb6 100644 --- a/cmd/entire/cli/paths/paths_test.go +++ b/cmd/entire/cli/paths/paths_test.go @@ -3,6 +3,7 @@ package paths import ( "os" "path/filepath" + "runtime" "testing" ) @@ -121,3 +122,37 @@ func TestGetClaudeProjectDir_Default(t *testing.T) { t.Errorf("GetClaudeProjectDir() = %q, want %q", result, expected) } } + +func TestNormalizeMSYSPath(t *testing.T) { + t.Parallel() + tests := []struct { + name string + path string + want string + }{ + {name: "msys drive c", path: "/c/Users/test/repo", want: "C:/Users/test/repo"}, + {name: "msys drive d", path: "/d/work/project", want: "D:/work/project"}, + {name: "already windows", path: "C:/Users/test/repo", want: "C:/Users/test/repo"}, + {name: "unix absolute", path: "/home/user/repo", want: "/home/user/repo"}, + {name: "relative path", path: "docs/red.md", want: "docs/red.md"}, + {name: "root slash only", path: "/", want: "/"}, + {name: "short path", path: "/c", want: "/c"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := normalizeMSYSPath(tt.path) + // On non-Windows, normalizeMSYSPath is a no-op + if runtime.GOOS == "windows" { + if got != tt.want { + t.Errorf("normalizeMSYSPath(%q) = %q, want %q", tt.path, got, tt.want) + } + } else { + if got != tt.path { + t.Errorf("normalizeMSYSPath(%q) should be no-op on %s, got %q", tt.path, runtime.GOOS, got) + } + } + }) + } +} From 87c24c67fcf967820789065ce574fd4eb09bc0a8 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Fri, 3 Apr 2026 10:57:52 -0700 Subject: [PATCH 12/17] fix: handle MSYS paths without drive letter in ToRelativePath MSYS/Git Bash on Windows sometimes omits the drive letter, producing paths like /Users/runneradmin/... instead of /c/Users/.... When this happens, prepend the drive letter from cwd (e.g. C:) so filepath.IsAbs and filepath.Rel can process them correctly. Previous fix only handled /c/... style paths. This covers both variants seen in CI: /c/Users/... and /Users/.... Entire-Checkpoint: 1b475aaf607d --- cmd/entire/cli/paths/paths.go | 11 +++++++- cmd/entire/cli/paths/paths_test.go | 42 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index b171d4db0..4c5093b28 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -20,6 +20,8 @@ const ( EntireDir = ".entire" EntireTmpDir = ".entire/tmp" EntireMetadataDir = ".entire/metadata" + + osWindows = "windows" ) // Metadata file names @@ -160,6 +162,13 @@ func ToRelativePath(absPath, cwd string) string { absPath = normalizeMSYSPath(absPath) cwd = normalizeMSYSPath(cwd) + // On Windows, MSYS/Git Bash sometimes omits the drive letter, producing + // paths like /Users/... that filepath.IsAbs doesn't recognize. Prepend + // the drive letter from cwd so filepath.Rel can match them. + if runtime.GOOS == osWindows && len(absPath) > 0 && absPath[0] == '/' && len(cwd) >= 2 && cwd[1] == ':' { + absPath = cwd[:2] + absPath + } + if !filepath.IsAbs(absPath) { return absPath } @@ -177,7 +186,7 @@ func ToRelativePath(absPath, cwd string) string { // Claude Code on Windows outputs MSYS paths in its transcript, but Go's // filepath package only recognizes Windows-style absolute paths. func normalizeMSYSPath(p string) string { - if runtime.GOOS != "windows" { + if runtime.GOOS != osWindows { return p } // MSYS paths look like /c/Users/... where the second char is a drive letter diff --git a/cmd/entire/cli/paths/paths_test.go b/cmd/entire/cli/paths/paths_test.go index 372b35fb6..b442c660a 100644 --- a/cmd/entire/cli/paths/paths_test.go +++ b/cmd/entire/cli/paths/paths_test.go @@ -123,6 +123,48 @@ func TestGetClaudeProjectDir_Default(t *testing.T) { } } +func TestToRelativePath_MSYSPaths(t *testing.T) { + t.Parallel() + if runtime.GOOS != "windows" { + t.Skip("MSYS path handling is Windows-only") + } + tests := []struct { + name string + absPath string + cwd string + want string + }{ + { + name: "msys with drive letter", + absPath: "/c/Users/test/repo/docs/red.md", + cwd: "C:/Users/test/repo", + want: "docs\\red.md", + }, + { + name: "msys without drive letter", + absPath: "/Users/test/repo/docs/red.md", + cwd: "C:/Users/test/repo", + want: "docs\\red.md", + }, + { + name: "msys without drive letter different cwd drive", + absPath: "/Users/test/repo/docs/red.md", + cwd: "D:/Users/test/repo", + want: "docs\\red.md", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ToRelativePath(tt.absPath, tt.cwd) + if got != tt.want { + t.Errorf("ToRelativePath(%q, %q) = %q, want %q", tt.absPath, tt.cwd, got, tt.want) + } + }) + } +} + func TestNormalizeMSYSPath(t *testing.T) { t.Parallel() tests := []struct { From 691a2e9545f1762e58d37619d2a22be3a0ebb73d Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Fri, 3 Apr 2026 13:13:22 -0700 Subject: [PATCH 13/17] fix: add content and anti-confirmation language to external agent test prompts Prompts like "create a file called X" without specifying content invite the agent to ask follow-up questions instead of acting. Add content descriptions and "Do not ask for confirmation or approval" to all external agent test prompts. Entire-Checkpoint: d0c625ddb08c --- e2e/tests/external_agent_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/tests/external_agent_test.go b/e2e/tests/external_agent_test.go index ca10e369d..3069d9f26 100644 --- a/e2e/tests/external_agent_test.go +++ b/e2e/tests/external_agent_test.go @@ -51,13 +51,13 @@ func TestExternalAgentMultipleTurnsManualCommit(t *testing.T) { } _, err := s.RunPrompt(t, ctx, - "create a file called src/alpha.txt") + "create a file called src/alpha.txt with a short paragraph. Do not ask for confirmation or approval, just make the change.") if err != nil { t.Fatalf("first prompt failed: %v", err) } _, err = s.RunPrompt(t, ctx, - "create a file called src/beta.txt") + "create a file called src/beta.txt with a short paragraph. Do not ask for confirmation or approval, just make the change.") if err != nil { t.Fatalf("second prompt failed: %v", err) } @@ -89,7 +89,7 @@ func TestExternalAgentMultipleTurnsManualCommit(t *testing.T) { func TestExternalAgentDeepCheckpointValidation(t *testing.T) { testutil.ForEachAgent(t, 2*time.Minute, func(t *testing.T, s *testutil.RepoState, ctx context.Context) { _, err := s.RunPrompt(t, ctx, - "create a file called notes/deep.md") + "create a file called notes/deep.md with a paragraph about deep validation. Do not ask for confirmation or approval, just make the change.") if err != nil { t.Fatalf("agent failed: %v", err) } @@ -117,7 +117,7 @@ func TestExternalAgentDeepCheckpointValidation(t *testing.T) { func TestExternalAgentSessionMetadata(t *testing.T) { testutil.ForEachAgent(t, 2*time.Minute, func(t *testing.T, s *testutil.RepoState, ctx context.Context) { _, err := s.RunPrompt(t, ctx, - "create a file called meta/test.md") + "create a file called meta/test.md with a short paragraph. Do not ask for confirmation or approval, just make the change.") if err != nil { t.Fatalf("agent failed: %v", err) } From f0cb5ff73619c883b1a1cae5b600053d4896e41e Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Fri, 3 Apr 2026 13:21:32 -0700 Subject: [PATCH 14/17] fix: remove temporary diagnostic logging from filterToUncommittedFiles Keep only the "file not in HEAD tree" log which catches path normalization bugs (the class of bug we just fixed). Remove all other diagnostic logging that was added to track down the root cause. Entire-Checkpoint: 026f6b89291b --- cmd/entire/cli/state.go | 37 ++++--------------------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 40eeee19d..7a084bcb9 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -291,46 +291,37 @@ func DetectFileChanges(ctx context.Context, previouslyUntracked []string) (*File // HEAD or with different content in the working tree are kept. Fails open: if any git // operation errors, returns the original list unchanged. func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot string) []string { - logCtx := logging.WithComponent(ctx, "filter-uncommitted") - if len(files) == 0 { return files } repo, err := openRepository(ctx) if err != nil { - logging.Debug(logCtx, "openRepository failed, returning all files", - slog.String("error", err.Error())) return files // fail open } head, err := repo.Head() if err != nil { - logging.Debug(logCtx, "repo.Head() failed, returning all files", - slog.String("error", err.Error())) return files // fail open (empty repo, detached HEAD, etc.) } commit, err := repo.CommitObject(head.Hash()) if err != nil { - logging.Debug(logCtx, "repo.CommitObject failed, returning all files", - slog.String("error", err.Error()), - slog.String("head", head.Hash().String())) return files // fail open } headTree, err := commit.Tree() if err != nil { - logging.Debug(logCtx, "commit.Tree() failed, returning all files", - slog.String("error", err.Error())) return files // fail open } + logCtx := logging.WithComponent(ctx, "filter-uncommitted") + var result []string for _, relPath := range files { headFile, err := headTree.File(relPath) if err != nil { - // File not in HEAD — it's uncommitted + // File not in HEAD — it's uncommitted (or path normalization bug) logging.Debug(logCtx, "file not in HEAD tree, keeping", slog.String("file", relPath), slog.String("error", err.Error())) @@ -342,30 +333,19 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri absPath := filepath.Join(repoRoot, relPath) workingContent, err := os.ReadFile(absPath) //nolint:gosec // path from controlled source if err != nil { - logging.Debug(logCtx, "cannot read working tree file, keeping", - slog.String("file", relPath), - slog.String("error", err.Error())) + // Can't read working tree file (deleted?) — keep it result = append(result, relPath) continue } headContent, err := headFile.Contents() if err != nil { - logging.Debug(logCtx, "cannot read HEAD blob, keeping", - slog.String("file", relPath), - slog.String("error", err.Error())) result = append(result, relPath) continue } if string(workingContent) != headContent { // Working tree differs from HEAD — uncommitted changes - logging.Debug(logCtx, "content differs from HEAD, keeping", - slog.String("file", relPath), - slog.Int("working_len", len(workingContent)), - slog.Int("head_len", len(headContent)), - slog.String("working_first_20", safePrefix(workingContent, 20)), - slog.String("head_first_20", safePrefix([]byte(headContent), 20))) result = append(result, relPath) } // else: content matches HEAD — already committed, skip @@ -374,15 +354,6 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri return result } -// safePrefix returns the first n bytes as a string, replacing non-printable -// characters with their hex escape. Used for diagnostic logging only. -func safePrefix(b []byte, n int) string { - if len(b) < n { - n = len(b) - } - return fmt.Sprintf("%q", string(b[:n])) -} - // FilterAndNormalizePaths converts absolute paths to relative and filters out // infrastructure paths and paths outside the repo. func FilterAndNormalizePaths(files []string, cwd string) []string { From 5a76374cdcefd310cee2fa495b1be429e4d7856e Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Fri, 3 Apr 2026 13:32:29 -0700 Subject: [PATCH 15/17] Update comment Entire-Checkpoint: 2c42bb1031ce --- cmd/entire/cli/state.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 7a084bcb9..600660706 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -321,7 +321,7 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri for _, relPath := range files { headFile, err := headTree.File(relPath) if err != nil { - // File not in HEAD — it's uncommitted (or path normalization bug) + // File not in HEAD — it's uncommitted logging.Debug(logCtx, "file not in HEAD tree, keeping", slog.String("file", relPath), slog.String("error", err.Error())) From 37b0c4f957abb03e013d2f6f9c808717f599967e Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Fri, 3 Apr 2026 13:55:28 -0700 Subject: [PATCH 16/17] fix: drop unrelativizable paths in transcript extraction, increase commit timeout Two fixes: 1. In extractModifiedFilesFromLiveTranscript, when ToRelativePath fails for an absolute path, skip the file instead of keeping it unchanged. Unrelativizable paths (e.g. /home/runner/work/C--Users-.../docs/blue.md) can never match committed file paths and create phantom carry-forward branches on Windows. 2. Use AssertNewCommitsWithTimeout(60s) instead of AssertNewCommits(20s) in TestInteractiveMultiStep. WaitFor can match on stale pane content before the agent processes the commit, and 20s is insufficient for slower agents. Entire-Checkpoint: 4bbd8c7f52a9 --- cmd/entire/cli/strategy/manual_commit_hooks.go | 6 +++++- e2e/tests/interactive_test.go | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index eb58be8c7..e66779065 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1728,9 +1728,13 @@ func (s *ManualCommitStrategy) extractModifiedFilesFromLiveTranscript(ctx contex for _, f := range modifiedFiles { if rel := paths.ToRelativePath(f, basePath); rel != "" { normalized = append(normalized, filepath.ToSlash(rel)) - } else { + } else if !filepath.IsAbs(f) && (len(f) == 0 || f[0] != '/') { + // Already relative — keep as-is normalized = append(normalized, filepath.ToSlash(f)) } + // else: absolute path outside repo — skip. These can't match + // committed file paths (which are repo-relative) and would + // create phantom carry-forward branches. } modifiedFiles = normalized } diff --git a/e2e/tests/interactive_test.go b/e2e/tests/interactive_test.go index b0d0ec764..6a08c144a 100644 --- a/e2e/tests/interactive_test.go +++ b/e2e/tests/interactive_test.go @@ -27,7 +27,7 @@ func TestInteractiveMultiStep(t *testing.T) { s.Send(t, session, "now commit it") s.WaitFor(t, session, prompt, 60*time.Second) - testutil.AssertNewCommits(t, s, 1) + testutil.AssertNewCommitsWithTimeout(t, s, 1, 60*time.Second) // Wait for the turn-end hook (including finalize) to complete before // reading the checkpoint branch. The finalize step writes a second From a1e07b620dfbca3a691d022e12ddb0d35797db35 Mon Sep 17 00:00:00 2001 From: Sven Pfleiderer Date: Fri, 3 Apr 2026 14:02:23 -0700 Subject: [PATCH 17/17] fix: reject empty strings in transcript path normalization Entire-Checkpoint: 281791867f26 --- cmd/entire/cli/strategy/manual_commit_hooks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index e66779065..df5184e95 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1728,7 +1728,7 @@ func (s *ManualCommitStrategy) extractModifiedFilesFromLiveTranscript(ctx contex for _, f := range modifiedFiles { if rel := paths.ToRelativePath(f, basePath); rel != "" { normalized = append(normalized, filepath.ToSlash(rel)) - } else if !filepath.IsAbs(f) && (len(f) == 0 || f[0] != '/') { + } else if len(f) > 0 && !filepath.IsAbs(f) && f[0] != '/' { // Already relative — keep as-is normalized = append(normalized, filepath.ToSlash(f)) }