diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index f22617249..09d6f423c 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -152,20 +152,6 @@ func IsSubpath(parent, child string) bool { return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } -// ToRelativePath converts an absolute path to relative. -// Returns empty string if the path is outside the working directory. -func ToRelativePath(absPath, cwd string) string { - if !filepath.IsAbs(absPath) { - return absPath - } - relPath, err := filepath.Rel(cwd, absPath) - if err != nil || strings.HasPrefix(relPath, "..") { - return "" - } - - return relPath -} - // nonAlphanumericRegex matches any non-alphanumeric character var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`) diff --git a/cmd/entire/cli/paths/relative_unix.go b/cmd/entire/cli/paths/relative_unix.go new file mode 100644 index 000000000..4be38ddf5 --- /dev/null +++ b/cmd/entire/cli/paths/relative_unix.go @@ -0,0 +1,22 @@ +//go:build unix + +package paths + +import ( + "path/filepath" + "strings" +) + +// ToRelativePath converts an absolute path to relative. +// Returns empty string if the path is outside the working directory. +func ToRelativePath(absPath, cwd string) string { + if !filepath.IsAbs(absPath) { + return absPath + } + relPath, err := filepath.Rel(cwd, absPath) + if err != nil || strings.HasPrefix(relPath, "..") { + return "" + } + + return relPath +} diff --git a/cmd/entire/cli/paths/relative_unix_test.go b/cmd/entire/cli/paths/relative_unix_test.go new file mode 100644 index 000000000..0eaacd13c --- /dev/null +++ b/cmd/entire/cli/paths/relative_unix_test.go @@ -0,0 +1,39 @@ +//go:build unix + +package paths + +import ( + "testing" +) + +func TestToRelativePath(t *testing.T) { + t.Parallel() + tests := []struct { + name string + absPath string + cwd string + want string + }{ + // Standard relative conversion + {name: "child of cwd", absPath: "/repo/docs/red.md", cwd: "/repo", want: "docs/red.md"}, + {name: "exact cwd", absPath: "/repo", cwd: "/repo", want: "."}, + {name: "outside cwd", absPath: "/other/file.txt", cwd: "/repo", want: ""}, + + // Already relative — pass through + {name: "relative unchanged", absPath: "docs/red.md", cwd: "/repo", want: "docs/red.md"}, + + // Unix paths that are valid on this platform + {name: "tmp is valid unix path", absPath: "/tmp/repo/file.txt", cwd: "/tmp/repo", want: "file.txt"}, + {name: "home is valid unix path", absPath: "/home/user/repo/f.go", cwd: "/home/user/repo", want: "f.go"}, + {name: "slash-c is valid unix path", absPath: "/c/Users/repo/f.go", cwd: "/c/Users/repo", want: "f.go"}, + } + 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) + } + }) + } +} diff --git a/cmd/entire/cli/paths/relative_windows.go b/cmd/entire/cli/paths/relative_windows.go new file mode 100644 index 000000000..b89b7918b --- /dev/null +++ b/cmd/entire/cli/paths/relative_windows.go @@ -0,0 +1,62 @@ +//go:build windows + +package paths + +import ( + "os" + "path/filepath" + "regexp" + "strings" +) + +// msysDrivePrefix matches MSYS/Git-Bash-style absolute paths like /c/ or /D/. +// Git for Windows executes hooks through MSYS2 bash, which converts Windows paths +// (C:\Users\...) to Unix-style (/c/Users/...) in tool output and transcripts. +var msysDrivePrefix = regexp.MustCompile(`^/([a-zA-Z])/`) + +// NormalizeMSYSPath converts MSYS/Git-Bash paths to Windows paths. +// Handles two MSYS conventions: +// - Drive paths: /c/Users/... → C:/Users/... +// - Virtual dirs: /tmp/... → /... (MSYS2 maps /tmp to the Windows temp dir) +// +// Returns the input unchanged if the path doesn't match any known MSYS pattern. +func NormalizeMSYSPath(p string) string { + if m := msysDrivePrefix.FindStringSubmatch(p); m != nil { + return strings.ToUpper(m[1]) + ":/" + p[3:] + } + // MSYS2 maps /tmp to the Windows temp directory. + if strings.HasPrefix(p, "/tmp/") { + if tmp := os.TempDir(); tmp != "" { + return filepath.Join(tmp, p[5:]) + } + } + return p +} + +// ToRelativePath converts an absolute path to relative. +// Returns empty string if the path is outside the working directory. +// +// On Windows, transcript-extracted paths may arrive in Unix formats from +// MSYS2/Git Bash (/c/Users/..., /tmp/...) or agent sandboxes (/home/user/...). +// These are normalized via NormalizeMSYSPath, and any remaining Unix-style +// paths that the OS can't resolve are dropped. +func ToRelativePath(absPath, cwd string) string { + absPath = NormalizeMSYSPath(absPath) + + // After MSYS normalization, a path starting with "/" that the OS still + // doesn't recognize as absolute is an unconvertible Unix path (e.g., + // /home/user/... from a container/sandbox). Filter it out. + if strings.HasPrefix(absPath, "/") && !filepath.IsAbs(absPath) { + return "" + } + + if !filepath.IsAbs(absPath) { + return absPath + } + relPath, err := filepath.Rel(cwd, absPath) + if err != nil || strings.HasPrefix(relPath, "..") { + return "" + } + + return relPath +} diff --git a/cmd/entire/cli/paths/relative_windows_test.go b/cmd/entire/cli/paths/relative_windows_test.go new file mode 100644 index 000000000..e737338c6 --- /dev/null +++ b/cmd/entire/cli/paths/relative_windows_test.go @@ -0,0 +1,78 @@ +//go:build windows + +package paths + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNormalizeMSYSPath(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in string + want string + }{ + // Drive letter paths + {name: "lowercase drive", in: "/c/Users/Victor/repo", want: "C:/Users/Victor/repo"}, + {name: "uppercase drive", in: "/D/Projects/app", want: "D:/Projects/app"}, + {name: "drive root", in: "/c/", want: "C:/"}, + + // /tmp mapping + {name: "tmp path", in: "/tmp/e2e-repo-123/docs/red.md", want: filepath.Join(os.TempDir(), "e2e-repo-123", "docs", "red.md")}, + {name: "tmp root file", in: "/tmp/file.txt", want: filepath.Join(os.TempDir(), "file.txt")}, + + // Paths that should NOT be converted + {name: "already relative", in: "docs/red.md", want: "docs/red.md"}, + {name: "windows absolute", in: "C:/Users/Victor/repo", want: "C:/Users/Victor/repo"}, + {name: "unix absolute non-drive", in: "/home/user/docs/red.md", want: "/home/user/docs/red.md"}, + {name: "empty string", in: "", want: ""}, + {name: "single slash", in: "/", want: "/"}, + {name: "tmp exact no trailing slash", in: "/tmp", want: "/tmp"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := NormalizeMSYSPath(tt.in) + if got != tt.want { + t.Errorf("NormalizeMSYSPath(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestToRelativePath(t *testing.T) { + t.Parallel() + tests := []struct { + name string + absPath string + cwd string + want string + }{ + // Standard relative conversion + {name: "child of cwd", absPath: "C:/repo/docs/red.md", cwd: "C:/repo", want: "docs/red.md"}, + {name: "exact cwd", absPath: "C:/repo", cwd: "C:/repo", want: "."}, + {name: "outside cwd", absPath: "C:/other/file.txt", cwd: "C:/repo", want: ""}, + + // Already relative — pass through + {name: "relative unchanged", absPath: "docs/red.md", cwd: "C:/repo", want: "docs/red.md"}, + + // MSYS drive paths get normalized and resolved + {name: "msys drive path", absPath: "/c/repo/docs/red.md", cwd: "C:/repo", want: "docs/red.md"}, + + // Container/sandbox paths are dropped (can't resolve on Windows) + {name: "container path dropped", absPath: "/home/user/docs/red.md", cwd: "C:/repo", want: ""}, + {name: "unix opt path dropped", absPath: "/opt/app/file.txt", cwd: "C:/repo", want: ""}, + } + 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) + } + }) + } +} diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 545f6e0b6..0d7e684eb 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -297,21 +297,37 @@ func filterToUncommittedFiles(ctx context.Context, files []string, repoRoot stri repo, err := openRepository(ctx) if err != nil { + logging.Warn(ctx, "filterToUncommittedFiles: failed to open repository, keeping all files", + slog.String("error", err.Error()), + slog.String("dir", repoRoot), + slog.Any("files", files)) return files // fail open } head, err := repo.Head() if err != nil { + logging.Warn(ctx, "filterToUncommittedFiles: failed to resolve HEAD, keeping all files", + slog.String("error", err.Error()), + slog.String("dir", repoRoot), + slog.Any("files", files)) return files // fail open (empty repo, detached HEAD, etc.) } commit, err := repo.CommitObject(head.Hash()) if err != nil { + logging.Warn(ctx, "filterToUncommittedFiles: failed to get commit object, keeping all files", + slog.String("error", err.Error()), + slog.String("dir", repoRoot), + slog.Any("files", files)) return files // fail open } headTree, err := commit.Tree() if err != nil { + logging.Warn(ctx, "filterToUncommittedFiles: failed to get commit tree, keeping all files", + slog.String("error", err.Error()), + slog.String("dir", repoRoot), + slog.Any("files", files)) return files // fail open }