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

umputun / ralphex / 21377193175

26 Jan 2026 10:56PM UTC coverage: 78.381% (-0.1%) from 78.506%
21377193175

push

github

umputun
docs: update changelog for v0.4.2

3437 of 4385 relevant lines covered (78.38%)

64.74 hits per line

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

68.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-git/v5"
13
        "github.com/go-git/go-git/v5/config"
14
        "github.com/go-git/go-git/v5/plumbing"
15
        "github.com/go-git/go-git/v5/plumbing/format/gitignore"
16
        "github.com/go-git/go-git/v5/plumbing/object"
17
)
18

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

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

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

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

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

55
        return rel, nil
3✔
56
}
57

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

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

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

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

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

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

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

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

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

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

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

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

143
        return nil
7✔
144
}
145

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

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

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

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

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

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

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

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

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

208
        return nil
2✔
209
}
210

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

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

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

228
        return nil
13✔
229
}
230

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

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

245
        return nil
9✔
246
}
247

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

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

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

282
// IsIgnored checks if a path is ignored by gitignore rules.
283
// Returns false, nil if no .gitignore exists or cannot be read.
284
func (r *Repo) IsIgnored(path string) (bool, error) {
3✔
285
        wt, err := r.repo.Worktree()
3✔
286
        if err != nil {
3✔
287
                return false, fmt.Errorf("get worktree: %w", err)
×
288
        }
×
289

290
        // read gitignore patterns from the worktree
291
        patterns, err := gitignore.ReadPatterns(wt.Filesystem, nil)
3✔
292
        if err != nil {
3✔
293
                // if no .gitignore, nothing is ignored
×
294
                return false, nil //nolint:nilerr // intentional - no gitignore means nothing is ignored
×
295
        }
×
296

297
        matcher := gitignore.NewMatcher(patterns)
3✔
298
        pathParts := strings.Split(filepath.ToSlash(path), "/")
3✔
299
        return matcher.Match(pathParts, false), nil
3✔
300
}
301

302
// IsDirty returns true if the worktree has uncommitted changes
303
// (staged or modified tracked files).
304
func (r *Repo) IsDirty() (bool, error) {
10✔
305
        wt, err := r.repo.Worktree()
10✔
306
        if err != nil {
10✔
307
                return false, fmt.Errorf("get worktree: %w", err)
×
308
        }
×
309

310
        status, err := wt.Status()
10✔
311
        if err != nil {
10✔
312
                return false, fmt.Errorf("get status: %w", err)
×
313
        }
×
314

315
        for _, s := range status {
15✔
316
                // check for staged changes
5✔
317
                if s.Staging != git.Unmodified && s.Staging != git.Untracked {
6✔
318
                        return true, nil
1✔
319
                }
1✔
320
                // check for unstaged changes to tracked files
321
                if s.Worktree == git.Modified || s.Worktree == git.Deleted {
7✔
322
                        return true, nil
3✔
323
                }
3✔
324
        }
325

326
        return false, nil
6✔
327
}
328

329
// HasChangesOtherThan returns true if there are uncommitted changes to files other than the given file.
330
// this includes modified/deleted tracked files, staged changes, and untracked files (excluding gitignored).
331
func (r *Repo) HasChangesOtherThan(filePath string) (bool, error) {
7✔
332
        wt, err := r.repo.Worktree()
7✔
333
        if err != nil {
7✔
334
                return false, fmt.Errorf("get worktree: %w", err)
×
335
        }
×
336

337
        status, err := wt.Status()
7✔
338
        if err != nil {
7✔
339
                return false, fmt.Errorf("get status: %w", err)
×
340
        }
×
341

342
        relPath, err := r.normalizeToRelative(filePath)
7✔
343
        if err != nil {
7✔
344
                return false, err
×
345
        }
×
346

347
        for path, s := range status {
15✔
348
                if path == relPath {
12✔
349
                        continue // skip the target file
4✔
350
                }
351
                if !r.fileHasChanges(s) {
4✔
352
                        continue
×
353
                }
354
                // for untracked files, check if they're gitignored
355
                if s.Worktree == git.Untracked && s.Staging == git.Unmodified {
4✔
356
                        ignored, err := r.IsIgnored(path)
×
357
                        if err != nil {
×
358
                                return false, fmt.Errorf("check ignored: %w", err)
×
359
                        }
×
360
                        if ignored {
×
361
                                continue // skip gitignored untracked files
×
362
                        }
363
                }
364
                return true, nil
4✔
365
        }
366

367
        return false, nil
3✔
368
}
369

370
// FileHasChanges returns true if the given file has uncommitted changes.
371
// this includes untracked, modified, deleted, or staged states.
372
func (r *Repo) FileHasChanges(filePath string) (bool, error) {
5✔
373
        wt, err := r.repo.Worktree()
5✔
374
        if err != nil {
5✔
375
                return false, fmt.Errorf("get worktree: %w", err)
×
376
        }
×
377

378
        status, err := wt.Status()
5✔
379
        if err != nil {
5✔
380
                return false, fmt.Errorf("get status: %w", err)
×
381
        }
×
382

383
        relPath, err := r.normalizeToRelative(filePath)
5✔
384
        if err != nil {
5✔
385
                return false, err
×
386
        }
×
387

388
        if s, ok := status[relPath]; ok {
8✔
389
                return r.fileHasChanges(s), nil
3✔
390
        }
3✔
391

392
        return false, nil
2✔
393
}
394

395
// normalizeToRelative converts a file path to be relative to the repository root.
396
func (r *Repo) normalizeToRelative(filePath string) (string, error) {
12✔
397
        absPath, err := filepath.Abs(filePath)
12✔
398
        if err != nil {
12✔
399
                return "", fmt.Errorf("get absolute path: %w", err)
×
400
        }
×
401
        relPath, err := filepath.Rel(r.path, absPath)
12✔
402
        if err != nil {
12✔
403
                return "", fmt.Errorf("get relative path: %w", err)
×
404
        }
×
405
        return relPath, nil
12✔
406
}
407

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