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
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ require (
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
Expand Down Expand Up @@ -121,6 +121,7 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.45.0 // indirect
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
Expand Down Expand Up @@ -274,8 +274,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
Expand Down
102 changes: 102 additions & 0 deletions internal/fingerprint/gitignore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package fingerprint

import (
"bufio"
"os"
"path/filepath"
"strings"

"github.com/go-task/task/v3/internal/gitignore"
)

type gitignoreRule struct {
dir string
matcher gitignore.Matcher
}

// loadGitignoreRules walks up from dir collecting .gitignore files.
// Stops at the first .git (file or directory) found.
// Returns nil if no .git is found (not in a git repo).
func loadGitignoreRules(dir string) []gitignoreRule {
dir, _ = filepath.Abs(dir)

var rules []gitignoreRule
foundGit := false
current := dir

for {
lines := readGitignoreLines(filepath.Join(current, ".gitignore"))
if len(lines) > 0 {
patterns := make([]gitignore.Pattern, 0, len(lines))
for _, line := range lines {
patterns = append(patterns, gitignore.ParsePattern(line, nil))
}
rules = append(rules, gitignoreRule{
dir: current,
matcher: gitignore.NewMatcher(patterns),
})
}
if _, err := os.Stat(filepath.Join(current, ".git")); err == nil {
foundGit = true
break
}
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}

if !foundGit {
return nil
}

return rules
}

func readGitignoreLines(path string) []string {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()

var lines []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
if line != "" && !strings.HasPrefix(line, "#") {
lines = append(lines, line)
}
}
if err := scanner.Err(); err != nil {
return nil
}
return lines
}

// filterGitignored removes entries from the file map that match gitignore rules.
func filterGitignored(files map[string]bool, dir string) map[string]bool {
rules := loadGitignoreRules(dir)
if len(rules) == 0 {
return files
}
Comment on lines +78 to +83
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filterGitignored loads .gitignore rules only based on the task/base dir (via loadGitignoreRules(dir)), so glob matches that live in subdirectories won't respect .gitignore files inside those subdirectories (e.g. sources like ./**/*). To match Git behavior, rule discovery needs to be based on each matched file’s directory (and its parents up to the repo root), ideally with caching to avoid repeated filesystem walks.

Copilot uses AI. Check for mistakes.

for path := range files {
for _, rule := range rules {
relPath, err := filepath.Rel(rule.dir, path)
if err != nil || strings.HasPrefix(relPath, "..") {
continue
}
// Sources are files, not directories; pass isDir=false. Per the
// gitignore spec this still matches files under an ignored dir
// (e.g. "build/" matches build/out.txt).
if rule.matcher.Match(strings.Split(filepath.ToSlash(relPath), "/"), false) {
files[path] = false
break
}
}
Comment on lines +85 to +98
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The matching logic treats a file as ignored if any .gitignore matcher returns true, but Git uses ordered rule evaluation where later (more specific) patterns can override earlier ones (including ! negation). With the current approach, a parent rule can incorrectly ignore a file even if a deeper .gitignore un-ignores it. Consider implementing “last match wins” across the full ordered set of patterns for a path (root → leaf), including support for negation.

Copilot uses AI. Check for mistakes.
}

return files
}
112 changes: 112 additions & 0 deletions internal/fingerprint/gitignore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package fingerprint

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/go-task/task/v3/taskfile/ast"
)

func initGitRepo(t *testing.T, dir string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755))
}

func TestGlobsWithGitignore(t *testing.T) {
t.Parallel()

dir := t.TempDir()
initGitRepo(t, dir)

require.NoError(t, os.WriteFile(filepath.Join(dir, "included.txt"), []byte("included"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "ignored.log"), []byte("ignored"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "also-included.txt"), []byte("also included"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644))

globs := []*ast.Glob{
{Glob: "./*"},
}

filesWithout, err := Globs(dir, globs, false)
require.NoError(t, err)

filesWith, err := Globs(dir, globs, true)
require.NoError(t, err)

hasLog := false
for _, f := range filesWithout {
if filepath.Base(f) == "ignored.log" {
hasLog = true
break
}
}
assert.True(t, hasLog, "ignored.log should be present without gitignore filter")

hasLog = false
for _, f := range filesWith {
if filepath.Base(f) == "ignored.log" {
hasLog = true
break
}
}
assert.False(t, hasLog, "ignored.log should be excluded with gitignore filter")

txtCount := 0
for _, f := range filesWith {
if filepath.Ext(f) == ".txt" {
txtCount++
}
}
assert.Equal(t, 2, txtCount, "both .txt files should remain")
}

func TestGlobsWithGitignoreNested(t *testing.T) {
t.Parallel()

dir := t.TempDir()
initGitRepo(t, dir)

subDir := filepath.Join(dir, "sub")
require.NoError(t, os.MkdirAll(subDir, 0o755))

require.NoError(t, os.WriteFile(filepath.Join(subDir, "keep.txt"), []byte("keep"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(subDir, "build.out"), []byte("build"), 0o644))

require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(subDir, ".gitignore"), []byte("*.out\n"), 0o644))

globs := []*ast.Glob{
{Glob: "./*"},
}

files, err := Globs(subDir, globs, true)
require.NoError(t, err)

for _, f := range files {
assert.NotEqual(t, "build.out", filepath.Base(f), "build.out should be excluded by nested .gitignore")
}
}

func TestGlobsWithGitignoreNoRepo(t *testing.T) {
t.Parallel()

// Cannot use t.TempDir() here because it creates a dir inside the
// go-task repo which has a .git parent, defeating the "no repo" test.
dir, err := os.MkdirTemp("", "task-gitignore-norepo-*") //nolint:usetesting
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(dir) })

require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0o644))

globs := []*ast.Glob{
{Glob: "./*"},
}

files, err := Globs(dir, globs, true)
require.NoError(t, err)
assert.Len(t, files, 1)
}
7 changes: 6 additions & 1 deletion internal/fingerprint/glob.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"github.com/go-task/task/v3/taskfile/ast"
)

func Globs(dir string, globs []*ast.Glob) ([]string, error) {
func Globs(dir string, globs []*ast.Glob, useGitignore bool) ([]string, error) {
resultMap := make(map[string]bool)
for _, g := range globs {
matches, err := glob(dir, g.Glob)
Expand All @@ -21,6 +21,11 @@ func Globs(dir string, globs []*ast.Glob) ([]string, error) {
resultMap[match] = !g.Negate
}
}

if useGitignore {
resultMap = filterGitignored(resultMap, dir)
}

return collectKeys(resultMap), nil
}

Expand Down
2 changes: 1 addition & 1 deletion internal/fingerprint/sources_checksum.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func (*ChecksumChecker) Kind() string {
}

func (c *ChecksumChecker) checksum(t *ast.Task) (string, error) {
sources, err := Globs(t.Dir, t.Sources)
sources, err := Globs(t.Dir, t.Sources, t.ShouldUseGitignore())
if err != nil {
return "", err
}
Expand Down
6 changes: 3 additions & 3 deletions internal/fingerprint/sources_timestamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) {
return false, nil
}

sources, err := Globs(t.Dir, t.Sources)
sources, err := Globs(t.Dir, t.Sources, t.ShouldUseGitignore())
if err != nil {
return false, nil
}
Expand All @@ -54,7 +54,7 @@ func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) {
}
}

generates, err := Globs(t.Dir, t.Generates)
generates, err := Globs(t.Dir, t.Generates, t.ShouldUseGitignore())
if err != nil {
return false, nil
}
Expand Down Expand Up @@ -112,7 +112,7 @@ func (checker *TimestampChecker) Kind() string {

// Value implements the Checker Interface
func (checker *TimestampChecker) Value(t *ast.Task) (any, error) {
sources, err := Globs(t.Dir, t.Sources)
sources, err := Globs(t.Dir, t.Sources, t.ShouldUseGitignore())
if err != nil {
return time.Now(), err
}
Expand Down
Loading
Loading