From 42faaeeadb0ad92534dec3535d443420d1195b83 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 18 Jul 2026 23:28:14 +0200 Subject: [PATCH 1/7] refactor: centralize fingerprint method resolution in a Fingerprinter The decision "is this task up to date?" was spread over five call sites: the method-selection idiom (task method falling back to the Taskfile method) was copied verbatim in RunTask, Status and ToEditorOutput, and the sources checker was constructed separately in statusOnError and in compiledTask for the CHECKSUM/TIMESTAMP variable injection. Introduce a Fingerprinter in internal/fingerprint that owns method resolution and checker construction behind a small interface (UpToDate, OnError, Kind, SourceValue), and route all five call sites through it. It is built on the fly from the Executor's current state because exported fields like Dry may legitimately be mutated between runs. This also fixes the fingerprint variable ignoring a method set at the Taskfile level: compiledTask picked the checker from the task's own method only, so with a Taskfile-level "method: timestamp" the injected variable was computed by the checksum checker while the up-to-date check used timestamp. The variable now follows the same resolution as the up-to-date check, and is no longer injected when the effective method is "none". --- CHANGELOG.md | 5 + executor.go | 13 ++ help.go | 13 +- internal/fingerprint/fingerprinter.go | 179 ++++++++++++++++++ .../{task_test.go => fingerprinter_test.go} | 7 +- internal/fingerprint/task.go | 132 ------------- status.go | 27 +-- task.go | 13 +- task_test.go | 51 +++++ testdata/method_taskfile_none/Taskfile.yml | 10 + testdata/method_taskfile_none/source.txt | 1 + .../method_taskfile_timestamp/Taskfile.yml | 10 + testdata/method_taskfile_timestamp/source.txt | 1 + variables.go | 16 +- 14 files changed, 281 insertions(+), 197 deletions(-) create mode 100644 internal/fingerprint/fingerprinter.go rename internal/fingerprint/{task_test.go => fingerprinter_test.go} (97%) delete mode 100644 internal/fingerprint/task.go create mode 100644 testdata/method_taskfile_none/Taskfile.yml create mode 100644 testdata/method_taskfile_none/source.txt create mode 100644 testdata/method_taskfile_timestamp/Taskfile.yml create mode 100644 testdata/method_taskfile_timestamp/source.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 75a230703c..6f6ec3646a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- 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, and is no longer injected when the + effective method is `none` (#2924 by @vmaerten). + - Considerably improve performance of fingerprinting on large repositories (monorepos). Fingerprinting is up to 86% faster and make up to 70% fewer memory allocations on the more advanced scenarios. Benchmarks were added as diff --git a/executor.go b/executor.go index 783f18ed0d..210350fea0 100644 --- a/executor.go +++ b/executor.go @@ -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" @@ -122,6 +123,18 @@ func (e *Executor) Options(opts ...ExecutorOption) { } } +// fingerprinter returns a [fingerprint.Fingerprinter] reflecting the +// Executor's current state. It 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 { diff --git a/help.go b/help.go index 9998bd38ad..c6399363eb 100644 --- a/help.go +++ b/help.go @@ -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" @@ -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 } diff --git a/internal/fingerprint/fingerprinter.go b/internal/fingerprint/fingerprinter.go new file mode 100644 index 0000000000..76ac1f751b --- /dev/null +++ b/internal/fingerprint/fingerprinter.go @@ -0,0 +1,179 @@ +package fingerprint + +import ( + "context" + + "github.com/go-task/task/v3/internal/logger" + "github.com/go-task/task/v3/taskfile/ast" +) + +type ( + // A FingerprinterOption is a functional option for a [Fingerprinter]. + FingerprinterOption func(*Fingerprinter) + + // A Fingerprinter answers whether a task is up-to-date. It owns the + // resolution of the fingerprinting method (the task's method, falling back + // to the default) and the construction of the underlying checkers, so that + // every caller gets the same answer for the same task. + Fingerprinter struct { + defaultMethod string + tempDir string + dry bool + logger *logger.Logger + statusChecker StatusCheckable + sourcesChecker SourcesCheckable + } +) + +// WithStatusChecker allows a custom [StatusCheckable] to be used instead of +// the default one. +func WithStatusChecker(checker StatusCheckable) FingerprinterOption { + return func(f *Fingerprinter) { + f.statusChecker = checker + } +} + +// WithSourcesChecker allows a custom [SourcesCheckable] to be used instead of +// the one selected by the resolved fingerprinting method. +func WithSourcesChecker(checker SourcesCheckable) FingerprinterOption { + return func(f *Fingerprinter) { + f.sourcesChecker = checker + } +} + +// NewFingerprinter creates a new [Fingerprinter]. The defaultMethod is used +// for tasks that don't declare a method of their own. +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 returns the kind of fingerprint variable ("checksum", "timestamp" or +// "none") produced by the method resolved for the given task. Unknown methods +// fall back to "checksum"; they are only rejected by [NewSourcesChecker]. +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 (CHECKSUM or +// TIMESTAMP) for the given task. It is potentially expensive, so callers +// should only invoke it when the task actually references the variable. +func (f *Fingerprinter) SourceValue(t *ast.Task) (any, error) { + sourcesChecker, err := f.resolveSourcesChecker(f.Kind(t)) + if err != nil { + return nil, err + } + return sourcesChecker.Value(t) +} + +// UpToDate reports whether the given task is up-to-date, considering both its +// status commands and its sources. +// +// | Status up-to-date | Sources up-to-date | Task is up-to-date | +// | ----------------- | ------------------ | ------------------ | +// | not set | not set | false | +// | not set | true | true | +// | not set | false | false | +// | true | not set | true | +// | true | true | true | +// | true | false | false | +// | false | not set | false | +// | false | true | false | +// | false | false | false | +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(f.resolveMethod(t)) + if err != nil { + return false, err + } + + statusIsSet := len(t.Status) != 0 + sourcesIsSet := len(t.Sources) != 0 + + // If status is set, check if it is up-to-date + if statusIsSet { + statusUpToDate, err = statusChecker.IsUpToDate(ctx, t) + if err != nil { + return false, err + } + } + + // If sources is set, check if they are up-to-date + if sourcesIsSet { + sourcesUpToDate, err = sourcesChecker.IsUpToDate(t) + if err != nil { + return false, err + } + } + + // If both status and sources are set, the task is up-to-date if both are up-to-date + if statusIsSet && sourcesIsSet { + return statusUpToDate && sourcesUpToDate, nil + } + + // If only status is set, the task is up-to-date if the status is up-to-date + if statusIsSet { + return statusUpToDate, nil + } + + // If only sources is set, the task is up-to-date if the sources are up-to-date + if sourcesIsSet { + return sourcesUpToDate, nil + } + + // If no status or sources are set, the task should always run + // i.e. it is never considered "up-to-date" + return false, nil +} + +// OnError gives the sources checker resolved for the given task a chance to +// clean up after a failed run. +func (f *Fingerprinter) OnError(t *ast.Task) error { + sourcesChecker, err := f.resolveSourcesChecker(f.resolveMethod(t)) + if err != nil { + return err + } + return sourcesChecker.OnError(t) +} + +func (f *Fingerprinter) resolveSourcesChecker(method string) (SourcesCheckable, error) { + if f.sourcesChecker != nil { + return f.sourcesChecker, nil + } + return NewSourcesChecker(method, f.tempDir, f.dry) +} diff --git a/internal/fingerprint/task_test.go b/internal/fingerprint/fingerprinter_test.go similarity index 97% rename from internal/fingerprint/task_test.go rename to internal/fingerprint/fingerprinter_test.go index 3452b19c91..eb123910dc 100644 --- a/internal/fingerprint/task_test.go +++ b/internal/fingerprint/fingerprinter_test.go @@ -23,7 +23,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 { @@ -162,12 +162,11 @@ 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) }) diff --git a/internal/fingerprint/task.go b/internal/fingerprint/task.go deleted file mode 100644 index 2b48e114c9..0000000000 --- a/internal/fingerprint/task.go +++ /dev/null @@ -1,132 +0,0 @@ -package fingerprint - -import ( - "context" - - "github.com/go-task/task/v3/internal/logger" - "github.com/go-task/task/v3/taskfile/ast" -) - -type ( - CheckerOption func(*CheckerConfig) - CheckerConfig struct { - method string - dry bool - tempDir string - logger *logger.Logger - statusChecker StatusCheckable - sourcesChecker SourcesCheckable - } -) - -func WithMethod(method string) CheckerOption { - return func(config *CheckerConfig) { - config.method = method - } -} - -func WithDry(dry bool) CheckerOption { - return func(config *CheckerConfig) { - config.dry = dry - } -} - -func WithTempDir(tempDir string) CheckerOption { - return func(config *CheckerConfig) { - config.tempDir = tempDir - } -} - -func WithLogger(logger *logger.Logger) CheckerOption { - return func(config *CheckerConfig) { - config.logger = logger - } -} - -func WithStatusChecker(checker StatusCheckable) CheckerOption { - return func(config *CheckerConfig) { - config.statusChecker = checker - } -} - -func WithSourcesChecker(checker SourcesCheckable) CheckerOption { - return func(config *CheckerConfig) { - config.sourcesChecker = checker - } -} - -func IsTaskUpToDate( - ctx context.Context, - t *ast.Task, - opts ...CheckerOption, -) (bool, error) { - var statusUpToDate bool - var sourcesUpToDate bool - var err error - - // Default config - config := &CheckerConfig{ - method: "none", - tempDir: "", - dry: false, - logger: nil, - statusChecker: nil, - sourcesChecker: nil, - } - - // Apply functional options - for _, opt := range opts { - opt(config) - } - - // If no status checker was given, set up the default one - if config.statusChecker == nil { - config.statusChecker = NewStatusChecker(config.logger) - } - - // If no sources checker was given, set up the default one - if config.sourcesChecker == nil { - config.sourcesChecker, err = NewSourcesChecker(config.method, config.tempDir, config.dry) - if err != nil { - return false, err - } - } - - statusIsSet := len(t.Status) != 0 - sourcesIsSet := len(t.Sources) != 0 - - // If status is set, check if it is up-to-date - if statusIsSet { - statusUpToDate, err = config.statusChecker.IsUpToDate(ctx, t) - if err != nil { - return false, err - } - } - - // If sources is set, check if they are up-to-date - if sourcesIsSet { - sourcesUpToDate, err = config.sourcesChecker.IsUpToDate(t) - if err != nil { - return false, err - } - } - - // If both status and sources are set, the task is up-to-date if both are up-to-date - if statusIsSet && sourcesIsSet { - return statusUpToDate && sourcesUpToDate, nil - } - - // If only status is set, the task is up-to-date if the status is up-to-date - if statusIsSet { - return statusUpToDate, nil - } - - // If only sources is set, the task is up-to-date if the sources are up-to-date - if sourcesIsSet { - return sourcesUpToDate, nil - } - - // If no status or sources are set, the task should always run - // i.e. it is never considered "up-to-date" - return false, nil -} diff --git a/status.go b/status.go index ae40f5ba5f..21fe861bb7 100644 --- a/status.go +++ b/status.go @@ -4,33 +4,18 @@ import ( "context" "fmt" - "github.com/go-task/task/v3/internal/fingerprint" "github.com/go-task/task/v3/taskfile/ast" ) // Status returns an error if any the of given tasks is not up-to-date func (e *Executor) Status(ctx context.Context, calls ...*Call) error { for _, call := range calls { - - // Compile the task t, err := e.CompiledTask(call) if err != nil { return err } - // Get the fingerprinting method to use - method := e.Taskfile.Method - if t.Method != "" { - method = t.Method - } - - // Check if the task is up-to-date - isUpToDate, err := fingerprint.IsTaskUpToDate(ctx, t, - fingerprint.WithMethod(method), - fingerprint.WithTempDir(e.TempDir.Fingerprint), - fingerprint.WithDry(e.Dry), - fingerprint.WithLogger(e.Logger), - ) + isUpToDate, err := e.fingerprinter().UpToDate(ctx, t) if err != nil { return err } @@ -42,13 +27,5 @@ func (e *Executor) Status(ctx context.Context, calls ...*Call) error { } func (e *Executor) statusOnError(t *ast.Task) error { - method := t.Method - if method == "" { - method = e.Taskfile.Method - } - checker, err := fingerprint.NewSourcesChecker(method, e.TempDir.Fingerprint, e.Dry) - if err != nil { - return err - } - return checker.OnError(t) + return e.fingerprinter().OnError(t) } diff --git a/task.go b/task.go index 98d340c976..482dc5cef7 100644 --- a/task.go +++ b/task.go @@ -15,7 +15,6 @@ import ( "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/env" "github.com/go-task/task/v3/internal/execext" - "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/slicesext" @@ -221,17 +220,7 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { return err } - // Get the fingerprinting method to use - method := e.Taskfile.Method - if t.Method != "" { - method = t.Method - } - upToDate, err := fingerprint.IsTaskUpToDate(ctx, t, - fingerprint.WithMethod(method), - fingerprint.WithTempDir(e.TempDir.Fingerprint), - fingerprint.WithDry(e.Dry), - fingerprint.WithLogger(e.Logger), - ) + upToDate, err := e.fingerprinter().UpToDate(ctx, t) if err != nil { return err } diff --git a/task_test.go b/task_test.go index b56930e77e..7425358f62 100644 --- a/task_test.go +++ b/task_test.go @@ -653,6 +653,57 @@ func TestStatusChecksumMissingGenerated(t *testing.T) { // nolint:paralleltest / require.NoError(t, err, "generated.txt should be recreated after third run") } +// TestFingerprintVarMethodInheritedFromTaskfile asserts that the fingerprint +// variable injected into a task follows the Taskfile-level method when the +// task doesn't declare one, like the up-to-date check does. +func TestFingerprintVarMethodInheritedFromTaskfile(t *testing.T) { + t.Parallel() + + const dir = "testdata/method_taskfile_timestamp" + _ = os.RemoveAll(filepathext.SmartJoin(dir, ".task")) + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithTempDir(task.TempDir{ + Remote: filepathext.SmartJoin(dir, ".task"), + Fingerprint: filepathext.SmartJoin(dir, ".task"), + }), + ) + require.NoError(t, e.Setup()) + + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + assert.Contains(t, buff.String(), "ts=") + assert.NotContains(t, buff.String(), "", "TIMESTAMP should be injected when method is inherited from the Taskfile") +} + +// TestFingerprintVarMethodNone asserts that no fingerprint variable is +// injected when the effective method is "none", including when it is +// inherited from the Taskfile level. +func TestFingerprintVarMethodNone(t *testing.T) { + t.Parallel() + + const dir = "testdata/method_taskfile_none" + _ = os.RemoveAll(filepathext.SmartJoin(dir, ".task")) + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithTempDir(task.TempDir{ + Remote: filepathext.SmartJoin(dir, ".task"), + Fingerprint: filepathext.SmartJoin(dir, ".task"), + }), + ) + require.NoError(t, e.Setup()) + + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + assert.Contains(t, buff.String(), "cs=\n", "CHECKSUM should not be injected when the effective method is none") +} + func writeFile(t *testing.T, dir, name, content string) { t.Helper() require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, name), []byte(content), 0o644)) diff --git a/testdata/method_taskfile_none/Taskfile.yml b/testdata/method_taskfile_none/Taskfile.yml new file mode 100644 index 0000000000..e212720cfa --- /dev/null +++ b/testdata/method_taskfile_none/Taskfile.yml @@ -0,0 +1,10 @@ +version: '3' + +method: none + +tasks: + build: + cmds: + - echo "cs={{.CHECKSUM}}" + sources: + - ./source.txt diff --git a/testdata/method_taskfile_none/source.txt b/testdata/method_taskfile_none/source.txt new file mode 100644 index 0000000000..5a18cd2fbf --- /dev/null +++ b/testdata/method_taskfile_none/source.txt @@ -0,0 +1 @@ +source diff --git a/testdata/method_taskfile_timestamp/Taskfile.yml b/testdata/method_taskfile_timestamp/Taskfile.yml new file mode 100644 index 0000000000..dc0f139e5d --- /dev/null +++ b/testdata/method_taskfile_timestamp/Taskfile.yml @@ -0,0 +1,10 @@ +version: '3' + +method: timestamp + +tasks: + build: + cmds: + - echo "ts={{.TIMESTAMP}}" + sources: + - ./source.txt diff --git a/testdata/method_taskfile_timestamp/source.txt b/testdata/method_taskfile_timestamp/source.txt new file mode 100644 index 0000000000..5a18cd2fbf --- /dev/null +++ b/testdata/method_taskfile_timestamp/source.txt @@ -0,0 +1 @@ +source diff --git a/variables.go b/variables.go index 900f87c0d9..a72cbffe64 100644 --- a/variables.go +++ b/variables.go @@ -208,21 +208,13 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err } } - 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) - } - - if origTask.ReferencesFingerprintVar(checker.Kind()) { - value, err := checker.Value(&new) + if kind := e.fingerprinter().Kind(&new); len(origTask.Sources) > 0 && kind != "none" { + if origTask.ReferencesFingerprintVar(kind) { + value, err := e.fingerprinter().SourceValue(&new) if err != nil { return nil, err } - vars.Set(strings.ToUpper(checker.Kind()), ast.Var{Live: value}) + vars.Set(strings.ToUpper(kind), ast.Var{Live: value}) // Adding new variables, requires us to refresh the templaters // cache of the the values manually From e34e69a25d47b63a98cb502b33160e7b1eb88d5c Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 9 Aug 2026 21:48:30 +0200 Subject: [PATCH 2/7] fix: resolve the fingerprint method from the method itself in SourceValue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SourceValue built its checker from the normalized Kind, so a mistyped "method:" produced a checksum on that path while UpToDate and OnError rejected it with "invalid method". Resolve the checker from the task's method in all three, and leave Kind as the one tolerant entry point: it only names the variable to inject, so compiling a task never fails over a method it doesn't use — runs that skip fingerprinting (--force) go through it all the same. Also hoist the sources guard in compiledTask so no Fingerprinter is built for tasks without sources, and reuse a single one for Kind and SourceValue. --- CHANGELOG.md | 12 +++++++----- internal/fingerprint/fingerprinter.go | 27 ++++++++++++++++++--------- variables.go | 8 +++++--- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f6ec3646a..abb1322765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,6 @@ ## Unreleased -- 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, and is no longer injected when the - effective method is `none` (#2924 by @vmaerten). - - Considerably improve performance of fingerprinting on large repositories (monorepos). Fingerprinting is up to 86% faster and make up to 70% fewer memory allocations on the more advanced scenarios. Benchmarks were added as @@ -22,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 diff --git a/internal/fingerprint/fingerprinter.go b/internal/fingerprint/fingerprinter.go index 76ac1f751b..a20068ec13 100644 --- a/internal/fingerprint/fingerprinter.go +++ b/internal/fingerprint/fingerprinter.go @@ -70,8 +70,11 @@ func (f *Fingerprinter) resolveMethod(t *ast.Task) string { } // Kind returns the kind of fingerprint variable ("checksum", "timestamp" or -// "none") produced by the method resolved for the given task. Unknown methods -// fall back to "checksum"; they are only rejected by [NewSourcesChecker]. +// "none") produced by the method resolved for the given task. It is the only +// entry point that tolerates an invalid method — it reports it as "checksum" +// so that merely naming a variable cannot fail; the method is validated by +// [Fingerprinter.SourceValue] and [Fingerprinter.UpToDate], on the paths that +// actually need a checker. func (f *Fingerprinter) Kind(t *ast.Task) string { if f.sourcesChecker != nil { return f.sourcesChecker.Kind() @@ -85,10 +88,13 @@ func (f *Fingerprinter) Kind(t *ast.Task) string { } // SourceValue returns the value of the fingerprint variable (CHECKSUM or -// TIMESTAMP) for the given task. It is potentially expensive, so callers -// should only invoke it when the task actually references the variable. +// TIMESTAMP) for the given task. It resolves the checker from the method +// itself, not from [Fingerprinter.Kind], so an invalid method is rejected here +// rather than silently fingerprinted as a checksum. It is potentially +// expensive, so callers should only invoke it when the task actually +// references the variable. func (f *Fingerprinter) SourceValue(t *ast.Task) (any, error) { - sourcesChecker, err := f.resolveSourcesChecker(f.Kind(t)) + sourcesChecker, err := f.resolveSourcesChecker(t) if err != nil { return nil, err } @@ -117,7 +123,7 @@ func (f *Fingerprinter) UpToDate(ctx context.Context, t *ast.Task) (bool, error) if statusChecker == nil { statusChecker = NewStatusChecker(f.logger) } - sourcesChecker, err := f.resolveSourcesChecker(f.resolveMethod(t)) + sourcesChecker, err := f.resolveSourcesChecker(t) if err != nil { return false, err } @@ -164,16 +170,19 @@ func (f *Fingerprinter) UpToDate(ctx context.Context, t *ast.Task) (bool, error) // OnError gives the sources checker resolved for the given task a chance to // clean up after a failed run. func (f *Fingerprinter) OnError(t *ast.Task) error { - sourcesChecker, err := f.resolveSourcesChecker(f.resolveMethod(t)) + sourcesChecker, err := f.resolveSourcesChecker(t) if err != nil { return err } return sourcesChecker.OnError(t) } -func (f *Fingerprinter) resolveSourcesChecker(method string) (SourcesCheckable, error) { +// resolveSourcesChecker is the single place where a task is mapped to a +// [SourcesCheckable], so that every entry point of the [Fingerprinter] agrees +// on the checker a given task gets. +func (f *Fingerprinter) resolveSourcesChecker(t *ast.Task) (SourcesCheckable, error) { if f.sourcesChecker != nil { return f.sourcesChecker, nil } - return NewSourcesChecker(method, f.tempDir, f.dry) + return NewSourcesChecker(f.resolveMethod(t), f.tempDir, f.dry) } diff --git a/variables.go b/variables.go index a72cbffe64..00eab32783 100644 --- a/variables.go +++ b/variables.go @@ -208,9 +208,11 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err } } - if kind := e.fingerprinter().Kind(&new); len(origTask.Sources) > 0 && kind != "none" { - if origTask.ReferencesFingerprintVar(kind) { - value, err := e.fingerprinter().SourceValue(&new) + if len(origTask.Sources) > 0 { + fingerprinter := e.fingerprinter() + kind := fingerprinter.Kind(&new) + if kind != "none" && origTask.ReferencesFingerprintVar(kind) { + value, err := fingerprinter.SourceValue(&new) if err != nil { return nil, err } From ba615fab3e9ea26f9e1c3bfb78ecd3a2a2b19451 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 9 Aug 2026 21:49:26 +0200 Subject: [PATCH 3/7] test: cover fingerprint method resolution and its variable Table the two Taskfile-level method tests, assert on an actual timestamp rather than on the "ts=" prefix an unresolved variable also satisfies, and add a --force case over an invalid method, which no test caught. On the unit side, check that Kind and SourceValue agree on the resolved method, and that an invalid one is rejected by every entry point that needs a checker. --- internal/fingerprint/fingerprinter_test.go | 97 +++++++++++++++++--- task_test.go | 101 ++++++++++++--------- testdata/method_invalid/Taskfile.yml | 9 ++ testdata/method_invalid/source.txt | 1 + 4 files changed, 151 insertions(+), 57 deletions(-) create mode 100644 testdata/method_invalid/Taskfile.yml create mode 100644 testdata/method_invalid/source.txt diff --git a/internal/fingerprint/fingerprinter_test.go b/internal/fingerprint/fingerprinter_test.go index eb123910dc..c631912e0c 100644 --- a/internal/fingerprint/fingerprinter_test.go +++ b/internal/fingerprint/fingerprinter_test.go @@ -1,7 +1,10 @@ package fingerprint import ( + "os" + "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -10,19 +13,7 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) -// TruthTable -// -// | Status up-to-date | Sources up-to-date | Task is up-to-date | -// | ----------------- | ------------------ | ------------------ | -// | not set | not set | false | -// | not set | true | true | -// | not set | false | false | -// | true | not set | true | -// | true | true | true | -// | true | false | false | -// | false | not set | false | -// | false | true | false | -// | false | false | false | +// The cases below walk the truth table documented on [Fingerprinter.UpToDate]. func TestFingerprinterUpToDate(t *testing.T) { t.Parallel() @@ -172,3 +163,83 @@ func TestFingerprinterUpToDate(t *testing.T) { }) } } + +// 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.EqualError(t, err, wantErr) + _, err = f.UpToDate(t.Context(), task) + require.EqualError(t, err, wantErr) + require.EqualError(t, f.OnError(task), wantErr) +} diff --git a/task_test.go b/task_test.go index 7425358f62..d0d8859835 100644 --- a/task_test.go +++ b/task_test.go @@ -653,55 +653,68 @@ func TestStatusChecksumMissingGenerated(t *testing.T) { // nolint:paralleltest / require.NoError(t, err, "generated.txt should be recreated after third run") } -// TestFingerprintVarMethodInheritedFromTaskfile asserts that the fingerprint -// variable injected into a task follows the Taskfile-level method when the -// task doesn't declare one, like the up-to-date check does. -func TestFingerprintVarMethodInheritedFromTaskfile(t *testing.T) { +// The injected fingerprint variable follows the method the up-to-date check +// uses, including when that method comes from the Taskfile level. +func TestFingerprintVarMethod(t *testing.T) { t.Parallel() - const dir = "testdata/method_taskfile_timestamp" - _ = os.RemoveAll(filepathext.SmartJoin(dir, ".task")) - - var buff bytes.Buffer - e := task.NewExecutor( - task.WithDir(dir), - task.WithStdout(&buff), - task.WithStderr(&buff), - task.WithTempDir(task.TempDir{ - Remote: filepathext.SmartJoin(dir, ".task"), - Fingerprint: filepathext.SmartJoin(dir, ".task"), - }), - ) - require.NoError(t, e.Setup()) - - require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) - assert.Contains(t, buff.String(), "ts=") - assert.NotContains(t, buff.String(), "", "TIMESTAMP should be injected when method is inherited from the Taskfile") -} - -// TestFingerprintVarMethodNone asserts that no fingerprint variable is -// injected when the effective method is "none", including when it is -// inherited from the Taskfile level. -func TestFingerprintVarMethodNone(t *testing.T) { - t.Parallel() + tests := []struct { + name string + dir string + executorOpts []task.ExecutorOption + assertOutput func(t *testing.T, output string) + }{ + { + name: "TIMESTAMP is injected when the method is inherited from the Taskfile", + dir: "testdata/method_taskfile_timestamp", + assertOutput: func(t *testing.T, output string) { + t.Helper() + // An unresolved variable renders as an empty string, so this + // has to match an actual timestamp, not just the prefix. + assert.Regexp(t, `ts=\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, output) + }, + }, + { + name: "no variable is injected when the effective method is none", + dir: "testdata/method_taskfile_none", + assertOutput: func(t *testing.T, output string) { + t.Helper() + assert.Contains(t, output, "cs=\n") + }, + }, + { + name: "an invalid method doesn't fail a run that skips fingerprinting", + dir: "testdata/method_invalid", + executorOpts: []task.ExecutorOption{task.WithForce(true)}, + assertOutput: func(t *testing.T, output string) { + t.Helper() + assert.Contains(t, output, "hello\n") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - const dir = "testdata/method_taskfile_none" - _ = os.RemoveAll(filepathext.SmartJoin(dir, ".task")) + _ = os.RemoveAll(filepathext.SmartJoin(tt.dir, ".task")) - var buff bytes.Buffer - e := task.NewExecutor( - task.WithDir(dir), - task.WithStdout(&buff), - task.WithStderr(&buff), - task.WithTempDir(task.TempDir{ - Remote: filepathext.SmartJoin(dir, ".task"), - Fingerprint: filepathext.SmartJoin(dir, ".task"), - }), - ) - require.NoError(t, e.Setup()) + var buff bytes.Buffer + opts := append([]task.ExecutorOption{ + task.WithDir(tt.dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithTempDir(task.TempDir{ + Remote: filepathext.SmartJoin(tt.dir, ".task"), + Fingerprint: filepathext.SmartJoin(tt.dir, ".task"), + }), + }, tt.executorOpts...) + e := task.NewExecutor(opts...) + require.NoError(t, e.Setup()) - require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) - assert.Contains(t, buff.String(), "cs=\n", "CHECKSUM should not be injected when the effective method is none") + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + tt.assertOutput(t, buff.String()) + }) + } } func writeFile(t *testing.T, dir, name, content string) { diff --git a/testdata/method_invalid/Taskfile.yml b/testdata/method_invalid/Taskfile.yml new file mode 100644 index 0000000000..b224f788f8 --- /dev/null +++ b/testdata/method_invalid/Taskfile.yml @@ -0,0 +1,9 @@ +version: '3' + +tasks: + build: + method: checksums # typo: not a valid method + cmds: + - echo "hello" + sources: + - ./source.txt diff --git a/testdata/method_invalid/source.txt b/testdata/method_invalid/source.txt new file mode 100644 index 0000000000..587be6b4c3 --- /dev/null +++ b/testdata/method_invalid/source.txt @@ -0,0 +1 @@ +x From 1c095ad3d59b3d25ff56db6de271a2185e154dcd Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 9 Aug 2026 21:50:14 +0200 Subject: [PATCH 4/7] refactor: trim comments that restate the code Drop the step-by-step comments inside UpToDate, which the truth table right above the function already spells out, and cut the doc comments carried over from the extraction down to what the signature doesn't say. --- executor.go | 5 ++-- internal/fingerprint/fingerprinter.go | 35 ++++++--------------------- 2 files changed, 9 insertions(+), 31 deletions(-) diff --git a/executor.go b/executor.go index 210350fea0..14b422f3e1 100644 --- a/executor.go +++ b/executor.go @@ -123,9 +123,8 @@ func (e *Executor) Options(opts ...ExecutorOption) { } } -// fingerprinter returns a [fingerprint.Fingerprinter] reflecting the -// Executor's current state. It is built on the fly rather than once in Setup -// because fields like Dry may be mutated between runs. +// 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, diff --git a/internal/fingerprint/fingerprinter.go b/internal/fingerprint/fingerprinter.go index a20068ec13..9adfd42dcd 100644 --- a/internal/fingerprint/fingerprinter.go +++ b/internal/fingerprint/fingerprinter.go @@ -12,9 +12,7 @@ type ( FingerprinterOption func(*Fingerprinter) // A Fingerprinter answers whether a task is up-to-date. It owns the - // resolution of the fingerprinting method (the task's method, falling back - // to the default) and the construction of the underlying checkers, so that - // every caller gets the same answer for the same task. + // resolution of the fingerprinting method and the checkers behind it. Fingerprinter struct { defaultMethod string tempDir string @@ -69,12 +67,9 @@ func (f *Fingerprinter) resolveMethod(t *ast.Task) string { return f.defaultMethod } -// Kind returns the kind of fingerprint variable ("checksum", "timestamp" or -// "none") produced by the method resolved for the given task. It is the only -// entry point that tolerates an invalid method — it reports it as "checksum" -// so that merely naming a variable cannot fail; the method is validated by -// [Fingerprinter.SourceValue] and [Fingerprinter.UpToDate], on the paths that -// actually need a checker. +// 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() @@ -87,12 +82,8 @@ func (f *Fingerprinter) Kind(t *ast.Task) string { } } -// SourceValue returns the value of the fingerprint variable (CHECKSUM or -// TIMESTAMP) for the given task. It resolves the checker from the method -// itself, not from [Fingerprinter.Kind], so an invalid method is rejected here -// rather than silently fingerprinted as a checksum. It is potentially -// expensive, so callers should only invoke it when the task actually -// references the variable. +// 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 { @@ -131,7 +122,6 @@ func (f *Fingerprinter) UpToDate(ctx context.Context, t *ast.Task) (bool, error) statusIsSet := len(t.Status) != 0 sourcesIsSet := len(t.Sources) != 0 - // If status is set, check if it is up-to-date if statusIsSet { statusUpToDate, err = statusChecker.IsUpToDate(ctx, t) if err != nil { @@ -139,7 +129,6 @@ func (f *Fingerprinter) UpToDate(ctx context.Context, t *ast.Task) (bool, error) } } - // If sources is set, check if they are up-to-date if sourcesIsSet { sourcesUpToDate, err = sourcesChecker.IsUpToDate(t) if err != nil { @@ -147,23 +136,15 @@ func (f *Fingerprinter) UpToDate(ctx context.Context, t *ast.Task) (bool, error) } } - // If both status and sources are set, the task is up-to-date if both are up-to-date if statusIsSet && sourcesIsSet { return statusUpToDate && sourcesUpToDate, nil } - - // If only status is set, the task is up-to-date if the status is up-to-date if statusIsSet { return statusUpToDate, nil } - - // If only sources is set, the task is up-to-date if the sources are up-to-date if sourcesIsSet { return sourcesUpToDate, nil } - - // If no status or sources are set, the task should always run - // i.e. it is never considered "up-to-date" return false, nil } @@ -177,9 +158,7 @@ func (f *Fingerprinter) OnError(t *ast.Task) error { return sourcesChecker.OnError(t) } -// resolveSourcesChecker is the single place where a task is mapped to a -// [SourcesCheckable], so that every entry point of the [Fingerprinter] agrees -// on the checker a given task gets. +// 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 From 879eb5578886fe61032d99b34aaea855e00daba2 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 9 Aug 2026 22:13:42 +0200 Subject: [PATCH 5/7] refactor: keep the up-to-date truth table with the test that walks it The table restated the if-chain right below it in the godoc; it belongs where it is exercised. --- internal/fingerprint/fingerprinter.go | 14 +------------- internal/fingerprint/fingerprinter_test.go | 14 +++++++++++++- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/fingerprint/fingerprinter.go b/internal/fingerprint/fingerprinter.go index 9adfd42dcd..9076ce7fc9 100644 --- a/internal/fingerprint/fingerprinter.go +++ b/internal/fingerprint/fingerprinter.go @@ -93,19 +93,7 @@ func (f *Fingerprinter) SourceValue(t *ast.Task) (any, error) { } // UpToDate reports whether the given task is up-to-date, considering both its -// status commands and its sources. -// -// | Status up-to-date | Sources up-to-date | Task is up-to-date | -// | ----------------- | ------------------ | ------------------ | -// | not set | not set | false | -// | not set | true | true | -// | not set | false | false | -// | true | not set | true | -// | true | true | true | -// | true | false | false | -// | false | not set | false | -// | false | true | false | -// | false | false | false | +// status commands and its sources. A task that declares neither never is. func (f *Fingerprinter) UpToDate(ctx context.Context, t *ast.Task) (bool, error) { var statusUpToDate bool var sourcesUpToDate bool diff --git a/internal/fingerprint/fingerprinter_test.go b/internal/fingerprint/fingerprinter_test.go index c631912e0c..c38ff8c253 100644 --- a/internal/fingerprint/fingerprinter_test.go +++ b/internal/fingerprint/fingerprinter_test.go @@ -13,7 +13,19 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) -// The cases below walk the truth table documented on [Fingerprinter.UpToDate]. +// TruthTable +// +// | Status up-to-date | Sources up-to-date | Task is up-to-date | +// | ----------------- | ------------------ | ------------------ | +// | not set | not set | false | +// | not set | true | true | +// | not set | false | false | +// | true | not set | true | +// | true | true | true | +// | true | false | false | +// | false | not set | false | +// | false | true | false | +// | false | false | false | func TestFingerprinterUpToDate(t *testing.T) { t.Parallel() From a9d5712862f63b52a2a1e4817e03c314a1b132c0 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 9 Aug 2026 22:18:36 +0200 Subject: [PATCH 6/7] fix: don't fail compilation over an invalid method Kind tolerates an invalid method so that naming a variable cannot fail, but SourceValue then rejected it, so a task referencing {{.CHECKSUM}} with a typo'd method failed to compile: --force, which skips fingerprinting entirely, stopped working, and the error lost the task name and its 201 exit code on every other path. Mark it with ErrInvalidMethod so compiledTask can skip the injection without swallowing a checker that genuinely failed on the sources, and leave the reporting to the up-to-date check, as before the refactor. --- internal/fingerprint/fingerprinter_test.go | 5 ++++- internal/fingerprint/sources.go | 13 +++++++++++-- task_test.go | 15 +++++++++++++-- testdata/method_invalid/Taskfile.yml | 2 +- variables.go | 15 ++++++++++----- 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/internal/fingerprint/fingerprinter_test.go b/internal/fingerprint/fingerprinter_test.go index c38ff8c253..3c268284cd 100644 --- a/internal/fingerprint/fingerprinter_test.go +++ b/internal/fingerprint/fingerprinter_test.go @@ -239,7 +239,9 @@ func TestFingerprinterMethodResolution(t *testing.T) { } // Only the entry points that need a checker reject an invalid method; Kind -// tolerates it, so that --force runs still compile. +// tolerates it, so that --force runs still compile. The error carries +// [ErrInvalidMethod], which is how compiledTask tells it apart from a checker +// failing on the sources themselves. func TestFingerprinterInvalidMethod(t *testing.T) { t.Parallel() @@ -250,6 +252,7 @@ func TestFingerprinterInvalidMethod(t *testing.T) { 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) diff --git a/internal/fingerprint/sources.go b/internal/fingerprint/sources.go index 34d3a04bee..3afd632873 100644 --- a/internal/fingerprint/sources.go +++ b/internal/fingerprint/sources.go @@ -1,6 +1,15 @@ package fingerprint -import "fmt" +import ( + "fmt" + + "github.com/go-task/task/v3/errors" +) + +// ErrInvalidMethod marks a method name that maps to no checker. Callers that +// only need a fingerprint value can tell it 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 { @@ -11,6 +20,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) } } diff --git a/task_test.go b/task_test.go index d0d8859835..b651a14fd9 100644 --- a/task_test.go +++ b/task_test.go @@ -662,6 +662,7 @@ func TestFingerprintVarMethod(t *testing.T) { name string dir string executorOpts []task.ExecutorOption + wantErr string assertOutput func(t *testing.T, output string) }{ { @@ -688,9 +689,14 @@ func TestFingerprintVarMethod(t *testing.T) { executorOpts: []task.ExecutorOption{task.WithForce(true)}, assertOutput: func(t *testing.T, output string) { t.Helper() - assert.Contains(t, output, "hello\n") + assert.Contains(t, output, "cs=[]\n") }, }, + { + name: "an invalid method is still reported by the up-to-date check", + dir: "testdata/method_invalid", + wantErr: `task: invalid method "checksums"`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -711,7 +717,12 @@ func TestFingerprintVarMethod(t *testing.T) { e := task.NewExecutor(opts...) require.NoError(t, e.Setup()) - require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + err := e.Run(t.Context(), &task.Call{Task: "build"}) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) tt.assertOutput(t, buff.String()) }) } diff --git a/testdata/method_invalid/Taskfile.yml b/testdata/method_invalid/Taskfile.yml index b224f788f8..443290b36e 100644 --- a/testdata/method_invalid/Taskfile.yml +++ b/testdata/method_invalid/Taskfile.yml @@ -4,6 +4,6 @@ tasks: build: method: checksums # typo: not a valid method cmds: - - echo "hello" + - echo "cs=[{{.CHECKSUM}}]" sources: - ./source.txt diff --git a/variables.go b/variables.go index 00eab32783..1a30652201 100644 --- a/variables.go +++ b/variables.go @@ -212,15 +212,20 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err fingerprinter := e.fingerprinter() kind := fingerprinter.Kind(&new) if kind != "none" && origTask.ReferencesFingerprintVar(kind) { + // An invalid method must not fail compilation: --force skips + // fingerprinting altogether, and the up-to-date check reports it + // on every other path. value, err := fingerprinter.SourceValue(&new) - if err != nil { + if err != nil && !errors.Is(err, fingerprint.ErrInvalidMethod) { return nil, err } - vars.Set(strings.ToUpper(kind), ast.Var{Live: value}) + if err == nil { + vars.Set(strings.ToUpper(kind), ast.Var{Live: value}) - // Adding new variables, requires us to refresh the templaters - // cache of the the values manually - cache.ResetCache() + // Adding new variables, requires us to refresh the templaters + // cache of the the values manually + cache.ResetCache() + } } } From 5abdeaab1bfbde3d31d51922fd8e9e39ec0cfffc Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 9 Aug 2026 22:57:47 +0200 Subject: [PATCH 7/7] refactor: drop godoc that only repeats the identifier The option constructors and the FingerprinterOption alias said nothing their names don't; the remaining comments are trimmed to the part the signature doesn't carry. --- internal/fingerprint/fingerprinter.go | 15 ++++----------- internal/fingerprint/fingerprinter_test.go | 4 +--- internal/fingerprint/sources.go | 5 ++--- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/internal/fingerprint/fingerprinter.go b/internal/fingerprint/fingerprinter.go index 9076ce7fc9..280abb96e9 100644 --- a/internal/fingerprint/fingerprinter.go +++ b/internal/fingerprint/fingerprinter.go @@ -8,7 +8,6 @@ import ( ) type ( - // A FingerprinterOption is a functional option for a [Fingerprinter]. FingerprinterOption func(*Fingerprinter) // A Fingerprinter answers whether a task is up-to-date. It owns the @@ -23,24 +22,19 @@ type ( } ) -// WithStatusChecker allows a custom [StatusCheckable] to be used instead of -// the default one. func WithStatusChecker(checker StatusCheckable) FingerprinterOption { return func(f *Fingerprinter) { f.statusChecker = checker } } -// WithSourcesChecker allows a custom [SourcesCheckable] to be used instead of -// the one selected by the resolved fingerprinting method. func WithSourcesChecker(checker SourcesCheckable) FingerprinterOption { return func(f *Fingerprinter) { f.sourcesChecker = checker } } -// NewFingerprinter creates a new [Fingerprinter]. The defaultMethod is used -// for tasks that don't declare a method of their own. +// NewFingerprinter uses defaultMethod for tasks that don't declare one. func NewFingerprinter( defaultMethod string, tempDir string, @@ -92,8 +86,8 @@ func (f *Fingerprinter) SourceValue(t *ast.Task) (any, error) { return sourcesChecker.Value(t) } -// UpToDate reports whether the given task is up-to-date, considering both its -// status commands and its sources. A task that declares neither never is. +// 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 @@ -136,8 +130,7 @@ func (f *Fingerprinter) UpToDate(ctx context.Context, t *ast.Task) (bool, error) return false, nil } -// OnError gives the sources checker resolved for the given task a chance to -// clean up after a failed run. +// 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 { diff --git a/internal/fingerprint/fingerprinter_test.go b/internal/fingerprint/fingerprinter_test.go index 3c268284cd..f8451aa4e6 100644 --- a/internal/fingerprint/fingerprinter_test.go +++ b/internal/fingerprint/fingerprinter_test.go @@ -239,9 +239,7 @@ func TestFingerprinterMethodResolution(t *testing.T) { } // Only the entry points that need a checker reject an invalid method; Kind -// tolerates it, so that --force runs still compile. The error carries -// [ErrInvalidMethod], which is how compiledTask tells it apart from a checker -// failing on the sources themselves. +// tolerates it, so that --force runs still compile. func TestFingerprinterInvalidMethod(t *testing.T) { t.Parallel() diff --git a/internal/fingerprint/sources.go b/internal/fingerprint/sources.go index 3afd632873..fbc6af502c 100644 --- a/internal/fingerprint/sources.go +++ b/internal/fingerprint/sources.go @@ -6,9 +6,8 @@ import ( "github.com/go-task/task/v3/errors" ) -// ErrInvalidMethod marks a method name that maps to no checker. Callers that -// only need a fingerprint value can tell it apart from a checker failing on -// the sources themselves. +// 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) {