From 47a73d6ec97db232b0bda2ac1625317826ebb949 Mon Sep 17 00:00:00 2001 From: Tide Date: Sun, 25 Oct 2026 22:28:07 +0800 Subject: [PATCH 1/3] fix: skip optional remote includes on offline cache miss --- task_test.go | 38 +++++++++++++++++++ taskfile/reader.go | 20 ++++++++++ .../Taskfile.yml | 10 +++++ .../Taskfile.yml | 11 ++++++ 4 files changed, 79 insertions(+) create mode 100644 testdata/includes_remote_nonoptional_offline/Taskfile.yml create mode 100644 testdata/includes_remote_optional_offline/Taskfile.yml diff --git a/task_test.go b/task_test.go index 540e7ba8de..e08ff940cc 100644 --- a/task_test.go +++ b/task_test.go @@ -1323,6 +1323,44 @@ func TestIncludesRemote(t *testing.T) { } } +func TestIncludesRemoteOptionalOfflineCacheMiss(t *testing.T) { + t.Parallel() + + t.Run("optional include is skipped", func(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/includes_remote_optional_offline"), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithOffline(true), + task.WithRemoteCacheDir(t.TempDir()), + ) + + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "local"})) + assert.Contains(t, buff.String(), "Hello from local") + }) + + t.Run("non-optional include still fails", func(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/includes_remote_nonoptional_offline"), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithOffline(true), + task.WithRemoteCacheDir(t.TempDir()), + ) + + err := e.Setup() + require.Error(t, err) + assert.Contains(t, err.Error(), "was not found in the cache") + }) +} + func TestIncludeCycle(t *testing.T) { t.Parallel() diff --git a/taskfile/reader.go b/taskfile/reader.go index d84abc80e1..cc5fbb5b88 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -213,6 +213,26 @@ func (r *Reader) include(ctx context.Context, node Node) error { // Recurse into the included Taskfile if err := r.include(ctx, includeNode); err != nil { + // An optional include that cannot be resolved from + // the offline cache is skipped instead of failing + // the run. Other errors (and non-optional includes) + // still fail. + if include.Optional { + if _, ok := errors.AsType[*errors.TaskfileCacheNotFoundError](err); ok { + // The failed read already added a vertex for + // the included Taskfile; remove it so the + // merge does not pick up a vertex with a nil + // Taskfile. If another include already + // attached an edge to it, fail instead of + // leaving a dangling vertex behind. + r.graph.Lock() + removeErr := r.graph.RemoveVertex(includeNode.Location()) + r.graph.Unlock() + if removeErr == nil { + return nil + } + } + } return err } diff --git a/testdata/includes_remote_nonoptional_offline/Taskfile.yml b/testdata/includes_remote_nonoptional_offline/Taskfile.yml new file mode 100644 index 0000000000..ab5b0940f1 --- /dev/null +++ b/testdata/includes_remote_nonoptional_offline/Taskfile.yml @@ -0,0 +1,10 @@ +version: "3" + +includes: + remote: + taskfile: https://github.com/go-task/task.git//Taskfile.yml + +tasks: + local: + cmds: + - echo "Hello from local" diff --git a/testdata/includes_remote_optional_offline/Taskfile.yml b/testdata/includes_remote_optional_offline/Taskfile.yml new file mode 100644 index 0000000000..ce5bab9624 --- /dev/null +++ b/testdata/includes_remote_optional_offline/Taskfile.yml @@ -0,0 +1,11 @@ +version: "3" + +includes: + remote: + taskfile: https://github.com/go-task/task.git//Taskfile.yml + optional: true + +tasks: + local: + cmds: + - echo "Hello from local" From e2e82dcdbe33c4b91f4d8a0209ce8a1f99f2301a Mon Sep 17 00:00:00 2001 From: Tidehyc <186134940+Tidehyc@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:10:40 +0800 Subject: [PATCH 2/3] fix: pre-check remote cache before recursing into optional includes --- task_test.go | 40 +++++++++++++++++++ taskfile/reader.go | 33 ++++++--------- .../Taskfile.yml | 11 +++++ .../opt.yml | 10 +++++ .../Taskfile.yml | 10 +++++ .../local.yml | 11 +++++ 6 files changed, 95 insertions(+), 20 deletions(-) create mode 100644 testdata/includes_remote_nested_optional_offline/Taskfile.yml create mode 100644 testdata/includes_remote_nested_optional_offline/opt.yml create mode 100644 testdata/includes_remote_nested_optional_offline_skip/Taskfile.yml create mode 100644 testdata/includes_remote_nested_optional_offline_skip/local.yml diff --git a/task_test.go b/task_test.go index e08ff940cc..a81155f444 100644 --- a/task_test.go +++ b/task_test.go @@ -1359,6 +1359,46 @@ func TestIncludesRemoteOptionalOfflineCacheMiss(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "was not found in the cache") }) + + t.Run("non-optional nested include still fails", func(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/includes_remote_nested_optional_offline"), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithOffline(true), + task.WithRemoteCacheDir(t.TempDir()), + ) + + // The nested remote include is not optional, so its offline cache + // miss must fail the run even though the parent include is + // optional: it must not be silently skipped. + err := e.Setup() + require.Error(t, err) + assert.Contains(t, err.Error(), "was not found in the cache") + }) + + t.Run("nested optional include is skipped", func(t *testing.T) { + t.Parallel() + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir("testdata/includes_remote_nested_optional_offline_skip"), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithOffline(true), + task.WithRemoteCacheDir(t.TempDir()), + ) + + // The symmetric case: an optional remote include nested inside a + // local include is still skipped on an offline cache miss, and + // the parent Taskfile keeps working. + require.NoError(t, e.Setup()) + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "local"})) + assert.Contains(t, buff.String(), "Hello from local") + }) } func TestIncludeCycle(t *testing.T) { diff --git a/taskfile/reader.go b/taskfile/reader.go index cc5fbb5b88..e3a84e2ec2 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -211,28 +211,21 @@ func (r *Reader) include(ctx context.Context, node Node) error { return err } - // Recurse into the included Taskfile - if err := r.include(ctx, includeNode); err != nil { - // An optional include that cannot be resolved from - // the offline cache is skipped instead of failing - // the run. Other errors (and non-optional includes) - // still fail. - if include.Optional { - if _, ok := errors.AsType[*errors.TaskfileCacheNotFoundError](err); ok { - // The failed read already added a vertex for - // the included Taskfile; remove it so the - // merge does not pick up a vertex with a nil - // Taskfile. If another include already - // attached an edge to it, fail instead of - // leaving a dangling vertex behind. - r.graph.Lock() - removeErr := r.graph.RemoveVertex(includeNode.Location()) - r.graph.Unlock() - if removeErr == nil { - return nil - } + // In offline mode, an optional remote include with no cached + // copy is skipped. The cache is checked up front so a cache + // miss in a nested include cannot be mistaken for this + // include's own miss, and the graph is never touched for a + // skipped include. + if include.Optional && r.offline { + if remote, ok := includeNode.(RemoteNode); ok { + if _, err := NewCacheNode(remote, r.tempDir).Read(); errors.Is(err, os.ErrNotExist) { + return nil } } + } + + // Recurse into the included Taskfile + if err := r.include(ctx, includeNode); err != nil { return err } diff --git a/testdata/includes_remote_nested_optional_offline/Taskfile.yml b/testdata/includes_remote_nested_optional_offline/Taskfile.yml new file mode 100644 index 0000000000..5f279751c8 --- /dev/null +++ b/testdata/includes_remote_nested_optional_offline/Taskfile.yml @@ -0,0 +1,11 @@ +version: "3" + +includes: + opt: + taskfile: ./opt.yml + optional: true + +tasks: + local: + cmds: + - echo "Hello from local" diff --git a/testdata/includes_remote_nested_optional_offline/opt.yml b/testdata/includes_remote_nested_optional_offline/opt.yml new file mode 100644 index 0000000000..9b2e6d92c1 --- /dev/null +++ b/testdata/includes_remote_nested_optional_offline/opt.yml @@ -0,0 +1,10 @@ +version: "3" + +includes: + remote: + taskfile: https://github.com/go-task/task.git//Taskfile.yml + +tasks: + nested: + cmds: + - echo "Hello from nested" diff --git a/testdata/includes_remote_nested_optional_offline_skip/Taskfile.yml b/testdata/includes_remote_nested_optional_offline_skip/Taskfile.yml new file mode 100644 index 0000000000..17c2b7266e --- /dev/null +++ b/testdata/includes_remote_nested_optional_offline_skip/Taskfile.yml @@ -0,0 +1,10 @@ +version: "3" + +includes: + local: + taskfile: ./local.yml + +tasks: + local: + cmds: + - echo "Hello from local" diff --git a/testdata/includes_remote_nested_optional_offline_skip/local.yml b/testdata/includes_remote_nested_optional_offline_skip/local.yml new file mode 100644 index 0000000000..9d0725a552 --- /dev/null +++ b/testdata/includes_remote_nested_optional_offline_skip/local.yml @@ -0,0 +1,11 @@ +version: "3" + +includes: + remote: + taskfile: https://github.com/go-task/task.git//Taskfile.yml + optional: true + +tasks: + nested: + cmds: + - echo "Hello from nested" From 200bbe138d9032c9cfbf609f39c803be32d2faef Mon Sep 17 00:00:00 2001 From: Tidehyc <186134940+Tidehyc@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:29:10 +0800 Subject: [PATCH 3/3] test: table-drive offline cache-miss tests and drop redundant nested case Address review feedback: convert TestIncludesRemoteOptionalOfflineCacheMiss to a table-driven test (dir, wantErr), drop the nested optional include case that shares the same code path along with its testdata, shorten the reader comment to one line, and rename the nested testdata dir to includes_remote_optional_parent_offline. --- task_test.go | 112 +++++++----------- taskfile/reader.go | 6 +- .../Taskfile.yml | 10 -- .../local.yml | 11 -- .../Taskfile.yml | 0 .../opt.yml | 0 6 files changed, 43 insertions(+), 96 deletions(-) delete mode 100644 testdata/includes_remote_nested_optional_offline_skip/Taskfile.yml delete mode 100644 testdata/includes_remote_nested_optional_offline_skip/local.yml rename testdata/{includes_remote_nested_optional_offline => includes_remote_optional_parent_offline}/Taskfile.yml (100%) rename testdata/{includes_remote_nested_optional_offline => includes_remote_optional_parent_offline}/opt.yml (100%) diff --git a/task_test.go b/task_test.go index a81155f444..8759bb351a 100644 --- a/task_test.go +++ b/task_test.go @@ -1326,79 +1326,51 @@ func TestIncludesRemote(t *testing.T) { func TestIncludesRemoteOptionalOfflineCacheMiss(t *testing.T) { t.Parallel() - t.Run("optional include is skipped", func(t *testing.T) { - t.Parallel() - - var buff bytes.Buffer - e := task.NewExecutor( - task.WithDir("testdata/includes_remote_optional_offline"), - task.WithStdout(&buff), - task.WithStderr(&buff), - task.WithOffline(true), - task.WithRemoteCacheDir(t.TempDir()), - ) - - require.NoError(t, e.Setup()) - require.NoError(t, e.Run(t.Context(), &task.Call{Task: "local"})) - assert.Contains(t, buff.String(), "Hello from local") - }) - - t.Run("non-optional include still fails", func(t *testing.T) { - t.Parallel() - - var buff bytes.Buffer - e := task.NewExecutor( - task.WithDir("testdata/includes_remote_nonoptional_offline"), - task.WithStdout(&buff), - task.WithStderr(&buff), - task.WithOffline(true), - task.WithRemoteCacheDir(t.TempDir()), - ) - - err := e.Setup() - require.Error(t, err) - assert.Contains(t, err.Error(), "was not found in the cache") - }) - - t.Run("non-optional nested include still fails", func(t *testing.T) { - t.Parallel() - - var buff bytes.Buffer - e := task.NewExecutor( - task.WithDir("testdata/includes_remote_nested_optional_offline"), - task.WithStdout(&buff), - task.WithStderr(&buff), - task.WithOffline(true), - task.WithRemoteCacheDir(t.TempDir()), - ) - - // The nested remote include is not optional, so its offline cache - // miss must fail the run even though the parent include is - // optional: it must not be silently skipped. - err := e.Setup() - require.Error(t, err) - assert.Contains(t, err.Error(), "was not found in the cache") - }) + tests := []struct { + name string + dir string + wantErr string + }{ + { + name: "optional include is skipped", + dir: "testdata/includes_remote_optional_offline", + }, + { + name: "non-optional include still fails", + dir: "testdata/includes_remote_nonoptional_offline", + wantErr: "was not found in the cache", + }, + { + name: "non-optional nested include still fails", + dir: "testdata/includes_remote_optional_parent_offline", + wantErr: "was not found in the cache", + }, + } - t.Run("nested optional include is skipped", func(t *testing.T) { - t.Parallel() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - var buff bytes.Buffer - e := task.NewExecutor( - task.WithDir("testdata/includes_remote_nested_optional_offline_skip"), - task.WithStdout(&buff), - task.WithStderr(&buff), - task.WithOffline(true), - task.WithRemoteCacheDir(t.TempDir()), - ) + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(tt.dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithOffline(true), + task.WithRemoteCacheDir(t.TempDir()), + ) - // The symmetric case: an optional remote include nested inside a - // local include is still skipped on an offline cache miss, and - // the parent Taskfile keeps working. - require.NoError(t, e.Setup()) - require.NoError(t, e.Run(t.Context(), &task.Call{Task: "local"})) - assert.Contains(t, buff.String(), "Hello from local") - }) + err := e.Setup() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "local"})) + assert.Contains(t, buff.String(), "Hello from local") + }) + } } func TestIncludeCycle(t *testing.T) { diff --git a/taskfile/reader.go b/taskfile/reader.go index e3a84e2ec2..da0148609a 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -211,11 +211,7 @@ func (r *Reader) include(ctx context.Context, node Node) error { return err } - // In offline mode, an optional remote include with no cached - // copy is skipped. The cache is checked up front so a cache - // miss in a nested include cannot be mistaken for this - // include's own miss, and the graph is never touched for a - // skipped include. + // Checked before recursing so a nested include's cache miss isn't mistaken for this one's. if include.Optional && r.offline { if remote, ok := includeNode.(RemoteNode); ok { if _, err := NewCacheNode(remote, r.tempDir).Read(); errors.Is(err, os.ErrNotExist) { diff --git a/testdata/includes_remote_nested_optional_offline_skip/Taskfile.yml b/testdata/includes_remote_nested_optional_offline_skip/Taskfile.yml deleted file mode 100644 index 17c2b7266e..0000000000 --- a/testdata/includes_remote_nested_optional_offline_skip/Taskfile.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: "3" - -includes: - local: - taskfile: ./local.yml - -tasks: - local: - cmds: - - echo "Hello from local" diff --git a/testdata/includes_remote_nested_optional_offline_skip/local.yml b/testdata/includes_remote_nested_optional_offline_skip/local.yml deleted file mode 100644 index 9d0725a552..0000000000 --- a/testdata/includes_remote_nested_optional_offline_skip/local.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: "3" - -includes: - remote: - taskfile: https://github.com/go-task/task.git//Taskfile.yml - optional: true - -tasks: - nested: - cmds: - - echo "Hello from nested" diff --git a/testdata/includes_remote_nested_optional_offline/Taskfile.yml b/testdata/includes_remote_optional_parent_offline/Taskfile.yml similarity index 100% rename from testdata/includes_remote_nested_optional_offline/Taskfile.yml rename to testdata/includes_remote_optional_parent_offline/Taskfile.yml diff --git a/testdata/includes_remote_nested_optional_offline/opt.yml b/testdata/includes_remote_optional_parent_offline/opt.yml similarity index 100% rename from testdata/includes_remote_nested_optional_offline/opt.yml rename to testdata/includes_remote_optional_parent_offline/opt.yml