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

umputun / ralphex / 21848286276

10 Feb 2026 01:43AM UTC coverage: 80.772% (+0.006%) from 80.766%
21848286276

Pull #76

github

melonamin
fix(web): use local timezone for timestamp parsing and improve session liveness logic

Progress log timestamps are written in local time without zone offsets,
so ParseInLocation with time.Local matches the actual semantics instead
of silently interpreting them as UTC.

Frontend session liveness now prefers server-provided session state over
the recency heuristic, centralizes end-time calculation in
getElapsedEndTime(), and guards the elapsed timer against running for
non-live sessions. Also fixes temporal consistency in tail.go by
capturing time.Now() once per event batch, and uses sessionIDFromPath
in watcher test for correctness.
Pull Request #76: Web dashboard fixes: diff stats, session replay, watcher improvements

154 of 171 new or added lines in 9 files covered. (90.06%)

241 existing lines in 6 files now uncovered.

5314 of 6579 relevant lines covered (80.77%)

198.26 hits per line

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

74.38
/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
        "log"
8
        "os"
9
        "path/filepath"
10
        "sort"
11
        "strings"
12
        "time"
13

14
        "github.com/go-git/go-billy/v5/osfs"
15
        "github.com/go-git/go-git/v5"
16
        "github.com/go-git/go-git/v5/config"
17
        "github.com/go-git/go-git/v5/plumbing"
18
        "github.com/go-git/go-git/v5/plumbing/format/gitignore"
19
        "github.com/go-git/go-git/v5/plumbing/object"
20
)
21

22
// repo provides low-level git operations using go-git.
23
// This is an internal type - use Service for the public API.
24
type repo struct {
25
        gitRepo *git.Repository
26
        path    string // absolute path to repository root
27
}
28

29
// headHash returns the current HEAD commit hash as a hex string.
30
func (r *repo) headHash() (string, error) {
31
        head, err := r.gitRepo.Head()
32
        if err != nil {
33
                return "", fmt.Errorf("get HEAD: %w", err)
34
        }
5✔
35
        return head.Hash().String(), nil
5✔
36
}
6✔
37

1✔
38
// Root returns the absolute path to the repository root.
1✔
39
func (r *repo) Root() string {
4✔
40
        return r.path
41
}
42

43
// toRelative converts a path to be relative to the repository root.
9✔
44
// Absolute paths are converted to repo-relative.
9✔
45
// Relative paths starting with ".." are resolved against CWD first.
9✔
46
// Other relative paths are assumed to already be repo-relative.
47
// Returns error if the resolved path is outside the repository.
48
func (r *repo) toRelative(path string) (string, error) {
49
        // for relative paths, just clean and validate
50
        if !filepath.IsAbs(path) {
51
                cleaned := filepath.Clean(path)
52
                if strings.HasPrefix(cleaned, "..") {
48✔
53
                        return "", fmt.Errorf("path %q escapes repository root", path)
48✔
54
                }
77✔
55
                return cleaned, nil
29✔
56
        }
31✔
57

2✔
58
        // convert absolute path to repo-relative
2✔
59
        rel, err := filepath.Rel(r.path, path)
27✔
60
        if err != nil {
61
                return "", fmt.Errorf("path outside repository: %w", err)
62
        }
63

19✔
64
        if strings.HasPrefix(rel, "..") {
19✔
UNCOV
65
                return "", fmt.Errorf("path %q is outside repository root %q", path, r.path)
×
UNCOV
66
        }
×
67

68
        return rel, nil
21✔
69
}
2✔
70

2✔
71
// openRepo opens a git repository at the given path.
72
// Supports both regular repositories and git worktrees.
17✔
73
func openRepo(path string) (*repo, error) {
74
        gitRepo, err := git.PlainOpenWithOptions(path, &git.PlainOpenOptions{
75
                EnableDotGitCommonDir: true,
76
        })
77
        if err != nil {
118✔
78
                return nil, fmt.Errorf("open repository: %w", err)
118✔
79
        }
118✔
80

118✔
81
        // get the worktree root path
120✔
82
        wt, err := gitRepo.Worktree()
2✔
83
        if err != nil {
2✔
84
                return nil, fmt.Errorf("get worktree: %w", err)
85
        }
86

116✔
87
        return &repo{gitRepo: gitRepo, path: wt.Filesystem.Root()}, nil
116✔
UNCOV
88
}
×
UNCOV
89

×
90
// HasCommits returns true if the repository has at least one commit.
91
func (r *repo) HasCommits() (bool, error) {
116✔
92
        _, err := r.gitRepo.Head()
93
        if err != nil {
94
                if errors.Is(err, plumbing.ErrReferenceNotFound) {
95
                        return false, nil // no commits yet
10✔
96
                }
10✔
97
                return false, fmt.Errorf("get HEAD: %w", err)
15✔
98
        }
10✔
99
        return true, nil
5✔
100
}
5✔
UNCOV
101

×
102
// CreateInitialCommit stages all non-ignored files and creates an initial commit.
103
// Returns error if no files to stage or commit fails.
5✔
104
// Respects local, global, and system gitignore patterns via IsIgnored.
105
func (r *repo) CreateInitialCommit(message string) error {
106
        wt, err := r.gitRepo.Worktree()
107
        if err != nil {
108
                return fmt.Errorf("get worktree: %w", err)
109
        }
7✔
110

7✔
111
        // get status to find untracked files
7✔
UNCOV
112
        status, err := wt.Status()
×
UNCOV
113
        if err != nil {
×
114
                return fmt.Errorf("get status: %w", err)
115
        }
116

7✔
117
        // collect untracked paths and sort for deterministic staging order
7✔
UNCOV
118
        var paths []string
×
UNCOV
119
        for path, s := range status {
×
120
                if s.Worktree == git.Untracked {
121
                        paths = append(paths, path)
122
                }
7✔
123
        }
15✔
124
        sort.Strings(paths)
16✔
125

8✔
126
        // stage each untracked file that's not ignored
8✔
127
        staged := 0
128
        for _, path := range paths {
7✔
129
                ignored, ignoreErr := r.IsIgnored(path)
7✔
130
                if ignoreErr != nil {
7✔
131
                        return fmt.Errorf("check ignored %s: %w", path, ignoreErr)
7✔
132
                }
15✔
133
                if ignored {
8✔
134
                        continue
8✔
UNCOV
135
                }
×
UNCOV
136
                if _, addErr := wt.Add(path); addErr != nil {
×
137
                        return fmt.Errorf("stage %s: %w", path, addErr)
9✔
138
                }
1✔
139
                staged++
140
        }
7✔
UNCOV
141

×
UNCOV
142
        if staged == 0 {
×
143
                return errors.New("no files to commit")
7✔
144
        }
145

146
        author := r.getAuthor()
9✔
147
        _, err = wt.Commit(message, &git.CommitOptions{Author: author})
2✔
148
        if err != nil {
2✔
149
                return fmt.Errorf("commit: %w", err)
150
        }
5✔
151

5✔
152
        return nil
5✔
UNCOV
153
}
×
UNCOV
154

×
155
// CurrentBranch returns the name of the current branch, or empty string for detached HEAD state.
156
func (r *repo) CurrentBranch() (string, error) {
5✔
157
        head, err := r.gitRepo.Head()
158
        if err != nil {
159
                return "", fmt.Errorf("get HEAD: %w", err)
160
        }
33✔
161
        if !head.Name().IsBranch() {
33✔
162
                return "", nil // detached HEAD
33✔
UNCOV
163
        }
×
UNCOV
164
        return head.Name().Short(), nil
×
165
}
35✔
166

2✔
167
// CreateBranch creates a new branch and switches to it.
2✔
168
// Returns error if branch already exists to prevent data loss.
31✔
169
func (r *repo) CreateBranch(name string) error {
170
        wt, err := r.gitRepo.Worktree()
171
        if err != nil {
172
                return fmt.Errorf("get worktree: %w", err)
173
        }
27✔
174

27✔
175
        head, err := r.gitRepo.Head()
27✔
UNCOV
176
        if err != nil {
×
177
                return fmt.Errorf("get HEAD: %w", err)
×
178
        }
179

27✔
180
        branchRef := plumbing.NewBranchReferenceName(name)
27✔
UNCOV
181

×
UNCOV
182
        // check if branch already exists to prevent overwriting
×
183
        if _, err := r.gitRepo.Reference(branchRef, false); err == nil {
184
                return fmt.Errorf("branch %q already exists", name)
27✔
185
        }
27✔
186

27✔
187
        // create the branch reference pointing to current HEAD
28✔
188
        ref := plumbing.NewHashReference(branchRef, head.Hash())
1✔
189
        if err := r.gitRepo.Storer.SetReference(ref); err != nil {
1✔
190
                return fmt.Errorf("create branch reference: %w", err)
191
        }
192

26✔
193
        // create branch config for tracking
26✔
UNCOV
194
        branchConfig := &config.Branch{
×
UNCOV
195
                Name: name,
×
196
        }
197
        if err := r.gitRepo.CreateBranch(branchConfig); err != nil {
198
                // ignore if branch config already exists
26✔
199
                if !errors.Is(err, git.ErrBranchExists) {
26✔
200
                        return fmt.Errorf("create branch config: %w", err)
26✔
201
                }
27✔
202
        }
1✔
203

2✔
204
        // checkout the new branch, Keep preserves untracked files
1✔
205
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
1✔
206
                return fmt.Errorf("checkout branch: %w", err)
207
        }
208

209
        return nil
25✔
UNCOV
210
}
×
UNCOV
211

×
212
// BranchExists checks if a branch with the given name exists.
213
func (r *repo) BranchExists(name string) bool {
25✔
214
        branchRef := plumbing.NewBranchReferenceName(name)
215
        _, err := r.gitRepo.Reference(branchRef, false)
216
        return err == nil
217
}
14✔
218

14✔
219
// CheckoutBranch switches to an existing branch.
14✔
220
func (r *repo) CheckoutBranch(name string) error {
14✔
221
        wt, err := r.gitRepo.Worktree()
14✔
222
        if err != nil {
223
                return fmt.Errorf("get worktree: %w", err)
224
        }
8✔
225

8✔
226
        branchRef := plumbing.NewBranchReferenceName(name)
8✔
UNCOV
227
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
×
UNCOV
228
                return fmt.Errorf("checkout branch: %w", err)
×
229
        }
230
        return nil
8✔
231
}
9✔
232

1✔
233
// MoveFile moves a file using git (equivalent to git mv).
1✔
234
// Paths can be absolute or relative to the repository root.
7✔
235
// The destination directory must already exist.
236
func (r *repo) MoveFile(src, dst string) error {
237
        // convert to relative paths for git operations
238
        srcRel, err := r.toRelative(src)
239
        if err != nil {
240
                return fmt.Errorf("invalid source path: %w", err)
7✔
241
        }
7✔
242
        dstRel, err := r.toRelative(dst)
7✔
243
        if err != nil {
8✔
244
                return fmt.Errorf("invalid destination path: %w", err)
1✔
245
        }
1✔
246

6✔
247
        wt, err := r.gitRepo.Worktree()
6✔
UNCOV
248
        if err != nil {
×
249
                return fmt.Errorf("get worktree: %w", err)
×
250
        }
251

6✔
252
        srcAbs := filepath.Join(r.path, srcRel)
6✔
UNCOV
253
        dstAbs := filepath.Join(r.path, dstRel)
×
UNCOV
254

×
255
        // move the file on filesystem
256
        if err := os.Rename(srcAbs, dstAbs); err != nil {
6✔
257
                return fmt.Errorf("rename file: %w", err)
6✔
258
        }
6✔
259

6✔
260
        // stage the removal of old path
7✔
261
        if _, err := wt.Remove(srcRel); err != nil {
1✔
262
                // rollback filesystem change
1✔
263
                if rbErr := os.Rename(dstAbs, srcAbs); rbErr != nil {
264
                        log.Printf("[WARN] rollback failed after Remove error: %v", rbErr)
265
                }
6✔
266
                return fmt.Errorf("remove old path: %w", err)
1✔
267
        }
1✔
UNCOV
268

×
UNCOV
269
        // stage the addition of new path
×
270
        if _, err := wt.Add(dstRel); err != nil {
1✔
271
                // rollback: unstage removal and restore file
272
                if rbErr := os.Rename(dstAbs, srcAbs); rbErr != nil {
273
                        log.Printf("[WARN] rollback failed after Add error: %v", rbErr)
274
                }
4✔
275
                return fmt.Errorf("add new path: %w", err)
×
UNCOV
276
        }
×
UNCOV
277

×
UNCOV
278
        return nil
×
UNCOV
279
}
×
280

281
// Add stages a file for commit.
282
// Path can be absolute or relative to the repository root.
4✔
283
func (r *repo) Add(path string) error {
284
        rel, err := r.toRelative(path)
285
        if err != nil {
286
                return fmt.Errorf("invalid path: %w", err)
287
        }
30✔
288

30✔
289
        wt, err := r.gitRepo.Worktree()
30✔
UNCOV
290
        if err != nil {
×
291
                return fmt.Errorf("get worktree: %w", err)
×
292
        }
293

30✔
294
        if _, err := wt.Add(rel); err != nil {
30✔
UNCOV
295
                return fmt.Errorf("add file: %w", err)
×
UNCOV
296
        }
×
297

298
        return nil
31✔
299
}
1✔
300

1✔
301
// Commit creates a commit with the given message.
302
// Returns error if no changes are staged.
29✔
303
func (r *repo) Commit(msg string) error {
304
        wt, err := r.gitRepo.Worktree()
305
        if err != nil {
306
                return fmt.Errorf("get worktree: %w", err)
307
        }
28✔
308

28✔
309
        author := r.getAuthor()
28✔
UNCOV
310
        _, err = wt.Commit(msg, &git.CommitOptions{Author: author})
×
UNCOV
311
        if err != nil {
×
312
                return fmt.Errorf("commit: %w", err)
313
        }
28✔
314

28✔
315
        return nil
29✔
316
}
1✔
317

1✔
318
// getAuthor returns the commit author from git config or a fallback.
319
// checks repository config first (.git/config), then falls back to global config,
27✔
320
// and finally to default values.
321
func (r *repo) getAuthor() *object.Signature {
322
        // try repository config first (merges local + global)
323
        if cfg, err := r.gitRepo.Config(); err == nil {
324
                if cfg.User.Name != "" && cfg.User.Email != "" {
325
                        return &object.Signature{
35✔
326
                                Name:  cfg.User.Name,
35✔
327
                                Email: cfg.User.Email,
70✔
328
                                When:  time.Now(),
35✔
329
                        }
×
330
                }
×
UNCOV
331
        }
×
UNCOV
332

×
UNCOV
333
        // fallback to global config only
×
UNCOV
334
        if cfg, err := config.LoadConfig(config.GlobalScope); err == nil {
×
335
                if cfg.User.Name != "" && cfg.User.Email != "" {
336
                        return &object.Signature{
337
                                Name:  cfg.User.Name,
338
                                Email: cfg.User.Email,
70✔
339
                                When:  time.Now(),
35✔
340
                        }
×
341
                }
×
UNCOV
342
        }
×
UNCOV
343

×
UNCOV
344
        // fallback to default author
×
UNCOV
345
        return &object.Signature{
×
346
                Name:  "ralphex",
347
                Email: "ralphex@localhost",
348
                When:  time.Now(),
349
        }
35✔
350
}
35✔
351

35✔
352
// IsIgnored checks if a path is ignored by gitignore rules.
35✔
353
// Checks local .gitignore files, global gitignore (from core.excludesfile or default
35✔
354
// XDG location ~/.config/git/ignore), and system gitignore (/etc/gitconfig).
355
// Returns false, nil if no gitignore rules exist.
356
//
357
// Precedence (highest to lowest): local .gitignore > global > system.
358
// go-git's Matcher checks patterns from end-to-start, so patterns at end have higher priority.
359
func (r *repo) IsIgnored(path string) (bool, error) {
360
        wt, err := r.gitRepo.Worktree()
361
        if err != nil {
362
                return false, fmt.Errorf("get worktree: %w", err)
363
        }
27✔
364

27✔
365
        var patterns []gitignore.Pattern
27✔
UNCOV
366
        rootFS := osfs.New("/")
×
UNCOV
367

×
368
        // load system patterns first (lowest priority)
369
        if systemPatterns, err := gitignore.LoadSystemPatterns(rootFS); err == nil {
27✔
370
                patterns = append(patterns, systemPatterns...)
27✔
371
        }
27✔
372

27✔
373
        // load global patterns (middle priority)
54✔
374
        if globalPatterns, err := gitignore.LoadGlobalPatterns(rootFS); err == nil && len(globalPatterns) > 0 {
27✔
375
                patterns = append(patterns, globalPatterns...)
27✔
376
        } else {
377
                // fallback to default XDG location if core.excludesfile not set
378
                // git uses $XDG_CONFIG_HOME/git/ignore (defaults to ~/.config/git/ignore)
27✔
UNCOV
379
                patterns = append(patterns, loadXDGGlobalPatterns()...)
×
380
        }
27✔
381

27✔
382
        // load local patterns last (highest priority)
27✔
383
        localPatterns, _ := gitignore.ReadPatterns(wt.Filesystem, nil)
27✔
384
        patterns = append(patterns, localPatterns...)
27✔
385

386
        matcher := gitignore.NewMatcher(patterns)
387
        pathParts := strings.Split(filepath.ToSlash(path), "/")
27✔
388
        return matcher.Match(pathParts, false), nil
27✔
389
}
27✔
390

27✔
391
// loadXDGGlobalPatterns loads gitignore patterns from the default XDG location.
27✔
392
// Git checks $XDG_CONFIG_HOME/git/ignore, defaulting to ~/.config/git/ignore.
27✔
393
func loadXDGGlobalPatterns() []gitignore.Pattern {
394
        // check XDG_CONFIG_HOME first, fall back to ~/.config
395
        configHome := os.Getenv("XDG_CONFIG_HOME")
396
        if configHome == "" {
397
                home, err := os.UserHomeDir()
27✔
398
                if err != nil {
27✔
399
                        return nil
27✔
400
                }
27✔
401
                configHome = filepath.Join(home, ".config")
×
UNCOV
402
        }
×
UNCOV
403

×
UNCOV
404
        ignorePath := filepath.Join(configHome, "git", "ignore")
×
UNCOV
405
        data, err := os.ReadFile(ignorePath) //nolint:gosec // user's gitignore file
×
406
        if err != nil {
407
                return nil
408
        }
27✔
409

27✔
410
        var patterns []gitignore.Pattern
49✔
411
        for line := range strings.SplitSeq(string(data), "\n") {
22✔
412
                line = strings.TrimSpace(line)
22✔
413
                if line == "" || strings.HasPrefix(line, "#") {
414
                        continue
5✔
415
                }
15✔
416
                patterns = append(patterns, gitignore.ParsePattern(line, nil))
10✔
417
        }
15✔
418
        return patterns
5✔
419
}
420

5✔
421
// IsDirty returns true if the worktree has uncommitted changes
422
// (staged or modified tracked files).
5✔
423
func (r *repo) IsDirty() (bool, error) {
424
        wt, err := r.gitRepo.Worktree()
425
        if err != nil {
426
                return false, fmt.Errorf("get worktree: %w", err)
427
        }
14✔
428

14✔
429
        status, err := wt.Status()
14✔
UNCOV
430
        if err != nil {
×
431
                return false, fmt.Errorf("get status: %w", err)
×
432
        }
433

14✔
434
        for _, s := range status {
14✔
UNCOV
435
                // check for staged changes
×
UNCOV
436
                if s.Staging != git.Unmodified && s.Staging != git.Untracked {
×
437
                        return true, nil
438
                }
21✔
439
                // check for unstaged changes to tracked files
7✔
440
                if s.Worktree == git.Modified || s.Worktree == git.Deleted {
8✔
441
                        return true, nil
1✔
442
                }
1✔
443
        }
444

10✔
445
        return false, nil
4✔
446
}
4✔
447

448
// HasChangesOtherThan returns true if there are uncommitted changes to files other than the given file.
449
// this includes modified/deleted tracked files, staged changes, and untracked files (excluding gitignored).
9✔
450
func (r *repo) HasChangesOtherThan(filePath string) (bool, error) {
451
        wt, err := r.gitRepo.Worktree()
452
        if err != nil {
453
                return false, fmt.Errorf("get worktree: %w", err)
454
        }
17✔
455

17✔
456
        status, err := wt.Status()
17✔
UNCOV
457
        if err != nil {
×
458
                return false, fmt.Errorf("get status: %w", err)
×
459
        }
460

17✔
461
        relPath, err := r.normalizeToRelative(filePath)
17✔
UNCOV
462
        if err != nil {
×
463
                return false, err
×
464
        }
465

17✔
466
        for path, s := range status {
17✔
UNCOV
467
                if path == relPath {
×
UNCOV
468
                        continue // skip the target file
×
469
                }
470
                if !r.fileHasChanges(s) {
31✔
471
                        continue
22✔
472
                }
8✔
473
                // for untracked files, check if they're gitignored
474
                // note: go-git sets both Staging and Worktree to Untracked for untracked files
6✔
UNCOV
475
                if s.Worktree == git.Untracked {
×
476
                        ignored, err := r.IsIgnored(path)
477
                        if err != nil {
478
                                return false, fmt.Errorf("check ignored: %w", err)
479
                        }
11✔
480
                        if ignored {
5✔
481
                                continue // skip gitignored untracked files
5✔
UNCOV
482
                        }
×
UNCOV
483
                }
×
484
                return true, nil
5✔
UNCOV
485
        }
×
486

487
        return false, nil
488
}
6✔
489

490
// FileHasChanges returns true if the given file has uncommitted changes.
491
// this includes untracked, modified, deleted, or staged states.
11✔
492
func (r *repo) FileHasChanges(filePath string) (bool, error) {
493
        wt, err := r.gitRepo.Worktree()
494
        if err != nil {
495
                return false, fmt.Errorf("get worktree: %w", err)
496
        }
14✔
497

14✔
498
        status, err := wt.Status()
14✔
UNCOV
499
        if err != nil {
×
500
                return false, fmt.Errorf("get status: %w", err)
×
501
        }
502

14✔
503
        relPath, err := r.normalizeToRelative(filePath)
14✔
UNCOV
504
        if err != nil {
×
505
                return false, err
×
506
        }
507

14✔
508
        if s, ok := status[relPath]; ok {
14✔
UNCOV
509
                return r.fileHasChanges(s), nil
×
UNCOV
510
        }
×
511

512
        return false, nil
23✔
513
}
9✔
514

9✔
515
// normalizeToRelative converts a file path to be relative to the repository root.
516
func (r *repo) normalizeToRelative(filePath string) (string, error) {
5✔
517
        absPath, err := filepath.Abs(filePath)
518
        if err != nil {
519
                return "", fmt.Errorf("get absolute path: %w", err)
520
        }
31✔
521
        relPath, err := filepath.Rel(r.path, absPath)
31✔
522
        if err != nil {
31✔
523
                return "", fmt.Errorf("get relative path: %w", err)
×
524
        }
×
525
        return relPath, nil
31✔
526
}
31✔
UNCOV
527

×
UNCOV
528
// fileHasChanges checks if a file status indicates uncommitted changes.
×
529
func (r *repo) fileHasChanges(s *git.FileStatus) bool {
31✔
530
        return s.Staging != git.Unmodified ||
531
                s.Worktree == git.Modified || s.Worktree == git.Deleted || s.Worktree == git.Untracked
532
}
533

15✔
534
// IsMainBranch returns true if the current branch is "main" or "master".
15✔
535
func (r *repo) IsMainBranch() (bool, error) {
15✔
536
        branch, err := r.CurrentBranch()
15✔
537
        if err != nil {
538
                return false, fmt.Errorf("get current branch: %w", err)
539
        }
13✔
540
        return branch == "main" || branch == "master", nil
13✔
541
}
13✔
UNCOV
542

×
UNCOV
543
// GetDefaultBranch returns the default branch name.
×
544
// detects the default branch in this order:
13✔
545
// 1. check origin/HEAD symbolic reference (most reliable for repos with remotes)
546
// 2. check common branch names: main, master, trunk, develop
547
// 3. fall back to "master" if nothing else found
548
func (r *repo) GetDefaultBranch() string {
549
        // first, try to get the default branch from origin/HEAD
550
        if branch := r.getDefaultBranchFromOriginHead(); branch != "" {
551
                return branch
552
        }
8✔
553

8✔
554
        // fallback: check which common branch names exist
9✔
555
        branches, err := r.gitRepo.Branches()
1✔
556
        if err != nil {
1✔
557
                return "master"
558
        }
559

7✔
560
        branchSet := make(map[string]bool)
7✔
UNCOV
561
        _ = branches.ForEach(func(ref *plumbing.Reference) error {
×
UNCOV
562
                branchSet[ref.Name().Short()] = true
×
563
                return nil
564
        })
7✔
565

16✔
566
        // check common default branch names in order of preference
9✔
567
        for _, name := range []string{"main", "master", "trunk", "develop"} {
9✔
568
                if branchSet[name] {
9✔
569
                        return name
570
                }
571
        }
22✔
572

21✔
573
        return "master"
6✔
574
}
6✔
575

576
// getDefaultBranchFromOriginHead attempts to detect default branch from origin/HEAD symbolic ref.
577
// returns empty string if origin/HEAD doesn't exist or isn't a symbolic reference.
1✔
578
// returns local branch name if it exists, otherwise returns remote-tracking branch (e.g., "origin/main").
579
func (r *repo) getDefaultBranchFromOriginHead() string {
580
        ref, err := r.gitRepo.Reference(plumbing.NewRemoteReferenceName("origin", "HEAD"), false)
581
        if err != nil {
582
                return ""
583
        }
8✔
584
        if ref.Type() != plumbing.SymbolicReference {
8✔
585
                return ""
15✔
586
        }
7✔
587

7✔
588
        target := ref.Target().Short()
1✔
UNCOV
589
        if !strings.HasPrefix(target, "origin/") {
×
590
                return target
×
591
        }
592

1✔
593
        // target is like "origin/main", extract branch name
1✔
UNCOV
594
        branchName := target[7:]
×
UNCOV
595
        // verify local branch exists before returning it
×
596
        // if local doesn't exist, return remote-tracking ref which git commands understand
597
        localRef := plumbing.NewBranchReferenceName(branchName)
598
        if _, err := r.gitRepo.Reference(localRef, false); err == nil {
1✔
599
                return branchName
1✔
600
        }
1✔
601
        // local branch doesn't exist, use remote-tracking branch (e.g., "origin/main")
1✔
602
        return target
1✔
UNCOV
603
}
×
UNCOV
604

×
605
// DiffStats holds statistics about changes between two commits.
606
type DiffStats struct {
1✔
607
        Files     int // number of files changed
608
        Additions int // lines added
609
        Deletions int // lines deleted
610
}
611

13✔
612
// diffStats returns change statistics between baseBranch and HEAD.
13✔
613
// returns zero stats if branches are equal or baseBranch doesn't exist.
13✔
614
func (r *repo) diffStats(baseBranch string) (DiffStats, error) {
16✔
615
        // resolve base branch to commit (try local first, then remote tracking)
3✔
616
        baseCommit, err := r.resolveToCommit(baseBranch)
3✔
617
        if err != nil {
618
                return DiffStats{}, nil //nolint:nilerr // base branch doesn't exist, return zero stats
619
        }
10✔
620

10✔
UNCOV
621
        // get HEAD commit
×
UNCOV
622
        headRef, err := r.gitRepo.Head()
×
623
        if err != nil {
10✔
624
                return DiffStats{}, fmt.Errorf("get HEAD: %w", err)
10✔
625
        }
×
UNCOV
626
        headCommit, err := r.gitRepo.CommitObject(headRef.Hash())
×
627
        if err != nil {
628
                return DiffStats{}, fmt.Errorf("get HEAD commit: %w", err)
629
        }
13✔
630

3✔
631
        // return zero stats if commits are equal
3✔
632
        if baseCommit.Hash == headCommit.Hash {
633
                return DiffStats{}, nil
634
        }
7✔
635

7✔
UNCOV
636
        // get patch between base and HEAD
×
UNCOV
637
        patch, err := baseCommit.Patch(headCommit)
×
638
        if err != nil {
639
                return DiffStats{}, fmt.Errorf("get patch: %w", err)
640
        }
7✔
641

7✔
642
        // count files and sum additions/deletions
7✔
643
        stats := patch.Stats()
16✔
644
        var result DiffStats
9✔
645
        result.Files = len(stats)
9✔
646
        for _, s := range stats {
9✔
647
                result.Additions += s.Addition
648
                result.Deletions += s.Deletion
7✔
649
        }
650

651
        return result, nil
652
}
653

16✔
654
// resolveToCommit resolves a branch name to a commit object.
16✔
655
// tries local branch first, then remote tracking branch (origin/name).
16✔
656
func (r *repo) resolveToCommit(branchName string) (*object.Commit, error) {
27✔
657
        // try local branch first
11✔
658
        localRef := plumbing.NewBranchReferenceName(branchName)
11✔
UNCOV
659
        if ref, err := r.gitRepo.Reference(localRef, true); err == nil {
×
UNCOV
660
                commit, commitErr := r.gitRepo.CommitObject(ref.Hash())
×
661
                if commitErr != nil {
11✔
662
                        return nil, fmt.Errorf("get commit for local branch: %w", commitErr)
663
                }
664
                return commit, nil
665
        }
5✔
666

5✔
UNCOV
667
        // try remote tracking branch (origin/branchName)
×
UNCOV
668
        remoteRef := plumbing.NewRemoteReferenceName("origin", branchName)
×
UNCOV
669
        if ref, err := r.gitRepo.Reference(remoteRef, true); err == nil {
×
670
                commit, commitErr := r.gitRepo.CommitObject(ref.Hash())
×
671
                if commitErr != nil {
×
672
                        return nil, fmt.Errorf("get commit for remote branch: %w", commitErr)
673
                }
674
                return commit, nil
675
        }
6✔
676

1✔
677
        // try as-is (might be "origin/main" already)
1✔
678
        if strings.HasPrefix(branchName, "origin/") {
2✔
679
                remoteName := branchName[7:]
1✔
680
                remoteRef := plumbing.NewRemoteReferenceName("origin", remoteName)
1✔
UNCOV
681
                if ref, err := r.gitRepo.Reference(remoteRef, true); err == nil {
×
UNCOV
682
                        commit, commitErr := r.gitRepo.CommitObject(ref.Hash())
×
683
                        if commitErr != nil {
1✔
684
                                return nil, fmt.Errorf("get commit for origin branch: %w", commitErr)
685
                        }
686
                        return commit, nil
687
                }
4✔
688
        }
689

690
        return nil, fmt.Errorf("branch %q not found", branchName)
691
}
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