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

evilmartians / lefthook / 8293101502

15 Mar 2024 08:06AM UTC coverage: 77.962% (-0.3%) from 78.228%
8293101502

Pull #676

github

web-flow
Merge 93f327845 into e9573bbd7
Pull Request #676: fix: try ignoring empty patch files on pre commit hook

8 of 19 new or added lines in 2 files covered. (42.11%)

1 existing line in 1 file now uncovered.

2395 of 3072 relevant lines covered (77.96%)

3.62 hits per line

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

79.85
/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
// 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()) {
12✔
77
                r.logSkip(r.HookName, "hook setting")
6✔
78
                return
6✔
79
        }
6✔
80

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

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

93
        r.preHook()
6✔
94

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

99
        r.runCommands(ctx)
6✔
100

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

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

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

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

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

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

130
        if git.IsLFSAvailable() {
6✔
131
                log.Debugf(
3✔
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(
3✔
136
                        ctx,
3✔
137
                        append(
3✔
138
                                []string{"git", "lfs", r.HookName},
3✔
139
                                r.GitArgs...,
3✔
140
                        ),
3✔
141
                        out,
3✔
142
                )
3✔
143

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

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

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

161
                return nil
3✔
162
        }
163

164
        if requiredExists || configExists {
×
165
                log.Errorf(
×
166
                        "This Repository requires Git LFS, but 'git-lfs' wasn't found.\n"+
×
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()
6✔
184
        if err != nil {
6✔
185
                log.Warnf("Couldn't find partially staged files: %s\n", err)
×
186
                return
×
187
        }
×
188

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

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

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

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

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

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

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

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

227
        if err := r.Repo.DropUnstagedStash(); err != nil {
6✔
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) {
6✔
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

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

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

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

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

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

262
                path := filepath.Join(dir, file.Name())
6✔
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()
3✔
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
                }
6✔
273
        }
274

275
        wg.Wait()
6✔
276

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

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

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

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

300
        if script.Interactive && !r.DisableTTY && !r.Hook.Follow {
6✔
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},
6✔
309
                FailText:    script.FailText,
6✔
310
                Interactive: script.Interactive && !r.DisableTTY,
6✔
311
                UseStdin:    script.UseStdin,
6✔
312
                Env:         script.Env,
6✔
313
        }, r.Hook.Follow)
6✔
314

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

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

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

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

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

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

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

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

361
        wg.Wait()
6✔
362

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

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

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

380
        if command.Interactive && !r.DisableTTY && !r.Hook.Follow {
6✔
381
                log.StopSpinner()
×
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,
6✔
389
                FailText:    command.FailText,
6✔
390
                Interactive: command.Interactive && !r.DisableTTY,
6✔
391
                UseStdin:    command.UseStdin,
6✔
392
                Env:         command.Env,
6✔
393
        }, r.Hook.Follow)
6✔
394

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

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

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

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

415
                r.addStagedFiles(files)
6✔
416
        }
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)
×
422
        }
×
423
}
424

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

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

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

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

446
                return err == nil
3✔
447
        }
448

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

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

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

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

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

6✔
467
        for _, v := range a {
9✔
468
                intersections[v] = struct{}{}
3✔
469
        }
3✔
470

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

477
        return false
6✔
478
}
479

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

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

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

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

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

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

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

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

534
// sortCommands sorts the command names by preceding numbers if they occur and special priority if it is set.
535
// If the command names starts with letter the command name will be sorted alphabetically.
536
//
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]]
3✔
541
                commandJ, jOk := commands[strs[j]]
3✔
542

3✔
543
                if iOk && commandI.Priority != 0 || jOk && commandJ.Priority != 0 {
6✔
544
                        if !iOk || commandI.Priority == 0 {
3✔
545
                                return false
×
546
                        }
×
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
3✔
555
                for idx, ch := range strs[i] {
6✔
556
                        if unicode.IsDigit(ch) {
6✔
557
                                numEnds = idx
3✔
558
                        } else {
6✔
559
                                break
3✔
560
                        }
561
                }
562
                if numEnds == -1 {
6✔
563
                        return strs[i] < strs[j]
3✔
564
                }
3✔
565
                numI, err := strconv.Atoi(strs[i][:numEnds+1])
3✔
566
                if err != nil {
3✔
567
                        return strs[i] < strs[j]
×
568
                }
×
569

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

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