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

evilmartians / lefthook / 8373477831

21 Mar 2024 10:31AM UTC coverage: 77.882% (-0.08%) from 77.962%
8373477831

Pull #684

github

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

25 of 32 new or added lines in 3 files covered. (78.13%)

4 existing lines in 2 files now uncovered.

2405 of 3088 relevant lines covered (77.88%)

3.62 hits per line

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

80.34
/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 {
3✔
63
        return &Runner{
3✔
64
                Options:  opts,
3✔
65
                executor: exec.CommandExecutor{},
3✔
66
        }
3✔
67
}
3✔
68

69
type executable interface {
70
        *config.Command | *config.Script
71
        GetPriority() int
72
}
73

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

81
        if r.Hook.DoSkip(r.Repo.State()) {
12✔
82
                r.logSkip(r.HookName, "hook setting")
6✔
83
                return
6✔
84
        }
6✔
85

86
        if !r.DisableTTY && !r.Hook.Follow {
12✔
87
                log.StartSpinner()
6✔
88
                defer log.StopSpinner()
6✔
89
        }
6✔
90

91
        scriptDirs := make([]string, 0, len(sourceDirs))
6✔
92
        for _, sourceDir := range sourceDirs {
12✔
93
                scriptDirs = append(scriptDirs, filepath.Join(
6✔
94
                        sourceDir, r.HookName,
6✔
95
                ))
6✔
96
        }
6✔
97

98
        r.preHook()
6✔
99

6✔
100
        for _, dir := range scriptDirs {
12✔
101
                r.runScripts(ctx, dir)
6✔
102
        }
6✔
103

104
        r.runCommands(ctx)
6✔
105

6✔
106
        r.postHook()
6✔
107
}
108

109
func (r *Runner) fail(name string, err error) {
5✔
110
        r.ResultChan <- resultFail(name, err.Error())
5✔
111
        r.failed.Store(true)
5✔
112
}
5✔
113

114
func (r *Runner) success(name string) {
6✔
115
        r.ResultChan <- resultSuccess(name)
6✔
116
}
6✔
117

118
func (r *Runner) runLFSHook(ctx context.Context) error {
6✔
119
        if !git.IsLFSHook(r.HookName) {
12✔
120
                return nil
6✔
121
        }
6✔
122

123
        lfsRequiredFile := filepath.Join(r.Repo.RootPath, git.LFSRequiredFile)
3✔
124
        lfsConfigFile := filepath.Join(r.Repo.RootPath, git.LFSConfigFile)
3✔
125

3✔
126
        requiredExists, err := afero.Exists(r.Repo.Fs, lfsRequiredFile)
3✔
127
        if err != nil {
3✔
128
                return err
×
129
        }
×
130
        configExists, err := afero.Exists(r.Repo.Fs, lfsConfigFile)
3✔
131
        if err != nil {
3✔
132
                return err
×
133
        }
×
134

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

3✔
149
                output := strings.Trim(out.String(), "\n")
3✔
150
                if output != "" {
3✔
151
                        log.Debug("[git-lfs] out: ", output)
×
152
                }
×
153
                if err != nil {
3✔
154
                        log.Debug("[git-lfs] err: ", err)
×
155
                }
×
156

157
                if err == nil && output != "" {
3✔
158
                        log.Info(output)
×
159
                }
×
160

161
                if err != nil && (requiredExists || configExists) {
3✔
162
                        log.Warnf("git-lfs command failed: %s\n", output)
×
163
                        return err
×
164
                }
×
165

166
                return nil
3✔
167
        }
168

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

180
        return nil
×
181
}
182

183
func (r *Runner) preHook() {
6✔
184
        if !config.HookUsesStagedFiles(r.HookName) {
12✔
185
                return
6✔
186
        }
6✔
187

188
        partiallyStagedFiles, err := r.Repo.PartiallyStagedFiles()
6✔
189
        if err != nil {
6✔
190
                log.Warnf("Couldn't find partially staged files: %s\n", err)
×
191
                return
×
192
        }
×
193

194
        if len(partiallyStagedFiles) == 0 {
12✔
195
                return
6✔
196
        }
6✔
197

198
        log.Debug("[lefthook] saving partially staged files")
2✔
199

2✔
200
        r.partiallyStagedFiles = partiallyStagedFiles
2✔
201
        err = r.Repo.SaveUnstaged(r.partiallyStagedFiles)
2✔
202
        if err != nil {
2✔
203
                log.Warnf("Couldn't save unstaged changes: %s\n", err)
×
204
                return
×
205
        }
×
206

207
        err = r.Repo.StashUnstaged()
2✔
208
        if err != nil {
4✔
209
                log.Warnf("Couldn't stash partially staged files: %s\n", err)
2✔
210
                return
2✔
211
        }
2✔
212

213
        err = r.Repo.HideUnstaged(r.partiallyStagedFiles)
2✔
214
        if err != nil {
2✔
215
                log.Warnf("Couldn't hide unstaged files: %s\n", err)
×
216
                return
×
217
        }
×
218

219
        log.Debugf("[lefthook] hide partially staged files: %v\n", r.partiallyStagedFiles)
2✔
220
}
221

222
func (r *Runner) postHook() {
6✔
223
        if !config.HookUsesStagedFiles(r.HookName) {
12✔
224
                return
6✔
225
        }
6✔
226

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

232
        if err := r.Repo.DropUnstagedStash(); err != nil {
6✔
233
                log.Warnf("Couldn't remove unstaged files backup: %s\n", err)
×
234
        }
×
235
}
236

237
func (r *Runner) runScripts(ctx context.Context, dir string) {
6✔
238
        files, err := afero.ReadDir(r.Repo.Fs, dir) // ReadDir already sorts files by .Name()
6✔
239
        if err != nil || len(files) == 0 {
9✔
240
                return
3✔
241
        }
3✔
242

243
        scripts := make([]string, 0, len(files))
6✔
244
        filesMap := make(map[string]os.FileInfo)
6✔
245
        for _, file := range files {
12✔
246
                filesMap[file.Name()] = file
6✔
247
                scripts = append(scripts, file.Name())
6✔
248
        }
6✔
249
        sortByPriority(scripts, r.Hook.Scripts)
6✔
250

6✔
251
        interactiveScripts := make([]os.FileInfo, 0)
6✔
252
        var wg sync.WaitGroup
6✔
253

6✔
254
        for _, name := range scripts {
12✔
255
                file := filesMap[name]
6✔
256

6✔
257
                if ctx.Err() != nil {
6✔
258
                        return
×
259
                }
×
260

261
                script, ok := r.Hook.Scripts[file.Name()]
6✔
262
                if !ok {
6✔
263
                        r.logSkip(file.Name(), "not specified in config file")
×
264
                        continue
×
265
                }
266

267
                if r.failed.Load() && r.Hook.Piped {
6✔
268
                        r.logSkip(file.Name(), "broken pipe")
×
269
                        continue
×
270
                }
271

272
                if script.Interactive && !r.Hook.Piped {
9✔
273
                        interactiveScripts = append(interactiveScripts, file)
3✔
274
                        continue
3✔
275
                }
276

277
                path := filepath.Join(dir, file.Name())
6✔
278

6✔
279
                if r.Hook.Parallel {
9✔
280
                        wg.Add(1)
3✔
281
                        go func(script *config.Script, path string, file os.FileInfo) {
6✔
282
                                defer wg.Done()
3✔
283
                                r.runScript(ctx, script, path, file)
3✔
284
                        }(script, path, file)
3✔
285
                } else {
6✔
286
                        r.runScript(ctx, script, path, file)
6✔
287
                }
6✔
288
        }
289

290
        wg.Wait()
6✔
291

6✔
292
        for _, file := range interactiveScripts {
9✔
293
                if ctx.Err() != nil {
3✔
294
                        return
×
295
                }
×
296

297
                script := r.Hook.Scripts[file.Name()]
3✔
298
                if r.failed.Load() {
6✔
299
                        r.logSkip(file.Name(), "non-interactive scripts failed")
3✔
300
                        continue
3✔
301
                }
302

303
                path := filepath.Join(dir, file.Name())
×
304
                r.runScript(ctx, script, path, file)
×
305
        }
306
}
307

308
func (r *Runner) runScript(ctx context.Context, script *config.Script, path string, file os.FileInfo) {
6✔
309
        command, err := r.prepareScript(script, path, file)
6✔
310
        if err != nil {
9✔
311
                r.logSkip(file.Name(), err.Error())
3✔
312
                return
3✔
313
        }
3✔
314

315
        if script.Interactive && !r.DisableTTY && !r.Hook.Follow {
6✔
316
                log.StopSpinner()
×
317
                defer log.StartSpinner()
×
318
        }
×
319

320
        finished := r.run(ctx, exec.Options{
6✔
321
                Name:        file.Name(),
6✔
322
                Root:        r.Repo.RootPath,
6✔
323
                Commands:    []string{command},
6✔
324
                FailText:    script.FailText,
6✔
325
                Interactive: script.Interactive && !r.DisableTTY,
6✔
326
                UseStdin:    script.UseStdin,
6✔
327
                Env:         script.Env,
6✔
328
        }, r.Hook.Follow)
6✔
329

6✔
330
        if finished && config.HookUsesStagedFiles(r.HookName) && script.StageFixed {
9✔
331
                files, err := r.Repo.StagedFiles()
3✔
332
                if err != nil {
3✔
333
                        log.Warn("Couldn't stage fixed files:", err)
×
334
                        return
×
335
                }
×
336

337
                r.addStagedFiles(files)
3✔
338
        }
339
}
340

341
func (r *Runner) runCommands(ctx context.Context) {
6✔
342
        commands := make([]string, 0, len(r.Hook.Commands))
6✔
343
        for name := range r.Hook.Commands {
12✔
344
                if len(r.RunOnlyCommands) == 0 || slices.Contains(r.RunOnlyCommands, name) {
12✔
345
                        commands = append(commands, name)
6✔
346
                }
6✔
347
        }
348

349
        sortByPriority(commands, r.Hook.Commands)
6✔
350

6✔
351
        interactiveCommands := make([]string, 0)
6✔
352
        var wg sync.WaitGroup
6✔
353

6✔
354
        for _, name := range commands {
12✔
355
                if r.failed.Load() && r.Hook.Piped {
6✔
356
                        r.logSkip(name, "broken pipe")
×
357
                        continue
×
358
                }
359

360
                if r.Hook.Commands[name].Interactive && !r.Hook.Piped {
9✔
361
                        interactiveCommands = append(interactiveCommands, name)
3✔
362
                        continue
3✔
363
                }
364

365
                if r.Hook.Parallel {
9✔
366
                        wg.Add(1)
3✔
367
                        go func(name string, command *config.Command) {
6✔
368
                                defer wg.Done()
3✔
369
                                r.runCommand(ctx, name, command)
3✔
370
                        }(name, r.Hook.Commands[name])
3✔
371
                } else {
6✔
372
                        r.runCommand(ctx, name, r.Hook.Commands[name])
6✔
373
                }
6✔
374
        }
375

376
        wg.Wait()
6✔
377

6✔
378
        for _, name := range interactiveCommands {
9✔
379
                if r.failed.Load() {
6✔
380
                        r.logSkip(name, "non-interactive commands failed")
3✔
381
                        continue
3✔
382
                }
383

384
                r.runCommand(ctx, name, r.Hook.Commands[name])
×
385
        }
386
}
387

388
func (r *Runner) runCommand(ctx context.Context, name string, command *config.Command) {
6✔
389
        run, err := r.prepareCommand(name, command)
6✔
390
        if err != nil {
9✔
391
                r.logSkip(name, err.Error())
3✔
392
                return
3✔
393
        }
3✔
394

395
        if command.Interactive && !r.DisableTTY && !r.Hook.Follow {
6✔
396
                log.StopSpinner()
×
397
                defer log.StartSpinner()
×
398
        }
×
399

400
        finished := r.run(ctx, exec.Options{
6✔
401
                Name:        name,
6✔
402
                Root:        filepath.Join(r.Repo.RootPath, command.Root),
6✔
403
                Commands:    run.commands,
6✔
404
                FailText:    command.FailText,
6✔
405
                Interactive: command.Interactive && !r.DisableTTY,
6✔
406
                UseStdin:    command.UseStdin,
6✔
407
                Env:         command.Env,
6✔
408
        }, r.Hook.Follow)
6✔
409

6✔
410
        if finished && config.HookUsesStagedFiles(r.HookName) && command.StageFixed {
12✔
411
                files := run.files
6✔
412

6✔
413
                if len(files) == 0 {
12✔
414
                        var err error
6✔
415
                        files, err = r.Repo.StagedFiles()
6✔
416
                        if err != nil {
6✔
417
                                log.Warn("Couldn't stage fixed files:", err)
×
418
                                return
×
419
                        }
×
420

421
                        files = filter.Apply(command, files)
6✔
422
                }
423

424
                if len(command.Root) > 0 {
9✔
425
                        for i, file := range files {
6✔
426
                                files[i] = filepath.Join(command.Root, file)
3✔
427
                        }
3✔
428
                }
429

430
                r.addStagedFiles(files)
6✔
431
        }
432
}
433

434
func (r *Runner) addStagedFiles(files []string) {
6✔
435
        if err := r.Repo.AddFiles(files); err != nil {
6✔
436
                log.Warn("Couldn't stage fixed files:", err)
×
437
        }
×
438
}
439

440
func (r *Runner) run(ctx context.Context, opts exec.Options, follow bool) bool {
6✔
441
        log.SetName(opts.Name)
6✔
442
        defer log.UnsetName(opts.Name)
6✔
443

6✔
444
        if (follow || opts.Interactive) && r.LogSettings.LogExecution() {
9✔
445
                r.logExecute(opts.Name, nil, nil)
3✔
446

3✔
447
                var out io.Writer
3✔
448
                if r.LogSettings.LogExecutionOutput() {
6✔
449
                        out = os.Stdout
3✔
450
                } else {
3✔
451
                        out = io.Discard
×
452
                }
×
453

454
                err := r.executor.Execute(ctx, opts, out)
3✔
455
                if err != nil {
3✔
456
                        r.fail(opts.Name, errors.New(opts.FailText))
×
457
                } else {
3✔
458
                        r.success(opts.Name)
3✔
459
                }
3✔
460

461
                return err == nil
3✔
462
        }
463

464
        out := bytes.NewBuffer(make([]byte, 0))
6✔
465
        err := r.executor.Execute(ctx, opts, out)
6✔
466

6✔
467
        if err != nil {
11✔
468
                r.fail(opts.Name, errors.New(opts.FailText))
5✔
469
        } else {
11✔
470
                r.success(opts.Name)
6✔
471
        }
6✔
472

473
        r.logExecute(opts.Name, err, out)
6✔
474

6✔
475
        return err == nil
6✔
476
}
477

478
// Returns whether two arrays have at least one similar element.
479
func intersect(a, b []string) bool {
6✔
480
        intersections := make(map[string]struct{}, len(a))
6✔
481

6✔
482
        for _, v := range a {
9✔
483
                intersections[v] = struct{}{}
3✔
484
        }
3✔
485

486
        for _, v := range b {
12✔
487
                if _, ok := intersections[v]; ok {
9✔
488
                        return true
3✔
489
                }
3✔
490
        }
491

492
        return false
6✔
493
}
494

495
func (r *Runner) logSkip(name, reason string) {
6✔
496
        if !r.LogSettings.LogSkips() {
9✔
497
                return
3✔
498
        }
3✔
499

500
        log.Styled().
3✔
501
                WithLeftBorder(lipgloss.NormalBorder(), log.ColorCyan).
3✔
502
                WithPadding(execLogPadding).
3✔
503
                Info(
3✔
504
                        log.Cyan(log.Bold(name)) + " " +
3✔
505
                                log.Gray("(skip)") + " " +
3✔
506
                                log.Yellow(reason),
3✔
507
                )
3✔
508
}
509

510
func (r *Runner) logExecute(name string, err error, out io.Reader) {
6✔
511
        if err == nil && !r.LogSettings.LogExecution() {
6✔
512
                return
×
513
        }
×
514

515
        var execLog string
6✔
516
        var color lipgloss.TerminalColor
6✔
517
        switch {
6✔
518
        case !r.LogSettings.LogExecutionInfo():
3✔
519
                execLog = ""
3✔
520
        case err != nil:
5✔
521
                execLog = log.Red(fmt.Sprintf("%s ❯ ", name))
5✔
522
                color = log.ColorRed
5✔
523
        default:
6✔
524
                execLog = log.Cyan(fmt.Sprintf("%s ❯ ", name))
6✔
525
                color = log.ColorCyan
6✔
526
        }
527

528
        if execLog != "" {
12✔
529
                log.Styled().
6✔
530
                        WithLeftBorder(lipgloss.ThickBorder(), color).
6✔
531
                        WithPadding(execLogPadding).
6✔
532
                        Info(execLog)
6✔
533
                log.Info()
6✔
534
        }
6✔
535

536
        if err == nil && !r.LogSettings.LogExecutionOutput() {
6✔
537
                return
×
538
        }
×
539

540
        if out != nil {
12✔
541
                log.Info(out)
6✔
542
        }
6✔
543

544
        if err != nil {
11✔
545
                log.Infof("%s", err)
5✔
546
        }
5✔
547
}
548

549
// sortByPriority sorts the names by preceding numbers if they occur and special priority if it is set.
550
// If the names starts with letter the command name will be sorted alphabetically.
551
// If there's a `priority` field defined for a command or script it will be used instead of alphanumeric sorting.
552
//
553
//        []string{"1_command", "10command", "3 command", "command5"} // -> 1_command, 3 command, 10command, command5
554
func sortByPriority[E executable](array []string, exe map[string]E) {
6✔
555
        sort.SliceStable(array, func(i, j int) bool {
9✔
556
                exeI, iOk := exe[array[i]]
3✔
557
                exeJ, jOk := exe[array[j]]
3✔
558

3✔
559
                if iOk && exeI.GetPriority() != 0 || jOk && exeJ.GetPriority() != 0 {
6✔
560
                        if !iOk || exeI.GetPriority() == 0 {
3✔
561
                                return false
×
562
                        }
×
563
                        if !jOk || exeJ.GetPriority() == 0 {
6✔
564
                                return true
3✔
565
                        }
3✔
566

567
                        return exeI.GetPriority() < exeJ.GetPriority()
3✔
568
                }
569

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

586
                numEnds = -1
3✔
587
                for idx, ch := range array[j] {
6✔
588
                        if unicode.IsDigit(ch) {
6✔
589
                                numEnds = idx
3✔
590
                        } else {
6✔
591
                                break
3✔
592
                        }
593
                }
594
                if numEnds == -1 {
3✔
595
                        return true
×
596
                }
×
597
                numJ, err := strconv.Atoi(array[j][:numEnds+1])
3✔
598
                if err != nil {
3✔
599
                        return true
×
600
                }
×
601

602
                return numI < numJ
3✔
603
        })
604
}
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