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
14 changes: 0 additions & 14 deletions cmd/entire/cli/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]`)

Expand Down
22 changes: 22 additions & 0 deletions cmd/entire/cli/paths/relative_unix.go
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions cmd/entire/cli/paths/relative_unix_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
62 changes: 62 additions & 0 deletions cmd/entire/cli/paths/relative_windows.go
Original file line number Diff line number Diff line change
@@ -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/... → <TEMP>/... (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
}
78 changes: 78 additions & 0 deletions cmd/entire/cli/paths/relative_windows_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
16 changes: 16 additions & 0 deletions cmd/entire/cli/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading