Files
go-task/variables.go
Valentin Maerten fe542d5418 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.
2026-06-07 15:02:39 +02:00

515 lines
15 KiB
Go

package task
import (
"fmt"
"maps"
"os"
"path/filepath"
"strings"
"github.com/joho/godotenv"
"github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/deepcopy"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/internal/fingerprint"
"github.com/go-task/task/v3/internal/templater"
"github.com/go-task/task/v3/taskfile/ast"
)
// CompiledTask returns a copy of a task, but replacing variables in almost all
// properties using the Go template package.
func (e *Executor) CompiledTask(call *Call) (*ast.Task, error) {
return e.compiledTask(call, true)
}
// FastCompiledTask is like CompiledTask, but it skippes dynamic variables.
func (e *Executor) FastCompiledTask(call *Call) (*ast.Task, error) {
return e.compiledTask(call, false)
}
func (e *Executor) CompiledTaskForTaskList(call *Call) (*ast.Task, error) {
origTask, err := e.GetTask(call)
if err != nil {
return nil, err
}
vars, err := e.Compiler.FastGetVariables(origTask, call)
if err != nil {
return nil, err
}
cache := &templater.Cache{Vars: vars}
return &ast.Task{
Task: origTask.Task,
Label: templater.Replace(origTask.Label, cache),
Desc: templater.Replace(origTask.Desc, cache),
Prompt: templater.Replace(origTask.Prompt, cache),
Summary: templater.Replace(origTask.Summary, cache),
Aliases: origTask.Aliases,
Sources: origTask.Sources,
Generates: origTask.Generates,
Dir: origTask.Dir,
Set: origTask.Set,
Shopt: origTask.Shopt,
Vars: vars,
Env: nil,
Dotenv: origTask.Dotenv,
Silent: deepcopy.Scalar(origTask.Silent),
Gitignore: deepcopy.Scalar(origTask.Gitignore),
Interactive: origTask.Interactive,
Internal: origTask.Internal,
Method: origTask.Method,
Prefix: origTask.Prefix,
IgnoreError: origTask.IgnoreError,
Run: origTask.Run,
IncludeVars: origTask.IncludeVars,
IncludedTaskfileVars: origTask.IncludedTaskfileVars,
Platforms: origTask.Platforms,
Location: origTask.Location,
Requires: origTask.Requires,
Watch: origTask.Watch,
Namespace: origTask.Namespace,
Failfast: origTask.Failfast,
}, nil
}
func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, error) {
origTask, err := e.GetTask(call)
if err != nil {
return nil, err
}
var vars *ast.Vars
if evaluateShVars {
vars, err = e.Compiler.GetVariables(origTask, call)
} else {
vars, err = e.Compiler.FastGetVariables(origTask, call)
}
if err != nil {
return nil, err
}
fullName := origTask.Task
if matches, exists := vars.Get("MATCH"); exists {
for _, match := range matches.Value.([]string) {
fullName = strings.Replace(fullName, "*", match, 1)
}
}
cache := &templater.Cache{Vars: vars}
// Resolve enum refs only when dynamic variables have been evaluated,
// since enum refs may depend on shell-derived variables (e.g. fromJson)
requires := origTask.Requires
if evaluateShVars {
requires = origTask.Requires.DeepCopy()
if err := resolveEnumRefs(requires, cache); err != nil {
return nil, err
}
}
gitignore := origTask.IsGitignore()
if origTask.Gitignore == nil {
gitignore = e.Taskfile.Gitignore
}
new := ast.Task{
Task: origTask.Task,
Label: templater.Replace(origTask.Label, cache),
Desc: templater.Replace(origTask.Desc, cache),
Prompt: templater.Replace(origTask.Prompt, cache),
Summary: templater.Replace(origTask.Summary, cache),
Aliases: origTask.Aliases,
Sources: templater.ReplaceGlobs(origTask.Sources, cache),
Generates: templater.ReplaceGlobs(origTask.Generates, cache),
Dir: templater.Replace(origTask.Dir, cache),
Set: origTask.Set,
Shopt: origTask.Shopt,
Vars: vars,
Env: nil,
Dotenv: templater.Replace(origTask.Dotenv, cache),
Silent: deepcopy.Scalar(origTask.Silent),
Gitignore: &gitignore,
Interactive: origTask.Interactive,
Internal: origTask.Internal,
Method: templater.Replace(origTask.Method, cache),
Prefix: templater.Replace(origTask.Prefix, cache),
IgnoreError: origTask.IgnoreError,
Run: templater.Replace(origTask.Run, cache),
IncludeVars: origTask.IncludeVars,
IncludedTaskfileVars: origTask.IncludedTaskfileVars,
Platforms: origTask.Platforms,
If: templater.Replace(origTask.If, cache),
Location: origTask.Location,
Requires: requires,
Watch: origTask.Watch,
Failfast: origTask.Failfast,
Namespace: origTask.Namespace,
FullName: fullName,
}
new.Dir, err = execext.ExpandLiteral(new.Dir)
if err != nil {
return nil, err
}
if e.Dir != "" {
new.Dir = filepathext.SmartJoin(e.Dir, new.Dir)
}
if new.Prefix == "" {
new.Prefix = new.Task
}
dotenvEnvs := ast.NewVars()
if len(new.Dotenv) > 0 {
for _, dotEnvPath := range new.Dotenv {
dotEnvPath = filepathext.SmartJoin(new.Dir, dotEnvPath)
if _, err := os.Stat(dotEnvPath); os.IsNotExist(err) {
continue
}
envs, err := godotenv.Read(dotEnvPath)
if err != nil {
return nil, err
}
for key, value := range envs {
if _, ok := dotenvEnvs.Get(key); !ok {
dotenvEnvs.Set(key, ast.Var{Value: value})
}
}
}
}
new.Env = ast.NewVars()
new.Env.Merge(templater.ReplaceVars(e.Taskfile.Env, cache), nil)
new.Env.Merge(templater.ReplaceVars(dotenvEnvs, cache), nil)
new.Env.Merge(templater.ReplaceVars(origTask.Env, cache), nil)
if evaluateShVars {
for k, v := range new.Env.All() {
// If the variable is not dynamic, we can set it and return
if v.Value != nil || v.Sh == nil {
new.Env.Set(k, ast.Var{Value: v.Value})
continue
}
static, err := e.Compiler.HandleDynamicVar(v, new.Dir, env.GetFromVars(new.Env))
if err != nil {
return nil, err
}
new.Env.Set(k, ast.Var{Value: static})
}
}
if len(origTask.Sources) > 0 && origTask.Method != "none" {
var checker fingerprint.SourcesCheckable
if origTask.Method == "timestamp" {
checker = fingerprint.NewTimestampChecker(e.TempDir.Fingerprint, e.Dry)
} else {
checker = fingerprint.NewChecksumChecker(e.TempDir.Fingerprint, e.Dry)
}
value, err := checker.Value(&new)
if err != nil {
return nil, err
}
vars.Set(strings.ToUpper(checker.Kind()), ast.Var{Live: value})
// Adding new variables, requires us to refresh the templaters
// cache of the the values manually
cache.ResetCache()
}
if len(origTask.Cmds) > 0 {
new.Cmds = make([]*ast.Cmd, 0, len(origTask.Cmds))
for _, cmd := range origTask.Cmds {
if cmd == nil {
continue
}
if cmd.For != nil {
list, keys, err := itemsFromFor(cmd.For, new.Dir, new.Sources, new.Generates, gitignore, vars, origTask.Location, cache)
if err != nil {
return nil, err
}
// Name the iterator variable
var as string
if cmd.For.As != "" {
as = cmd.For.As
} else {
as = "ITEM"
}
// Create a new command for each item in the list
for i, loopValue := range list {
extra := map[string]any{
as: loopValue,
}
if len(keys) > 0 {
extra["KEY"] = keys[i]
}
newCmd := cmd.DeepCopy()
newCmd.Cmd = templater.ReplaceWithExtra(cmd.Cmd, cache, extra)
newCmd.Task = templater.ReplaceWithExtra(cmd.Task, cache, extra)
newCmd.If = templater.ReplaceWithExtra(cmd.If, cache, extra)
newCmd.Vars = templater.ReplaceVarsWithExtra(cmd.Vars, cache, extra)
new.Cmds = append(new.Cmds, newCmd)
}
continue
}
// Defer commands are replaced in a lazy manner because
// we need to include EXIT_CODE.
if cmd.Defer {
new.Cmds = append(new.Cmds, cmd.DeepCopy())
continue
}
newCmd := cmd.DeepCopy()
newCmd.Cmd = templater.Replace(cmd.Cmd, cache)
newCmd.Task = templater.Replace(cmd.Task, cache)
newCmd.If = templater.Replace(cmd.If, cache)
newCmd.Vars = templater.ReplaceVars(cmd.Vars, cache)
new.Cmds = append(new.Cmds, newCmd)
}
}
if len(origTask.Deps) > 0 {
new.Deps = make([]*ast.Dep, 0, len(origTask.Deps))
for _, dep := range origTask.Deps {
if dep == nil {
continue
}
if dep.For != nil {
list, keys, err := itemsFromFor(dep.For, new.Dir, new.Sources, new.Generates, gitignore, vars, origTask.Location, cache)
if err != nil {
return nil, err
}
// Name the iterator variable
var as string
if dep.For.As != "" {
as = dep.For.As
} else {
as = "ITEM"
}
// Create a new command for each item in the list
for i, loopValue := range list {
extra := map[string]any{
as: loopValue,
}
if len(keys) > 0 {
extra["KEY"] = keys[i]
}
newDep := dep.DeepCopy()
newDep.Task = templater.ReplaceWithExtra(dep.Task, cache, extra)
newDep.Vars = templater.ReplaceVarsWithExtra(dep.Vars, cache, extra)
new.Deps = append(new.Deps, newDep)
}
continue
}
newDep := dep.DeepCopy()
newDep.Task = templater.Replace(dep.Task, cache)
newDep.Vars = templater.ReplaceVars(dep.Vars, cache)
new.Deps = append(new.Deps, newDep)
}
}
if len(origTask.Preconditions) > 0 {
new.Preconditions = make([]*ast.Precondition, 0, len(origTask.Preconditions))
for _, precondition := range origTask.Preconditions {
if precondition == nil {
continue
}
newPrecondition := precondition.DeepCopy()
newPrecondition.Sh = templater.Replace(precondition.Sh, cache)
newPrecondition.Msg = templater.Replace(precondition.Msg, cache)
new.Preconditions = append(new.Preconditions, newPrecondition)
}
}
if len(origTask.Status) > 0 {
new.Status = templater.Replace(origTask.Status, cache)
}
// We only care about templater errors if we are evaluating shell variables
if evaluateShVars && cache.Err() != nil {
return &new, cache.Err()
}
return &new, nil
}
func asAnySlice[T any](slice []T) []any {
ret := make([]any, len(slice))
for i, v := range slice {
ret[i] = v
}
return ret
}
func itemsFromFor(
f *ast.For,
dir string,
sources []*ast.Glob,
generates []*ast.Glob,
gitignore bool,
vars *ast.Vars,
location *ast.Location,
cache *templater.Cache,
) ([]any, []string, error) {
var keys []string // The list of keys to loop over (only if looping over a map)
var values []any // The list of values to loop over
// Get the list from a matrix
if f.Matrix.Len() != 0 {
if err := resolveMatrixRefs(f.Matrix, cache); err != nil {
return nil, nil, errors.TaskfileInvalidError{
URI: location.Taskfile,
Err: err,
}
}
return asAnySlice(product(f.Matrix)), nil, nil
}
// Get the list from the explicit for list
if len(f.List) > 0 {
return f.List, nil, nil
}
// Get the list from the task sources
if f.From == "sources" {
glist, err := fingerprint.Globs(dir, sources, gitignore)
if err != nil {
return nil, nil, err
}
// Make the paths relative to the task dir
for i, v := range glist {
if glist[i], err = filepath.Rel(dir, v); err != nil {
return nil, nil, err
}
}
values = asAnySlice(glist)
}
// Get the list from the task generates
if f.From == "generates" {
glist, err := fingerprint.Globs(dir, generates, gitignore)
if err != nil {
return nil, nil, err
}
// Make the paths relative to the task dir
for i, v := range glist {
if glist[i], err = filepath.Rel(dir, v); err != nil {
return nil, nil, err
}
}
values = asAnySlice(glist)
}
// Get the list from a variable and split it up
if f.Var != "" {
if vars != nil {
v, ok := vars.Get(f.Var)
// If the variable is dynamic, then it hasn't been resolved yet
// and we can't use it as a list. This happens when fast compiling a task
// for use in --list or --list-all etc.
if ok && v.Value != nil && v.Sh == nil {
switch value := v.Value.(type) {
case string:
if f.Split != "" {
values = asAnySlice(strings.Split(value, f.Split))
} else {
values = asAnySlice(strings.Fields(value))
}
case []string:
values = asAnySlice(value)
case []int:
values = asAnySlice(value)
case []any:
values = value
case map[string]any:
for k, v := range value {
keys = append(keys, k)
values = append(values, v)
}
default:
return nil, nil, errors.TaskfileInvalidError{
URI: location.Taskfile,
Err: errors.New("loop var must be a delimiter-separated string, list or a map"),
}
}
}
}
}
return values, keys, nil
}
func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) error {
if matrix.Len() == 0 {
return nil
}
for _, row := range matrix.All() {
if row.Ref != "" {
v := templater.ResolveRef(row.Ref, cache)
switch value := v.(type) {
case []any:
row.Value = value
default:
return fmt.Errorf("matrix reference %q must resolve to a list", row.Ref)
}
}
}
return nil
}
func resolveEnumRefs(requires *ast.Requires, cache *templater.Cache) error {
if requires == nil || len(requires.Vars) == 0 {
return nil
}
for _, v := range requires.Vars {
if v.Enum == nil || v.Enum.Ref == "" {
continue
}
resolved := templater.ResolveRef(v.Enum.Ref, cache)
if cache.Err() != nil {
return cache.Err()
}
arr, ok := resolved.([]any)
if !ok {
return fmt.Errorf("enum reference %q must resolve to a list", v.Enum.Ref)
}
strValues := make([]string, 0, len(arr))
for _, item := range arr {
s, ok := item.(string)
if !ok {
return fmt.Errorf("enum reference %q must contain only strings", v.Enum.Ref)
}
strValues = append(strValues, s)
}
v.Enum.Value = strValues
}
return nil
}
// product generates the cartesian product of the input map of slices.
func product(matrix *ast.Matrix) []map[string]any {
if matrix.Len() == 0 {
return nil
}
// Start with an empty product result
result := []map[string]any{{}}
// Iterate over each slice in the slices
for key, row := range matrix.All() {
var newResult []map[string]any
// For each combination in the current result
for _, combination := range result {
// Append each element from the current slice to the combinations
for _, item := range row.Value {
newComb := make(map[string]any, len(combination))
// Copy the existing combination
maps.Copy(newComb, combination)
// Add the current item with the corresponding key
newComb[key] = item
newResult = append(newResult, newComb)
}
}
// Update result with the new combinations
result = newResult
}
return result
}