Skip to content
Draft
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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/puzpuzpuz/xsync/v4 v4.4.0
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sajari/fuzzy v1.0.0
github.com/sebdah/goldie/v2 v2.8.0
github.com/spf13/pflag v1.0.10
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
Expand All @@ -236,6 +238,7 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/u-root/u-root v0.15.1-0.20251208185023-2f8c7e763cf8 h1:cq+DjLAjz3ZPwh0+G571O/jMH0c0DzReDPLjQGL2/BA=
Expand Down Expand Up @@ -311,6 +314,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/moreinterp v0.0.0-20260120230322-19def062a997 h1:3bbJwtPFh98dJ6lxRdR3eLHTH1CmR3BcU6TriIMiXjE=
Expand Down
92 changes: 92 additions & 0 deletions internal/fingerprint/gitignore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package fingerprint

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

ignore "github.com/sabhiram/go-gitignore"
)

type gitignoreRule struct {
dir string
matcher *ignore.GitIgnore
}

// 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 {
rules = append(rules, gitignoreRule{
dir: current,
matcher: ignore.CompileIgnoreLines(lines...),
})
}
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 := scanner.Text()
if line != "" && !strings.HasPrefix(line, "#") {
lines = append(lines, line)
}
}
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
}

for path := range files {
for _, rule := range rules {
relPath, err := filepath.Rel(rule.dir, path)
if err != nil || strings.HasPrefix(relPath, "..") {
continue
}
if rule.matcher.MatchesPath(filepath.ToSlash(relPath)) {
files[path] = false
break
}
}
}

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 @@ -88,7 +88,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,11 +28,11 @@ 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
}
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 @@ -90,7 +90,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
49 changes: 49 additions & 0 deletions task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,55 @@ func TestStatusChecksum(t *testing.T) { // nolint:paralleltest // cannot run in
}
}

func TestGitignoreChecksum(t *testing.T) { //nolint:paralleltest // cannot run in parallel
const dir = "testdata/gitignore"

// Clean up
_ = os.RemoveAll(filepathext.SmartJoin(dir, ".task"))
_ = os.Remove(filepathext.SmartJoin(dir, "generated.txt"))

var buff bytes.Buffer
tempDir := task.TempDir{
Remote: filepathext.SmartJoin(dir, ".task"),
Fingerprint: filepathext.SmartJoin(dir, ".task"),
}
e := task.NewExecutor(
task.WithDir(dir),
task.WithStdout(&buff),
task.WithStderr(&buff),
task.WithTempDir(tempDir),
)
require.NoError(t, e.Setup())

// First run - should execute
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"}))

// Second run - should be up to date
buff.Reset()
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"}))
assert.Equal(t, "task: Task \"build\" is up to date\n", buff.String())

// Modify the ignored file - should still be up to date
require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "ignored.txt"), []byte("modified\n"), 0o644))
buff.Reset()
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"}))
assert.Equal(t, "task: Task \"build\" is up to date\n", buff.String())

// Modify the source file - should re-execute
require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "source.txt"), []byte("modified source\n"), 0o644))
buff.Reset()
require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"}))
assert.NotEqual(t, "task: Task \"build\" is up to date\n", buff.String())

// Restore source file
require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "source.txt"), []byte("source content\n"), 0o644))
require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "ignored.txt"), []byte("ignored content\n"), 0o644))

// Clean up
_ = os.RemoveAll(filepathext.SmartJoin(dir, ".task"))
_ = os.Remove(filepathext.SmartJoin(dir, "generated.txt"))
}

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

Expand Down
12 changes: 11 additions & 1 deletion taskfile/ast/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Task struct {
Method string
Prefix string `hash:"ignore"`
IgnoreError bool
UseGitignore *bool
Run string
Platforms []*Platform
If string
Expand Down Expand Up @@ -75,6 +76,12 @@ func (t *Task) IsSilent() bool {
return t.Silent != nil && *t.Silent
}

// ShouldUseGitignore returns true if the task has gitignore filtering explicitly enabled.
// Returns false if UseGitignore is nil (not set) or explicitly set to false.
func (t *Task) ShouldUseGitignore() bool {
return t.UseGitignore != nil && *t.UseGitignore
}

// WildcardMatch will check if the given string matches the name of the Task and returns any wildcard values.
func (t *Task) WildcardMatch(name string) (bool, []string) {
names := append([]string{t.Task}, t.Aliases...)
Expand Down Expand Up @@ -149,7 +156,8 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
Internal bool
Method string
Prefix string
IgnoreError bool `yaml:"ignore_error"`
IgnoreError bool `yaml:"ignore_error"`
UseGitignore *bool `yaml:"use_gitignore,omitempty"`
Run string
Platforms []*Platform
If string
Expand Down Expand Up @@ -190,6 +198,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
t.Method = task.Method
t.Prefix = task.Prefix
t.IgnoreError = task.IgnoreError
t.UseGitignore = deepcopy.Scalar(task.UseGitignore)
t.Run = task.Run
t.Platforms = task.Platforms
t.If = task.If
Expand Down Expand Up @@ -233,6 +242,7 @@ func (t *Task) DeepCopy() *Task {
Method: t.Method,
Prefix: t.Prefix,
IgnoreError: t.IgnoreError,
UseGitignore: deepcopy.Scalar(t.UseGitignore),
Run: t.Run,
IncludeVars: t.IncludeVars.DeepCopy(),
IncludedTaskfileVars: t.IncludedTaskfileVars.DeepCopy(),
Expand Down
Loading
Loading