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
30 changes: 30 additions & 0 deletions cmd/entire/cli/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import (
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"unicode"

"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
)
Expand All @@ -18,6 +20,8 @@ const (
EntireDir = ".entire"
EntireTmpDir = ".entire/tmp"
EntireMetadataDir = ".entire/metadata"

osWindows = "windows"
)

// Metadata file names
Expand Down Expand Up @@ -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
}
Expand All @@ -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]`)

Expand Down
77 changes: 77 additions & 0 deletions cmd/entire/cli/paths/paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package paths
import (
"os"
"path/filepath"
"runtime"
"testing"
)

Expand Down Expand Up @@ -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)
}
}
})
}
}
5 changes: 5 additions & 0 deletions cmd/entire/cli/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
43 changes: 43 additions & 0 deletions cmd/entire/cli/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions cmd/entire/cli/testutil/testutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions e2e/tests/external_agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
5 changes: 5 additions & 0 deletions e2e/tests/interactive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, 15*time.Second)
testutil.WaitForCheckpoint(t, s, 30*time.Second)
testutil.AssertCommitLinkedToCheckpoint(t, s.Dir, "HEAD")
testutil.WaitForNoShadowBranches(t, s.Dir, 10*time.Second)
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/mid_turn_commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
8 changes: 4 additions & 4 deletions e2e/tests/resume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions e2e/testutil/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading