From 47a73d6ec97db232b0bda2ac1625317826ebb949 Mon Sep 17 00:00:00 2001 From: Tide Date: Sun, 25 Oct 2026 22:28:07 +0800 Subject: [PATCH] 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"