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

gabyx / Githooks / 6712

20 Feb 2026 05:56PM UTC coverage: 78.813% (-0.01%) from 78.823%
6712

push

circleci

gabyx
chore: add golines goimport linters :anchor:

281 of 358 new or added lines in 33 files covered. (78.49%)

10 existing lines in 7 files now uncovered.

9549 of 12116 relevant lines covered (78.81%)

1729.84 hits per line

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

72.39
/githooks/git/gitcommon.go
1
package git
2

3
import (
4
        "os"
5
        "os/exec"
6
        "path"
7
        "path/filepath"
8
        "regexp"
9
        "runtime"
10
        "strings"
11

12
        cm "github.com/gabyx/githooks/githooks/common"
13
        strs "github.com/gabyx/githooks/githooks/strings"
14

15
        "github.com/hashicorp/go-version"
16
)
17

18
const (
19
        // NullRef is the null reference used by git during certain hook execution.
20
        NullRef = "0000000000000000000000000000000000000000"
21
)
22

23
var lfsVersionRegex = regexp.MustCompile(`\d+\.\d+\.\d+`)
24

25
// Get Git LFS version.
26
func GetGitLFSVersion() (ver *version.Version, err error) {
944✔
27
        res, err := NewCtx().Get("lfs", "--version")
944✔
28
        if err != nil {
944✔
29
                return nil, err
×
30
        }
×
31

32
        ver, err = version.NewVersion(lfsVersionRegex.FindString(res))
944✔
33

944✔
34
        return
944✔
35
}
36

37
// IsBareRepo returns `true` if `c.GetCwd()` is a bare repository.
38
func (c *Context) IsBareRepo() bool {
1,425✔
39
        out, _ := c.Get("rev-parse", "--is-bare-repository")
1,425✔
40
        return out == GitCVTrue //nolint:nlreturn
1,425✔
41
}
1,425✔
42

43
// IsGitRepo returns `true` if `path` is a git repository (bare or non-bare).
44
func (c *Context) IsGitRepo() bool {
485✔
45
        return c.Check("rev-parse") == nil
485✔
46
}
485✔
47

48
// IsGitDir returns `true` if `c.GetCwd()` is a git repository (bare or non-bare).
49
func (c *Context) IsGitDir() bool {
52✔
50
        s, err := c.Get("rev-parse", "--is-inside-git-dir")
52✔
51

52✔
52
        return err == nil && s == GitCVTrue
52✔
53
}
52✔
54

55
// GetMainWorktree returns the main worktree
56
// based on the current context's working directory.
57
func (c *Context) GetMainWorktree() (string, error) {
79✔
58
        // This feature is kind of buggy in earlier version of git < 2.28.0
79✔
59
        // it returns a git directory instead of the work tree
79✔
60
        // We strip "/.git" from the output.
79✔
61
        trees, err := c.Get("worktree", "list", "--porcelain")
79✔
62
        if err != nil {
79✔
63
                return "", err
×
64
        }
×
65

66
        list := strs.SplitLinesN(trees, 2) //nolint:mnd
79✔
67
        if len(list) == 0 {
79✔
68
                return "", cm.ErrorF("Could not get main worktree in '%s'", c.GetCwd())
×
69
        }
×
70

71
        tree := strings.TrimSuffix(
79✔
72
                strings.TrimSpace(
79✔
73
                        strings.TrimPrefix(list[0], "worktree")),
79✔
74
                "/.git")
79✔
75

79✔
76
        if strs.IsEmpty(tree) {
79✔
77
                return "", cm.ErrorF("Could not get main worktree in '%s'", c.GetCwd())
×
78
        }
×
79

80
        return filepath.ToSlash(tree), nil
79✔
81
}
82

83
// GetGitDirCommon returns the common Git directory.
84
// For normal repos this points to the `.git` directory.
85
// For worktrees this points to the main worktrees git dir.
86
// The env. variable GIT_COMMON_DIR has especially
87
// be introduced for multiple worktrees, see:
88
// https://github.com/git/git/commit/c7b3a3d2fe2688a30ddb8d516ed000eeda13c24e
89
func (c *Context) GetGitDirCommon() (gitDir string, err error) {
1,978✔
90
        // Git 2.46.x apparently runs `reference-transaction` on `git init`
1,978✔
91
        // where `rev-parse` fails despite the documentation saying it reports `$GIT_COMMON_DIR` if set
1,978✔
92
        // (it is set!) -> Bug was reported.
1,978✔
93
        gitDir = os.Getenv("GIT_COMMON_DIR")
1,978✔
94
        if strs.IsEmpty(gitDir) {
3,956✔
95
                gitDir = os.Getenv("GIT_DIR")
1,978✔
96
                if strs.IsEmpty(gitDir) {
3,948✔
97
                        gitDir, err = c.Get("rev-parse", "--git-common-dir")
1,970✔
98
                        if err != nil {
2,050✔
99
                                return
80✔
100
                        }
80✔
101
                }
102
        }
103

104
        if !filepath.IsAbs(gitDir) {
3,784✔
105
                gitDir = filepath.Join(c.GetCwd(), gitDir)
1,886✔
106
        }
1,886✔
107

108
        gitDir, err = filepath.Abs(gitDir)
1,898✔
109
        if err != nil {
1,898✔
110
                return
×
111
        }
×
112

113
        gitDir = filepath.ToSlash(gitDir)
1,898✔
114

1,898✔
115
        return
1,898✔
116
}
117

118
// GetGitDirWorktree returns the Git directory.
119
// For normal repos this points to the `.git` directory.
120
// For worktrees this points to the actual worktrees git dir `.git/worktrees/<....>/`.
121
func (c *Context) GetGitDirWorktree() (gitDir string, err error) {
2,635✔
122
        // Git 2.46.x apparently runs `reference-transaction` on `git init`
2,635✔
123
        // where `rev-parse` fails despite the documentation saying it reports `$GIT_DIR` if set
2,635✔
124
        // (it is set!) -> Bug was reported.
2,635✔
125
        gitDir = os.Getenv("GIT_DIR")
2,635✔
126
        if strs.IsEmpty(gitDir) {
4,886✔
127
                gitDir, err = c.Get("rev-parse", "--absolute-git-dir")
2,251✔
128
                if err != nil {
2,251✔
129
                        return
×
130
                }
×
131
        }
132

133
        gitDir = filepath.ToSlash(gitDir)
2,635✔
134

2,635✔
135
        return
2,635✔
136
}
137

138
// GetRepoRoot returns the top level directory in a non-bare repository or the
139
// absolute Git directory in a bare repository for `topLevel`.
140
// This is the root level for Githooks.
141
// The `gitDir` is the common Git directory (main Git dir for worktrees).
142
func (c *Context) GetRepoRoot() (topLevel string, gitDir string, gitDirWorktree string, err error) {
1,199✔
143
        if gitDir, err = c.GetGitDirCommon(); err != nil {
1,279✔
144
                return
80✔
145
        }
80✔
146

147
        if gitDirWorktree, err = c.GetGitDirWorktree(); err != nil {
1,119✔
148
                return
×
149
        }
×
150

151
        if c.IsBareRepo() {
1,141✔
152
                topLevel = gitDir
22✔
153
        } else {
1,119✔
154
                if topLevel, err = c.Get("rev-parse", "--show-toplevel"); err != nil {
1,097✔
155
                        return
×
156
                }
×
157
                topLevel = filepath.ToSlash(topLevel)
1,097✔
158
        }
159

160
        return
1,119✔
161
}
162

163
// GetCurrentBranch gets the current branch in repository.
164
func (c *Context) GetCurrentBranch() (string, error) {
188✔
165
        return c.Get("branch", "--show-current")
188✔
166
}
188✔
167

168
// FindGitDirs returns Git directories inside `searchDir`.
169
// Paths relative to `searchDir` containing `.dotfiles` (hidden files)
170
// will never be reported. Optionally the output can be sorted.
171
func FindGitDirs(searchDir string) (all []string, err error) {
27✔
172
        candidates, err := cm.Glob(path.Join(searchDir, "**/HEAD"), true)
27✔
173
        if err != nil {
27✔
174
                return all, err
×
175
        }
×
176

177
        // We obtain a list of HEAD files, e.g.
178
        //         - ~/a/b/normal/.git/HEAD
179
        //         - ~/a/b/normal/.git/.../HEAD  1) filter out
180
        //         - ~/a/b/bare-repo/HEAD
181
        //         - ~/a/b/bare-repo/.../HEAD
182

183
        repos := make(strs.StringSet, len(candidates))
27✔
184
        var dir, relPath string
27✔
185

27✔
186
        // Be consistent here , on windows we might get twice `
27✔
187
        // C:/a/.git` and also `c:/a/.git` in the loop below
27✔
188
        // because of the output of `GetGitDirCommon()``.
27✔
189
        // -> adjust the volume label to UpperCase always for storing
27✔
190
        // the result in the `StringMap`
27✔
191
        adjustVolumeNameCase := runtime.GOOS == cm.WindowsOsName
27✔
192

27✔
193
        // Filter wrong dirs out.
27✔
194
        for i := range candidates {
104✔
195
                dir = path.Dir(candidates[i])
77✔
196
                normalGitDir := path.Base(dir) == ".git"
77✔
197

77✔
198
                relPath, err = filepath.Rel(searchDir, dir) // filepath, because path.Rel is not available.
77✔
199
                if err != nil {
77✔
200
                        return all, err
×
201
                }
×
202
                relPath = filepath.ToSlash(relPath)
77✔
203

77✔
204
                if normalGitDir && cm.ContainsDotFile(path.Dir(relPath)) ||
77✔
205
                        !normalGitDir && cm.ContainsDotFile(relPath) {
103✔
206
                        // With that we filter out matches which
26✔
207
                        // contain .dotfiles in the relative path to the search dir.
26✔
208
                        continue
26✔
209
                }
210

211
                // gitDir is always an absolute path
212
                gitDir, e := NewCtxAt(dir).GetGitDirCommon()
51✔
213

51✔
214
                if adjustVolumeNameCase && len(gitDir) >= 2 {
51✔
215
                        gitDir = strings.ToUpper(gitDir[0:2]) + gitDir[2:]
×
216
                }
×
217

218
                // Check if its really a git directory.
219
                if e == nil &&
51✔
220
                        NewCtxAt(gitDir).IsGitRepo() {
102✔
221
                        repos.Insert(gitDir)
51✔
222
                }
51✔
223
        }
224

225
        all = repos.ToList()
27✔
226

27✔
227
        return all, err
27✔
228
}
229

230
// Initialize an empty repo at path `repoPath`.
231
func Init(repoPath string, bare bool) error {
185✔
232
        args := []string{"-c", "core.hooksPath=.git/hooks", "init", "--template="}
185✔
233
        if bare {
370✔
234
                args = append(args, "--bare")
185✔
235
        }
185✔
236

237
        // We must not execute this clone command inside a Git repo  (e.g. A)
238
        // due to `core.hooksPath=.git/hooks` which get applied to `A` -> Bug ?:
239
        // https://stackoverflow.com/questions/67273420/why-does-git-execute-hooks-from-an-other-repository
240
        ctx := NewCtxSanitizedAt(repoPath)
185✔
241
        if !cm.IsDirectory(ctx.GetCwd()) {
185✔
242
                if e := os.MkdirAll(ctx.GetCwd(), cm.DefaultFileModeDirectory); e != nil {
×
243
                        return cm.ErrorF("Could not create working directory '%s'.", ctx.GetCwd())
×
244
                }
×
245
        }
246

247
        out, e := ctx.GetCombined(args...)
185✔
248

185✔
249
        if e != nil {
185✔
250
                return cm.ErrorF("Init Git repository in '%s' failed:\n%s", repoPath, out)
×
251
        }
×
252

253
        return nil
185✔
254
}
255

256
// Clone an URL to a path `repoPath`.
257
func Clone(repoPath string, url string, branch string, depth int) error {
242✔
258
        // Its important to not use any template directory here to not
242✔
259
        // install accidentally Githooks run-wrappers.
242✔
260
        // We set the `core.hooksPath` explicitly to its internal
242✔
261
        // hooks directory to not interfere
242✔
262
        // with global settings.
242✔
263
        // Also this installs LFS hooks, which comes handy for certain shared hook repos
242✔
264
        // with prebuilt binaries.
242✔
265
        args := []string{"clone", "-c", "core.hooksPath=.git/hooks", "--template=", "--single-branch"}
242✔
266

242✔
267
        if branch != "" {
248✔
268
                args = append(args, "--branch", branch)
6✔
269
        }
6✔
270

271
        if depth > 0 {
292✔
272
                args = append(args, strs.Fmt("--depth=%v", depth))
50✔
273
        }
50✔
274

275
        args = append(args, []string{url, repoPath}...)
242✔
276

242✔
277
        // We must not execute this clone command inside a Git repo  (e.g. A)
242✔
278
        // due to `core.hooksPath=.git/hooks` which get applied to `A` -> Bug ?:
242✔
279
        // https://stackoverflow.com/questions/67273420/why-does-git-execute-hooks-from-an-other-repository
242✔
280
        ctx := NewCtxSanitizedAt(path.Dir(repoPath))
242✔
281
        if !cm.IsDirectory(ctx.GetCwd()) {
458✔
282
                if e := os.MkdirAll(ctx.GetCwd(), cm.DefaultFileModeDirectory); e != nil {
216✔
283
                        return cm.ErrorF("Could not create working directory '%s'.", ctx.GetCwd())
×
284
                }
×
285
        }
286

287
        out, e := ctx.GetCombined(args...)
242✔
288

242✔
289
        if e != nil {
242✔
NEW
290
                return cm.ErrorF(
×
NEW
291
                        "Cloning of '%s' [branch: '%s']\ninto '%s' failed:\n%s",
×
NEW
292
                        url,
×
NEW
293
                        branch,
×
NEW
294
                        repoPath,
×
NEW
295
                        out,
×
NEW
296
                )
×
UNCOV
297
        }
×
298

299
        return nil
242✔
300
}
301

302
// Pull executes a pull in `repoPath`.
303
func (c *Context) Pull(remote string) error {
6✔
304
        out, e := c.GetCombined("pull", remote)
6✔
305
        if e != nil {
6✔
306
                return cm.ErrorF("Pulling '%s' in '%s' failed:\n%s", remote, c.GetCwd(), out)
×
307
        }
×
308

309
        return nil
6✔
310
}
311

312
// FetchBranch executes a fetch of a `branch` from the `remote` in `repoPath`.
313
// This command sadly does not automatically (git 2.30) fetch the tags on this branch
314
// automatically. Use `tagPattern` to specify explicitly which tags to fetch.
315
func (c *Context) FetchBranch(remote string, branch string, tagPattern string) error {
282✔
316
        cmd := []string{"fetch", "--force", "--prune", "--prune-tags", "--no-tags", remote, branch}
282✔
317

282✔
318
        if strs.IsNotEmpty(tagPattern) {
564✔
319
                cmd = append(cmd, strs.Fmt("refs/tags/%s:refs/tags/%s", tagPattern, tagPattern))
282✔
320
        }
282✔
321

322
        out, e := c.GetCombined(cmd...)
282✔
323
        if e != nil {
282✔
324
                return cm.ErrorF("Fetching of '%s' from '%s'\nin '%s' failed:\n%s",
×
325
                        branch, remote, c.GetCwd(), out)
×
326
        }
×
327

328
        return nil
282✔
329
}
330

331
// GetCommits gets all commits in the ancestry path starting from `firstSHA` (excluded in the result)
332
// up to and including `lastSHA`.
333
func (c *Context) GetCommits(firstSHA string, lastSHA string) ([]string, error) {
45✔
334
        return c.GetSplit("rev-list", "--ancestry-path", strs.Fmt("%s..%s", firstSHA, lastSHA))
45✔
335
}
45✔
336

337
// GetCommitLog gets all commits in the ancestry path starting from `firstSHA` (excluded in the result)
338
// up to and including `lastSHA`.
339
func (c *Context) GetCommitLog(commitSHA string, format string) (string, error) {
×
340
        return c.Get("log", strs.Fmt("--format=%s", format), commitSHA)
×
341
}
×
342

343
// GetRemoteURLAndBranch reports the `remote`s `url` and
344
// the current `branch` of HEAD.
345
func (c *Context) GetRemoteURLAndBranch(
346
        remote string,
347
) (currentURL string, currentBranch string, err error) {
92✔
348
        currentURL = c.GetConfig("remote."+remote+".url", LocalScope)
92✔
349
        currentBranch, err = c.Get("symbolic-ref", "-q", "--short", HEAD)
92✔
350

92✔
351
        return
92✔
352
}
92✔
353

354
// PullOrClone either executes a pull in `repoPath` or if not
355
// existing, clones to this path.
356
func PullOrClone(
357
        repoPath string,
358
        url string,
359
        branch string,
360
        depth int,
361
        repoCheck func(*Context) error) (isNewClone bool, err error) {
56✔
362
        gitx := NewCtxSanitizedAt(repoPath)
56✔
363
        if gitx.IsGitRepo() {
62✔
364
                isNewClone = false
6✔
365

6✔
366
                if repoCheck != nil {
6✔
367
                        if err = repoCheck(gitx); err != nil {
×
368
                                return
×
369
                        }
×
370
                }
371

372
                err = gitx.Pull("origin")
6✔
373
        } else {
50✔
374
                isNewClone = true
50✔
375

50✔
376
                if err = os.RemoveAll(repoPath); err != nil {
50✔
377
                        err = cm.ErrorF("Could not remove directory '%s'.", repoPath)
×
378
                        return //nolint:nlreturn
×
379
                }
×
380

381
                err = Clone(repoPath, url, branch, depth)
50✔
382
        }
383

384
        return
56✔
385
}
386

387
// RepoCheck is the function which is executed before a fetch.
388
// Arguments 1 and 2 are `url`, `branch`.
389
// Return an error to abort the action.
390
// Return `true` to trigger a complete reclone.
391
// Available ConfigScope's.
392
type RepoCheck = func(Context, string, string) (bool, error)
393

394
// FetchOrClone either executes a fetch in `repoPath` or if not
395
// existing, clones to this path.
396
// The callback `repoCheck` before a fetch can trigger a reclone.
397
func FetchOrClone(
398
        repoPath string,
399
        url string, branch string,
400
        depth int,
401
        tagPattern string,
402
        repoCheck RepoCheck) (isNewClone bool, gitx *Context, err error) {
282✔
403
        gitx = NewCtxSanitizedAt(repoPath)
282✔
404

282✔
405
        if gitx.IsGitRepo() {
374✔
406
                isNewClone = false
92✔
407

92✔
408
                if repoCheck != nil {
184✔
409
                        var reclone bool
92✔
410
                        if reclone, err = repoCheck(*gitx, url, branch); err != nil {
92✔
411
                                return
×
412
                        }
×
413

414
                        isNewClone = reclone
92✔
415
                }
416
        } else {
190✔
417
                isNewClone = true
190✔
418
        }
190✔
419

420
        if isNewClone {
472✔
421
                if err = os.RemoveAll(repoPath); err != nil {
190✔
422
                        return
×
423
                }
×
424
                err = Clone(repoPath, url, branch, depth)
190✔
425
        }
426

427
        if err != nil {
282✔
428
                return
×
429
        }
×
430

431
        err = gitx.FetchBranch("origin", branch, tagPattern)
282✔
432

282✔
433
        return
282✔
434
}
435

436
// IsRefReachable reports if `ref` (can be branch/tag/commit) is contained starting
437
// from `startRef`.
438
func IsRefReachable(gitx *Context, startRef string, ref string) (bool, error) {
282✔
439
        i, err := gitx.GetExitCode("merge-base", "--is-ancestor", ref, startRef)
282✔
440

282✔
441
        return i == 0, err
282✔
442
}
282✔
443

444
// GetTags gets the tags  at `commitSHA`.
445
func GetTags(gitx *Context, commitSHA string) ([]string, error) {
380✔
446
        if strs.IsEmpty(commitSHA) {
380✔
447
                commitSHA = HEAD
×
448
        }
×
449

450
        return gitx.GetSplit("tag", "--points-at", commitSHA)
380✔
451
}
452

453
// GetVersionAt gets the version & tag from the tags at `commitSHA`.
454
func GetVersionAt(gitx *Context, commitSHA string) (*version.Version, string, error) {
380✔
455
        tags, err := GetTags(gitx, commitSHA)
380✔
456
        if err != nil {
380✔
457
                return nil, "", err
×
458
        }
×
459

460
        for _, tag := range tags {
731✔
461
                ver, e := version.NewVersion(tag)
351✔
462
                if e == nil && ver != nil {
702✔
463
                        return ver, tag, nil
351✔
464
                }
351✔
465
        }
466

467
        return nil, "", nil
29✔
468
}
469

470
// GetVersion gets the semantic version and its tag.
471
func GetVersion(
472
        gitx *Context,
473
        commitSHA string,
474
        matchPattern string,
NEW
475
) (v *version.Version, tag string, err error) {
×
476
        if commitSHA == HEAD {
×
477
                commitSHA, err = GetCommitSHA(gitx, HEAD)
×
478
                if err != nil {
×
479
                        return
×
480
                }
×
481
        }
482

483
        tag, err = gitx.Get("describe", "--tags", "--abbrev=0", "--match", matchPattern)
×
484
        if err != nil {
×
485
                return
×
486
        }
×
487
        ver := tag
×
488

×
489
        // Get number of commits ahead.
×
490
        commitsAhead, err := gitx.Get("rev-list", "--count", strs.Fmt("%s..%s", ver, commitSHA))
×
491
        if err != nil {
×
492
                return
×
493
        }
×
494

495
        if commitsAhead != "0" {
×
496
                ver = strs.Fmt("%s+%s.%s", ver, commitsAhead, commitSHA[:7])
×
497
        }
×
498

499
        ver = strings.TrimPrefix(ver, "v")
×
500
        v, err = version.NewVersion(ver)
×
501

×
502
        return v, tag, err
×
503
}
504

505
// GetCommitSHA gets the commit SHA1 of the ref.
506
func GetCommitSHA(gitx *Context, ref string) (string, error) {
×
507
        if strs.IsEmpty(ref) {
×
508
                ref = HEAD
×
509
        }
×
510

511
        return gitx.Get("rev-parse", ref)
×
512
}
513

514
// GetLFSConfigFile gets the LFS config file inside the repository and
515
// `true` if existing.
516
func GetLFSConfigFile(repoDir string) (string, bool) {
118✔
517
        s := path.Join(repoDir, ".lfsconfig")
118✔
518

118✔
519
        return s, cm.IsFile(s)
118✔
520
}
118✔
521

522
// IsLFSAvailable tells if git-lfs is available in the path.
523
func IsLFSAvailable() bool {
1,062✔
524
        _, err := exec.LookPath("git-lfs")
1,062✔
525

1,062✔
526
        return err == nil
1,062✔
527
}
1,062✔
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