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

evilmartians / lefthook / 17762708138

16 Sep 2025 10:23AM UTC coverage: 71.322% (-2.7%) from 73.996%
17762708138

push

github

web-flow
refactor: reduce the amount of code in a single file (#1131)

* refactor: reduce the amount of code in a single file

251 of 302 new or added lines in 5 files covered. (83.11%)

154 existing lines in 5 files now uncovered.

3457 of 4847 relevant lines covered (71.32%)

3.15 hits per line

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

85.99
/internal/run/controller/command/build_command.go
1
package command
2

3
import (
4
        "fmt"
5
        "regexp"
6
        "runtime"
7
        "strings"
8

9
        "github.com/alessio/shellescape"
10

11
        "github.com/evilmartians/lefthook/internal/config"
12
        "github.com/evilmartians/lefthook/internal/log"
13
        "github.com/evilmartians/lefthook/internal/run/controller/filters"
14
        "github.com/evilmartians/lefthook/internal/system"
15
)
16

17
var surroundingQuotesRegexp = regexp.MustCompile(`^'(.*)'$`)
18

19
// fileTemplate contains for template replacements in a command string.
20
type filesTemplate struct {
21
        files []string
22
        cnt   int
23
}
24

25
func (b *Builder) buildCommand(params *JobParams) ([]string, []string, error) {
3✔
26
        if err := params.validateCommand(); err != nil {
3✔
NEW
27
                return nil, nil, err
×
NEW
28
        }
×
29

30
        filesCmd := params.FilesCmd
3✔
31
        if len(filesCmd) > 0 {
6✔
32
                filesCmd = replacePositionalArguments(filesCmd, b.opts.GitArgs)
3✔
33
        }
3✔
34

35
        var stagedFiles func() ([]string, error)
3✔
36
        var stagedFilesWithDeleted func() ([]string, error)
3✔
37
        var pushFiles func() ([]string, error)
3✔
38
        var allFiles func() ([]string, error)
3✔
39
        var cmdFiles func() ([]string, error)
3✔
40

3✔
41
        if len(b.opts.ForceFiles) > 0 {
6✔
42
                stagedFiles = func() ([]string, error) { return b.opts.ForceFiles, nil }
6✔
43
                stagedFilesWithDeleted = stagedFiles
3✔
44
                pushFiles = stagedFiles
3✔
45
                allFiles = stagedFiles
3✔
46
                cmdFiles = stagedFiles
3✔
47
        } else {
3✔
48
                stagedFiles = b.git.StagedFiles
3✔
49
                stagedFilesWithDeleted = b.git.StagedFilesWithDeleted
3✔
50
                pushFiles = b.git.PushFiles
3✔
51
                allFiles = b.git.AllFiles
3✔
52
                cmdFiles = func() ([]string, error) {
6✔
53
                        var cmd []string
3✔
54
                        if runtime.GOOS == "windows" {
4✔
55
                                cmd = strings.Split(filesCmd, " ")
1✔
56
                        } else {
3✔
57
                                cmd = []string{"sh", "-c", filesCmd}
2✔
58
                        }
2✔
59
                        return b.git.FindExistingFiles(cmd, params.Root)
3✔
60
                }
61
        }
62

63
        filesFns := map[string]func() ([]string, error){
3✔
64
                config.SubStagedFiles: stagedFiles,
3✔
65
                config.SubPushFiles:   pushFiles,
3✔
66
                config.SubAllFiles:    allFiles,
3✔
67
                config.SubFiles:       cmdFiles,
3✔
68
        }
3✔
69

3✔
70
        filesTemplates := make(map[string]*filesTemplate)
3✔
71

3✔
72
        filterParams := filters.Params{
3✔
73
                Glob:         params.Glob,
3✔
74
                ExcludeFiles: params.ExcludeFiles,
3✔
75
                Root:         params.Root,
3✔
76
                FileTypes:    params.FileTypes,
3✔
77
        }
3✔
78
        for filesType, fn := range filesFns {
6✔
79
                cnt := strings.Count(params.Run, filesType)
3✔
80
                if cnt == 0 {
6✔
81
                        continue
3✔
82
                }
83

84
                templ := &filesTemplate{cnt: cnt}
3✔
85
                filesTemplates[filesType] = templ
3✔
86

3✔
87
                files, err := fn()
3✔
88
                if err != nil {
3✔
NEW
89
                        return nil, nil, fmt.Errorf("error replacing %s: %w", filesType, err)
×
NEW
90
                }
×
91

92
                files = filters.Apply(b.git.Fs, files, filterParams)
3✔
93
                if !b.opts.Force && len(files) == 0 {
6✔
94
                        return nil, nil, SkipError{"no files for inspection"}
3✔
95
                }
3✔
96

97
                templ.files = files
3✔
98
        }
99

100
        // Checking substitutions and skipping execution if it is empty.
101
        //
102
        // Special case for `files` option: return if the result of files command is empty.
103
        if !b.opts.Force && len(filesCmd) > 0 && filesTemplates[config.SubFiles] == nil {
3✔
NEW
104
                files, err := filesFns[config.SubFiles]()
×
NEW
105
                if err != nil {
×
NEW
106
                        return nil, nil, fmt.Errorf("error calling replace command for %s: %w", config.SubFiles, err)
×
NEW
107
                }
×
108

NEW
109
                files = filters.Apply(b.git.Fs, files, filterParams)
×
NEW
110

×
NEW
111
                if len(files) == 0 {
×
NEW
112
                        return nil, nil, SkipError{"no files for inspection"}
×
NEW
113
                }
×
114
        }
115

116
        runString := params.Run
3✔
117
        runString = replacePositionalArguments(runString, b.opts.GitArgs)
3✔
118

3✔
119
        for keyword, replacement := range b.opts.Templates {
6✔
120
                runString = strings.ReplaceAll(runString, "{"+keyword+"}", replacement)
3✔
121
        }
3✔
122

123
        runString = strings.ReplaceAll(runString, "{lefthook_job_name}", shellescape.Quote(params.Name))
3✔
124

3✔
125
        maxlen := system.MaxCmdLen()
3✔
126
        commands, files := replaceInChunks(runString, filesTemplates, maxlen)
3✔
127

3✔
128
        if b.opts.Force || len(files) != 0 {
6✔
129
                return commands, files, nil
3✔
130
        }
3✔
131

132
        if config.HookUsesStagedFiles(b.opts.HookName) {
6✔
133
                ok, err := b.canSkipJob(filterParams, filesTemplates[config.SubStagedFiles], stagedFilesWithDeleted)
3✔
134
                if err != nil {
3✔
NEW
135
                        return nil, nil, err
×
NEW
136
                }
×
137
                if ok {
6✔
138
                        return nil, nil, SkipError{"no matching staged files"}
3✔
139
                }
3✔
140
        }
141

142
        if config.HookUsesPushFiles(b.opts.HookName) {
3✔
NEW
143
                ok, err := b.canSkipJob(filterParams, filesTemplates[config.SubPushFiles], pushFiles)
×
NEW
144
                if err != nil {
×
NEW
145
                        return nil, nil, err
×
NEW
146
                }
×
NEW
147
                if ok {
×
NEW
148
                        return nil, nil, SkipError{"no matching push files"}
×
NEW
149
                }
×
150
        }
151

152
        return commands, files, nil
3✔
153
}
154

155
func (b *Builder) canSkipJob(filterParams filters.Params, template *filesTemplate, filesFn func() ([]string, error)) (bool, error) {
3✔
156
        if template != nil {
3✔
NEW
157
                return len(template.files) == 0, nil
×
NEW
158
        }
×
159

160
        files, err := filesFn()
3✔
161
        if err != nil {
3✔
NEW
162
                return false, fmt.Errorf("error getting files: %w", err)
×
NEW
163
        }
×
164
        if len(filters.Apply(b.git.Fs, files, filterParams)) == 0 {
6✔
165
                return true, nil
3✔
166
        }
3✔
167

168
        return false, nil
3✔
169
}
170

171
func replacePositionalArguments(str string, args []string) string {
3✔
172
        str = strings.ReplaceAll(str, "{0}", strings.Join(args, " "))
3✔
173
        for i, arg := range args {
3✔
NEW
174
                str = strings.ReplaceAll(str, fmt.Sprintf("{%d}", i+1), arg)
×
NEW
175
        }
×
176
        return str
3✔
177
}
178

179
// Escape file names to prevent unexpected bugs.
180
func escapeFiles(files []string) []string {
6✔
181
        var filesEsc []string
6✔
182
        for _, fileName := range files {
12✔
183
                if len(fileName) > 0 {
12✔
184
                        filesEsc = append(filesEsc, shellescape.Quote(fileName))
6✔
185
                }
6✔
186
        }
187

188
        log.Builder(log.DebugLevel, "[lefthook] ").
6✔
189
                Add("files after escaping: ", filesEsc).
6✔
190
                Log()
6✔
191

6✔
192
        return filesEsc
6✔
193
}
194

195
func replaceInChunks(str string, templates map[string]*filesTemplate, maxlen int) ([]string, []string) {
6✔
196
        if len(templates) == 0 {
9✔
197
                return []string{str}, nil
3✔
198
        }
3✔
199

200
        var cnt int
6✔
201

6✔
202
        allFiles := make([]string, 0)
6✔
203
        for name, template := range templates {
12✔
204
                if template.cnt == 0 {
6✔
NEW
205
                        continue
×
206
                }
207

208
                cnt += template.cnt
6✔
209
                maxlen += template.cnt * len(name)
6✔
210
                allFiles = append(allFiles, template.files...)
6✔
211
                template.files = escapeFiles(template.files)
6✔
212
        }
213

214
        maxlen -= len(str)
6✔
215

6✔
216
        if cnt > 0 {
12✔
217
                maxlen /= cnt
6✔
218
        }
6✔
219

220
        var exhausted int
6✔
221
        commands := make([]string, 0)
6✔
222
        for {
12✔
223
                command := str
6✔
224
                for name, template := range templates {
12✔
225
                        added, rest := getNChars(template.files, maxlen)
6✔
226
                        if len(rest) == 0 {
12✔
227
                                exhausted += 1
6✔
228
                        } else {
9✔
229
                                template.files = rest
3✔
230
                        }
3✔
231
                        command = replaceQuoted(command, name, added)
6✔
232
                }
233

234
                log.Debug("[lefthook] job: ", command)
6✔
235
                commands = append(commands, command)
6✔
236
                if exhausted >= len(templates) {
12✔
237
                        break
6✔
238
                }
239
        }
240

241
        return commands, allFiles
6✔
242
}
243

244
func getNChars(s []string, n int) ([]string, []string) {
6✔
245
        if len(s) == 0 {
9✔
246
                return nil, nil
3✔
247
        }
3✔
248

249
        var cnt int
6✔
250
        for i, str := range s {
12✔
251
                cnt += len(str)
6✔
252
                if i > 0 {
12✔
253
                        cnt += 1 // a space
6✔
254
                }
6✔
255
                if cnt > n {
9✔
256
                        if i == 0 {
6✔
257
                                i = 1
3✔
258
                        }
3✔
259
                        return s[:i], s[i:]
3✔
260
                }
261
        }
262

263
        return s, nil
6✔
264
}
265

266
func replaceQuoted(source, substitution string, files []string) string {
6✔
267
        for _, elem := range [][]string{
6✔
268
                {"\"", "\"" + substitution + "\""},
6✔
269
                {"'", "'" + substitution + "'"},
6✔
270
                {"", substitution},
6✔
271
        } {
12✔
272
                quote := elem[0]
6✔
273
                sub := elem[1]
6✔
274
                if !strings.Contains(source, sub) {
12✔
275
                        continue
6✔
276
                }
277

278
                quotedFiles := files
6✔
279
                if len(quote) != 0 {
12✔
280
                        quotedFiles = make([]string, 0, len(files))
6✔
281
                        for _, fileName := range files {
12✔
282
                                quotedFiles = append(quotedFiles,
6✔
283
                                        quote+surroundingQuotesRegexp.ReplaceAllString(fileName, "$1")+quote)
6✔
284
                        }
6✔
285
                }
286

287
                source = strings.ReplaceAll(
6✔
288
                        source, sub, strings.Join(quotedFiles, " "),
6✔
289
                )
6✔
290
        }
291

292
        return source
6✔
293
}
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