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

umputun / ralphex / 21378927894

27 Jan 2026 12:04AM UTC coverage: 78.369% (-0.01%) from 78.381%
21378927894

push

github

web-flow
fix: IsIgnored now loads global and system gitignore patterns (#35)

* fix: IsIgnored now loads global and system gitignore patterns

Two issues fixed:

1. go-git's LoadGlobalPatterns() only reads core.excludesfile from
   ~/.gitconfig, but git also checks the default XDG location
   (~/.config/git/ignore) even without core.excludesfile being set.
   Added loadXDGGlobalPatterns() fallback for this case.

2. HasChangesOtherThan() had incorrect condition for detecting untracked
   files. go-git sets BOTH Staging and Worktree to Untracked ('?') for
   untracked files, but the code checked for Staging == Unmodified (' ').
   Simplified condition to just check Worktree == Untracked.

* style: use strings.SplitSeq for efficiency

22 of 35 new or added lines in 1 file covered. (62.86%)

3460 of 4415 relevant lines covered (78.37%)

64.32 hits per line

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

69.52
/pkg/git/git.go
1
// Package git provides git repository operations using go-git library.
2
package git
3

4
import (
5
        "errors"
6
        "fmt"
7
        "os"
8
        "path/filepath"
9
        "strings"
10
        "time"
11

12
        "github.com/go-git/go-billy/v5/osfs"
13
        "github.com/go-git/go-git/v5"
14
        "github.com/go-git/go-git/v5/config"
15
        "github.com/go-git/go-git/v5/plumbing"
16
        "github.com/go-git/go-git/v5/plumbing/format/gitignore"
17
        "github.com/go-git/go-git/v5/plumbing/object"
18
)
19

20
// Repo provides git operations using go-git.
21
type Repo struct {
22
        repo *git.Repository
23
        path string // absolute path to repository root
24
}
25

26
// Root returns the absolute path to the repository root.
27
func (r *Repo) Root() string {
×
28
        return r.path
×
29
}
×
30

31
// toRelative converts a path to be relative to the repository root.
32
// Absolute paths are converted to repo-relative.
33
// Relative paths starting with ".." are resolved against CWD first.
34
// Other relative paths are assumed to already be repo-relative.
35
// Returns error if the resolved path is outside the repository.
36
func (r *Repo) toRelative(path string) (string, error) {
26✔
37
        // for relative paths, just clean and validate
26✔
38
        if !filepath.IsAbs(path) {
47✔
39
                cleaned := filepath.Clean(path)
21✔
40
                if strings.HasPrefix(cleaned, "..") {
23✔
41
                        return "", fmt.Errorf("path %q escapes repository root", path)
2✔
42
                }
2✔
43
                return cleaned, nil
19✔
44
        }
45

46
        // convert absolute path to repo-relative
47
        rel, err := filepath.Rel(r.path, path)
5✔
48
        if err != nil {
5✔
49
                return "", fmt.Errorf("path outside repository: %w", err)
×
50
        }
×
51

52
        if strings.HasPrefix(rel, "..") {
7✔
53
                return "", fmt.Errorf("path %q is outside repository root %q", path, r.path)
2✔
54
        }
2✔
55

56
        return rel, nil
3✔
57
}
58

59
// Open opens a git repository at the given path.
60
// Supports both regular repositories and git worktrees.
61
func Open(path string) (*Repo, error) {
58✔
62
        repo, err := git.PlainOpenWithOptions(path, &git.PlainOpenOptions{
58✔
63
                EnableDotGitCommonDir: true,
58✔
64
        })
58✔
65
        if err != nil {
59✔
66
                return nil, fmt.Errorf("open repository: %w", err)
1✔
67
        }
1✔
68

69
        // get the worktree root path
70
        wt, err := repo.Worktree()
57✔
71
        if err != nil {
57✔
72
                return nil, fmt.Errorf("get worktree: %w", err)
×
73
        }
×
74

75
        return &Repo{repo: repo, path: wt.Filesystem.Root()}, nil
57✔
76
}
77

78
// HasCommits returns true if the repository has at least one commit.
79
func (r *Repo) HasCommits() (bool, error) {
2✔
80
        _, err := r.repo.Head()
2✔
81
        if err != nil {
3✔
82
                if errors.Is(err, plumbing.ErrReferenceNotFound) {
2✔
83
                        return false, nil // no commits yet
1✔
84
                }
1✔
85
                return false, fmt.Errorf("get HEAD: %w", err)
×
86
        }
87
        return true, nil
1✔
88
}
89

90
// CurrentBranch returns the name of the current branch, or empty string for detached HEAD state.
91
func (r *Repo) CurrentBranch() (string, error) {
6✔
92
        head, err := r.repo.Head()
6✔
93
        if err != nil {
6✔
94
                return "", fmt.Errorf("get HEAD: %w", err)
×
95
        }
×
96
        if !head.Name().IsBranch() {
7✔
97
                return "", nil // detached HEAD
1✔
98
        }
1✔
99
        return head.Name().Short(), nil
5✔
100
}
101

102
// CreateBranch creates a new branch and switches to it.
103
// Returns error if branch already exists to prevent data loss.
104
func (r *Repo) CreateBranch(name string) error {
9✔
105
        wt, err := r.repo.Worktree()
9✔
106
        if err != nil {
9✔
107
                return fmt.Errorf("get worktree: %w", err)
×
108
        }
×
109

110
        head, err := r.repo.Head()
9✔
111
        if err != nil {
9✔
112
                return fmt.Errorf("get HEAD: %w", err)
×
113
        }
×
114

115
        branchRef := plumbing.NewBranchReferenceName(name)
9✔
116

9✔
117
        // check if branch already exists to prevent overwriting
9✔
118
        if _, err := r.repo.Reference(branchRef, false); err == nil {
10✔
119
                return fmt.Errorf("branch %q already exists", name)
1✔
120
        }
1✔
121

122
        // create the branch reference pointing to current HEAD
123
        ref := plumbing.NewHashReference(branchRef, head.Hash())
8✔
124
        if err := r.repo.Storer.SetReference(ref); err != nil {
8✔
125
                return fmt.Errorf("create branch reference: %w", err)
×
126
        }
×
127

128
        // create branch config for tracking
129
        branchConfig := &config.Branch{
8✔
130
                Name: name,
8✔
131
        }
8✔
132
        if err := r.repo.CreateBranch(branchConfig); err != nil {
9✔
133
                // ignore if branch config already exists
1✔
134
                if !errors.Is(err, git.ErrBranchExists) {
2✔
135
                        return fmt.Errorf("create branch config: %w", err)
1✔
136
                }
1✔
137
        }
138

139
        // checkout the new branch, Keep preserves untracked files
140
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
7✔
141
                return fmt.Errorf("checkout branch: %w", err)
×
142
        }
×
143

144
        return nil
7✔
145
}
146

147
// BranchExists checks if a branch with the given name exists.
148
func (r *Repo) BranchExists(name string) bool {
3✔
149
        branchRef := plumbing.NewBranchReferenceName(name)
3✔
150
        _, err := r.repo.Reference(branchRef, false)
3✔
151
        return err == nil
3✔
152
}
3✔
153

154
// CheckoutBranch switches to an existing branch.
155
func (r *Repo) CheckoutBranch(name string) error {
5✔
156
        wt, err := r.repo.Worktree()
5✔
157
        if err != nil {
5✔
158
                return fmt.Errorf("get worktree: %w", err)
×
159
        }
×
160

161
        branchRef := plumbing.NewBranchReferenceName(name)
5✔
162
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
6✔
163
                return fmt.Errorf("checkout branch: %w", err)
1✔
164
        }
1✔
165
        return nil
4✔
166
}
167

168
// MoveFile moves a file using git (equivalent to git mv).
169
// Paths can be absolute or relative to the repository root.
170
// The destination directory must already exist.
171
func (r *Repo) MoveFile(src, dst string) error {
4✔
172
        // convert to relative paths for git operations
4✔
173
        srcRel, err := r.toRelative(src)
4✔
174
        if err != nil {
5✔
175
                return fmt.Errorf("invalid source path: %w", err)
1✔
176
        }
1✔
177
        dstRel, err := r.toRelative(dst)
3✔
178
        if err != nil {
3✔
179
                return fmt.Errorf("invalid destination path: %w", err)
×
180
        }
×
181

182
        wt, err := r.repo.Worktree()
3✔
183
        if err != nil {
3✔
184
                return fmt.Errorf("get worktree: %w", err)
×
185
        }
×
186

187
        srcAbs := filepath.Join(r.path, srcRel)
3✔
188
        dstAbs := filepath.Join(r.path, dstRel)
3✔
189

3✔
190
        // move the file on filesystem
3✔
191
        if err := os.Rename(srcAbs, dstAbs); err != nil {
4✔
192
                return fmt.Errorf("rename file: %w", err)
1✔
193
        }
1✔
194

195
        // stage the removal of old path
196
        if _, err := wt.Remove(srcRel); err != nil {
2✔
197
                // rollback filesystem change
×
198
                _ = os.Rename(dstAbs, srcAbs)
×
199
                return fmt.Errorf("remove old path: %w", err)
×
200
        }
×
201

202
        // stage the addition of new path
203
        if _, err := wt.Add(dstRel); err != nil {
2✔
204
                // rollback: unstage removal and restore file
×
205
                _ = os.Rename(dstAbs, srcAbs)
×
206
                return fmt.Errorf("add new path: %w", err)
×
207
        }
×
208

209
        return nil
2✔
210
}
211

212
// Add stages a file for commit.
213
// Path can be absolute or relative to the repository root.
214
func (r *Repo) Add(path string) error {
14✔
215
        rel, err := r.toRelative(path)
14✔
216
        if err != nil {
14✔
217
                return fmt.Errorf("invalid path: %w", err)
×
218
        }
×
219

220
        wt, err := r.repo.Worktree()
14✔
221
        if err != nil {
14✔
222
                return fmt.Errorf("get worktree: %w", err)
×
223
        }
×
224

225
        if _, err := wt.Add(rel); err != nil {
15✔
226
                return fmt.Errorf("add file: %w", err)
1✔
227
        }
1✔
228

229
        return nil
13✔
230
}
231

232
// Commit creates a commit with the given message.
233
// Returns error if no changes are staged.
234
func (r *Repo) Commit(msg string) error {
10✔
235
        wt, err := r.repo.Worktree()
10✔
236
        if err != nil {
10✔
237
                return fmt.Errorf("get worktree: %w", err)
×
238
        }
×
239

240
        author := r.getAuthor()
10✔
241
        _, err = wt.Commit(msg, &git.CommitOptions{Author: author})
10✔
242
        if err != nil {
11✔
243
                return fmt.Errorf("commit: %w", err)
1✔
244
        }
1✔
245

246
        return nil
9✔
247
}
248

249
// getAuthor returns the commit author from git config or a fallback.
250
// checks repository config first (.git/config), then falls back to global config,
251
// and finally to default values.
252
func (r *Repo) getAuthor() *object.Signature {
12✔
253
        // try repository config first (merges local + global)
12✔
254
        if cfg, err := r.repo.Config(); err == nil {
24✔
255
                if cfg.User.Name != "" && cfg.User.Email != "" {
12✔
256
                        return &object.Signature{
×
257
                                Name:  cfg.User.Name,
×
258
                                Email: cfg.User.Email,
×
259
                                When:  time.Now(),
×
260
                        }
×
261
                }
×
262
        }
263

264
        // fallback to global config only
265
        if cfg, err := config.LoadConfig(config.GlobalScope); err == nil {
24✔
266
                if cfg.User.Name != "" && cfg.User.Email != "" {
12✔
267
                        return &object.Signature{
×
268
                                Name:  cfg.User.Name,
×
269
                                Email: cfg.User.Email,
×
270
                                When:  time.Now(),
×
271
                        }
×
272
                }
×
273
        }
274

275
        // fallback to default author
276
        return &object.Signature{
12✔
277
                Name:  "ralphex",
12✔
278
                Email: "ralphex@localhost",
12✔
279
                When:  time.Now(),
12✔
280
        }
12✔
281
}
282

283
// IsIgnored checks if a path is ignored by gitignore rules.
284
// Checks local .gitignore files, global gitignore (from core.excludesfile or default
285
// XDG location ~/.config/git/ignore), and system gitignore (/etc/gitconfig).
286
// Returns false, nil if no gitignore rules exist.
287
func (r *Repo) IsIgnored(path string) (bool, error) {
6✔
288
        wt, err := r.repo.Worktree()
6✔
289
        if err != nil {
6✔
290
                return false, fmt.Errorf("get worktree: %w", err)
×
291
        }
×
292

293
        // read gitignore patterns from the worktree (.gitignore files)
294
        patterns, _ := gitignore.ReadPatterns(wt.Filesystem, nil)
6✔
295

6✔
296
        // load global patterns from ~/.gitconfig's core.excludesfile
6✔
297
        rootFS := osfs.New("/")
6✔
298
        if globalPatterns, err := gitignore.LoadGlobalPatterns(rootFS); err == nil && len(globalPatterns) > 0 {
6✔
NEW
299
                patterns = append(patterns, globalPatterns...)
×
300
        } else {
6✔
301
                // fallback to default XDG location if core.excludesfile not set
6✔
302
                // git uses $XDG_CONFIG_HOME/git/ignore (defaults to ~/.config/git/ignore)
6✔
303
                patterns = append(patterns, loadXDGGlobalPatterns()...)
6✔
304
        }
6✔
305

306
        // load system patterns from /etc/gitconfig's core.excludesfile
307
        if systemPatterns, err := gitignore.LoadSystemPatterns(rootFS); err == nil {
12✔
308
                patterns = append(patterns, systemPatterns...)
6✔
309
        }
6✔
310

311
        matcher := gitignore.NewMatcher(patterns)
6✔
312
        pathParts := strings.Split(filepath.ToSlash(path), "/")
6✔
313
        return matcher.Match(pathParts, false), nil
6✔
314
}
315

316
// loadXDGGlobalPatterns loads gitignore patterns from the default XDG location.
317
// Git checks $XDG_CONFIG_HOME/git/ignore, defaulting to ~/.config/git/ignore.
318
func loadXDGGlobalPatterns() []gitignore.Pattern {
6✔
319
        // check XDG_CONFIG_HOME first, fall back to ~/.config
6✔
320
        configHome := os.Getenv("XDG_CONFIG_HOME")
6✔
321
        if configHome == "" {
6✔
NEW
322
                home, err := os.UserHomeDir()
×
NEW
323
                if err != nil {
×
NEW
324
                        return nil
×
NEW
325
                }
×
NEW
326
                configHome = filepath.Join(home, ".config")
×
327
        }
328

329
        ignorePath := filepath.Join(configHome, "git", "ignore")
6✔
330
        data, err := os.ReadFile(ignorePath) //nolint:gosec // user's gitignore file
6✔
331
        if err != nil {
12✔
332
                return nil
6✔
333
        }
6✔
334

NEW
335
        var patterns []gitignore.Pattern
×
NEW
336
        for line := range strings.SplitSeq(string(data), "\n") {
×
NEW
337
                line = strings.TrimSpace(line)
×
NEW
338
                if line == "" || strings.HasPrefix(line, "#") {
×
NEW
339
                        continue
×
340
                }
NEW
341
                patterns = append(patterns, gitignore.ParsePattern(line, nil))
×
342
        }
NEW
343
        return patterns
×
344
}
345

346
// IsDirty returns true if the worktree has uncommitted changes
347
// (staged or modified tracked files).
348
func (r *Repo) IsDirty() (bool, error) {
10✔
349
        wt, err := r.repo.Worktree()
10✔
350
        if err != nil {
10✔
351
                return false, fmt.Errorf("get worktree: %w", err)
×
352
        }
×
353

354
        status, err := wt.Status()
10✔
355
        if err != nil {
10✔
356
                return false, fmt.Errorf("get status: %w", err)
×
357
        }
×
358

359
        for _, s := range status {
15✔
360
                // check for staged changes
5✔
361
                if s.Staging != git.Unmodified && s.Staging != git.Untracked {
6✔
362
                        return true, nil
1✔
363
                }
1✔
364
                // check for unstaged changes to tracked files
365
                if s.Worktree == git.Modified || s.Worktree == git.Deleted {
7✔
366
                        return true, nil
3✔
367
                }
3✔
368
        }
369

370
        return false, nil
6✔
371
}
372

373
// HasChangesOtherThan returns true if there are uncommitted changes to files other than the given file.
374
// this includes modified/deleted tracked files, staged changes, and untracked files (excluding gitignored).
375
func (r *Repo) HasChangesOtherThan(filePath string) (bool, error) {
7✔
376
        wt, err := r.repo.Worktree()
7✔
377
        if err != nil {
7✔
378
                return false, fmt.Errorf("get worktree: %w", err)
×
379
        }
×
380

381
        status, err := wt.Status()
7✔
382
        if err != nil {
7✔
383
                return false, fmt.Errorf("get status: %w", err)
×
384
        }
×
385

386
        relPath, err := r.normalizeToRelative(filePath)
7✔
387
        if err != nil {
7✔
388
                return false, err
×
389
        }
×
390

391
        for path, s := range status {
15✔
392
                if path == relPath {
12✔
393
                        continue // skip the target file
4✔
394
                }
395
                if !r.fileHasChanges(s) {
4✔
396
                        continue
×
397
                }
398
                // for untracked files, check if they're gitignored
399
                // note: go-git sets both Staging and Worktree to Untracked for untracked files
400
                if s.Worktree == git.Untracked {
7✔
401
                        ignored, err := r.IsIgnored(path)
3✔
402
                        if err != nil {
3✔
403
                                return false, fmt.Errorf("check ignored: %w", err)
×
404
                        }
×
405
                        if ignored {
3✔
406
                                continue // skip gitignored untracked files
×
407
                        }
408
                }
409
                return true, nil
4✔
410
        }
411

412
        return false, nil
3✔
413
}
414

415
// FileHasChanges returns true if the given file has uncommitted changes.
416
// this includes untracked, modified, deleted, or staged states.
417
func (r *Repo) FileHasChanges(filePath string) (bool, error) {
5✔
418
        wt, err := r.repo.Worktree()
5✔
419
        if err != nil {
5✔
420
                return false, fmt.Errorf("get worktree: %w", err)
×
421
        }
×
422

423
        status, err := wt.Status()
5✔
424
        if err != nil {
5✔
425
                return false, fmt.Errorf("get status: %w", err)
×
426
        }
×
427

428
        relPath, err := r.normalizeToRelative(filePath)
5✔
429
        if err != nil {
5✔
430
                return false, err
×
431
        }
×
432

433
        if s, ok := status[relPath]; ok {
8✔
434
                return r.fileHasChanges(s), nil
3✔
435
        }
3✔
436

437
        return false, nil
2✔
438
}
439

440
// normalizeToRelative converts a file path to be relative to the repository root.
441
func (r *Repo) normalizeToRelative(filePath string) (string, error) {
12✔
442
        absPath, err := filepath.Abs(filePath)
12✔
443
        if err != nil {
12✔
444
                return "", fmt.Errorf("get absolute path: %w", err)
×
445
        }
×
446
        relPath, err := filepath.Rel(r.path, absPath)
12✔
447
        if err != nil {
12✔
448
                return "", fmt.Errorf("get relative path: %w", err)
×
449
        }
×
450
        return relPath, nil
12✔
451
}
452

453
// fileHasChanges checks if a file status indicates uncommitted changes.
454
func (r *Repo) fileHasChanges(s *git.FileStatus) bool {
7✔
455
        return s.Staging != git.Unmodified ||
7✔
456
                s.Worktree == git.Modified || s.Worktree == git.Deleted || s.Worktree == git.Untracked
7✔
457
}
7✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc