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

umputun / ralphex / 21456951034

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

push

github

web-flow
feat(git): auto-create initial commit for empty repos (#41)

* feat(git): auto-create initial commit for empty repos

add AskYesNo function to input package for Y/n prompts.
add CreateInitialCommit method to git package.
change validateRepoHasCommits to ensureRepoHasCommits that prompts
user to create initial commit when repository is empty.

Related to #38

* 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

* 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.

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.78 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 {
14✔
452
                if path == relPath {
10✔
453
                        continue // skip the target file
3✔
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