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

umputun / ralphex / 21461163438

29 Jan 2026 12:25AM UTC coverage: 79.94% (+1.4%) from 78.528%
21461163438

Pull #36

github

melonamin
Improve watch-only dashboard UX
Pull Request #36: feat(web): interactive plan creation from web dashboard

808 of 932 new or added lines in 11 files covered. (86.7%)

334 existing lines in 3 files now uncovered.

4531 of 5668 relevant lines covered (79.94%)

54.44 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
        "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
}
×
UNCOV
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) {
37
        // for relative paths, just clean and validate
26✔
38
        if !filepath.IsAbs(path) {
26✔
39
                cleaned := filepath.Clean(path)
47✔
40
                if strings.HasPrefix(cleaned, "..") {
21✔
41
                        return "", fmt.Errorf("path %q escapes repository root", path)
23✔
42
                }
2✔
43
                return cleaned, nil
2✔
44
        }
19✔
45

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

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

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

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

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

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

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

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

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

5✔
115
        branchRef := plumbing.NewBranchReferenceName(name)
5✔
116

5✔
117
        // check if branch already exists to prevent overwriting
12✔
118
        if _, err := r.repo.Reference(branchRef, false); err == nil {
7✔
119
                return fmt.Errorf("branch %q already exists", name)
7✔
UNCOV
120
        }
×
UNCOV
121

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

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

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

144
        return nil
145
}
6✔
146

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

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

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

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

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

9✔
187
        srcAbs := filepath.Join(r.path, srcRel)
1✔
188
        dstAbs := filepath.Join(r.path, dstRel)
2✔
189

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

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

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

209
        return nil
5✔
210
}
5✔
211

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

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

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

5✔
229
        return nil
1✔
230
}
1✔
231

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

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

4✔
246
        return nil
1✔
247
}
1✔
248

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

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

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

283
// IsIgnored checks if a path is ignored by gitignore rules.
13✔
284
// Checks local .gitignore files, global gitignore (from core.excludesfile or default
285
// XDG location ~/.config/git/ignore), and system gitignore (/etc/gitconfig).
286
// Returns false, nil if no gitignore rules exist.
287
func (r *Repo) IsIgnored(path string) (bool, error) {
288
        wt, err := r.repo.Worktree()
10✔
289
        if err != nil {
10✔
290
                return false, fmt.Errorf("get worktree: %w", err)
10✔
291
        }
×
UNCOV
292

×
293
        // read gitignore patterns from the worktree (.gitignore files)
294
        patterns, _ := gitignore.ReadPatterns(wt.Filesystem, nil)
10✔
295

10✔
296
        // load global patterns from ~/.gitconfig's core.excludesfile
11✔
297
        rootFS := osfs.New("/")
1✔
298
        if globalPatterns, err := gitignore.LoadGlobalPatterns(rootFS); err == nil && len(globalPatterns) > 0 {
1✔
299
                patterns = append(patterns, globalPatterns...)
300
        } else {
9✔
301
                // fallback to default XDG location if core.excludesfile not set
302
                // git uses $XDG_CONFIG_HOME/git/ignore (defaults to ~/.config/git/ignore)
303
                patterns = append(patterns, loadXDGGlobalPatterns()...)
304
        }
305

306
        // load system patterns from /etc/gitconfig's core.excludesfile
16✔
307
        if systemPatterns, err := gitignore.LoadSystemPatterns(rootFS); err == nil {
16✔
308
                patterns = append(patterns, systemPatterns...)
32✔
309
        }
16✔
UNCOV
310

×
UNCOV
311
        matcher := gitignore.NewMatcher(patterns)
×
UNCOV
312
        pathParts := strings.Split(filepath.ToSlash(path), "/")
×
UNCOV
313
        return matcher.Match(pathParts, false), nil
×
UNCOV
314
}
×
UNCOV
315

×
316
// loadXDGGlobalPatterns loads gitignore patterns from the default XDG location.
317
// Git checks $XDG_CONFIG_HOME/git/ignore, defaulting to ~/.config/git/ignore.
318
func loadXDGGlobalPatterns() []gitignore.Pattern {
319
        // check XDG_CONFIG_HOME first, fall back to ~/.config
32✔
320
        configHome := os.Getenv("XDG_CONFIG_HOME")
16✔
UNCOV
321
        if configHome == "" {
×
322
                home, err := os.UserHomeDir()
×
323
                if err != nil {
×
324
                        return nil
×
325
                }
×
326
                configHome = filepath.Join(home, ".config")
×
327
        }
328

329
        ignorePath := filepath.Join(configHome, "git", "ignore")
330
        data, err := os.ReadFile(ignorePath) //nolint:gosec // user's gitignore file
16✔
331
        if err != nil {
16✔
332
                return nil
16✔
333
        }
16✔
334

16✔
335
        var patterns []gitignore.Pattern
336
        for line := range strings.SplitSeq(string(data), "\n") {
337
                line = strings.TrimSpace(line)
338
                if line == "" || strings.HasPrefix(line, "#") {
339
                        continue
340
                }
341
                patterns = append(patterns, gitignore.ParsePattern(line, nil))
342
        }
343
        return patterns
344
}
16✔
345

16✔
346
// IsDirty returns true if the worktree has uncommitted changes
16✔
UNCOV
347
// (staged or modified tracked files).
×
UNCOV
348
func (r *Repo) IsDirty() (bool, error) {
×
349
        wt, err := r.repo.Worktree()
350
        if err != nil {
16✔
351
                return false, fmt.Errorf("get worktree: %w", err)
16✔
352
        }
16✔
353

16✔
354
        status, err := wt.Status()
32✔
355
        if err != nil {
16✔
356
                return false, fmt.Errorf("get status: %w", err)
16✔
357
        }
358

359
        for _, s := range status {
16✔
UNCOV
360
                // check for staged changes
×
361
                if s.Staging != git.Unmodified && s.Staging != git.Untracked {
16✔
362
                        return true, nil
16✔
363
                }
16✔
364
                // check for unstaged changes to tracked files
16✔
365
                if s.Worktree == git.Modified || s.Worktree == git.Deleted {
16✔
366
                        return true, nil
367
                }
368
        }
16✔
369

16✔
370
        return false, nil
16✔
371
}
16✔
372

16✔
373
// HasChangesOtherThan returns true if there are uncommitted changes to files other than the given file.
16✔
374
// this includes modified/deleted tracked files, staged changes, and untracked files (excluding gitignored).
375
func (r *Repo) HasChangesOtherThan(filePath string) (bool, error) {
376
        wt, err := r.repo.Worktree()
377
        if err != nil {
378
                return false, fmt.Errorf("get worktree: %w", err)
16✔
379
        }
16✔
380

16✔
381
        status, err := wt.Status()
16✔
UNCOV
382
        if err != nil {
×
383
                return false, fmt.Errorf("get status: %w", err)
×
384
        }
×
UNCOV
385

×
UNCOV
386
        relPath, err := r.normalizeToRelative(filePath)
×
387
        if err != nil {
388
                return false, err
389
        }
16✔
390

16✔
391
        for path, s := range status {
27✔
392
                if path == relPath {
11✔
393
                        continue // skip the target file
11✔
394
                }
395
                if !r.fileHasChanges(s) {
5✔
396
                        continue
15✔
397
                }
10✔
398
                // for untracked files, check if they're gitignored
15✔
399
                // note: go-git sets both Staging and Worktree to Untracked for untracked files
5✔
400
                if s.Worktree == git.Untracked {
401
                        ignored, err := r.IsIgnored(path)
5✔
402
                        if err != nil {
403
                                return false, fmt.Errorf("check ignored: %w", err)
5✔
404
                        }
405
                        if ignored {
406
                                continue // skip gitignored untracked files
407
                        }
408
                }
10✔
409
                return true, nil
10✔
410
        }
10✔
UNCOV
411

×
UNCOV
412
        return false, nil
×
413
}
414

10✔
415
// FileHasChanges returns true if the given file has uncommitted changes.
10✔
UNCOV
416
// this includes untracked, modified, deleted, or staged states.
×
UNCOV
417
func (r *Repo) FileHasChanges(filePath string) (bool, error) {
×
418
        wt, err := r.repo.Worktree()
419
        if err != nil {
15✔
420
                return false, fmt.Errorf("get worktree: %w", err)
5✔
421
        }
6✔
422

1✔
423
        status, err := wt.Status()
1✔
424
        if err != nil {
425
                return false, fmt.Errorf("get status: %w", err)
7✔
426
        }
3✔
427

3✔
428
        relPath, err := r.normalizeToRelative(filePath)
429
        if err != nil {
430
                return false, err
6✔
431
        }
432

433
        if s, ok := status[relPath]; ok {
434
                return r.fileHasChanges(s), nil
435
        }
7✔
436

7✔
437
        return false, nil
7✔
UNCOV
438
}
×
UNCOV
439

×
440
// normalizeToRelative converts a file path to be relative to the repository root.
441
func (r *Repo) normalizeToRelative(filePath string) (string, error) {
7✔
442
        absPath, err := filepath.Abs(filePath)
7✔
UNCOV
443
        if err != nil {
×
444
                return "", fmt.Errorf("get absolute path: %w", err)
×
445
        }
446
        relPath, err := filepath.Rel(r.path, absPath)
7✔
447
        if err != nil {
7✔
448
                return "", fmt.Errorf("get relative path: %w", err)
×
449
        }
×
450
        return relPath, nil
451
}
15✔
452

12✔
453
// fileHasChanges checks if a file status indicates uncommitted changes.
4✔
454
func (r *Repo) fileHasChanges(s *git.FileStatus) bool {
455
        return s.Staging != git.Unmodified ||
4✔
UNCOV
456
                s.Worktree == git.Modified || s.Worktree == git.Deleted || s.Worktree == git.Untracked
×
457
}
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