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

umputun / ralphex / 21466334556

29 Jan 2026 05:02AM UTC coverage: 79.621% (+1.4%) from 78.255%
21466334556

Pull #43

github

umputun
refactor: simplify main.go using extracted packages

main.go reduced from 1100 to ~670 lines by using:
- pkg/plan.Selector for plan file selection
- pkg/git.IsMainBranch and EnsureIgnored for git operations
- pkg/web.Dashboard for web dashboard management

unified printStartupInfo to handle both execution and plan modes
Pull Request #43: Refactor main.go into extracted packages

329 of 516 new or added lines in 7 files covered. (63.76%)

7 existing lines in 1 file now uncovered.

3868 of 4858 relevant lines covered (79.62%)

61.25 hits per line

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

72.45
/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 git operations using go-git.
23
type Repo struct {
24
        repo *git.Repository
25
        path string // absolute path to repository root
26
}
27

28
// Root returns the absolute path to the repository root.
29
func (r *Repo) Root() string {
×
30
        return r.path
×
31
}
×
32

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

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

54
        if strings.HasPrefix(rel, "..") {
21✔
55
                return "", fmt.Errorf("path %q is outside repository root %q", path, r.path)
2✔
56
        }
2✔
57

58
        return rel, nil
17✔
59
}
60

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

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

77
        return &Repo{repo: repo, path: wt.Filesystem.Root()}, nil
88✔
78
}
79

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

92
// CreateInitialCommit stages all non-ignored files and creates an initial commit.
93
// Returns error if no files to stage or commit fails.
94
// Respects local, global, and system gitignore patterns via IsIgnored.
95
func (r *Repo) CreateInitialCommit(message string) error {
7✔
96
        wt, err := r.repo.Worktree()
7✔
97
        if err != nil {
7✔
98
                return fmt.Errorf("get worktree: %w", err)
×
99
        }
×
100

101
        // get status to find untracked files
102
        status, err := wt.Status()
7✔
103
        if err != nil {
7✔
104
                return fmt.Errorf("get status: %w", err)
×
105
        }
×
106

107
        // collect untracked paths and sort for deterministic staging order
108
        var paths []string
7✔
109
        for path, s := range status {
15✔
110
                if s.Worktree == git.Untracked {
16✔
111
                        paths = append(paths, path)
8✔
112
                }
8✔
113
        }
114
        sort.Strings(paths)
7✔
115

7✔
116
        // stage each untracked file that's not ignored
7✔
117
        staged := 0
7✔
118
        for _, path := range paths {
15✔
119
                ignored, ignoreErr := r.IsIgnored(path)
8✔
120
                if ignoreErr != nil {
8✔
121
                        return fmt.Errorf("check ignored %s: %w", path, ignoreErr)
×
122
                }
×
123
                if ignored {
9✔
124
                        continue
1✔
125
                }
126
                if _, addErr := wt.Add(path); addErr != nil {
7✔
127
                        return fmt.Errorf("stage %s: %w", path, addErr)
×
128
                }
×
129
                staged++
7✔
130
        }
131

132
        if staged == 0 {
9✔
133
                return errors.New("no files to commit")
2✔
134
        }
2✔
135

136
        author := r.getAuthor()
5✔
137
        _, err = wt.Commit(message, &git.CommitOptions{Author: author})
5✔
138
        if err != nil {
5✔
139
                return fmt.Errorf("commit: %w", err)
×
140
        }
×
141

142
        return nil
5✔
143
}
144

145
// CurrentBranch returns the name of the current branch, or empty string for detached HEAD state.
146
func (r *Repo) CurrentBranch() (string, error) {
27✔
147
        head, err := r.repo.Head()
27✔
148
        if err != nil {
27✔
149
                return "", fmt.Errorf("get HEAD: %w", err)
×
150
        }
×
151
        if !head.Name().IsBranch() {
29✔
152
                return "", nil // detached HEAD
2✔
153
        }
2✔
154
        return head.Name().Short(), nil
25✔
155
}
156

157
// CreateBranch creates a new branch and switches to it.
158
// Returns error if branch already exists to prevent data loss.
159
func (r *Repo) CreateBranch(name string) error {
17✔
160
        wt, err := r.repo.Worktree()
17✔
161
        if err != nil {
17✔
162
                return fmt.Errorf("get worktree: %w", err)
×
163
        }
×
164

165
        head, err := r.repo.Head()
17✔
166
        if err != nil {
17✔
167
                return fmt.Errorf("get HEAD: %w", err)
×
168
        }
×
169

170
        branchRef := plumbing.NewBranchReferenceName(name)
17✔
171

17✔
172
        // check if branch already exists to prevent overwriting
17✔
173
        if _, err := r.repo.Reference(branchRef, false); err == nil {
18✔
174
                return fmt.Errorf("branch %q already exists", name)
1✔
175
        }
1✔
176

177
        // create the branch reference pointing to current HEAD
178
        ref := plumbing.NewHashReference(branchRef, head.Hash())
16✔
179
        if err := r.repo.Storer.SetReference(ref); err != nil {
16✔
180
                return fmt.Errorf("create branch reference: %w", err)
×
181
        }
×
182

183
        // create branch config for tracking
184
        branchConfig := &config.Branch{
16✔
185
                Name: name,
16✔
186
        }
16✔
187
        if err := r.repo.CreateBranch(branchConfig); err != nil {
17✔
188
                // ignore if branch config already exists
1✔
189
                if !errors.Is(err, git.ErrBranchExists) {
2✔
190
                        return fmt.Errorf("create branch config: %w", err)
1✔
191
                }
1✔
192
        }
193

194
        // checkout the new branch, Keep preserves untracked files
195
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
15✔
196
                return fmt.Errorf("checkout branch: %w", err)
×
197
        }
×
198

199
        return nil
15✔
200
}
201

202
// BranchExists checks if a branch with the given name exists.
203
func (r *Repo) BranchExists(name string) bool {
8✔
204
        branchRef := plumbing.NewBranchReferenceName(name)
8✔
205
        _, err := r.repo.Reference(branchRef, false)
8✔
206
        return err == nil
8✔
207
}
8✔
208

209
// CheckoutBranch switches to an existing branch.
210
func (r *Repo) CheckoutBranch(name string) error {
7✔
211
        wt, err := r.repo.Worktree()
7✔
212
        if err != nil {
7✔
213
                return fmt.Errorf("get worktree: %w", err)
×
214
        }
×
215

216
        branchRef := plumbing.NewBranchReferenceName(name)
7✔
217
        if err := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Keep: true}); err != nil {
8✔
218
                return fmt.Errorf("checkout branch: %w", err)
1✔
219
        }
1✔
220
        return nil
6✔
221
}
222

223
// MoveFile moves a file using git (equivalent to git mv).
224
// Paths can be absolute or relative to the repository root.
225
// The destination directory must already exist.
226
func (r *Repo) MoveFile(src, dst string) error {
7✔
227
        // convert to relative paths for git operations
7✔
228
        srcRel, err := r.toRelative(src)
7✔
229
        if err != nil {
8✔
230
                return fmt.Errorf("invalid source path: %w", err)
1✔
231
        }
1✔
232
        dstRel, err := r.toRelative(dst)
6✔
233
        if err != nil {
6✔
234
                return fmt.Errorf("invalid destination path: %w", err)
×
235
        }
×
236

237
        wt, err := r.repo.Worktree()
6✔
238
        if err != nil {
6✔
239
                return fmt.Errorf("get worktree: %w", err)
×
240
        }
×
241

242
        srcAbs := filepath.Join(r.path, srcRel)
6✔
243
        dstAbs := filepath.Join(r.path, dstRel)
6✔
244

6✔
245
        // move the file on filesystem
6✔
246
        if err := os.Rename(srcAbs, dstAbs); err != nil {
7✔
247
                return fmt.Errorf("rename file: %w", err)
1✔
248
        }
1✔
249

250
        // stage the removal of old path
251
        if _, err := wt.Remove(srcRel); err != nil {
6✔
252
                // rollback filesystem change
1✔
253
                if rbErr := os.Rename(dstAbs, srcAbs); rbErr != nil {
1✔
NEW
254
                        log.Printf("[WARN] rollback failed after Remove error: %v", rbErr)
×
NEW
255
                }
×
256
                return fmt.Errorf("remove old path: %w", err)
1✔
257
        }
258

259
        // stage the addition of new path
260
        if _, err := wt.Add(dstRel); err != nil {
4✔
261
                // rollback: unstage removal and restore file
×
NEW
262
                if rbErr := os.Rename(dstAbs, srcAbs); rbErr != nil {
×
NEW
263
                        log.Printf("[WARN] rollback failed after Add error: %v", rbErr)
×
NEW
264
                }
×
265
                return fmt.Errorf("add new path: %w", err)
×
266
        }
267

268
        return nil
4✔
269
}
270

271
// Add stages a file for commit.
272
// Path can be absolute or relative to the repository root.
273
func (r *Repo) Add(path string) error {
22✔
274
        rel, err := r.toRelative(path)
22✔
275
        if err != nil {
22✔
276
                return fmt.Errorf("invalid path: %w", err)
×
277
        }
×
278

279
        wt, err := r.repo.Worktree()
22✔
280
        if err != nil {
22✔
281
                return fmt.Errorf("get worktree: %w", err)
×
282
        }
×
283

284
        if _, err := wt.Add(rel); err != nil {
23✔
285
                return fmt.Errorf("add file: %w", err)
1✔
286
        }
1✔
287

288
        return nil
21✔
289
}
290

291
// Commit creates a commit with the given message.
292
// Returns error if no changes are staged.
293
func (r *Repo) Commit(msg string) error {
20✔
294
        wt, err := r.repo.Worktree()
20✔
295
        if err != nil {
20✔
296
                return fmt.Errorf("get worktree: %w", err)
×
297
        }
×
298

299
        author := r.getAuthor()
20✔
300
        _, err = wt.Commit(msg, &git.CommitOptions{Author: author})
20✔
301
        if err != nil {
21✔
302
                return fmt.Errorf("commit: %w", err)
1✔
303
        }
1✔
304

305
        return nil
19✔
306
}
307

308
// getAuthor returns the commit author from git config or a fallback.
309
// checks repository config first (.git/config), then falls back to global config,
310
// and finally to default values.
311
func (r *Repo) getAuthor() *object.Signature {
27✔
312
        // try repository config first (merges local + global)
27✔
313
        if cfg, err := r.repo.Config(); err == nil {
54✔
314
                if cfg.User.Name != "" && cfg.User.Email != "" {
27✔
315
                        return &object.Signature{
×
316
                                Name:  cfg.User.Name,
×
317
                                Email: cfg.User.Email,
×
318
                                When:  time.Now(),
×
319
                        }
×
320
                }
×
321
        }
322

323
        // fallback to global config only
324
        if cfg, err := config.LoadConfig(config.GlobalScope); err == nil {
54✔
325
                if cfg.User.Name != "" && cfg.User.Email != "" {
27✔
326
                        return &object.Signature{
×
327
                                Name:  cfg.User.Name,
×
328
                                Email: cfg.User.Email,
×
329
                                When:  time.Now(),
×
330
                        }
×
331
                }
×
332
        }
333

334
        // fallback to default author
335
        return &object.Signature{
27✔
336
                Name:  "ralphex",
27✔
337
                Email: "ralphex@localhost",
27✔
338
                When:  time.Now(),
27✔
339
        }
27✔
340
}
341

342
// IsIgnored checks if a path is ignored by gitignore rules.
343
// Checks local .gitignore files, global gitignore (from core.excludesfile or default
344
// XDG location ~/.config/git/ignore), and system gitignore (/etc/gitconfig).
345
// Returns false, nil if no gitignore rules exist.
346
//
347
// Precedence (highest to lowest): local .gitignore > global > system.
348
// go-git's Matcher checks patterns from end-to-start, so patterns at end have higher priority.
349
func (r *Repo) IsIgnored(path string) (bool, error) {
23✔
350
        wt, err := r.repo.Worktree()
23✔
351
        if err != nil {
23✔
352
                return false, fmt.Errorf("get worktree: %w", err)
×
353
        }
×
354

355
        var patterns []gitignore.Pattern
23✔
356
        rootFS := osfs.New("/")
23✔
357

23✔
358
        // load system patterns first (lowest priority)
23✔
359
        if systemPatterns, err := gitignore.LoadSystemPatterns(rootFS); err == nil {
46✔
360
                patterns = append(patterns, systemPatterns...)
23✔
361
        }
23✔
362

363
        // load global patterns (middle priority)
364
        if globalPatterns, err := gitignore.LoadGlobalPatterns(rootFS); err == nil && len(globalPatterns) > 0 {
23✔
365
                patterns = append(patterns, globalPatterns...)
×
366
        } else {
23✔
367
                // fallback to default XDG location if core.excludesfile not set
23✔
368
                // git uses $XDG_CONFIG_HOME/git/ignore (defaults to ~/.config/git/ignore)
23✔
369
                patterns = append(patterns, loadXDGGlobalPatterns()...)
23✔
370
        }
23✔
371

372
        // load local patterns last (highest priority)
373
        localPatterns, _ := gitignore.ReadPatterns(wt.Filesystem, nil)
23✔
374
        patterns = append(patterns, localPatterns...)
23✔
375

23✔
376
        matcher := gitignore.NewMatcher(patterns)
23✔
377
        pathParts := strings.Split(filepath.ToSlash(path), "/")
23✔
378
        return matcher.Match(pathParts, false), nil
23✔
379
}
380

381
// loadXDGGlobalPatterns loads gitignore patterns from the default XDG location.
382
// Git checks $XDG_CONFIG_HOME/git/ignore, defaulting to ~/.config/git/ignore.
383
func loadXDGGlobalPatterns() []gitignore.Pattern {
23✔
384
        // check XDG_CONFIG_HOME first, fall back to ~/.config
23✔
385
        configHome := os.Getenv("XDG_CONFIG_HOME")
23✔
386
        if configHome == "" {
23✔
387
                home, err := os.UserHomeDir()
×
388
                if err != nil {
×
389
                        return nil
×
390
                }
×
391
                configHome = filepath.Join(home, ".config")
×
392
        }
393

394
        ignorePath := filepath.Join(configHome, "git", "ignore")
23✔
395
        data, err := os.ReadFile(ignorePath) //nolint:gosec // user's gitignore file
23✔
396
        if err != nil {
41✔
397
                return nil
18✔
398
        }
18✔
399

400
        var patterns []gitignore.Pattern
5✔
401
        for line := range strings.SplitSeq(string(data), "\n") {
15✔
402
                line = strings.TrimSpace(line)
10✔
403
                if line == "" || strings.HasPrefix(line, "#") {
15✔
404
                        continue
5✔
405
                }
406
                patterns = append(patterns, gitignore.ParsePattern(line, nil))
5✔
407
        }
408
        return patterns
5✔
409
}
410

411
// IsDirty returns true if the worktree has uncommitted changes
412
// (staged or modified tracked files).
413
func (r *Repo) IsDirty() (bool, error) {
10✔
414
        wt, err := r.repo.Worktree()
10✔
415
        if err != nil {
10✔
416
                return false, fmt.Errorf("get worktree: %w", err)
×
417
        }
×
418

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

424
        for _, s := range status {
15✔
425
                // check for staged changes
5✔
426
                if s.Staging != git.Unmodified && s.Staging != git.Untracked {
6✔
427
                        return true, nil
1✔
428
                }
1✔
429
                // check for unstaged changes to tracked files
430
                if s.Worktree == git.Modified || s.Worktree == git.Deleted {
7✔
431
                        return true, nil
3✔
432
                }
3✔
433
        }
434

435
        return false, nil
6✔
436
}
437

438
// HasChangesOtherThan returns true if there are uncommitted changes to files other than the given file.
439
// this includes modified/deleted tracked files, staged changes, and untracked files (excluding gitignored).
440
func (r *Repo) HasChangesOtherThan(filePath string) (bool, error) {
13✔
441
        wt, err := r.repo.Worktree()
13✔
442
        if err != nil {
13✔
443
                return false, fmt.Errorf("get worktree: %w", err)
×
444
        }
×
445

446
        status, err := wt.Status()
13✔
447
        if err != nil {
13✔
448
                return false, fmt.Errorf("get status: %w", err)
×
449
        }
×
450

451
        relPath, err := r.normalizeToRelative(filePath)
13✔
452
        if err != nil {
13✔
453
                return false, err
×
454
        }
×
455

456
        for path, s := range status {
26✔
457
                if path == relPath {
21✔
458
                        continue // skip the target file
8✔
459
                }
460
                if !r.fileHasChanges(s) {
5✔
461
                        continue
×
462
                }
463
                // for untracked files, check if they're gitignored
464
                // note: go-git sets both Staging and Worktree to Untracked for untracked files
465
                if s.Worktree == git.Untracked {
9✔
466
                        ignored, err := r.IsIgnored(path)
4✔
467
                        if err != nil {
4✔
468
                                return false, fmt.Errorf("check ignored: %w", err)
×
469
                        }
×
470
                        if ignored {
4✔
471
                                continue // skip gitignored untracked files
×
472
                        }
473
                }
474
                return true, nil
5✔
475
        }
476

477
        return false, nil
8✔
478
}
479

480
// FileHasChanges returns true if the given file has uncommitted changes.
481
// this includes untracked, modified, deleted, or staged states.
482
func (r *Repo) FileHasChanges(filePath string) (bool, error) {
11✔
483
        wt, err := r.repo.Worktree()
11✔
484
        if err != nil {
11✔
485
                return false, fmt.Errorf("get worktree: %w", err)
×
486
        }
×
487

488
        status, err := wt.Status()
11✔
489
        if err != nil {
11✔
490
                return false, fmt.Errorf("get status: %w", err)
×
491
        }
×
492

493
        relPath, err := r.normalizeToRelative(filePath)
11✔
494
        if err != nil {
11✔
495
                return false, err
×
496
        }
×
497

498
        if s, ok := status[relPath]; ok {
18✔
499
                return r.fileHasChanges(s), nil
7✔
500
        }
7✔
501

502
        return false, nil
4✔
503
}
504

505
// normalizeToRelative converts a file path to be relative to the repository root.
506
func (r *Repo) normalizeToRelative(filePath string) (string, error) {
24✔
507
        absPath, err := filepath.Abs(filePath)
24✔
508
        if err != nil {
24✔
509
                return "", fmt.Errorf("get absolute path: %w", err)
×
510
        }
×
511
        relPath, err := filepath.Rel(r.path, absPath)
24✔
512
        if err != nil {
24✔
513
                return "", fmt.Errorf("get relative path: %w", err)
×
514
        }
×
515
        return relPath, nil
24✔
516
}
517

518
// fileHasChanges checks if a file status indicates uncommitted changes.
519
func (r *Repo) fileHasChanges(s *git.FileStatus) bool {
12✔
520
        return s.Staging != git.Unmodified ||
12✔
521
                s.Worktree == git.Modified || s.Worktree == git.Deleted || s.Worktree == git.Untracked
12✔
522
}
12✔
523

524
// IsMainBranch returns true if the current branch is "main" or "master".
525
func (r *Repo) IsMainBranch() (bool, error) {
11✔
526
        branch, err := r.CurrentBranch()
11✔
527
        if err != nil {
11✔
NEW
528
                return false, fmt.Errorf("get current branch: %w", err)
×
NEW
529
        }
×
530
        return branch == "main" || branch == "master", nil
11✔
531
}
532

533
// EnsureIgnored ensures a pattern is in .gitignore.
534
// uses probePath to check if pattern is already ignored before adding.
535
// if pattern is already ignored, does nothing.
536
// if pattern is not ignored, appends it to .gitignore with comment.
537
// logFn function is called if pattern is added (can be nil to disable logging).
538
func (r *Repo) EnsureIgnored(pattern, probePath string, logFn func(string, ...any)) error {
5✔
539
        // check if already ignored
5✔
540
        ignored, err := r.IsIgnored(probePath)
5✔
541
        if err == nil && ignored {
6✔
542
                return nil // already ignored
1✔
543
        }
1✔
544

545
        // write to .gitignore at repo root
546
        gitignorePath := filepath.Join(r.path, ".gitignore")
4✔
547
        f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) //nolint:gosec // .gitignore needs world-readable
4✔
548
        if err != nil {
4✔
NEW
549
                return fmt.Errorf("open .gitignore: %w", err)
×
NEW
550
        }
×
551

552
        if _, err := fmt.Fprintf(f, "\n# ralphex progress logs\n%s\n", pattern); err != nil {
4✔
NEW
553
                f.Close()
×
NEW
554
                return fmt.Errorf("write .gitignore: %w", err)
×
NEW
555
        }
×
556

557
        if err := f.Close(); err != nil {
4✔
NEW
558
                return fmt.Errorf("close .gitignore: %w", err)
×
NEW
559
        }
×
560

561
        if logFn != nil {
5✔
562
                logFn("added %s to .gitignore", pattern)
1✔
563
        }
1✔
564
        return nil
4✔
565
}
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