From 276fbc6b9e84e04f64549a247864a0d9b6f4c9be Mon Sep 17 00:00:00 2001 From: Doug Richardson Date: Wed, 4 Mar 2026 17:54:30 -0800 Subject: [PATCH 1/3] fix: re-run task when generated files are missing with method: timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using method: timestamp, deleting a generated file (e.g. after a clean) caused the task to be incorrectly skipped. The timestamp sentinel file at .task/timestamp/ was still present from the previous run, so TimestampChecker compared source timestamps against the sentinel alone and found nothing newer—never noticing that the actual outputs were gone. Fix by adding the same guard that ChecksumChecker already uses: before the timestamp comparison, iterate the declared generates patterns and return false (not up to date) if any pattern matches zero files. Fixes #1230 Assisted by AI Co-Authored-By: Claude --- internal/fingerprint/sources_timestamp.go | 21 ++++++++++ task_test.go | 49 +++++++++++++++++++++++ testdata/timestamp/.gitignore | 2 + testdata/timestamp/Taskfile.yml | 11 +++++ testdata/timestamp/source.txt | 1 + 5 files changed, 84 insertions(+) create mode 100644 testdata/timestamp/.gitignore create mode 100644 testdata/timestamp/Taskfile.yml create mode 100644 testdata/timestamp/source.txt diff --git a/internal/fingerprint/sources_timestamp.go b/internal/fingerprint/sources_timestamp.go index b1a6f299d5..ca43a6d0d3 100644 --- a/internal/fingerprint/sources_timestamp.go +++ b/internal/fingerprint/sources_timestamp.go @@ -32,6 +32,27 @@ func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) { if err != nil { return false, nil } + + // If generates are declared, ensure they all exist. A missing generated + // file means the task must run regardless of timestamps. + if len(t.Generates) > 0 { + for _, g := range t.Generates { + if g.Negate { + continue + } + files, err := glob(t.Dir, g.Glob) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if len(files) == 0 { + return false, nil + } + } + } + generates, err := Globs(t.Dir, t.Generates) if err != nil { return false, nil diff --git a/task_test.go b/task_test.go index 9d54af9740..3ed810044b 100644 --- a/task_test.go +++ b/task_test.go @@ -487,6 +487,55 @@ func TestStatusChecksum(t *testing.T) { // nolint:paralleltest // cannot run in } } +// TestStatusTimestamp is a regression test for https://github.com/go-task/task/issues/1230. +// When using method: timestamp, deleting a generated file should cause the task to re-run, +// not be skipped because the timestamp file is still present. +func TestStatusTimestamp(t *testing.T) { // nolint:paralleltest // cannot run in parallel + const dir = "testdata/timestamp" + + generatedFile := filepathext.SmartJoin(dir, "generated.txt") + tempDir := task.TempDir{ + Remote: filepathext.SmartJoin(dir, ".task"), + Fingerprint: filepathext.SmartJoin(dir, ".task"), + } + + // Clean up any state from previous runs. + _ = os.Remove(generatedFile) + _ = os.RemoveAll(filepathext.SmartJoin(dir, ".task")) + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithTempDir(tempDir), + ) + require.NoError(t, e.Setup()) + + // First run: task should execute and create generated.txt. + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + _, err := os.Stat(generatedFile) + require.NoError(t, err, "generated.txt should exist after first run") + buff.Reset() + + // Second run: task should be up to date. + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + assert.Equal(t, `task: Task "build" is up to date`+"\n", buff.String()) + buff.Reset() + + // Delete the generated file (simulate a clean), but leave the timestamp file. + require.NoError(t, os.Remove(generatedFile)) + _, err = os.Stat(generatedFile) + require.Error(t, err, "generated.txt should be gone") + + // Third run: task MUST re-run because generated.txt is missing. + // This is the regression: previously the task was incorrectly skipped. + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + assert.NotContains(t, buff.String(), "is up to date", "task should re-run when generated file is missing") + _, err = os.Stat(generatedFile) + require.NoError(t, err, "generated.txt should be recreated after third run") +} + func TestStatusVariables(t *testing.T) { t.Parallel() diff --git a/testdata/timestamp/.gitignore b/testdata/timestamp/.gitignore new file mode 100644 index 0000000000..8443a36988 --- /dev/null +++ b/testdata/timestamp/.gitignore @@ -0,0 +1,2 @@ +.task +generated.txt diff --git a/testdata/timestamp/Taskfile.yml b/testdata/timestamp/Taskfile.yml new file mode 100644 index 0000000000..450ed56021 --- /dev/null +++ b/testdata/timestamp/Taskfile.yml @@ -0,0 +1,11 @@ +version: '3' + +tasks: + build: + cmds: + - cp ./source.txt ./generated.txt + sources: + - ./source.txt + generates: + - ./generated.txt + method: timestamp diff --git a/testdata/timestamp/source.txt b/testdata/timestamp/source.txt new file mode 100644 index 0000000000..3a4e1f3cac --- /dev/null +++ b/testdata/timestamp/source.txt @@ -0,0 +1 @@ +hello from source From 382729c29856b9f3d9f3da1dc90300a3741c1ab3 Mon Sep 17 00:00:00 2001 From: Doug Richardson Date: Fri, 6 Mar 2026 18:37:31 -0800 Subject: [PATCH 2/3] test: add regression test for checksum method with missing generated file Add TestStatusChecksumMissingGenerated to verify that ChecksumChecker also re-runs the task when a generated file is deleted after the checksum file is established, mirroring the coverage added for TimestampChecker. Assisted by AI Co-Authored-By: Claude --- task_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/task_test.go b/task_test.go index 3ed810044b..3b85f764ec 100644 --- a/task_test.go +++ b/task_test.go @@ -536,6 +536,55 @@ func TestStatusTimestamp(t *testing.T) { // nolint:paralleltest // cannot run in require.NoError(t, err, "generated.txt should be recreated after third run") } +// TestStatusChecksumMissingGenerated is a regression test for https://github.com/go-task/task/issues/1230. +// When using method: checksum, deleting a generated file should cause the task to re-run, +// not be skipped because the checksum file still matches. +func TestStatusChecksumMissingGenerated(t *testing.T) { // nolint:paralleltest // cannot run in parallel + const dir = "testdata/checksum" + + generatedFile := filepathext.SmartJoin(dir, "generated.txt") + tempDir := task.TempDir{ + Remote: filepathext.SmartJoin(dir, ".task"), + Fingerprint: filepathext.SmartJoin(dir, ".task"), + } + + // Clean up any state from previous runs. + _ = os.Remove(generatedFile) + _ = os.RemoveAll(filepathext.SmartJoin(dir, ".task")) + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithTempDir(tempDir), + ) + require.NoError(t, e.Setup()) + + // First run: task should execute and create generated.txt. + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + _, err := os.Stat(generatedFile) + require.NoError(t, err, "generated.txt should exist after first run") + buff.Reset() + + // Second run: task should be up to date. + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + assert.Equal(t, `task: Task "build" is up to date`+"\n", buff.String()) + buff.Reset() + + // Delete the generated file (simulate a clean), but leave the checksum file. + require.NoError(t, os.Remove(generatedFile)) + _, err = os.Stat(generatedFile) + require.Error(t, err, "generated.txt should be gone") + + // Third run: task MUST re-run because generated.txt is missing. + // This is the regression: previously the task was incorrectly skipped. + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + assert.NotContains(t, buff.String(), "is up to date", "task should re-run when generated file is missing") + _, err = os.Stat(generatedFile) + require.NoError(t, err, "generated.txt should be recreated after third run") +} + func TestStatusVariables(t *testing.T) { t.Parallel() From bc2973712551421cfab0056b25eb86787dbf7c54 Mon Sep 17 00:00:00 2001 From: Doug Richardson Date: Sat, 7 Mar 2026 13:20:00 -0800 Subject: [PATCH 3/3] docs: clarify why negated generates globs are skipped in existence check Assisted by AI Co-Authored-By: Claude --- internal/fingerprint/sources_checksum.go | 1 + internal/fingerprint/sources_timestamp.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/fingerprint/sources_checksum.go b/internal/fingerprint/sources_checksum.go index 16a98c1640..f1108e111c 100644 --- a/internal/fingerprint/sources_checksum.go +++ b/internal/fingerprint/sources_checksum.go @@ -53,6 +53,7 @@ func (checker *ChecksumChecker) IsUpToDate(t *ast.Task) (bool, error) { if len(t.Generates) > 0 { // For each specified 'generates' field, check whether the files actually exist for _, g := range t.Generates { + // Exclusion patterns don't represent output files; skip them. if g.Negate { continue } diff --git a/internal/fingerprint/sources_timestamp.go b/internal/fingerprint/sources_timestamp.go index ca43a6d0d3..258d9386d9 100644 --- a/internal/fingerprint/sources_timestamp.go +++ b/internal/fingerprint/sources_timestamp.go @@ -37,6 +37,7 @@ func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) { // file means the task must run regardless of timestamps. if len(t.Generates) > 0 { for _, g := range t.Generates { + // Exclusion patterns don't represent output files; skip them. if g.Negate { continue }