Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@
- Further improved fingerprinting performance on large repositories: hashing
source files now reuses a single buffer, reducing memory allocations by ~98%
and wall-clock time by ~7% (#2925 by @vmaerten).
- Fixed the fingerprint variable (`{{.CHECKSUM}}`/`{{.TIMESTAMP}}`) ignoring a
`method:` set at the Taskfile level: the variable now follows the same method
resolution as the up-to-date check. Only the variable matching the effective
method is injected, so a task inheriting a Taskfile-level `method: timestamp`
gets `{{.TIMESTAMP}}` and no longer a `{{.CHECKSUM}}` (which now renders as an
empty string), and neither variable is injected when the effective method is
`none` (#2924 by @vmaerten).

## v3.52.0 - 2026-07-02

Expand Down
12 changes: 12 additions & 0 deletions executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/puzpuzpuz/xsync/v4"
"github.com/sajari/fuzzy"

"github.com/go-task/task/v3/internal/fingerprint"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/output"
"github.com/go-task/task/v3/internal/sort"
Expand Down Expand Up @@ -122,6 +123,17 @@ func (e *Executor) Options(opts ...ExecutorOption) {
}
}

// fingerprinter is built on the fly rather than once in Setup because fields
// like Dry may be mutated between runs.
func (e *Executor) fingerprinter() *fingerprint.Fingerprinter {
return fingerprint.NewFingerprinter(
e.Taskfile.Method,
e.TempDir.Fingerprint,
e.Dry,
e.Logger,
)
}

// WithDir sets the working directory of the [Executor]. By default, the
// directory is set to the user's current working directory.
func WithDir(dir string) ExecutorOption {
Expand Down
13 changes: 1 addition & 12 deletions help.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"golang.org/x/sync/errgroup"

"github.com/go-task/task/v3/internal/editors"
"github.com/go-task/task/v3/internal/fingerprint"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/sort"
"github.com/go-task/task/v3/taskfile/ast"
Expand Down Expand Up @@ -151,17 +150,7 @@ func (e *Executor) ToEditorOutput(tasks []*ast.Task, noStatus bool, nested bool)
return nil
}

// Get the fingerprinting method to use
method := e.Taskfile.Method
if tasks[i].Method != "" {
method = tasks[i].Method
}
upToDate, err := fingerprint.IsTaskUpToDate(context.Background(), tasks[i],
fingerprint.WithMethod(method),
fingerprint.WithTempDir(e.TempDir.Fingerprint),
fingerprint.WithDry(e.Dry),
fingerprint.WithLogger(e.Logger),
)
upToDate, err := e.fingerprinter().UpToDate(context.Background(), tasks[i])
if err != nil {
return err
}
Expand Down
148 changes: 148 additions & 0 deletions internal/fingerprint/fingerprinter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package fingerprint

import (
"context"

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

type (
FingerprinterOption func(*Fingerprinter)

// A Fingerprinter answers whether a task is up-to-date. It owns the
// resolution of the fingerprinting method and the checkers behind it.
Fingerprinter struct {
defaultMethod string
tempDir string
dry bool
logger *logger.Logger
statusChecker StatusCheckable
sourcesChecker SourcesCheckable
}
)

func WithStatusChecker(checker StatusCheckable) FingerprinterOption {
return func(f *Fingerprinter) {
f.statusChecker = checker
}
}

func WithSourcesChecker(checker SourcesCheckable) FingerprinterOption {
return func(f *Fingerprinter) {
f.sourcesChecker = checker
}
}

// NewFingerprinter uses defaultMethod for tasks that don't declare one.
func NewFingerprinter(
defaultMethod string,
tempDir string,
dry bool,
logger *logger.Logger,
opts ...FingerprinterOption,
) *Fingerprinter {
f := &Fingerprinter{
defaultMethod: defaultMethod,
tempDir: tempDir,
dry: dry,
logger: logger,
}
for _, opt := range opts {
opt(f)
}
return f
}

func (f *Fingerprinter) resolveMethod(t *ast.Task) string {
if t.Method != "" {
return t.Method
}
return f.defaultMethod
}

// Kind names the fingerprint variable ("checksum", "timestamp" or "none") the
// resolved method injects. An invalid method is reported as "checksum" here and
// rejected by the entry points that build a checker.
func (f *Fingerprinter) Kind(t *ast.Task) string {
if f.sourcesChecker != nil {
return f.sourcesChecker.Kind()
}
switch method := f.resolveMethod(t); method {
case "timestamp", "none":
return method
default:
return "checksum"
}
}

// SourceValue returns the value of the fingerprint variable for the given task.
// It is potentially expensive, so only call it when the task references it.
func (f *Fingerprinter) SourceValue(t *ast.Task) (any, error) {
sourcesChecker, err := f.resolveSourcesChecker(t)
if err != nil {
return nil, err
}
return sourcesChecker.Value(t)
}

// UpToDate considers both the status commands and the sources of a task; one
// that declares neither never is.
func (f *Fingerprinter) UpToDate(ctx context.Context, t *ast.Task) (bool, error) {
var statusUpToDate bool
var sourcesUpToDate bool

statusChecker := f.statusChecker
if statusChecker == nil {
statusChecker = NewStatusChecker(f.logger)
}
sourcesChecker, err := f.resolveSourcesChecker(t)
if err != nil {
return false, err
}

statusIsSet := len(t.Status) != 0
sourcesIsSet := len(t.Sources) != 0

if statusIsSet {
statusUpToDate, err = statusChecker.IsUpToDate(ctx, t)
if err != nil {
return false, err
}
}

if sourcesIsSet {
sourcesUpToDate, err = sourcesChecker.IsUpToDate(t)
if err != nil {
return false, err
}
}

if statusIsSet && sourcesIsSet {
return statusUpToDate && sourcesUpToDate, nil
}
if statusIsSet {
return statusUpToDate, nil
}
if sourcesIsSet {
return sourcesUpToDate, nil
}
return false, nil
}

// OnError lets the resolved sources checker clean up after a failed run.
func (f *Fingerprinter) OnError(t *ast.Task) error {
sourcesChecker, err := f.resolveSourcesChecker(t)
if err != nil {
return err
}
return sourcesChecker.OnError(t)
}

// resolveSourcesChecker is the single place where a task is mapped to a checker.
func (f *Fingerprinter) resolveSourcesChecker(t *ast.Task) (SourcesCheckable, error) {
if f.sourcesChecker != nil {
return f.sourcesChecker, nil
}
return NewSourcesChecker(f.resolveMethod(t), f.tempDir, f.dry)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package fingerprint

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

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
Expand All @@ -23,7 +26,7 @@ import (
// | false | not set | false |
// | false | true | false |
// | false | false | false |
func TestIsTaskUpToDate(t *testing.T) {
func TestFingerprinterUpToDate(t *testing.T) {
t.Parallel()

tests := []struct {
Expand Down Expand Up @@ -162,14 +165,94 @@ func TestIsTaskUpToDate(t *testing.T) {
tt.setupMockSourcesChecker(mockSourcesChecker)
}

result, err := IsTaskUpToDate(
t.Context(),
tt.task,
f := NewFingerprinter("checksum", "", false, nil,
WithStatusChecker(mockStatusChecker),
WithSourcesChecker(mockSourcesChecker),
)
result, err := f.UpToDate(t.Context(), tt.task)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}

// The task's own method wins over the Taskfile default, for the injected
// variable as much as for the up-to-date check.
func TestFingerprinterMethodResolution(t *testing.T) {
t.Parallel()

tests := []struct {
name string
defaultMethod string
method string
expectedKind string
expectedValue any
}{
{
name: "task method wins over the default",
defaultMethod: "checksum",
method: "timestamp",
expectedKind: "timestamp",
expectedValue: time.Time{},
},
{
name: "default method is inherited when the task declares none",
defaultMethod: "timestamp",
expectedKind: "timestamp",
expectedValue: time.Time{},
},
{
name: "checksum is inherited too",
defaultMethod: "checksum",
expectedKind: "checksum",
expectedValue: "",
},
{
name: "none is inherited too",
defaultMethod: "none",
expectedKind: "none",
expectedValue: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "source.txt"), []byte("content"), 0o644))
task := &ast.Task{
Dir: dir,
Method: tt.method,
Sources: []*ast.Glob{{Glob: "source.txt"}},
}

f := NewFingerprinter(tt.defaultMethod, t.TempDir(), true, nil)

assert.Equal(t, tt.expectedKind, f.Kind(task))

// A timestamp checker yields a time, the other two a string.
value, err := f.SourceValue(task)
require.NoError(t, err)
assert.IsType(t, tt.expectedValue, value)
})
}
}

// Only the entry points that need a checker reject an invalid method; Kind
// tolerates it, so that --force runs still compile.
func TestFingerprinterInvalidMethod(t *testing.T) {
t.Parallel()

const wantErr = `task: invalid method "Checksum"`
task := &ast.Task{Sources: []*ast.Glob{{Glob: "source.txt"}}}
f := NewFingerprinter("Checksum", t.TempDir(), true, nil)

assert.Equal(t, "checksum", f.Kind(task))

_, err := f.SourceValue(task)
require.ErrorIs(t, err, ErrInvalidMethod)
require.EqualError(t, err, wantErr)
_, err = f.UpToDate(t.Context(), task)
require.EqualError(t, err, wantErr)
require.EqualError(t, f.OnError(task), wantErr)
}
12 changes: 10 additions & 2 deletions internal/fingerprint/sources.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
package fingerprint

import "fmt"
import (
"fmt"

"github.com/go-task/task/v3/errors"
)

// ErrInvalidMethod lets callers that only need a fingerprint value tell a bad
// method name apart from a checker failing on the sources themselves.
var ErrInvalidMethod = errors.New("invalid method")

func NewSourcesChecker(method, tempDir string, dry bool) (SourcesCheckable, error) {
switch method {
Expand All @@ -11,6 +19,6 @@ func NewSourcesChecker(method, tempDir string, dry bool) (SourcesCheckable, erro
case "none":
return NoneChecker{}, nil
default:
return nil, fmt.Errorf(`task: invalid method "%s"`, method)
return nil, fmt.Errorf(`task: %w "%s"`, ErrInvalidMethod, method)
}
}
Loading