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

umputun / ralphex / 21453829748

28 Jan 2026 08:11PM UTC coverage: 78.216% (+0.03%) from 78.183%
21453829748

Pull #41

github

umputun
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
Pull Request #41: feat(git): auto-create initial commit for empty repos

42 of 52 new or added lines in 3 files covered. (80.77%)

1 existing line in 1 file now uncovered.

3727 of 4765 relevant lines covered (78.22%)

61.84 hits per line

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

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

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

75
        return &Repo{repo: repo, path: wt.Filesystem.Root()}, nil
62✔
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 files and creates an initial commit.
91
// returns error if no files to stage or commit fails.
92
func (r *Repo) CreateInitialCommit(message string) error {
4✔
93
        wt, err := r.repo.Worktree()
4✔
94
        if err != nil {
4✔
NEW
95
                return fmt.Errorf("get worktree: %w", err)
×
NEW
96
        }
×
97

98
        // stage all files
99
        if addErr := wt.AddGlob("."); addErr != nil {
4✔
NEW
100
                return fmt.Errorf("stage files: %w", addErr)
×
NEW
101
        }
×
102

103
        // check if anything was staged
104
        status, err := wt.Status()
4✔
105
        if err != nil {
4✔
NEW
106
                return fmt.Errorf("get status: %w", err)
×
NEW
107
        }
×
108

109
        hasStaged := false
4✔
110
        for _, s := range status {
7✔
111
                if s.Staging != git.Unmodified && s.Staging != git.Untracked {
6✔
112
                        hasStaged = true
3✔
113
                        break
3✔
114
                }
115
        }
116
        if !hasStaged {
5✔
117
                return errors.New("no files to commit")
1✔
118
        }
1✔
119

120
        author := r.getAuthor()
3✔
121
        _, err = wt.Commit(message, &git.CommitOptions{Author: author})
3✔
122
        if err != nil {
3✔
NEW
123
                return fmt.Errorf("commit: %w", err)
×
NEW
124
        }
×
125

126
        return nil
3✔
127
}
128

129
// CurrentBranch returns the name of the current branch, or empty string for detached HEAD state.
130
func (r *Repo) CurrentBranch() (string, error) {
6✔
131
        head, err := r.repo.Head()
6✔
132
        if err != nil {
6✔
133
                return "", fmt.Errorf("get HEAD: %w", err)
×
134
        }
×
135
        if !head.Name().IsBranch() {
7✔
136
                return "", nil // detached HEAD
1✔
137
        }
1✔
138
        return head.Name().Short(), nil
5✔
139
}
140

141
// CreateBranch creates a new branch and switches to it.
142
// Returns error if branch already exists to prevent data loss.
143
func (r *Repo) CreateBranch(name string) error {
9✔
144
        wt, err := r.repo.Worktree()
9✔
145
        if err != nil {
9✔
146
                return fmt.Errorf("get worktree: %w", err)
×
147
        }
×
148

149
        head, err := r.repo.Head()
9✔
150
        if err != nil {
9✔
151
                return fmt.Errorf("get HEAD: %w", err)
×
152
        }
×
153

154
        branchRef := plumbing.NewBranchReferenceName(name)
9✔
155

9✔
156
        // check if branch already exists to prevent overwriting
9✔
157
        if _, err := r.repo.Reference(branchRef, false); err == nil {
10✔
158
                return fmt.Errorf("branch %q already exists", name)
1✔
159
        }
1✔
160

161
        // create the branch reference pointing to current HEAD
162
        ref := plumbing.NewHashReference(branchRef, head.Hash())
8✔
163
        if err := r.repo.Storer.SetReference(ref); err != nil {
8✔
164
                return fmt.Errorf("create branch reference: %w", err)
×
165
        }
×
166

167
        // create branch config for tracking
168
        branchConfig := &config.Branch{
8✔
169
                Name: name,
8✔
170
        }
8✔
171
        if err := r.repo.CreateBranch(branchConfig); err != nil {
9✔
172
                // ignore if branch config already exists
1✔
173
                if !errors.Is(err, git.ErrBranchExists) {
2✔
174
                        return fmt.Errorf("create branch config: %w", err)
1✔
175
                }
1✔
176
        }
177

178
        // checkout the new branch, Keep preserves untracked files
179
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
7✔
180
                return fmt.Errorf("checkout branch: %w", err)
×
181
        }
×
182

183
        return nil
7✔
184
}
185

186
// BranchExists checks if a branch with the given name exists.
187
func (r *Repo) BranchExists(name string) bool {
3✔
188
        branchRef := plumbing.NewBranchReferenceName(name)
3✔
189
        _, err := r.repo.Reference(branchRef, false)
3✔
190
        return err == nil
3✔
191
}
3✔
192

193
// CheckoutBranch switches to an existing branch.
194
func (r *Repo) CheckoutBranch(name string) error {
5✔
195
        wt, err := r.repo.Worktree()
5✔
196
        if err != nil {
5✔
197
                return fmt.Errorf("get worktree: %w", err)
×
198
        }
×
199

200
        branchRef := plumbing.NewBranchReferenceName(name)
5✔
201
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
6✔
202
                return fmt.Errorf("checkout branch: %w", err)
1✔
203
        }
1✔
204
        return nil
4✔
205
}
206

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

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

226
        srcAbs := filepath.Join(r.path, srcRel)
3✔
227
        dstAbs := filepath.Join(r.path, dstRel)
3✔
228

3✔
229
        // move the file on filesystem
3✔
230
        if err := os.Rename(srcAbs, dstAbs); err != nil {
4✔
231
                return fmt.Errorf("rename file: %w", err)
1✔
232
        }
1✔
233

234
        // stage the removal of old path
235
        if _, err := wt.Remove(srcRel); err != nil {
2✔
236
                // rollback filesystem change
×
237
                _ = os.Rename(dstAbs, srcAbs)
×
238
                return fmt.Errorf("remove old path: %w", err)
×
239
        }
×
240

241
        // stage the addition of new path
242
        if _, err := wt.Add(dstRel); err != nil {
2✔
243
                // rollback: unstage removal and restore file
×
244
                _ = os.Rename(dstAbs, srcAbs)
×
245
                return fmt.Errorf("add new path: %w", err)
×
246
        }
×
247

248
        return nil
2✔
249
}
250

251
// Add stages a file for commit.
252
// Path can be absolute or relative to the repository root.
253
func (r *Repo) Add(path string) error {
14✔
254
        rel, err := r.toRelative(path)
14✔
255
        if err != nil {
14✔
256
                return fmt.Errorf("invalid path: %w", err)
×
257
        }
×
258

259
        wt, err := r.repo.Worktree()
14✔
260
        if err != nil {
14✔
261
                return fmt.Errorf("get worktree: %w", err)
×
262
        }
×
263

264
        if _, err := wt.Add(rel); err != nil {
15✔
265
                return fmt.Errorf("add file: %w", err)
1✔
266
        }
1✔
267

268
        return nil
13✔
269
}
270

271
// Commit creates a commit with the given message.
272
// Returns error if no changes are staged.
273
func (r *Repo) Commit(msg string) error {
10✔
274
        wt, err := r.repo.Worktree()
10✔
275
        if err != nil {
10✔
276
                return fmt.Errorf("get worktree: %w", err)
×
277
        }
×
278

279
        author := r.getAuthor()
10✔
280
        _, err = wt.Commit(msg, &git.CommitOptions{Author: author})
10✔
281
        if err != nil {
11✔
282
                return fmt.Errorf("commit: %w", err)
1✔
283
        }
1✔
284

285
        return nil
9✔
286
}
287

288
// getAuthor returns the commit author from git config or a fallback.
289
// checks repository config first (.git/config), then falls back to global config,
290
// and finally to default values.
291
func (r *Repo) getAuthor() *object.Signature {
15✔
292
        // try repository config first (merges local + global)
15✔
293
        if cfg, err := r.repo.Config(); err == nil {
30✔
294
                if cfg.User.Name != "" && cfg.User.Email != "" {
15✔
295
                        return &object.Signature{
×
296
                                Name:  cfg.User.Name,
×
297
                                Email: cfg.User.Email,
×
298
                                When:  time.Now(),
×
299
                        }
×
300
                }
×
301
        }
302

303
        // fallback to global config only
304
        if cfg, err := config.LoadConfig(config.GlobalScope); err == nil {
30✔
305
                if cfg.User.Name != "" && cfg.User.Email != "" {
15✔
306
                        return &object.Signature{
×
307
                                Name:  cfg.User.Name,
×
308
                                Email: cfg.User.Email,
×
309
                                When:  time.Now(),
×
310
                        }
×
311
                }
×
312
        }
313

314
        // fallback to default author
315
        return &object.Signature{
15✔
316
                Name:  "ralphex",
15✔
317
                Email: "ralphex@localhost",
15✔
318
                When:  time.Now(),
15✔
319
        }
15✔
320
}
321

322
// IsIgnored checks if a path is ignored by gitignore rules.
323
// Checks local .gitignore files, global gitignore (from core.excludesfile or default
324
// XDG location ~/.config/git/ignore), and system gitignore (/etc/gitconfig).
325
// Returns false, nil if no gitignore rules exist.
326
func (r *Repo) IsIgnored(path string) (bool, error) {
7✔
327
        wt, err := r.repo.Worktree()
7✔
328
        if err != nil {
7✔
329
                return false, fmt.Errorf("get worktree: %w", err)
×
330
        }
×
331

332
        // read gitignore patterns from the worktree (.gitignore files)
333
        patterns, _ := gitignore.ReadPatterns(wt.Filesystem, nil)
7✔
334

7✔
335
        // load global patterns from ~/.gitconfig's core.excludesfile
7✔
336
        rootFS := osfs.New("/")
7✔
337
        if globalPatterns, err := gitignore.LoadGlobalPatterns(rootFS); err == nil && len(globalPatterns) > 0 {
7✔
338
                patterns = append(patterns, globalPatterns...)
×
339
        } else {
7✔
340
                // fallback to default XDG location if core.excludesfile not set
7✔
341
                // git uses $XDG_CONFIG_HOME/git/ignore (defaults to ~/.config/git/ignore)
7✔
342
                patterns = append(patterns, loadXDGGlobalPatterns()...)
7✔
343
        }
7✔
344

345
        // load system patterns from /etc/gitconfig's core.excludesfile
346
        if systemPatterns, err := gitignore.LoadSystemPatterns(rootFS); err == nil {
14✔
347
                patterns = append(patterns, systemPatterns...)
7✔
348
        }
7✔
349

350
        matcher := gitignore.NewMatcher(patterns)
7✔
351
        pathParts := strings.Split(filepath.ToSlash(path), "/")
7✔
352
        return matcher.Match(pathParts, false), nil
7✔
353
}
354

355
// loadXDGGlobalPatterns loads gitignore patterns from the default XDG location.
356
// Git checks $XDG_CONFIG_HOME/git/ignore, defaulting to ~/.config/git/ignore.
357
func loadXDGGlobalPatterns() []gitignore.Pattern {
7✔
358
        // check XDG_CONFIG_HOME first, fall back to ~/.config
7✔
359
        configHome := os.Getenv("XDG_CONFIG_HOME")
7✔
360
        if configHome == "" {
7✔
361
                home, err := os.UserHomeDir()
×
362
                if err != nil {
×
363
                        return nil
×
364
                }
×
365
                configHome = filepath.Join(home, ".config")
×
366
        }
367

368
        ignorePath := filepath.Join(configHome, "git", "ignore")
7✔
369
        data, err := os.ReadFile(ignorePath) //nolint:gosec // user's gitignore file
7✔
370
        if err != nil {
13✔
371
                return nil
6✔
372
        }
6✔
373

374
        var patterns []gitignore.Pattern
1✔
375
        for line := range strings.SplitSeq(string(data), "\n") {
3✔
376
                line = strings.TrimSpace(line)
2✔
377
                if line == "" || strings.HasPrefix(line, "#") {
3✔
378
                        continue
1✔
379
                }
380
                patterns = append(patterns, gitignore.ParsePattern(line, nil))
1✔
381
        }
382
        return patterns
1✔
383
}
384

385
// IsDirty returns true if the worktree has uncommitted changes
386
// (staged or modified tracked files).
387
func (r *Repo) IsDirty() (bool, error) {
10✔
388
        wt, err := r.repo.Worktree()
10✔
389
        if err != nil {
10✔
390
                return false, fmt.Errorf("get worktree: %w", err)
×
391
        }
×
392

393
        status, err := wt.Status()
10✔
394
        if err != nil {
10✔
395
                return false, fmt.Errorf("get status: %w", err)
×
396
        }
×
397

398
        for _, s := range status {
15✔
399
                // check for staged changes
5✔
400
                if s.Staging != git.Unmodified && s.Staging != git.Untracked {
6✔
401
                        return true, nil
1✔
402
                }
1✔
403
                // check for unstaged changes to tracked files
404
                if s.Worktree == git.Modified || s.Worktree == git.Deleted {
7✔
405
                        return true, nil
3✔
406
                }
3✔
407
        }
408

409
        return false, nil
6✔
410
}
411

412
// HasChangesOtherThan returns true if there are uncommitted changes to files other than the given file.
413
// this includes modified/deleted tracked files, staged changes, and untracked files (excluding gitignored).
414
func (r *Repo) HasChangesOtherThan(filePath string) (bool, error) {
7✔
415
        wt, err := r.repo.Worktree()
7✔
416
        if err != nil {
7✔
417
                return false, fmt.Errorf("get worktree: %w", err)
×
418
        }
×
419

420
        status, err := wt.Status()
7✔
421
        if err != nil {
7✔
422
                return false, fmt.Errorf("get status: %w", err)
×
423
        }
×
424

425
        relPath, err := r.normalizeToRelative(filePath)
7✔
426
        if err != nil {
7✔
427
                return false, err
×
428
        }
×
429

430
        for path, s := range status {
15✔
431
                if path == relPath {
12✔
432
                        continue // skip the target file
4✔
433
                }
434
                if !r.fileHasChanges(s) {
4✔
435
                        continue
×
436
                }
437
                // for untracked files, check if they're gitignored
438
                // note: go-git sets both Staging and Worktree to Untracked for untracked files
439
                if s.Worktree == git.Untracked {
7✔
440
                        ignored, err := r.IsIgnored(path)
3✔
441
                        if err != nil {
3✔
442
                                return false, fmt.Errorf("check ignored: %w", err)
×
443
                        }
×
444
                        if ignored {
3✔
445
                                continue // skip gitignored untracked files
×
446
                        }
447
                }
448
                return true, nil
4✔
449
        }
450

451
        return false, nil
3✔
452
}
453

454
// FileHasChanges returns true if the given file has uncommitted changes.
455
// this includes untracked, modified, deleted, or staged states.
456
func (r *Repo) FileHasChanges(filePath string) (bool, error) {
5✔
457
        wt, err := r.repo.Worktree()
5✔
458
        if err != nil {
5✔
459
                return false, fmt.Errorf("get worktree: %w", err)
×
460
        }
×
461

462
        status, err := wt.Status()
5✔
463
        if err != nil {
5✔
464
                return false, fmt.Errorf("get status: %w", err)
×
465
        }
×
466

467
        relPath, err := r.normalizeToRelative(filePath)
5✔
468
        if err != nil {
5✔
469
                return false, err
×
470
        }
×
471

472
        if s, ok := status[relPath]; ok {
8✔
473
                return r.fileHasChanges(s), nil
3✔
474
        }
3✔
475

476
        return false, nil
2✔
477
}
478

479
// normalizeToRelative converts a file path to be relative to the repository root.
480
func (r *Repo) normalizeToRelative(filePath string) (string, error) {
12✔
481
        absPath, err := filepath.Abs(filePath)
12✔
482
        if err != nil {
12✔
483
                return "", fmt.Errorf("get absolute path: %w", err)
×
484
        }
×
485
        relPath, err := filepath.Rel(r.path, absPath)
12✔
486
        if err != nil {
12✔
487
                return "", fmt.Errorf("get relative path: %w", err)
×
488
        }
×
489
        return relPath, nil
12✔
490
}
491

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