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

umputun / ralphex / 21456522188

28 Jan 2026 09:41PM UTC coverage: 78.194% (+0.01%) from 78.183%
21456522188

Pull #41

github

umputun
fix(git): correct gitignore precedence and CreateInitialCommit filtering

IsIgnored now loads patterns in correct order: system → global → local
(local has highest priority since go-git checks patterns end-to-start).

CreateInitialCommit now iterates files individually and checks each with
IsIgnored instead of using AddGlob, properly respecting global gitignore.

Related to #38
Pull Request #41: feat(git): auto-create initial commit for empty repos

53 of 66 new or added lines in 3 files covered. (80.3%)

1 existing line in 1 file now uncovered.

3733 of 4774 relevant lines covered (78.19%)

61.37 hits per line

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

71.56
/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) {
65✔
62
        repo, err := git.PlainOpenWithOptions(path, &git.PlainOpenOptions{
65✔
63
                EnableDotGitCommonDir: true,
65✔
64
        })
65✔
65
        if err != nil {
66✔
66
                return nil, fmt.Errorf("open repository: %w", err)
1✔
67
        }
1✔
68

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

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

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

90
// CreateInitialCommit stages all non-ignored files and creates an initial commit.
91
// Returns error if no files to stage or commit fails.
92
// Respects local, global, and system gitignore patterns via IsIgnored.
93
func (r *Repo) CreateInitialCommit(message string) error {
5✔
94
        wt, err := r.repo.Worktree()
5✔
95
        if err != nil {
5✔
NEW
96
                return fmt.Errorf("get worktree: %w", err)
×
NEW
97
        }
×
98

99
        // get status to find untracked files
100
        status, err := wt.Status()
5✔
101
        if err != nil {
5✔
NEW
102
                return fmt.Errorf("get status: %w", err)
×
NEW
103
        }
×
104

105
        // stage each untracked file that's not ignored
106
        staged := 0
5✔
107
        for path, s := range status {
12✔
108
                if s.Worktree != git.Untracked {
7✔
NEW
109
                        continue
×
110
                }
111
                ignored, ignoreErr := r.IsIgnored(path)
7✔
112
                if ignoreErr != nil {
7✔
NEW
113
                        return fmt.Errorf("check ignored %s: %w", path, ignoreErr)
×
NEW
114
                }
×
115
                if ignored {
8✔
116
                        continue
1✔
117
                }
118
                if _, addErr := wt.Add(path); addErr != nil {
6✔
NEW
119
                        return fmt.Errorf("stage %s: %w", path, addErr)
×
NEW
120
                }
×
121
                staged++
6✔
122
        }
123

124
        if staged == 0 {
6✔
125
                return errors.New("no files to commit")
1✔
126
        }
1✔
127

128
        author := r.getAuthor()
4✔
129
        _, err = wt.Commit(message, &git.CommitOptions{Author: author})
4✔
130
        if err != nil {
4✔
NEW
131
                return fmt.Errorf("commit: %w", err)
×
NEW
132
        }
×
133

134
        return nil
4✔
135
}
136

137
// CurrentBranch returns the name of the current branch, or empty string for detached HEAD state.
138
func (r *Repo) CurrentBranch() (string, error) {
6✔
139
        head, err := r.repo.Head()
6✔
140
        if err != nil {
6✔
141
                return "", fmt.Errorf("get HEAD: %w", err)
×
142
        }
×
143
        if !head.Name().IsBranch() {
7✔
144
                return "", nil // detached HEAD
1✔
145
        }
1✔
146
        return head.Name().Short(), nil
5✔
147
}
148

149
// CreateBranch creates a new branch and switches to it.
150
// Returns error if branch already exists to prevent data loss.
151
func (r *Repo) CreateBranch(name string) error {
9✔
152
        wt, err := r.repo.Worktree()
9✔
153
        if err != nil {
9✔
154
                return fmt.Errorf("get worktree: %w", err)
×
155
        }
×
156

157
        head, err := r.repo.Head()
9✔
158
        if err != nil {
9✔
159
                return fmt.Errorf("get HEAD: %w", err)
×
160
        }
×
161

162
        branchRef := plumbing.NewBranchReferenceName(name)
9✔
163

9✔
164
        // check if branch already exists to prevent overwriting
9✔
165
        if _, err := r.repo.Reference(branchRef, false); err == nil {
10✔
166
                return fmt.Errorf("branch %q already exists", name)
1✔
167
        }
1✔
168

169
        // create the branch reference pointing to current HEAD
170
        ref := plumbing.NewHashReference(branchRef, head.Hash())
8✔
171
        if err := r.repo.Storer.SetReference(ref); err != nil {
8✔
172
                return fmt.Errorf("create branch reference: %w", err)
×
173
        }
×
174

175
        // create branch config for tracking
176
        branchConfig := &config.Branch{
8✔
177
                Name: name,
8✔
178
        }
8✔
179
        if err := r.repo.CreateBranch(branchConfig); err != nil {
9✔
180
                // ignore if branch config already exists
1✔
181
                if !errors.Is(err, git.ErrBranchExists) {
2✔
182
                        return fmt.Errorf("create branch config: %w", err)
1✔
183
                }
1✔
184
        }
185

186
        // checkout the new branch, Keep preserves untracked files
187
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
7✔
188
                return fmt.Errorf("checkout branch: %w", err)
×
189
        }
×
190

191
        return nil
7✔
192
}
193

194
// BranchExists checks if a branch with the given name exists.
195
func (r *Repo) BranchExists(name string) bool {
3✔
196
        branchRef := plumbing.NewBranchReferenceName(name)
3✔
197
        _, err := r.repo.Reference(branchRef, false)
3✔
198
        return err == nil
3✔
199
}
3✔
200

201
// CheckoutBranch switches to an existing branch.
202
func (r *Repo) CheckoutBranch(name string) error {
5✔
203
        wt, err := r.repo.Worktree()
5✔
204
        if err != nil {
5✔
205
                return fmt.Errorf("get worktree: %w", err)
×
206
        }
×
207

208
        branchRef := plumbing.NewBranchReferenceName(name)
5✔
209
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
6✔
210
                return fmt.Errorf("checkout branch: %w", err)
1✔
211
        }
1✔
212
        return nil
4✔
213
}
214

215
// MoveFile moves a file using git (equivalent to git mv).
216
// Paths can be absolute or relative to the repository root.
217
// The destination directory must already exist.
218
func (r *Repo) MoveFile(src, dst string) error {
4✔
219
        // convert to relative paths for git operations
4✔
220
        srcRel, err := r.toRelative(src)
4✔
221
        if err != nil {
5✔
222
                return fmt.Errorf("invalid source path: %w", err)
1✔
223
        }
1✔
224
        dstRel, err := r.toRelative(dst)
3✔
225
        if err != nil {
3✔
226
                return fmt.Errorf("invalid destination path: %w", err)
×
227
        }
×
228

229
        wt, err := r.repo.Worktree()
3✔
230
        if err != nil {
3✔
231
                return fmt.Errorf("get worktree: %w", err)
×
232
        }
×
233

234
        srcAbs := filepath.Join(r.path, srcRel)
3✔
235
        dstAbs := filepath.Join(r.path, dstRel)
3✔
236

3✔
237
        // move the file on filesystem
3✔
238
        if err := os.Rename(srcAbs, dstAbs); err != nil {
4✔
239
                return fmt.Errorf("rename file: %w", err)
1✔
240
        }
1✔
241

242
        // stage the removal of old path
243
        if _, err := wt.Remove(srcRel); err != nil {
2✔
244
                // rollback filesystem change
×
245
                _ = os.Rename(dstAbs, srcAbs)
×
246
                return fmt.Errorf("remove old path: %w", err)
×
247
        }
×
248

249
        // stage the addition of new path
250
        if _, err := wt.Add(dstRel); err != nil {
2✔
251
                // rollback: unstage removal and restore file
×
252
                _ = os.Rename(dstAbs, srcAbs)
×
253
                return fmt.Errorf("add new path: %w", err)
×
254
        }
×
255

256
        return nil
2✔
257
}
258

259
// Add stages a file for commit.
260
// Path can be absolute or relative to the repository root.
261
func (r *Repo) Add(path string) error {
14✔
262
        rel, err := r.toRelative(path)
14✔
263
        if err != nil {
14✔
264
                return fmt.Errorf("invalid path: %w", err)
×
265
        }
×
266

267
        wt, err := r.repo.Worktree()
14✔
268
        if err != nil {
14✔
269
                return fmt.Errorf("get worktree: %w", err)
×
270
        }
×
271

272
        if _, err := wt.Add(rel); err != nil {
15✔
273
                return fmt.Errorf("add file: %w", err)
1✔
274
        }
1✔
275

276
        return nil
13✔
277
}
278

279
// Commit creates a commit with the given message.
280
// Returns error if no changes are staged.
281
func (r *Repo) Commit(msg string) error {
10✔
282
        wt, err := r.repo.Worktree()
10✔
283
        if err != nil {
10✔
284
                return fmt.Errorf("get worktree: %w", err)
×
285
        }
×
286

287
        author := r.getAuthor()
10✔
288
        _, err = wt.Commit(msg, &git.CommitOptions{Author: author})
10✔
289
        if err != nil {
11✔
290
                return fmt.Errorf("commit: %w", err)
1✔
291
        }
1✔
292

293
        return nil
9✔
294
}
295

296
// getAuthor returns the commit author from git config or a fallback.
297
// checks repository config first (.git/config), then falls back to global config,
298
// and finally to default values.
299
func (r *Repo) getAuthor() *object.Signature {
16✔
300
        // try repository config first (merges local + global)
16✔
301
        if cfg, err := r.repo.Config(); err == nil {
32✔
302
                if cfg.User.Name != "" && cfg.User.Email != "" {
16✔
303
                        return &object.Signature{
×
304
                                Name:  cfg.User.Name,
×
305
                                Email: cfg.User.Email,
×
306
                                When:  time.Now(),
×
307
                        }
×
308
                }
×
309
        }
310

311
        // fallback to global config only
312
        if cfg, err := config.LoadConfig(config.GlobalScope); err == nil {
32✔
313
                if cfg.User.Name != "" && cfg.User.Email != "" {
16✔
314
                        return &object.Signature{
×
315
                                Name:  cfg.User.Name,
×
316
                                Email: cfg.User.Email,
×
317
                                When:  time.Now(),
×
318
                        }
×
319
                }
×
320
        }
321

322
        // fallback to default author
323
        return &object.Signature{
16✔
324
                Name:  "ralphex",
16✔
325
                Email: "ralphex@localhost",
16✔
326
                When:  time.Now(),
16✔
327
        }
16✔
328
}
329

330
// IsIgnored checks if a path is ignored by gitignore rules.
331
// Checks local .gitignore files, global gitignore (from core.excludesfile or default
332
// XDG location ~/.config/git/ignore), and system gitignore (/etc/gitconfig).
333
// Returns false, nil if no gitignore rules exist.
334
//
335
// Precedence (highest to lowest): local .gitignore > global > system.
336
// go-git's Matcher checks patterns from end-to-start, so patterns at end have higher priority.
337
func (r *Repo) IsIgnored(path string) (bool, error) {
16✔
338
        wt, err := r.repo.Worktree()
16✔
339
        if err != nil {
16✔
340
                return false, fmt.Errorf("get worktree: %w", err)
×
341
        }
×
342

343
        var patterns []gitignore.Pattern
16✔
344
        rootFS := osfs.New("/")
16✔
345

16✔
346
        // load system patterns first (lowest priority)
16✔
347
        if systemPatterns, err := gitignore.LoadSystemPatterns(rootFS); err == nil {
32✔
348
                patterns = append(patterns, systemPatterns...)
16✔
349
        }
16✔
350

351
        // load global patterns (middle priority)
352
        if globalPatterns, err := gitignore.LoadGlobalPatterns(rootFS); err == nil && len(globalPatterns) > 0 {
16✔
353
                patterns = append(patterns, globalPatterns...)
×
354
        } else {
16✔
355
                // fallback to default XDG location if core.excludesfile not set
16✔
356
                // git uses $XDG_CONFIG_HOME/git/ignore (defaults to ~/.config/git/ignore)
16✔
357
                patterns = append(patterns, loadXDGGlobalPatterns()...)
16✔
358
        }
16✔
359

360
        // load local patterns last (highest priority)
361
        localPatterns, _ := gitignore.ReadPatterns(wt.Filesystem, nil)
16✔
362
        patterns = append(patterns, localPatterns...)
16✔
363

16✔
364
        matcher := gitignore.NewMatcher(patterns)
16✔
365
        pathParts := strings.Split(filepath.ToSlash(path), "/")
16✔
366
        return matcher.Match(pathParts, false), nil
16✔
367
}
368

369
// loadXDGGlobalPatterns loads gitignore patterns from the default XDG location.
370
// Git checks $XDG_CONFIG_HOME/git/ignore, defaulting to ~/.config/git/ignore.
371
func loadXDGGlobalPatterns() []gitignore.Pattern {
16✔
372
        // check XDG_CONFIG_HOME first, fall back to ~/.config
16✔
373
        configHome := os.Getenv("XDG_CONFIG_HOME")
16✔
374
        if configHome == "" {
16✔
375
                home, err := os.UserHomeDir()
×
376
                if err != nil {
×
377
                        return nil
×
378
                }
×
379
                configHome = filepath.Join(home, ".config")
×
380
        }
381

382
        ignorePath := filepath.Join(configHome, "git", "ignore")
16✔
383
        data, err := os.ReadFile(ignorePath) //nolint:gosec // user's gitignore file
16✔
384
        if err != nil {
27✔
385
                return nil
11✔
386
        }
11✔
387

388
        var patterns []gitignore.Pattern
5✔
389
        for line := range strings.SplitSeq(string(data), "\n") {
15✔
390
                line = strings.TrimSpace(line)
10✔
391
                if line == "" || strings.HasPrefix(line, "#") {
15✔
392
                        continue
5✔
393
                }
394
                patterns = append(patterns, gitignore.ParsePattern(line, nil))
5✔
395
        }
396
        return patterns
5✔
397
}
398

399
// IsDirty returns true if the worktree has uncommitted changes
400
// (staged or modified tracked files).
401
func (r *Repo) IsDirty() (bool, error) {
10✔
402
        wt, err := r.repo.Worktree()
10✔
403
        if err != nil {
10✔
404
                return false, fmt.Errorf("get worktree: %w", err)
×
405
        }
×
406

407
        status, err := wt.Status()
10✔
408
        if err != nil {
10✔
409
                return false, fmt.Errorf("get status: %w", err)
×
410
        }
×
411

412
        for _, s := range status {
15✔
413
                // check for staged changes
5✔
414
                if s.Staging != git.Unmodified && s.Staging != git.Untracked {
6✔
415
                        return true, nil
1✔
416
                }
1✔
417
                // check for unstaged changes to tracked files
418
                if s.Worktree == git.Modified || s.Worktree == git.Deleted {
7✔
419
                        return true, nil
3✔
420
                }
3✔
421
        }
422

423
        return false, nil
6✔
424
}
425

426
// HasChangesOtherThan returns true if there are uncommitted changes to files other than the given file.
427
// this includes modified/deleted tracked files, staged changes, and untracked files (excluding gitignored).
428
func (r *Repo) HasChangesOtherThan(filePath string) (bool, error) {
7✔
429
        wt, err := r.repo.Worktree()
7✔
430
        if err != nil {
7✔
431
                return false, fmt.Errorf("get worktree: %w", err)
×
432
        }
×
433

434
        status, err := wt.Status()
7✔
435
        if err != nil {
7✔
436
                return false, fmt.Errorf("get status: %w", err)
×
437
        }
×
438

439
        relPath, err := r.normalizeToRelative(filePath)
7✔
440
        if err != nil {
7✔
441
                return false, err
×
442
        }
×
443

444
        for path, s := range status {
15✔
445
                if path == relPath {
12✔
446
                        continue // skip the target file
4✔
447
                }
448
                if !r.fileHasChanges(s) {
4✔
449
                        continue
×
450
                }
451
                // for untracked files, check if they're gitignored
452
                // note: go-git sets both Staging and Worktree to Untracked for untracked files
453
                if s.Worktree == git.Untracked {
7✔
454
                        ignored, err := r.IsIgnored(path)
3✔
455
                        if err != nil {
3✔
456
                                return false, fmt.Errorf("check ignored: %w", err)
×
457
                        }
×
458
                        if ignored {
3✔
459
                                continue // skip gitignored untracked files
×
460
                        }
461
                }
462
                return true, nil
4✔
463
        }
464

465
        return false, nil
3✔
466
}
467

468
// FileHasChanges returns true if the given file has uncommitted changes.
469
// this includes untracked, modified, deleted, or staged states.
470
func (r *Repo) FileHasChanges(filePath string) (bool, error) {
5✔
471
        wt, err := r.repo.Worktree()
5✔
472
        if err != nil {
5✔
473
                return false, fmt.Errorf("get worktree: %w", err)
×
474
        }
×
475

476
        status, err := wt.Status()
5✔
477
        if err != nil {
5✔
478
                return false, fmt.Errorf("get status: %w", err)
×
479
        }
×
480

481
        relPath, err := r.normalizeToRelative(filePath)
5✔
482
        if err != nil {
5✔
483
                return false, err
×
484
        }
×
485

486
        if s, ok := status[relPath]; ok {
8✔
487
                return r.fileHasChanges(s), nil
3✔
488
        }
3✔
489

490
        return false, nil
2✔
491
}
492

493
// normalizeToRelative converts a file path to be relative to the repository root.
494
func (r *Repo) normalizeToRelative(filePath string) (string, error) {
12✔
495
        absPath, err := filepath.Abs(filePath)
12✔
496
        if err != nil {
12✔
497
                return "", fmt.Errorf("get absolute path: %w", err)
×
498
        }
×
499
        relPath, err := filepath.Rel(r.path, absPath)
12✔
500
        if err != nil {
12✔
501
                return "", fmt.Errorf("get relative path: %w", err)
×
502
        }
×
503
        return relPath, nil
12✔
504
}
505

506
// fileHasChanges checks if a file status indicates uncommitted changes.
507
func (r *Repo) fileHasChanges(s *git.FileStatus) bool {
7✔
508
        return s.Staging != git.Unmodified ||
7✔
509
                s.Worktree == git.Modified || s.Worktree == git.Deleted || s.Worktree == git.Untracked
7✔
510
}
7✔
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