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

umputun / ralphex / 21225943707

21 Jan 2026 09:14PM UTC coverage: 75.347% (-0.03%) from 75.379%
21225943707

Pull #10

github

melonamin
test: add unit test for git worktree support

Verify Open() and CurrentBranch() work correctly when called
from a git worktree directory. Uses git CLI for worktree setup
since go-git doesn't support worktree creation.
Pull Request #10: fix: support git worktrees

3 of 3 new or added lines in 1 file covered. (100.0%)

32 existing lines in 2 files now uncovered.

1464 of 1943 relevant lines covered (75.35%)

23.63 hits per line

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

70.41
/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) {
17✔
36
        // for relative paths, just clean and validate
17✔
37
        if !filepath.IsAbs(path) {
29✔
38
                cleaned := filepath.Clean(path)
12✔
39
                if strings.HasPrefix(cleaned, "..") {
14✔
40
                        return "", fmt.Errorf("path %q escapes repository root", path)
2✔
41
                }
2✔
42
                return cleaned, nil
10✔
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) {
40✔
61
        repo, err := git.PlainOpenWithOptions(path, &git.PlainOpenOptions{
40✔
62
                EnableDotGitCommonDir: true,
40✔
63
        })
40✔
64
        if err != nil {
41✔
65
                return nil, fmt.Errorf("open repository: %w", err)
1✔
66
        }
1✔
67

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

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

77
// CurrentBranch returns the name of the current branch, or empty string for detached HEAD state.
78
func (r *Repo) CurrentBranch() (string, error) {
6✔
79
        head, err := r.repo.Head()
6✔
80
        if err != nil {
6✔
81
                return "", fmt.Errorf("get HEAD: %w", err)
×
82
        }
×
83
        if !head.Name().IsBranch() {
7✔
84
                return "", nil // detached HEAD
1✔
85
        }
1✔
86
        return head.Name().Short(), nil
5✔
87
}
88

89
// CreateBranch creates a new branch and switches to it.
90
// Returns error if branch already exists to prevent data loss.
91
func (r *Repo) CreateBranch(name string) error {
9✔
92
        wt, err := r.repo.Worktree()
9✔
93
        if err != nil {
9✔
94
                return fmt.Errorf("get worktree: %w", err)
×
95
        }
×
96

97
        head, err := r.repo.Head()
9✔
98
        if err != nil {
9✔
99
                return fmt.Errorf("get HEAD: %w", err)
×
100
        }
×
101

102
        branchRef := plumbing.NewBranchReferenceName(name)
9✔
103

9✔
104
        // check if branch already exists to prevent overwriting
9✔
105
        if _, err := r.repo.Reference(branchRef, false); err == nil {
10✔
106
                return fmt.Errorf("branch %q already exists", name)
1✔
107
        }
1✔
108

109
        // create the branch reference pointing to current HEAD
110
        ref := plumbing.NewHashReference(branchRef, head.Hash())
8✔
111
        if err := r.repo.Storer.SetReference(ref); err != nil {
8✔
112
                return fmt.Errorf("create branch reference: %w", err)
×
113
        }
×
114

115
        // create branch config for tracking
116
        branchConfig := &config.Branch{
8✔
117
                Name: name,
8✔
118
        }
8✔
119
        if err := r.repo.CreateBranch(branchConfig); err != nil {
9✔
120
                // ignore if branch config already exists
1✔
121
                if !errors.Is(err, git.ErrBranchExists) {
2✔
122
                        return fmt.Errorf("create branch config: %w", err)
1✔
123
                }
1✔
124
        }
125

126
        // checkout the new branch
127
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef}); err != nil {
7✔
128
                return fmt.Errorf("checkout branch: %w", err)
×
129
        }
×
130

131
        return nil
7✔
132
}
133

134
// BranchExists checks if a branch with the given name exists.
135
func (r *Repo) BranchExists(name string) bool {
3✔
136
        branchRef := plumbing.NewBranchReferenceName(name)
3✔
137
        _, err := r.repo.Reference(branchRef, false)
3✔
138
        return err == nil
3✔
139
}
3✔
140

141
// CheckoutBranch switches to an existing branch.
142
func (r *Repo) CheckoutBranch(name string) error {
5✔
143
        wt, err := r.repo.Worktree()
5✔
144
        if err != nil {
5✔
145
                return fmt.Errorf("get worktree: %w", err)
×
146
        }
×
147

148
        branchRef := plumbing.NewBranchReferenceName(name)
5✔
149
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef}); err != nil {
6✔
150
                return fmt.Errorf("checkout branch: %w", err)
1✔
151
        }
1✔
152
        return nil
4✔
153
}
154

155
// MoveFile moves a file using git (equivalent to git mv).
156
// Paths can be absolute or relative to the repository root.
157
// The destination directory must already exist.
158
func (r *Repo) MoveFile(src, dst string) error {
4✔
159
        // convert to relative paths for git operations
4✔
160
        srcRel, err := r.toRelative(src)
4✔
161
        if err != nil {
5✔
162
                return fmt.Errorf("invalid source path: %w", err)
1✔
163
        }
1✔
164
        dstRel, err := r.toRelative(dst)
3✔
165
        if err != nil {
3✔
166
                return fmt.Errorf("invalid destination path: %w", err)
×
167
        }
×
168

169
        wt, err := r.repo.Worktree()
3✔
170
        if err != nil {
3✔
171
                return fmt.Errorf("get worktree: %w", err)
×
172
        }
×
173

174
        srcAbs := filepath.Join(r.path, srcRel)
3✔
175
        dstAbs := filepath.Join(r.path, dstRel)
3✔
176

3✔
177
        // move the file on filesystem
3✔
178
        if err := os.Rename(srcAbs, dstAbs); err != nil {
4✔
179
                return fmt.Errorf("rename file: %w", err)
1✔
180
        }
1✔
181

182
        // stage the removal of old path
183
        if _, err := wt.Remove(srcRel); err != nil {
2✔
184
                // rollback filesystem change
×
185
                _ = os.Rename(dstAbs, srcAbs)
×
186
                return fmt.Errorf("remove old path: %w", err)
×
187
        }
×
188

189
        // stage the addition of new path
190
        if _, err := wt.Add(dstRel); err != nil {
2✔
191
                // rollback: unstage removal and restore file
×
192
                _ = os.Rename(dstAbs, srcAbs)
×
193
                return fmt.Errorf("add new path: %w", err)
×
194
        }
×
195

196
        return nil
2✔
197
}
198

199
// Add stages a file for commit.
200
// Path can be absolute or relative to the repository root.
201
func (r *Repo) Add(path string) error {
5✔
202
        rel, err := r.toRelative(path)
5✔
203
        if err != nil {
5✔
204
                return fmt.Errorf("invalid path: %w", err)
×
205
        }
×
206

207
        wt, err := r.repo.Worktree()
5✔
208
        if err != nil {
5✔
209
                return fmt.Errorf("get worktree: %w", err)
×
210
        }
×
211

212
        if _, err := wt.Add(rel); err != nil {
6✔
213
                return fmt.Errorf("add file: %w", err)
1✔
214
        }
1✔
215

216
        return nil
4✔
217
}
218

219
// Commit creates a commit with the given message.
220
// Returns error if no changes are staged.
221
func (r *Repo) Commit(msg string) error {
3✔
222
        wt, err := r.repo.Worktree()
3✔
223
        if err != nil {
3✔
224
                return fmt.Errorf("get worktree: %w", err)
×
225
        }
×
226

227
        author := r.getAuthor()
3✔
228
        _, err = wt.Commit(msg, &git.CommitOptions{Author: author})
3✔
229
        if err != nil {
4✔
230
                return fmt.Errorf("commit: %w", err)
1✔
231
        }
1✔
232

233
        return nil
2✔
234
}
235

236
// getAuthor returns the commit author from git config or a fallback.
237
// checks repository config first (.git/config), then falls back to global config,
238
// and finally to default values.
239
func (r *Repo) getAuthor() *object.Signature {
5✔
240
        // try repository config first (merges local + global)
5✔
241
        if cfg, err := r.repo.Config(); err == nil {
10✔
242
                if cfg.User.Name != "" && cfg.User.Email != "" {
5✔
243
                        return &object.Signature{
×
244
                                Name:  cfg.User.Name,
×
245
                                Email: cfg.User.Email,
×
246
                                When:  time.Now(),
×
247
                        }
×
248
                }
×
249
        }
250

251
        // fallback to global config only
252
        if cfg, err := config.LoadConfig(config.GlobalScope); err == nil {
10✔
253
                if cfg.User.Name != "" && cfg.User.Email != "" {
5✔
254
                        return &object.Signature{
×
255
                                Name:  cfg.User.Name,
×
256
                                Email: cfg.User.Email,
×
257
                                When:  time.Now(),
×
258
                        }
×
259
                }
×
260
        }
261

262
        // fallback to default author
263
        return &object.Signature{
5✔
264
                Name:  "ralphex",
5✔
265
                Email: "ralphex@localhost",
5✔
266
                When:  time.Now(),
5✔
267
        }
5✔
268
}
269

270
// IsIgnored checks if a path is ignored by gitignore rules.
271
// Returns false, nil if no .gitignore exists or cannot be read.
272
func (r *Repo) IsIgnored(path string) (bool, error) {
3✔
273
        wt, err := r.repo.Worktree()
3✔
274
        if err != nil {
3✔
275
                return false, fmt.Errorf("get worktree: %w", err)
×
276
        }
×
277

278
        // read gitignore patterns from the worktree
279
        patterns, err := gitignore.ReadPatterns(wt.Filesystem, nil)
3✔
280
        if err != nil {
3✔
281
                // if no .gitignore, nothing is ignored
×
282
                return false, nil //nolint:nilerr // intentional - no gitignore means nothing is ignored
×
283
        }
×
284

285
        matcher := gitignore.NewMatcher(patterns)
3✔
286
        pathParts := strings.Split(filepath.ToSlash(path), "/")
3✔
287
        return matcher.Match(pathParts, false), nil
3✔
288
}
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