diff --git a/.gitattributes b/.gitattributes index 2d6ae1c1bb..4142711b3c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ * text=auto *.mdx -linguist-detectable + +# Keep LF line endings in testdata for consistent checksums across platforms +testdata/** text eol=lf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9174b0a28a..d03fb1c466 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,27 +12,19 @@ jobs: test: name: Test strategy: + fail-fast: false matrix: go-version: [1.24.x, 1.25.x] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{matrix.platform}} steps: + - name: Check out code + uses: actions/checkout@v6 + - name: Set up Go ${{matrix.go-version}} uses: actions/setup-go@v6 with: go-version: ${{matrix.go-version}} - id: go - - - name: Check out code into the Go module directory - uses: actions/checkout@v6 - - - name: Download Go modules - run: go mod download - env: - GOPROXY: https://proxy.golang.org - - - name: Build - run: go build -o ./bin/task -v ./cmd/task - name: Test - run: ./bin/task test --output=group --output-group-begin='::group::{{.TASK}}' --output-group-end='::endgroup::' + run: go run ./cmd/task test diff --git a/compiler.go b/compiler.go index 311fd58423..733d5f3b21 100644 --- a/compiler.go +++ b/compiler.go @@ -198,18 +198,21 @@ func (c *Compiler) ResetCache() { } func (c *Compiler) getSpecialVars(t *ast.Task, call *Call) (map[string]string, error) { + // Use filepath.ToSlash for all paths to ensure consistent forward slashes + // across platforms. This prevents issues with backslashes being interpreted + // as escape sequences when paths are used in shell commands on Windows. allVars := map[string]string{ "TASK_EXE": filepath.ToSlash(os.Args[0]), - "ROOT_TASKFILE": filepathext.SmartJoin(c.Dir, c.Entrypoint), - "ROOT_DIR": c.Dir, - "USER_WORKING_DIR": c.UserWorkingDir, + "ROOT_TASKFILE": filepath.ToSlash(filepathext.SmartJoin(c.Dir, c.Entrypoint)), + "ROOT_DIR": filepath.ToSlash(c.Dir), + "USER_WORKING_DIR": filepath.ToSlash(c.UserWorkingDir), "TASK_VERSION": version.GetVersion(), } if t != nil { allVars["TASK"] = t.Task - allVars["TASK_DIR"] = filepathext.SmartJoin(c.Dir, t.Dir) - allVars["TASKFILE"] = t.Location.Taskfile - allVars["TASKFILE_DIR"] = filepath.Dir(t.Location.Taskfile) + allVars["TASK_DIR"] = filepath.ToSlash(filepathext.SmartJoin(c.Dir, t.Dir)) + allVars["TASKFILE"] = filepath.ToSlash(t.Location.Taskfile) + allVars["TASKFILE_DIR"] = filepath.ToSlash(filepath.Dir(t.Location.Taskfile)) } else { allVars["TASK"] = "" allVars["TASK_DIR"] = "" diff --git a/errors/errors_taskfile.go b/errors/errors_taskfile.go index ad665d29f8..6fbaefb53e 100644 --- a/errors/errors_taskfile.go +++ b/errors/errors_taskfile.go @@ -3,6 +3,7 @@ package errors import ( "fmt" "net/http" + "path/filepath" "time" "github.com/Masterminds/semver/v3" @@ -24,7 +25,7 @@ func (err TaskfileNotFoundError) Error() string { if err.AskInit { walkText += " Run `task --init` to create a new Taskfile." } - return fmt.Sprintf(`task: No Taskfile found at %q%s`, err.URI, walkText) + return fmt.Sprintf(`task: No Taskfile found at %q%s`, filepath.ToSlash(err.URI), walkText) } func (err TaskfileNotFoundError) Code() int { @@ -51,7 +52,7 @@ type TaskfileInvalidError struct { } func (err TaskfileInvalidError) Error() string { - return fmt.Sprintf("task: Failed to parse %s:\n%v", err.URI, err.Err) + return fmt.Sprintf("task: Failed to parse %s:\n%v", filepath.ToSlash(err.URI), err.Err) } func (err TaskfileInvalidError) Code() int { @@ -70,7 +71,7 @@ func (err TaskfileFetchFailedError) Error() string { if err.HTTPStatusCode != 0 { statusText = fmt.Sprintf(" with status code %d (%s)", err.HTTPStatusCode, http.StatusText(err.HTTPStatusCode)) } - return fmt.Sprintf(`task: Download of %q failed%s`, err.URI, statusText) + return fmt.Sprintf(`task: Download of %q failed%s`, filepath.ToSlash(err.URI), statusText) } func (err TaskfileFetchFailedError) Code() int { @@ -86,7 +87,7 @@ type TaskfileNotTrustedError struct { func (err *TaskfileNotTrustedError) Error() string { return fmt.Sprintf( `task: Taskfile %q not trusted by user`, - err.URI, + filepath.ToSlash(err.URI), ) } @@ -103,7 +104,7 @@ type TaskfileNotSecureError struct { func (err *TaskfileNotSecureError) Error() string { return fmt.Sprintf( `task: Taskfile %q cannot be downloaded over an insecure connection. You can override this by using the --insecure flag`, - err.URI, + filepath.ToSlash(err.URI), ) } @@ -120,7 +121,7 @@ type TaskfileCacheNotFoundError struct { func (err *TaskfileCacheNotFoundError) Error() string { return fmt.Sprintf( `task: Taskfile %q was not found in the cache. Remove the --offline flag to use a remote copy or download it using the --download flag`, - err.URI, + filepath.ToSlash(err.URI), ) } @@ -141,12 +142,12 @@ func (err *TaskfileVersionCheckError) Error() string { if err.SchemaVersion == nil { return fmt.Sprintf( `task: Missing schema version in Taskfile %q`, - err.URI, + filepath.ToSlash(err.URI), ) } return fmt.Sprintf( "task: Invalid schema version in Taskfile %q:\nSchema version (%s) %s", - err.URI, + filepath.ToSlash(err.URI), err.SchemaVersion.String(), err.Message, ) @@ -166,7 +167,7 @@ type TaskfileNetworkTimeoutError struct { func (err *TaskfileNetworkTimeoutError) Error() string { return fmt.Sprintf( `task: Network connection timed out after %s while attempting to download Taskfile %q`, - err.Timeout, err.URI, + err.Timeout, filepath.ToSlash(err.URI), ) } @@ -183,8 +184,8 @@ type TaskfileCycleError struct { func (err TaskfileCycleError) Error() string { return fmt.Sprintf("task: include cycle detected between %s <--> %s", - err.Source, - err.Destination, + filepath.ToSlash(err.Source), + filepath.ToSlash(err.Destination), ) } @@ -203,7 +204,7 @@ type TaskfileDoesNotMatchChecksum struct { func (err *TaskfileDoesNotMatchChecksum) Error() string { return fmt.Sprintf( "task: The checksum of the Taskfile at %q does not match!\ngot: %q\nwant: %q", - err.URI, + filepath.ToSlash(err.URI), err.ActualChecksum, err.ExpectedChecksum, ) diff --git a/executor_test.go b/executor_test.go index 6e3ff3e1ec..f72104a6dc 100644 --- a/executor_test.go +++ b/executor_test.go @@ -165,6 +165,7 @@ func (tt *ExecutorTest) run(t *testing.T) { // Create a golden fixture file for the output g := goldie.New(t, goldie.WithFixtureDir(filepath.Join(e.Dir, "testdata")), + goldie.WithEqualFn(NormalizedEqual), ) // Call setup and check for errors diff --git a/formatter_test.go b/formatter_test.go index 7221ff769e..b92c8d5579 100644 --- a/formatter_test.go +++ b/formatter_test.go @@ -127,6 +127,7 @@ func (tt *FormatterTest) run(t *testing.T) { // Create a golden fixture file for the output g := goldie.New(t, goldie.WithFixtureDir(filepath.Join(e.Dir, "testdata")), + goldie.WithEqualFn(NormalizedEqual), ) // Call setup and check for errors diff --git a/internal/fingerprint/glob.go b/internal/fingerprint/glob.go index bf12afdd12..fd3cafceaa 100644 --- a/internal/fingerprint/glob.go +++ b/internal/fingerprint/glob.go @@ -2,6 +2,7 @@ package fingerprint import ( "os" + "path/filepath" "sort" "github.com/go-task/task/v3/internal/execext" @@ -50,7 +51,8 @@ func collectKeys(m map[string]bool) []string { keys := make([]string, 0, len(m)) for k, v := range m { if v { - keys = append(keys, k) + // Normalize path separators for consistent sorting across platforms + keys = append(keys, filepath.ToSlash(k)) } } sort.Strings(keys) diff --git a/task_test.go b/task_test.go index 9d54af9740..a09e496f8b 100644 --- a/task_test.go +++ b/task_test.go @@ -88,13 +88,14 @@ func (tt *TaskTest) writeFixture( if tt.fixtureTemplatingEnabled { fixtureTemplateData := map[string]any{ "TEST_NAME": t.Name(), - "TEST_DIR": wd, + "TEST_DIR": filepath.ToSlash(wd), } // If the test has additional template data, copy it into the map if tt.fixtureTemplateData != nil { maps.Copy(fixtureTemplateData, tt.fixtureTemplateData) } - g.AssertWithTemplate(t, goldenFileName, fixtureTemplateData, b) + // Normalize output before comparison (CRLF→LF, backslash→forward slash) + g.AssertWithTemplate(t, goldenFileName, fixtureTemplateData, normalizeOutput(b)) } else { g.Assert(t, goldenFileName, b) } @@ -308,6 +309,73 @@ func PPSortedLines(t *testing.T, b []byte) []byte { return []byte(strings.Join(lines, "\n") + "\n") } +// normalizeOutput normalizes cross-platform differences for byte slice comparison: +// - Converts CRLF and CR to LF (line endings) +// - Converts backslashes to forward slashes (Windows paths) +// - Handles escaped backslashes in JSON (\\) by converting to single forward slash +func normalizeOutput(b []byte) []byte { + b = bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n")) + b = bytes.ReplaceAll(b, []byte("\r"), []byte("\n")) + // First replace escaped backslashes (common in JSON), then single backslashes + b = bytes.ReplaceAll(b, []byte("\\\\"), []byte("/")) + b = bytes.ReplaceAll(b, []byte("\\"), []byte("/")) + return b +} + +// normalizePathSeparators converts backslashes to forward slashes for cross-platform path comparison. +func normalizePathSeparators(s string) string { + return strings.ReplaceAll(s, "\\", "/") +} + +// NormalizedEqual compares two byte slices after normalizing output. +// This is used as a custom goldie.EqualFn for cross-platform golden file tests. +func NormalizedEqual(actual, expected []byte) bool { + return bytes.Equal(normalizeOutput(actual), normalizeOutput(expected)) +} + +func TestNormalizeOutput(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input []byte + expected []byte + }{ + {"CRLF to LF", []byte("line1\r\nline2\r\n"), []byte("line1\nline2\n")}, + {"CR to LF", []byte("line1\rline2\r"), []byte("line1\nline2\n")}, + {"Windows path", []byte(`D:\a\task\task`), []byte(`D:/a/task/task`)}, + {"JSON escaped backslash", []byte(`{"path":"D:\\a\\task"}`), []byte(`{"path":"D:/a/task"}`)}, + {"Mixed", []byte("D:\\a\\task\r\n"), []byte("D:/a/task\n")}, + {"Unix path unchanged", []byte("/home/user/task\n"), []byte("/home/user/task\n")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := normalizeOutput(tt.input) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestNormalizePathSeparators(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + }{ + {"Windows path", `D:\a\task\task`, `D:/a/task/task`}, + {"Unix path unchanged", `/home/user/task`, `/home/user/task`}, + {"Mixed separators", `C:\Users/name\file`, `C:/Users/name/file`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := normalizePathSeparators(tt.input) + assert.Equal(t, tt.expected, got) + }) + } +} + // SyncBuffer is a threadsafe buffer for testing. // Some times replace stdout/stderr with a buffer to capture output. // stdout and stderr are threadsafe, but a regular bytes.Buffer is not. @@ -1078,7 +1146,7 @@ func TestIncludesOptionalImplicitFalse(t *testing.T) { wd, _ := os.Getwd() message := "task: No Taskfile found at \"%s/%s/TaskfileOptional.yml\"" - expected := fmt.Sprintf(message, wd, dir) + expected := fmt.Sprintf(message, filepath.ToSlash(wd), dir) e := task.NewExecutor( task.WithDir(dir), @@ -1098,7 +1166,7 @@ func TestIncludesOptionalExplicitFalse(t *testing.T) { wd, _ := os.Getwd() message := "task: No Taskfile found at \"%s/%s/TaskfileOptional.yml\"" - expected := fmt.Sprintf(message, wd, dir) + expected := fmt.Sprintf(message, filepath.ToSlash(wd), dir) e := task.NewExecutor( task.WithDir(dir), @@ -1146,11 +1214,11 @@ func TestIncludesRelativePath(t *testing.T) { require.NoError(t, e.Setup()) require.NoError(t, e.Run(t.Context(), &task.Call{Task: "common:pwd"})) - assert.Contains(t, buff.String(), "testdata/includes_rel_path/common") + assert.Contains(t, filepath.ToSlash(buff.String()), "testdata/includes_rel_path/common") buff.Reset() require.NoError(t, e.Run(t.Context(), &task.Call{Task: "included:common:pwd"})) - assert.Contains(t, buff.String(), "testdata/includes_rel_path/common") + assert.Contains(t, filepath.ToSlash(buff.String()), "testdata/includes_rel_path/common") } func TestIncludesInternal(t *testing.T) { @@ -1328,7 +1396,7 @@ func TestIncludedTaskfileVarMerging(t *testing.T) { err := e.Run(t.Context(), &task.Call{Task: test.task}) require.NoError(t, err) - assert.Contains(t, buff.String(), test.expectedOutput) + assert.Contains(t, filepath.ToSlash(buff.String()), test.expectedOutput) }) } } @@ -1475,7 +1543,9 @@ func TestWhenNoDirAttributeItRunsInSameDirAsTaskfile(t *testing.T) { require.NoError(t, e.Run(t.Context(), &task.Call{Task: "whereami"})) // got should be the "dir" part of "testdata/dir" - got := strings.TrimSuffix(filepath.Base(out.String()), "\n") + // Normalize path separators for cross-platform compatibility (Windows uses backslashes) + normalized := normalizePathSeparators(out.String()) + got := strings.TrimSuffix(filepath.Base(normalized), "\n") assert.Equal(t, expected, got, "Mismatch in the working directory") } @@ -1494,7 +1564,9 @@ func TestWhenDirAttributeAndDirExistsItRunsInThatDir(t *testing.T) { require.NoError(t, e.Setup()) require.NoError(t, e.Run(t.Context(), &task.Call{Task: "whereami"})) - got := strings.TrimSuffix(filepath.Base(out.String()), "\n") + // Normalize path separators for cross-platform compatibility (Windows uses backslashes) + normalized := normalizePathSeparators(out.String()) + got := strings.TrimSuffix(filepath.Base(normalized), "\n") assert.Equal(t, expected, got, "Mismatch in the working directory") } @@ -1520,7 +1592,9 @@ func TestWhenDirAttributeItCreatesMissingAndRunsInThatDir(t *testing.T) { require.NoError(t, e.Setup()) require.NoError(t, e.Run(t.Context(), &task.Call{Task: target})) - got := strings.TrimSuffix(filepath.Base(out.String()), "\n") + // Normalize path separators for cross-platform compatibility (Windows uses backslashes) + normalized := normalizePathSeparators(out.String()) + got := strings.TrimSuffix(filepath.Base(normalized), "\n") assert.Equal(t, expected, got, "Mismatch in the working directory") // Clean-up after ourselves only if no error. @@ -1549,7 +1623,11 @@ func TestDynamicVariablesRunOnTheNewCreatedDir(t *testing.T) { require.NoError(t, e.Setup()) require.NoError(t, e.Run(t.Context(), &task.Call{Task: target})) - got := strings.TrimSuffix(filepath.Base(out.String()), "\n") + // Normalize path separators for cross-platform compatibility (Windows uses backslashes) + // Take only the first line as Windows may output additional debug info + normalized := normalizePathSeparators(out.String()) + firstLine := strings.Split(normalized, "\n")[0] + got := filepath.Base(firstLine) assert.Equal(t, expected, got, "Mismatch in the working directory") // Clean-up after ourselves only if no error. @@ -2268,7 +2346,8 @@ func TestUserWorkingDirectory(t *testing.T) { require.NoError(t, err) require.NoError(t, e.Setup()) require.NoError(t, e.Run(t.Context(), &task.Call{Task: "default"})) - assert.Equal(t, fmt.Sprintf("%s\n", wd), buff.String()) + // Use filepath.ToSlash because USER_WORKING_DIR uses forward slashes on all platforms + assert.Equal(t, fmt.Sprintf("%s\n", filepath.ToSlash(wd)), buff.String()) } func TestUserWorkingDirectoryWithIncluded(t *testing.T) { @@ -2277,7 +2356,7 @@ func TestUserWorkingDirectoryWithIncluded(t *testing.T) { wd, err := os.Getwd() require.NoError(t, err) - wd = filepathext.SmartJoin(wd, "testdata/user_working_dir_with_includes/somedir") + wd = filepath.ToSlash(filepathext.SmartJoin(wd, "testdata/user_working_dir_with_includes/somedir")) var buff bytes.Buffer e := task.NewExecutor( @@ -2290,7 +2369,8 @@ func TestUserWorkingDirectoryWithIncluded(t *testing.T) { require.NoError(t, err) require.NoError(t, e.Setup()) require.NoError(t, e.Run(t.Context(), &task.Call{Task: "included:echo"})) - assert.Equal(t, fmt.Sprintf("%s\n", wd), buff.String()) + // Normalize path separators for cross-platform compatibility (Windows uses backslashes) + assert.Equal(t, fmt.Sprintf("%s\n", wd), normalizePathSeparators(buff.String())) } func TestPlatforms(t *testing.T) {