diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index f22617249..4c5093b28 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" ) @@ -18,6 +20,8 @@ const ( EntireDir = ".entire" EntireTmpDir = ".entire/tmp" EntireMetadataDir = ".entire/metadata" + + osWindows = "windows" ) // Metadata file names @@ -155,6 +159,16 @@ 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) + + // 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 } @@ -166,6 +180,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 != osWindows { + 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..b442c660a 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,79 @@ func TestGetClaudeProjectDir_Default(t *testing.T) { t.Errorf("GetClaudeProjectDir() = %q, want %q", result, expected) } } + +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 { + 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) + } + } + }) + } +} diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 545f6e0b6..600660706 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -315,11 +315,16 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri 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 + logging.Debug(logCtx, "file not in HEAD tree, keeping", + slog.String("file", relPath), + slog.String("error", err.Error())) result = append(result, relPath) continue } diff --git a/cmd/entire/cli/state_test.go b/cmd/entire/cli/state_test.go index dc55bc8bd..f8d88bc4a 100644 --- a/cmd/entire/cli/state_test.go +++ b/cmd/entire/cli/state_test.go @@ -796,3 +796,46 @@ func TestMergeUnique(t *testing.T) { }) } } + +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/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index eb58be8c7..df5184e95 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 len(f) > 0 && !filepath.IsAbs(f) && 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/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/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) } diff --git a/e2e/tests/interactive_test.go b/e2e/tests/interactive_test.go index f40de506e..6a08c144a 100644 --- a/e2e/tests/interactive_test.go +++ b/e2e/tests/interactive_test.go @@ -27,8 +27,13 @@ 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 + // 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, 15*time.Second) testutil.WaitForCheckpoint(t, s, 30*time.Second) testutil.AssertCommitLinkedToCheckpoint(t, s.Dir, "HEAD") testutil.WaitForNoShadowBranches(t, s.Dir, 10*time.Second) 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) } 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) } 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,