feat: add gitignore option to exclude ignored files from sources/generates

When `gitignore: true` is set at the Taskfile or task level, files
matching .gitignore rules are automatically excluded from sources and
generates glob resolution. This prevents rebuilds triggered by changes
to files that are in .gitignore (build artifacts, generated files, etc.).

Uses go-git to load .gitignore patterns including nested .gitignore
files, .git/info/exclude, and global gitignore configuration.
This commit is contained in:
Valentin Maerten
2026-04-03 22:35:04 +02:00
parent 81d621d8aa
commit fe542d5418
16 changed files with 395 additions and 20 deletions

View File

@@ -0,0 +1,60 @@
package fingerprint
import (
"path/filepath"
"strings"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
)
// filterGitignored removes entries from the file map that match gitignore rules.
// Files are expected to be absolute paths. The dir parameter is used to find the git repository.
// Returns the input map unchanged if the directory is not inside a git repository.
func filterGitignored(files map[string]bool, dir string) map[string]bool {
repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{
DetectDotGit: true,
})
if err != nil {
return files
}
wt, err := repo.Worktree()
if err != nil {
return files
}
var allPatterns []gitignore.Pattern
if ps, err := gitignore.LoadSystemPatterns(wt.Filesystem); err == nil {
allPatterns = append(allPatterns, ps...)
}
if ps, err := gitignore.LoadGlobalPatterns(wt.Filesystem); err == nil {
allPatterns = append(allPatterns, ps...)
}
if ps, err := gitignore.ReadPatterns(wt.Filesystem, nil); err == nil {
allPatterns = append(allPatterns, ps...)
}
if len(allPatterns) == 0 {
return files
}
matcher := gitignore.NewMatcher(allPatterns)
gitRoot := wt.Filesystem.Root()
for path := range files {
relPath, err := filepath.Rel(gitRoot, path)
if err != nil {
continue
}
pathComponents := strings.Split(filepath.ToSlash(relPath), "/")
if matcher.Match(pathComponents, false) {
files[path] = false
}
}
return files
}

View File

@@ -0,0 +1,127 @@
package fingerprint
import (
"os"
"path/filepath"
"testing"
"github.com/go-git/go-git/v5"
"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()
_, err := git.PlainInit(dir, false)
require.NoError(t, err)
}
func TestGlobsWithGitignore(t *testing.T) {
t.Parallel()
dir, err := os.MkdirTemp("", "task-gitignore-test-*")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(dir) })
initGitRepo(t, dir)
// Create test files
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))
// Create .gitignore
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644))
globs := []*ast.Glob{
{Glob: "./*"},
}
// Without gitignore - should include all files
filesWithout, err := Globs(dir, globs, false)
require.NoError(t, err)
// With gitignore - should exclude .log files
filesWith, err := Globs(dir, globs, true)
require.NoError(t, err)
// The .log file should be in the unfiltered list
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")
// The .log file should NOT be in the filtered list
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")
// .txt files should still be present
txtCount := 0
for _, f := range filesWith {
if filepath.Ext(f) == ".txt" {
txtCount++
}
}
assert.Equal(t, 2, txtCount, "both .txt files should remain")
}
func TestGlobsWithGitignoreDisabled(t *testing.T) {
t.Parallel()
dir, err := os.MkdirTemp("", "task-gitignore-disabled-*")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(dir) })
initGitRepo(t, dir)
require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "file.log"), []byte("content"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644))
globs := []*ast.Glob{
{Glob: "./*"},
}
// WithGitignore(false, ...) should not filter
files, err := Globs(dir, globs, false)
require.NoError(t, err)
hasLog := false
for _, f := range files {
if filepath.Base(f) == "file.log" {
hasLog = true
break
}
}
assert.True(t, hasLog, "file.log should be present when gitignore is disabled")
}
func TestGlobsWithGitignoreNoRepo(t *testing.T) {
t.Parallel()
dir, err := os.MkdirTemp("", "task-gitignore-norepo-*")
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: "./*"},
}
// Should not error and should return all files
files, err := Globs(dir, globs, true)
require.NoError(t, err)
assert.Len(t, files, 1)
}

View File

@@ -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, gitignore bool) ([]string, error) {
resultMap := make(map[string]bool)
for _, g := range globs {
matches, err := glob(dir, g.Glob)
@@ -21,6 +21,11 @@ func Globs(dir string, globs []*ast.Glob) ([]string, error) {
resultMap[match] = !g.Negate
}
}
if gitignore {
resultMap = filterGitignored(resultMap, dir)
}
return collectKeys(resultMap), nil
}

View File

@@ -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.IsGitignore())
if err != nil {
return "", err
}

View File

@@ -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.IsGitignore())
if err != nil {
return false, nil
}
@@ -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.IsGitignore())
if err != nil {
return false, nil
}
@@ -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.IsGitignore())
if err != nil {
return time.Now(), err
}