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

umputun / ralphex / 21456832369

28 Jan 2026 09:51PM UTC coverage: 78.252% (+0.07%) from 78.183%
21456832369

Pull #41

github

umputun
refactor(git): deterministic file staging order in CreateInitialCommit

Sort paths before staging for reproducible commit tree hashes.
Add clarifying comment for scanner error handling in AskYesNo.
Pull Request #41: feat(git): auto-create initial commit for empty repos

62 of 74 new or added lines in 3 files covered. (83.78%)

1 existing line in 1 file now uncovered.

3742 of 4782 relevant lines covered (78.25%)

61.69 hits per line

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

72.37
/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
        "sort"
10
        "strings"
11
        "time"
12

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

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

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

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

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

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

57
        return rel, nil
3✔
58
}
59

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

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

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

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

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

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

106
        // collect untracked paths and sort for deterministic staging order
107
        var paths []string
5✔
108
        for path, s := range status {
12✔
109
                if s.Worktree == git.Untracked {
14✔
110
                        paths = append(paths, path)
7✔
111
                }
7✔
112
        }
113
        sort.Strings(paths)
5✔
114

5✔
115
        // stage each untracked file that's not ignored
5✔
116
        staged := 0
5✔
117
        for _, path := range paths {
12✔
118
                ignored, ignoreErr := r.IsIgnored(path)
7✔
119
                if ignoreErr != nil {
7✔
NEW
120
                        return fmt.Errorf("check ignored %s: %w", path, ignoreErr)
×
NEW
121
                }
×
122
                if ignored {
8✔
123
                        continue
1✔
124
                }
125
                if _, addErr := wt.Add(path); addErr != nil {
6✔
NEW
126
                        return fmt.Errorf("stage %s: %w", path, addErr)
×
NEW
127
                }
×
128
                staged++
6✔
129
        }
130

131
        if staged == 0 {
6✔
132
                return errors.New("no files to commit")
1✔
133
        }
1✔
134

135
        author := r.getAuthor()
4✔
136
        _, err = wt.Commit(message, &git.CommitOptions{Author: author})
4✔
137
        if err != nil {
4✔
NEW
138
                return fmt.Errorf("commit: %w", err)
×
NEW
139
        }
×
140

141
        return nil
4✔
142
}
143

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

156
// CreateBranch creates a new branch and switches to it.
157
// Returns error if branch already exists to prevent data loss.
158
func (r *Repo) CreateBranch(name string) error {
9✔
159
        wt, err := r.repo.Worktree()
9✔
160
        if err != nil {
9✔
161
                return fmt.Errorf("get worktree: %w", err)
×
162
        }
×
163

164
        head, err := r.repo.Head()
9✔
165
        if err != nil {
9✔
166
                return fmt.Errorf("get HEAD: %w", err)
×
167
        }
×
168

169
        branchRef := plumbing.NewBranchReferenceName(name)
9✔
170

9✔
171
        // check if branch already exists to prevent overwriting
9✔
172
        if _, err := r.repo.Reference(branchRef, false); err == nil {
10✔
173
                return fmt.Errorf("branch %q already exists", name)
1✔
174
        }
1✔
175

176
        // create the branch reference pointing to current HEAD
177
        ref := plumbing.NewHashReference(branchRef, head.Hash())
8✔
178
        if err := r.repo.Storer.SetReference(ref); err != nil {
8✔
179
                return fmt.Errorf("create branch reference: %w", err)
×
180
        }
×
181

182
        // create branch config for tracking
183
        branchConfig := &config.Branch{
8✔
184
                Name: name,
8✔
185
        }
8✔
186
        if err := r.repo.CreateBranch(branchConfig); err != nil {
9✔
187
                // ignore if branch config already exists
1✔
188
                if !errors.Is(err, git.ErrBranchExists) {
2✔
189
                        return fmt.Errorf("create branch config: %w", err)
1✔
190
                }
1✔
191
        }
192

193
        // checkout the new branch, Keep preserves untracked files
194
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
7✔
195
                return fmt.Errorf("checkout branch: %w", err)
×
196
        }
×
197

198
        return nil
7✔
199
}
200

201
// BranchExists checks if a branch with the given name exists.
202
func (r *Repo) BranchExists(name string) bool {
3✔
203
        branchRef := plumbing.NewBranchReferenceName(name)
3✔
204
        _, err := r.repo.Reference(branchRef, false)
3✔
205
        return err == nil
3✔
206
}
3✔
207

208
// CheckoutBranch switches to an existing branch.
209
func (r *Repo) CheckoutBranch(name string) error {
5✔
210
        wt, err := r.repo.Worktree()
5✔
211
        if err != nil {
5✔
212
                return fmt.Errorf("get worktree: %w", err)
×
213
        }
×
214

215
        branchRef := plumbing.NewBranchReferenceName(name)
5✔
216
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
6✔
217
                return fmt.Errorf("checkout branch: %w", err)
1✔
218
        }
1✔
219
        return nil
4✔
220
}
221

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

236
        wt, err := r.repo.Worktree()
3✔
237
        if err != nil {
3✔
238
                return fmt.Errorf("get worktree: %w", err)
×
239
        }
×
240

241
        srcAbs := filepath.Join(r.path, srcRel)
3✔
242
        dstAbs := filepath.Join(r.path, dstRel)
3✔
243

3✔
244
        // move the file on filesystem
3✔
245
        if err := os.Rename(srcAbs, dstAbs); err != nil {
4✔
246
                return fmt.Errorf("rename file: %w", err)
1✔
247
        }
1✔
248

249
        // stage the removal of old path
250
        if _, err := wt.Remove(srcRel); err != nil {
2✔
251
                // rollback filesystem change
×
252
                _ = os.Rename(dstAbs, srcAbs)
×
253
                return fmt.Errorf("remove old path: %w", err)
×
254
        }
×
255

256
        // stage the addition of new path
257
        if _, err := wt.Add(dstRel); err != nil {
2✔
258
                // rollback: unstage removal and restore file
×
259
                _ = os.Rename(dstAbs, srcAbs)
×
260
                return fmt.Errorf("add new path: %w", err)
×
261
        }
×
262

263
        return nil
2✔
264
}
265

266
// Add stages a file for commit.
267
// Path can be absolute or relative to the repository root.
268
func (r *Repo) Add(path string) error {
14✔
269
        rel, err := r.toRelative(path)
14✔
270
        if err != nil {
14✔
271
                return fmt.Errorf("invalid path: %w", err)
×
272
        }
×
273

274
        wt, err := r.repo.Worktree()
14✔
275
        if err != nil {
14✔
276
                return fmt.Errorf("get worktree: %w", err)
×
277
        }
×
278

279
        if _, err := wt.Add(rel); err != nil {
15✔
280
                return fmt.Errorf("add file: %w", err)
1✔
281
        }
1✔
282

283
        return nil
13✔
284
}
285

286
// Commit creates a commit with the given message.
287
// Returns error if no changes are staged.
288
func (r *Repo) Commit(msg string) error {
10✔
289
        wt, err := r.repo.Worktree()
10✔
290
        if err != nil {
10✔
291
                return fmt.Errorf("get worktree: %w", err)
×
292
        }
×
293

294
        author := r.getAuthor()
10✔
295
        _, err = wt.Commit(msg, &git.CommitOptions{Author: author})
10✔
296
        if err != nil {
11✔
297
                return fmt.Errorf("commit: %w", err)
1✔
298
        }
1✔
299

300
        return nil
9✔
301
}
302

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

318
        // fallback to global config only
319
        if cfg, err := config.LoadConfig(config.GlobalScope); err == nil {
32✔
320
                if cfg.User.Name != "" && cfg.User.Email != "" {
16✔
321
                        return &object.Signature{
×
322
                                Name:  cfg.User.Name,
×
323
                                Email: cfg.User.Email,
×
324
                                When:  time.Now(),
×
325
                        }
×
326
                }
×
327
        }
328

329
        // fallback to default author
330
        return &object.Signature{
16✔
331
                Name:  "ralphex",
16✔
332
                Email: "ralphex@localhost",
16✔
333
                When:  time.Now(),
16✔
334
        }
16✔
335
}
336

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

350
        var patterns []gitignore.Pattern
16✔
351
        rootFS := osfs.New("/")
16✔
352

16✔
353
        // load system patterns first (lowest priority)
16✔
354
        if systemPatterns, err := gitignore.LoadSystemPatterns(rootFS); err == nil {
32✔
355
                patterns = append(patterns, systemPatterns...)
16✔
356
        }
16✔
357

358
        // load global patterns (middle priority)
359
        if globalPatterns, err := gitignore.LoadGlobalPatterns(rootFS); err == nil && len(globalPatterns) > 0 {
16✔
360
                patterns = append(patterns, globalPatterns...)
×
361
        } else {
16✔
362
                // fallback to default XDG location if core.excludesfile not set
16✔
363
                // git uses $XDG_CONFIG_HOME/git/ignore (defaults to ~/.config/git/ignore)
16✔
364
                patterns = append(patterns, loadXDGGlobalPatterns()...)
16✔
365
        }
16✔
366

367
        // load local patterns last (highest priority)
368
        localPatterns, _ := gitignore.ReadPatterns(wt.Filesystem, nil)
16✔
369
        patterns = append(patterns, localPatterns...)
16✔
370

16✔
371
        matcher := gitignore.NewMatcher(patterns)
16✔
372
        pathParts := strings.Split(filepath.ToSlash(path), "/")
16✔
373
        return matcher.Match(pathParts, false), nil
16✔
374
}
375

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

389
        ignorePath := filepath.Join(configHome, "git", "ignore")
16✔
390
        data, err := os.ReadFile(ignorePath) //nolint:gosec // user's gitignore file
16✔
391
        if err != nil {
27✔
392
                return nil
11✔
393
        }
11✔
394

395
        var patterns []gitignore.Pattern
5✔
396
        for line := range strings.SplitSeq(string(data), "\n") {
15✔
397
                line = strings.TrimSpace(line)
10✔
398
                if line == "" || strings.HasPrefix(line, "#") {
15✔
399
                        continue
5✔
400
                }
401
                patterns = append(patterns, gitignore.ParsePattern(line, nil))
5✔
402
        }
403
        return patterns
5✔
404
}
405

406
// IsDirty returns true if the worktree has uncommitted changes
407
// (staged or modified tracked files).
408
func (r *Repo) IsDirty() (bool, error) {
10✔
409
        wt, err := r.repo.Worktree()
10✔
410
        if err != nil {
10✔
411
                return false, fmt.Errorf("get worktree: %w", err)
×
412
        }
×
413

414
        status, err := wt.Status()
10✔
415
        if err != nil {
10✔
416
                return false, fmt.Errorf("get status: %w", err)
×
417
        }
×
418

419
        for _, s := range status {
15✔
420
                // check for staged changes
5✔
421
                if s.Staging != git.Unmodified && s.Staging != git.Untracked {
6✔
422
                        return true, nil
1✔
423
                }
1✔
424
                // check for unstaged changes to tracked files
425
                if s.Worktree == git.Modified || s.Worktree == git.Deleted {
7✔
426
                        return true, nil
3✔
427
                }
3✔
428
        }
429

430
        return false, nil
6✔
431
}
432

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

441
        status, err := wt.Status()
7✔
442
        if err != nil {
7✔
443
                return false, fmt.Errorf("get status: %w", err)
×
444
        }
×
445

446
        relPath, err := r.normalizeToRelative(filePath)
7✔
447
        if err != nil {
7✔
448
                return false, err
×
449
        }
×
450

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

472
        return false, nil
3✔
473
}
474

475
// FileHasChanges returns true if the given file has uncommitted changes.
476
// this includes untracked, modified, deleted, or staged states.
477
func (r *Repo) FileHasChanges(filePath string) (bool, error) {
5✔
478
        wt, err := r.repo.Worktree()
5✔
479
        if err != nil {
5✔
480
                return false, fmt.Errorf("get worktree: %w", err)
×
481
        }
×
482

483
        status, err := wt.Status()
5✔
484
        if err != nil {
5✔
485
                return false, fmt.Errorf("get status: %w", err)
×
486
        }
×
487

488
        relPath, err := r.normalizeToRelative(filePath)
5✔
489
        if err != nil {
5✔
490
                return false, err
×
491
        }
×
492

493
        if s, ok := status[relPath]; ok {
8✔
494
                return r.fileHasChanges(s), nil
3✔
495
        }
3✔
496

497
        return false, nil
2✔
498
}
499

500
// normalizeToRelative converts a file path to be relative to the repository root.
501
func (r *Repo) normalizeToRelative(filePath string) (string, error) {
12✔
502
        absPath, err := filepath.Abs(filePath)
12✔
503
        if err != nil {
12✔
504
                return "", fmt.Errorf("get absolute path: %w", err)
×
505
        }
×
506
        relPath, err := filepath.Rel(r.path, absPath)
12✔
507
        if err != nil {
12✔
508
                return "", fmt.Errorf("get relative path: %w", err)
×
509
        }
×
510
        return relPath, nil
12✔
511
}
512

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