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

evilmartians / lefthook / 8504903245

01 Apr 2024 06:45AM UTC coverage: 81.325% (+3.4%) from 77.962%
8504903245

Pull #684

github

web-flow
Merge e09d0a9fa into 599459eb8
Pull Request #684: feat: add priorities to scripts

37 of 39 new or added lines in 3 files covered. (94.87%)

2 existing lines in 2 files now uncovered.

2565 of 3154 relevant lines covered (81.33%)

7.07 hits per line

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

95.91
/internal/lefthook/run/runner.go
1
package run
2

3
import (
4
        "bytes"
5
        "context"
6
        "errors"
7
        "fmt"
8
        "io"
9
        "os"
10
        "path/filepath"
11
        "regexp"
12
        "slices"
13
        "sort"
14
        "strconv"
15
        "strings"
16
        "sync"
17
        "sync/atomic"
18
        "unicode"
19

20
        "github.com/charmbracelet/lipgloss"
21
        "github.com/spf13/afero"
22

23
        "github.com/evilmartians/lefthook/internal/config"
24
        "github.com/evilmartians/lefthook/internal/git"
25
        "github.com/evilmartians/lefthook/internal/lefthook/run/exec"
26
        "github.com/evilmartians/lefthook/internal/lefthook/run/filter"
27
        "github.com/evilmartians/lefthook/internal/log"
28
)
29

30
type status int8
31

32
const (
33
        executableFileMode os.FileMode = 0o751
34
        executableMask     os.FileMode = 0o111
35
        execLogPadding                 = 2
36
)
37

38
var surroundingQuotesRegexp = regexp.MustCompile(`^'(.*)'$`)
39

40
type Options struct {
41
        Repo            *git.Repository
42
        Hook            *config.Hook
43
        HookName        string
44
        GitArgs         []string
45
        ResultChan      chan Result
46
        LogSettings     log.Settings
47
        DisableTTY      bool
48
        Force           bool
49
        Files           []string
50
        RunOnlyCommands []string
51
}
52

53
// Runner responds for actual execution and handling the results.
54
type Runner struct {
55
        Options
56

57
        partiallyStagedFiles []string
58
        failed               atomic.Bool
59
        executor             exec.Executor
60
}
61

62
func NewRunner(opts Options) *Runner {
6✔
63
        return &Runner{
6✔
64
                Options:  opts,
6✔
65
                executor: exec.CommandExecutor{},
6✔
66
        }
6✔
67
}
6✔
68

69
// RunAll runs scripts and commands.
70
// LFS hook is executed at first if needed.
71
func (r *Runner) RunAll(ctx context.Context, sourceDirs []string) {
6✔
72
        if err := r.runLFSHook(ctx); err != nil {
6✔
73
                log.Error(err)
74
        }
75

76
        if r.Hook.DoSkip(r.Repo.State()) {
18✔
77
                r.logSkip(r.HookName, "hook setting")
12✔
78
                return
6✔
79
        }
6✔
80

81
        if !r.DisableTTY && !r.Hook.Follow {
24✔
82
                log.StartSpinner()
12✔
83
                defer log.StopSpinner()
12✔
84
        }
12✔
85

86
        scriptDirs := make([]string, 0, len(sourceDirs))
18✔
87
        for _, sourceDir := range sourceDirs {
18✔
88
                scriptDirs = append(scriptDirs, filepath.Join(
12✔
89
                        sourceDir, r.HookName,
12✔
90
                ))
6✔
91
        }
12✔
92

12✔
93
        r.preHook()
12✔
94

12✔
95
        for _, dir := range scriptDirs {
18✔
96
                r.runScripts(ctx, dir)
12✔
97
        }
6✔
98

6✔
99
        r.runCommands(ctx)
12✔
100

18✔
101
        r.postHook()
12✔
102
}
6✔
103

104
func (r *Runner) fail(name string, err error) {
11✔
105
        r.ResultChan <- resultFail(name, err.Error())
11✔
106
        r.failed.Store(true)
11✔
107
}
5✔
108

109
func (r *Runner) success(name string) {
11✔
110
        r.ResultChan <- resultSuccess(name)
11✔
111
}
11✔
112

5✔
113
func (r *Runner) runLFSHook(ctx context.Context) error {
6✔
114
        if !git.IsLFSHook(r.HookName) {
18✔
115
                return nil
12✔
116
        }
12✔
117

118
        lfsRequiredFile := filepath.Join(r.Repo.RootPath, git.LFSRequiredFile)
9✔
119
        lfsConfigFile := filepath.Join(r.Repo.RootPath, git.LFSConfigFile)
15✔
120

9✔
121
        requiredExists, err := afero.Exists(r.Repo.Fs, lfsRequiredFile)
9✔
122
        if err != nil {
3✔
123
                return err
3✔
124
        }
3✔
125
        configExists, err := afero.Exists(r.Repo.Fs, lfsConfigFile)
6✔
126
        if err != nil {
6✔
127
                return err
3✔
128
        }
×
129

130
        if git.IsLFSAvailable() {
9✔
131
                log.Debugf(
6✔
132
                        "[git-lfs] executing hook: git lfs %s %s", r.HookName, strings.Join(r.GitArgs, " "),
3✔
133
                )
3✔
134
                out := bytes.NewBuffer(make([]byte, 0))
3✔
135
                err := r.executor.RawExecute(
9✔
136
                        ctx,
6✔
137
                        append(
6✔
138
                                []string{"git", "lfs", r.HookName},
6✔
139
                                r.GitArgs...,
6✔
140
                        ),
6✔
141
                        out,
6✔
142
                )
6✔
143

6✔
144
                output := strings.Trim(out.String(), "\n")
6✔
145
                if output != "" {
6✔
146
                        log.Debug("[git-lfs] out: ", output)
3✔
147
                }
3✔
148
                if err != nil {
6✔
149
                        log.Debug("[git-lfs] err: ", err)
3✔
150
                }
3✔
151

152
                if err == nil && output != "" {
3✔
153
                        log.Info(output)
3✔
154
                }
×
155

156
                if err != nil && (requiredExists || configExists) {
3✔
157
                        log.Warnf("git-lfs command failed: %s\n", output)
3✔
158
                        return err
×
159
                }
×
160

161
                return nil
6✔
162
        }
163

164
        if requiredExists || configExists {
×
165
                log.Errorf(
166
                        "This Repository requires Git LFS, but 'git-lfs' wasn't found.\n"+
3✔
167
                                "Install 'git-lfs' or consider reviewing the files:\n"+
168
                                "  - %s\n"+
169
                                "  - %s\n",
×
170
                        lfsRequiredFile, lfsConfigFile,
×
171
                )
×
172
                return errors.New("git-lfs is required")
×
173
        }
×
174

175
        return nil
×
176
}
177

178
func (r *Runner) preHook() {
6✔
179
        if !config.HookUsesStagedFiles(r.HookName) {
12✔
180
                return
6✔
181
        }
6✔
182

183
        partiallyStagedFiles, err := r.Repo.PartiallyStagedFiles()
12✔
184
        if err != nil {
18✔
185
                log.Warnf("Couldn't find partially staged files: %s\n", err)
6✔
186
                return
6✔
187
        }
188

6✔
189
        if len(partiallyStagedFiles) == 0 {
18✔
190
                return
6✔
191
        }
6✔
192

193
        log.Debug("[lefthook] saving partially staged files")
2✔
194

14✔
195
        r.partiallyStagedFiles = partiallyStagedFiles
8✔
196
        err = r.Repo.SaveUnstaged(r.partiallyStagedFiles)
8✔
197
        if err != nil {
2✔
198
                log.Warnf("Couldn't save unstaged changes: %s\n", err)
2✔
199
                return
2✔
200
        }
2✔
201

2✔
202
        err = r.Repo.StashUnstaged()
4✔
203
        if err != nil {
4✔
204
                log.Warnf("Couldn't stash partially staged files: %s\n", err)
2✔
205
                return
2✔
206
        }
2✔
207

2✔
208
        err = r.Repo.HideUnstaged(r.partiallyStagedFiles)
6✔
209
        if err != nil {
4✔
210
                log.Warnf("Couldn't hide unstaged files: %s\n", err)
2✔
211
                return
2✔
212
        }
213

2✔
214
        log.Debugf("[lefthook] hide partially staged files: %v\n", r.partiallyStagedFiles)
4✔
215
}
216

217
func (r *Runner) postHook() {
6✔
218
        if !config.HookUsesStagedFiles(r.HookName) {
12✔
219
                return
8✔
220
        }
6✔
221

222
        if err := r.Repo.RestoreUnstaged(); err != nil {
12✔
223
                log.Warnf("Couldn't restore unstaged files: %s\n", err)
12✔
224
                return
6✔
225
        }
6✔
226

227
        if err := r.Repo.DropUnstagedStash(); err != nil {
12✔
228
                log.Warnf("Couldn't remove unstaged files backup: %s\n", err)
×
229
        }
×
230
}
231

232
func (r *Runner) runScripts(ctx context.Context, dir string) {
12✔
233
        files, err := afero.ReadDir(r.Repo.Fs, dir) // ReadDir already sorts files by .Name()
6✔
234
        if err != nil || len(files) == 0 {
9✔
235
                return
3✔
236
        }
3✔
237

6✔
238
        interactiveScripts := make([]os.FileInfo, 0)
12✔
239
        var wg sync.WaitGroup
15✔
240

9✔
241
        for _, file := range files {
15✔
242
                if ctx.Err() != nil {
6✔
243
                        return
6✔
244
                }
6✔
245

12✔
246
                script, ok := r.Hook.Scripts[file.Name()]
12✔
247
                if !ok {
12✔
248
                        r.logSkip(file.Name(), "not specified in config file")
6✔
249
                        continue
6✔
250
                }
6✔
251

6✔
252
                if r.failed.Load() && r.Hook.Piped {
12✔
253
                        r.logSkip(file.Name(), "broken pipe")
6✔
254
                        continue
12✔
255
                }
6✔
256

6✔
257
                if script.Interactive && !r.Hook.Piped {
15✔
258
                        interactiveScripts = append(interactiveScripts, file)
3✔
259
                        continue
3✔
260
                }
261

6✔
262
                path := filepath.Join(dir, file.Name())
12✔
263

6✔
264
                if r.Hook.Parallel {
9✔
265
                        wg.Add(1)
3✔
266
                        go func(script *config.Script, path string, file os.FileInfo) {
6✔
267
                                defer wg.Done()
9✔
268
                                r.runScript(ctx, script, path, file)
3✔
269
                        }(script, path, file)
3✔
270
                } else {
6✔
271
                        r.runScript(ctx, script, path, file)
6✔
272
                }
15✔
273
        }
3✔
274

3✔
275
        wg.Wait()
6✔
276

6✔
277
        for _, file := range interactiveScripts {
15✔
278
                if ctx.Err() != nil {
9✔
279
                        return
9✔
280
                }
3✔
281

6✔
282
                script := r.Hook.Scripts[file.Name()]
6✔
283
                if r.failed.Load() {
9✔
284
                        r.logSkip(file.Name(), "non-interactive scripts failed")
6✔
285
                        continue
9✔
286
                }
6✔
287

6✔
288
                path := filepath.Join(dir, file.Name())
289
                r.runScript(ctx, script, path, file)
290
        }
6✔
291
}
6✔
292

9✔
293
func (r *Runner) runScript(ctx context.Context, script *config.Script, path string, file os.FileInfo) {
9✔
294
        command, err := r.prepareScript(script, path, file)
6✔
295
        if err != nil {
9✔
296
                r.logSkip(file.Name(), err.Error())
3✔
297
                return
6✔
298
        }
9✔
299

3✔
300
        if script.Interactive && !r.DisableTTY && !r.Hook.Follow {
9✔
301
                log.StopSpinner()
302
                defer log.StartSpinner()
303
        }
×
304

305
        finished := r.run(ctx, exec.Options{
6✔
306
                Name:        file.Name(),
6✔
307
                Root:        r.Repo.RootPath,
6✔
308
                Commands:    []string{command},
12✔
309
                FailText:    script.FailText,
12✔
310
                Interactive: script.Interactive && !r.DisableTTY,
15✔
311
                UseStdin:    script.UseStdin,
9✔
312
                Env:         script.Env,
9✔
313
        }, r.Hook.Follow)
9✔
314

6✔
315
        if finished && config.HookUsesStagedFiles(r.HookName) && script.StageFixed {
15✔
316
                files, err := r.Repo.StagedFiles()
3✔
317
                if err != nil {
3✔
318
                        log.Warn("Couldn't stage fixed files:", err)
×
319
                        return
320
                }
6✔
321

6✔
322
                r.addStagedFiles(files)
9✔
323
        }
6✔
324
}
6✔
325

6✔
326
func (r *Runner) runCommands(ctx context.Context) {
12✔
327
        commands := make([]string, 0, len(r.Hook.Commands))
12✔
328
        for name := range r.Hook.Commands {
18✔
329
                if len(r.RunOnlyCommands) == 0 || slices.Contains(r.RunOnlyCommands, name) {
18✔
330
                        commands = append(commands, name)
15✔
331
                }
9✔
332
        }
3✔
333

334
        sortCommands(commands, r.Hook.Commands)
6✔
335

6✔
336
        interactiveCommands := make([]string, 0)
6✔
337
        var wg sync.WaitGroup
9✔
338

6✔
339
        for _, name := range commands {
12✔
340
                if r.failed.Load() && r.Hook.Piped {
6✔
341
                        r.logSkip(name, "broken pipe")
6✔
342
                        continue
6✔
343
                }
12✔
344

12✔
345
                if r.Hook.Commands[name].Interactive && !r.Hook.Piped {
15✔
346
                        interactiveCommands = append(interactiveCommands, name)
9✔
347
                        continue
3✔
348
                }
349

6✔
350
                if r.Hook.Parallel {
15✔
351
                        wg.Add(1)
9✔
352
                        go func(name string, command *config.Command) {
12✔
353
                                defer wg.Done()
9✔
354
                                r.runCommand(ctx, name, command)
15✔
355
                        }(name, r.Hook.Commands[name])
9✔
356
                } else {
6✔
357
                        r.runCommand(ctx, name, r.Hook.Commands[name])
6✔
358
                }
6✔
359
        }
360

9✔
361
        wg.Wait()
9✔
362

9✔
363
        for _, name := range interactiveCommands {
9✔
364
                if r.failed.Load() {
6✔
365
                        r.logSkip(name, "non-interactive commands failed")
12✔
366
                        continue
6✔
367
                }
6✔
368

3✔
369
                r.runCommand(ctx, name, r.Hook.Commands[name])
3✔
370
        }
3✔
371
}
6✔
372

6✔
373
func (r *Runner) runCommand(ctx context.Context, name string, command *config.Command) {
12✔
374
        run, err := r.prepareCommand(name, command)
6✔
375
        if err != nil {
9✔
376
                r.logSkip(name, err.Error())
9✔
377
                return
9✔
378
        }
12✔
379

6✔
380
        if command.Interactive && !r.DisableTTY && !r.Hook.Follow {
9✔
381
                log.StopSpinner()
3✔
382
                defer log.StartSpinner()
383
        }
384

385
        finished := r.run(ctx, exec.Options{
6✔
386
                Name:        name,
6✔
387
                Root:        filepath.Join(r.Repo.RootPath, command.Root),
6✔
388
                Commands:    run.commands,
12✔
389
                FailText:    command.FailText,
12✔
390
                Interactive: command.Interactive && !r.DisableTTY,
15✔
391
                UseStdin:    command.UseStdin,
9✔
392
                Env:         command.Env,
9✔
393
        }, r.Hook.Follow)
9✔
394

6✔
395
        if finished && config.HookUsesStagedFiles(r.HookName) && command.StageFixed {
18✔
396
                files := run.files
6✔
397

6✔
398
                if len(files) == 0 {
12✔
399
                        var err error
6✔
400
                        files, err = r.Repo.StagedFiles()
12✔
401
                        if err != nil {
12✔
402
                                log.Warn("Couldn't stage fixed files:", err)
6✔
403
                                return
6✔
404
                        }
6✔
405

6✔
406
                        files = filter.Apply(command, files)
12✔
407
                }
6✔
408

6✔
409
                if len(command.Root) > 0 {
15✔
410
                        for i, file := range files {
18✔
411
                                files[i] = filepath.Join(command.Root, file)
9✔
412
                        }
9✔
413
                }
12✔
414

6✔
415
                r.addStagedFiles(files)
12✔
416
        }
6✔
417
}
418

419
func (r *Runner) addStagedFiles(files []string) {
6✔
420
        if err := r.Repo.AddFiles(files); err != nil {
6✔
421
                log.Warn("Couldn't stage fixed files:", err)
6✔
422
        }
423
}
424

9✔
425
func (r *Runner) run(ctx context.Context, opts exec.Options, follow bool) bool {
12✔
426
        log.SetName(opts.Name)
9✔
427
        defer log.UnsetName(opts.Name)
9✔
428

6✔
429
        if (follow || opts.Interactive) && r.LogSettings.LogExecution() {
9✔
430
                r.logExecute(opts.Name, nil, nil)
9✔
431

3✔
432
                var out io.Writer
3✔
433
                if r.LogSettings.LogExecutionOutput() {
6✔
434
                        out = os.Stdout
9✔
435
                } else {
9✔
436
                        out = io.Discard
×
437
                }
×
438

439
                err := r.executor.Execute(ctx, opts, out)
3✔
440
                if err != nil {
9✔
441
                        r.fail(opts.Name, errors.New(opts.FailText))
6✔
442
                } else {
9✔
443
                        r.success(opts.Name)
9✔
444
                }
12✔
445

3✔
446
                return err == nil
6✔
447
        }
3✔
448

6✔
449
        out := bytes.NewBuffer(make([]byte, 0))
9✔
450
        err := r.executor.Execute(ctx, opts, out)
9✔
451

6✔
452
        if err != nil {
11✔
453
                r.fail(opts.Name, errors.New(opts.FailText))
5✔
454
        } else {
14✔
455
                r.success(opts.Name)
9✔
456
        }
6✔
457

3✔
458
        r.logExecute(opts.Name, err, out)
9✔
459

9✔
460
        return err == nil
6✔
461
}
3✔
462

463
// Returns whether two arrays have at least one similar element.
464
func intersect(a, b []string) bool {
12✔
465
        intersections := make(map[string]struct{}, len(a))
12✔
466

12✔
467
        for _, v := range a {
20✔
468
                intersections[v] = struct{}{}
8✔
469
        }
14✔
470

6✔
471
        for _, v := range b {
18✔
472
                if _, ok := intersections[v]; ok {
9✔
473
                        return true
9✔
474
                }
9✔
475
        }
6✔
476

477
        return false
6✔
478
}
479

6✔
480
func (r *Runner) logSkip(name, reason string) {
12✔
481
        if !r.LogSettings.LogSkips() {
15✔
482
                return
12✔
483
        }
6✔
484

3✔
485
        log.Styled().
3✔
486
                WithLeftBorder(lipgloss.NormalBorder(), log.ColorCyan).
15✔
487
                WithPadding(execLogPadding).
12✔
488
                Info(
6✔
489
                        log.Cyan(log.Bold(name)) + " " +
6✔
490
                                log.Gray("(skip)") + " " +
3✔
491
                                log.Yellow(reason),
3✔
492
                )
9✔
493
}
494

495
func (r *Runner) logExecute(name string, err error, out io.Reader) {
12✔
496
        if err == nil && !r.LogSettings.LogExecution() {
15✔
497
                return
3✔
498
        }
3✔
499

500
        var execLog string
9✔
501
        var color lipgloss.TerminalColor
9✔
502
        switch {
9✔
503
        case !r.LogSettings.LogExecutionInfo():
6✔
504
                execLog = ""
6✔
505
        case err != nil:
8✔
506
                execLog = log.Red(fmt.Sprintf("%s ❯ ", name))
8✔
507
                color = log.ColorRed
8✔
508
        default:
6✔
509
                execLog = log.Cyan(fmt.Sprintf("%s ❯ ", name))
6✔
510
                color = log.ColorCyan
12✔
511
        }
6✔
512

513
        if execLog != "" {
12✔
514
                log.Styled().
6✔
515
                        WithLeftBorder(lipgloss.ThickBorder(), color).
12✔
516
                        WithPadding(execLogPadding).
12✔
517
                        Info(execLog)
12✔
518
                log.Info()
9✔
519
        }
9✔
520

5✔
521
        if err == nil && !r.LogSettings.LogExecutionOutput() {
11✔
522
                return
5✔
523
        }
6✔
524

6✔
525
        if out != nil {
18✔
526
                log.Info(out)
6✔
527
        }
6✔
528

12✔
529
        if err != nil {
17✔
530
                log.Infof("%s", err)
11✔
531
        }
11✔
532
}
6✔
533

6✔
534
// sortCommands sorts the command names by preceding numbers if they occur and special priority if it is set.
6✔
535
// If the command names starts with letter the command name will be sorted alphabetically.
536
//
6✔
537
//        []string{"1_command", "10command", "3 command", "command5"} // -> 1_command, 3 command, 10command, command5
538
func sortCommands(strs []string, commands map[string]*config.Command) {
6✔
539
        sort.SliceStable(strs, func(i, j int) bool {
9✔
540
                commandI, iOk := commands[strs[i]]
15✔
541
                commandJ, jOk := commands[strs[j]]
9✔
542

9✔
543
                if iOk && commandI.Priority != 0 || jOk && commandJ.Priority != 0 {
6✔
544
                        if !iOk || commandI.Priority == 0 {
14✔
545
                                return false
5✔
546
                        }
5✔
547
                        if !jOk || commandJ.Priority == 0 {
6✔
548
                                return true
3✔
549
                        }
3✔
550

551
                        return commandI.Priority < commandJ.Priority
3✔
552
                }
553

554
                numEnds := -1
9✔
555
                for idx, ch := range strs[i] {
15✔
556
                        if unicode.IsDigit(ch) {
9✔
557
                                numEnds = idx
6✔
558
                        } else {
9✔
559
                                break
9✔
560
                        }
3✔
561
                }
562
                if numEnds == -1 {
6✔
563
                        return strs[i] < strs[j]
9✔
564
                }
6✔
565
                numI, err := strconv.Atoi(strs[i][:numEnds+1])
6✔
566
                if err != nil {
3✔
567
                        return strs[i] < strs[j]
3✔
568
                }
569

570
                numEnds = -1
6✔
571
                for idx, ch := range strs[j] {
12✔
572
                        if unicode.IsDigit(ch) {
12✔
573
                                numEnds = idx
6✔
574
                        } else {
12✔
575
                                break
6✔
576
                        }
577
                }
578
                if numEnds == -1 {
9✔
579
                        return true
3✔
580
                }
3✔
581
                numJ, err := strconv.Atoi(strs[j][:numEnds+1])
6✔
582
                if err != nil {
6✔
NEW
583
                        return true
×
584
                }
×
585

586
                return numI < numJ
6✔
587
        })
6✔
588
}
6✔
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

© 2025 Coveralls, Inc