• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

samsarahq / taskrunner / 25516302028

07 May 2026 07:05PM UTC coverage: 40.25% (+2.0%) from 38.25%
25516302028

push

github

samloop
executor: tag OnStart/OnStop hook errors with hook index

When a hook returns an error, the run aborts with a bare error message
that doesn't say which hook failed. The cache snapshot hook hitting
exit 128 was particularly hard to trace because the surfaced error was
just "exit status 128" with no indication it came from the OnStart
phase. Wrapping with oops.Wrapf("OnStart/OnStop hook %d failed", i)
gives future failures a stack frame and a phase tag.

2 of 4 new or added lines in 1 file covered. (50.0%)

28 existing lines in 2 files now uncovered.

836 of 2077 relevant lines covered (40.25%)

3.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

34.88
/cache/cache.go
1
package cache
2

3
import (
4
        "context"
5
        "fmt"
6
        "log"
7
        "os"
8
        "path"
9
        "strings"
10
        "sync"
11

12
        "github.com/samsarahq/taskrunner"
13
        "github.com/samsarahq/taskrunner/shell"
14
)
15

16
const CachePath = ".cache/taskrunner"
17

18
var CacheDir = path.Join(os.Getenv("HOME"), CachePath)
19

20
type Cache struct {
21
        ranOnce      map[*taskrunner.Task]bool
22
        snapshotter  *snapshotter
23
        cacheFile    string
24
        allDirty     bool
25
        dirtyFiles   []string
26
        ranOnceMutex sync.Mutex
27

28
        opts []shell.RunOption
29
}
30

31
// Ignore ignores the cache (useful for conditionally bypassing the cache).
32
func (c *Cache) Ignore() { c.allDirty = true }
×
33

34
func New(opts ...shell.RunOption) *Cache {
2✔
35
        return &Cache{
2✔
36
                ranOnce: make(map[*taskrunner.Task]bool),
2✔
37
                opts:    opts,
2✔
38
        }
2✔
39
}
2✔
40

41
func (c *Cache) Option(r *taskrunner.Runtime) {
×
42
        r.OnStart(func(ctx context.Context, executor *taskrunner.Executor) error {
×
43
                c.cacheFile = getCacheFilePath(executor.Config().WorkingDir)
×
44
                return c.Start(ctx)
×
45
        })
×
46
        r.OnStop(func(ctx context.Context, executor *taskrunner.Executor) error {
×
47
                return c.Finish(ctx)
×
48
        })
×
49
}
50

51
func getCacheFilePath(dir string) string {
×
52
        hashedName := strings.Replace(dir, "/", "%", -1)
×
53
        return path.Join(CacheDir, hashedName)
×
54
}
×
55

56
func (c *Cache) Start(ctx context.Context) error {
1✔
57
        if c.snapshotter == nil {
1✔
58
                c.snapshotter = newSnapshotter(
×
59
                        func(ctx context.Context, command string, opts ...shell.RunOption) error {
×
60
                                return shell.Run(ctx, command, append(opts, c.opts...)...)
×
UNCOV
61
                        },
×
62
                )
63
        }
64

65
        s, err := c.snapshotter.Read(c.cacheFile)
1✔
66
        // If we can't get a cache file, assume that everything is dirty and needs to be re-run.
1✔
67
        if err != nil {
2✔
68
                c.allDirty = true
1✔
69
                s = &snapshot{}
1✔
70
        }
1✔
71

72
        // Truncate the snapshot after we read it in order to prevent a stale cache, should taskrunner
73
        // be terminated unexpectedly.
74
        _ = os.Truncate(c.cacheFile, 0)
1✔
75

1✔
76
        files, err := c.snapshotter.Diff(ctx, s)
1✔
77
        if err != nil {
2✔
78
                // Don't fatal on transient git failures; fall back to allDirty so the run makes forward progress.
1✔
79
                log.Printf("Warning: cache diff failed (%v), treating all files as dirty", err)
1✔
80
                c.allDirty = true
1✔
81
                return nil
1✔
82
        }
1✔
UNCOV
83
        c.dirtyFiles = files
×
84

×
85
        return nil
×
86
}
87

88
// Finish creates and saves the cache state.
89
func (c *Cache) Finish(ctx context.Context) error {
1✔
90
        if err := os.MkdirAll(CacheDir, os.ModePerm); err != nil {
1✔
91
                return err
×
92
        }
×
93
        if err := c.snapshotter.Write(ctx, c.cacheFile); err != nil {
2✔
94
                // Don't fatal here either; the next run just won't benefit from caching.
1✔
95
                log.Printf("Warning: cache write failed (%v), next run will not benefit from caching", err)
1✔
96
                return nil
1✔
97
        }
1✔
UNCOV
98
        return nil
×
99
}
100

101
func (c *Cache) isFirstRun(task *taskrunner.Task) bool {
×
102
        c.ranOnceMutex.Lock()
×
103
        defer c.ranOnceMutex.Unlock()
×
104
        ran := c.ranOnce[task]
×
105
        c.ranOnce[task] = true
×
106
        return !ran
×
UNCOV
107
}
×
108

UNCOV
109
func (c *Cache) isValid(task *taskrunner.Task) bool {
×
UNCOV
110
        if c.allDirty {
×
111
                return false
×
112
        }
×
113
        for _, f := range c.dirtyFiles {
×
114
                if taskrunner.IsTaskSource(task, f) {
×
115
                        return false
×
116
                }
×
117
        }
118
        return true
×
119
}
120

121
func (c *Cache) maybeRun(task *taskrunner.Task) func(context.Context, shell.ShellRun) error {
×
UNCOV
122
        return func(ctx context.Context, shellRun shell.ShellRun) error {
×
UNCOV
123
                if c.isFirstRun(task) && c.isValid(task) {
×
UNCOV
124
                        // report that the task wasn't run
×
UNCOV
125
                        logger := taskrunner.LoggerFromContext(ctx)
×
UNCOV
126
                        if logger != nil {
×
127
                                fmt.Fprintln(logger.Stdout, "no changes (cache)")
×
128
                                return nil
×
129
                        }
×
130
                }
131
                return task.Run(ctx, shellRun)
×
132
        }
133
}
134

135
// WrapWithPersistentCache prevents the task from being invalidated between runs if the files it
136
// depends on don't change.
UNCOV
137
func (c *Cache) WrapWithPersistentCache(task *taskrunner.Task) *taskrunner.Task {
×
UNCOV
138
        if len(task.Sources) == 0 {
×
UNCOV
139
                log.Fatalf("Task %s cannot be wrapped with a persistent cache as it has no sources", task.Name)
×
UNCOV
140
        }
×
UNCOV
141
        newTask := *task
×
UNCOV
142
        newTask.Run = c.maybeRun(task)
×
UNCOV
143
        return &newTask
×
144
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc