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

valksor / kvelmo / 23415557731

22 Mar 2026 11:47PM UTC coverage: 52.116% (+0.6%) from 51.49%
23415557731

push

github

k0d3r1s
Update web and desktop dependencies [skip ci]

- Bump form and websocket libraries
- Update windowing and string parsing crates
- Refresh system bindings and internal macros

797 of 1308 branches covered (60.93%)

Branch coverage included in aggregate %.

21146 of 40796 relevant lines covered (51.83%)

0.9 hits per line

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

66.02
/pkg/git/git.go
1
package git
2

3
import (
4
        "bytes"
5
        "context"
6
        "errors"
7
        "fmt"
8
        "log/slog"
9
        "os/exec"
10
        "regexp"
11
        "strings"
12
        "time"
13

14
        "github.com/valksor/kvelmo/pkg/retry"
15
)
16

17
type Repository struct {
18
        path        string
19
        signCommits bool
20
}
21

22
func Open(path string) (*Repository, error) {
1✔
23
        // Verify it's a git repo
1✔
24
        cmd := exec.Command("git", "-C", path, "rev-parse", "--git-dir") //nolint:noctx // Quick one-shot existence check, no meaningful context to propagate
1✔
25
        if err := cmd.Run(); err != nil {
2✔
26
                return nil, fmt.Errorf("not a git repository: %s", path)
1✔
27
        }
1✔
28

29
        return &Repository{path: path}, nil
1✔
30
}
31

32
func (r *Repository) Path() string {
1✔
33
        return r.path
1✔
34
}
1✔
35

36
// gitRetryDelay is the base delay between retries for lock-contended git operations.
37
const gitRetryDelay = 100 * time.Millisecond
38

39
// gitMaxRetries is the number of retry attempts for retryable git operations.
40
const gitMaxRetries = 3
41

42
func (r *Repository) run(ctx context.Context, args ...string) (string, error) {
1✔
43
        cmd := exec.CommandContext(ctx, "git", append([]string{"-C", r.path}, args...)...)
1✔
44
        var stdout, stderr bytes.Buffer
1✔
45
        cmd.Stdout = &stdout
1✔
46
        cmd.Stderr = &stderr
1✔
47
        if err := cmd.Run(); err != nil {
2✔
48
                slog.Debug("git: command failed", "args", args, "error", err, "stderr", stderr.String())
1✔
49

1✔
50
                return "", formatGitError(args, stderr.String(), err)
1✔
51
        }
1✔
52

53
        return strings.TrimSpace(stdout.String()), nil
1✔
54
}
55

56
// runRetryable executes a git command with automatic retry on lock-file
57
// conflicts and other transient errors.
58
func (r *Repository) runRetryable(ctx context.Context, args ...string) error {
1✔
59
        return retry.RetryableOp(ctx, gitMaxRetries, gitRetryDelay, func() error {
2✔
60
                _, runErr := r.run(ctx, args...)
1✔
61

1✔
62
                return runErr
1✔
63
        }, retry.WithRetryCheck(isRetryableGitError))
1✔
64
}
65

66
// isRetryableGitError checks for lock file conflicts and transient git errors
67
// that may resolve on retry.
68
func isRetryableGitError(err error) bool {
1✔
69
        if err == nil {
1✔
70
                return false
×
71
        }
×
72

73
        return retry.IsRetryable(err)
1✔
74
}
75

76
// formatGitError converts git command errors to user-friendly messages.
77
func formatGitError(args []string, stderr string, err error) error {
1✔
78
        stderr = strings.TrimSpace(stderr)
1✔
79

1✔
80
        // Common error patterns with user-friendly messages
1✔
81
        switch {
1✔
82
        case strings.Contains(stderr, "not a git repository"):
1✔
83
                return errors.New("not a git repository\nRun 'git init' to initialize one, or navigate to a project directory")
1✔
84

85
        case strings.Contains(stderr, "already exists"):
1✔
86
                if len(args) > 0 && args[0] == "checkout" {
2✔
87
                        return fmt.Errorf("branch already exists: %s", stderr)
1✔
88
                }
1✔
89

90
                return fmt.Errorf("already exists: %s", stderr)
1✔
91

92
        case strings.Contains(stderr, "did not match any"):
1✔
93
                return fmt.Errorf("branch or commit not found: %s", stderr)
1✔
94

95
        case strings.Contains(stderr, "Your local changes"):
1✔
96
                return errors.New("uncommitted changes would be overwritten\nCommit or stash your changes first")
1✔
97

98
        case strings.Contains(stderr, "CONFLICT"):
1✔
99
                return errors.New("merge conflict detected\nResolve conflicts manually, then run 'git add' and 'git commit'")
1✔
100

101
        case strings.Contains(stderr, "Permission denied"):
1✔
102
                return fmt.Errorf("permission denied: %s", stderr)
1✔
103

104
        case strings.Contains(stderr, "Could not resolve host"):
1✔
105
                return errors.New("cannot reach remote server\nCheck your network connection")
1✔
106

107
        case strings.Contains(stderr, "Authentication failed"):
1✔
108
                return errors.New("authentication failed\nCheck your credentials or token")
1✔
109

110
        case strings.Contains(stderr, "No space left on device") || strings.Contains(stderr, "ENOSPC"):
1✔
111
                return errors.New("disk full — free up space and retry")
1✔
112
        }
113

114
        // Default: include stderr if present
115
        if stderr != "" {
2✔
116
                return fmt.Errorf("%w: %s", err, stderr)
1✔
117
        }
1✔
118

119
        return err
1✔
120
}
121

122
func (r *Repository) CurrentBranch(ctx context.Context) (string, error) {
1✔
123
        return r.run(ctx, "rev-parse", "--abbrev-ref", "HEAD")
1✔
124
}
1✔
125

126
func (r *Repository) CurrentCommit(ctx context.Context) (string, error) {
1✔
127
        return r.run(ctx, "rev-parse", "HEAD")
1✔
128
}
1✔
129

130
func (r *Repository) CreateBranch(ctx context.Context, name, startPoint string) error {
1✔
131
        slog.Debug("git: creating branch", "name", name, "startPoint", startPoint)
1✔
132

1✔
133
        args := []string{"checkout", "-b", name}
1✔
134
        if startPoint != "" {
2✔
135
                args = append(args, startPoint)
1✔
136
        }
1✔
137

138
        _, err := r.run(ctx, args...)
1✔
139

1✔
140
        return err
1✔
141
}
142

143
func (r *Repository) SwitchBranch(ctx context.Context, name string) error {
1✔
144
        _, err := r.run(ctx, "checkout", name)
1✔
145

1✔
146
        return err
1✔
147
}
1✔
148

149
// Checkout is an alias for SwitchBranch.
150
func (r *Repository) Checkout(ctx context.Context, name string) error {
×
151
        return r.SwitchBranch(ctx, name)
×
152
}
×
153

154
func (r *Repository) DeleteBranch(ctx context.Context, name string) error {
1✔
155
        slog.Debug("git: deleting branch", "name", name)
1✔
156
        _, err := r.run(ctx, "branch", "-D", name)
1✔
157

1✔
158
        return err
1✔
159
}
1✔
160

161
// DeleteRemoteBranch deletes a branch from the remote (origin).
162
func (r *Repository) DeleteRemoteBranch(ctx context.Context, name string) error {
×
163
        slog.Debug("git: deleting remote branch", "name", name)
×
164
        _, err := r.run(ctx, "push", "origin", "--delete", name)
×
165

×
166
        return err
×
167
}
×
168

169
// BranchExists checks if a branch exists (local or remote).
170
func (r *Repository) BranchExists(ctx context.Context, name string) bool {
×
171
        _, err := r.run(ctx, "rev-parse", "--verify", name)
×
172

×
173
        return err == nil
×
174
}
×
175

176
// LocalBranchExists checks if a local branch exists (refs/heads/ only).
177
// Unlike BranchExists, this won't match remote tracking branches.
178
func (r *Repository) LocalBranchExists(ctx context.Context, name string) bool {
1✔
179
        _, err := r.run(ctx, "rev-parse", "--verify", "refs/heads/"+name)
1✔
180

1✔
181
        return err == nil
1✔
182
}
1✔
183

184
func (r *Repository) HasUncommittedChanges(ctx context.Context) (bool, error) {
1✔
185
        out, err := r.run(ctx, "status", "--porcelain")
1✔
186
        if err != nil {
1✔
187
                return false, err
×
188
        }
×
189

190
        return len(out) > 0, nil
1✔
191
}
192

193
func (r *Repository) StageAll(ctx context.Context) error {
1✔
194
        err := r.runRetryable(ctx, "add", "-A")
1✔
195

1✔
196
        return err
1✔
197
}
1✔
198

199
// ValidateCommitMessage checks if a commit message subject line matches the required pattern.
200
// Returns nil if pattern is empty (no validation) or if the message matches.
201
func ValidateCommitMessage(message, pattern string) error {
1✔
202
        if pattern == "" {
2✔
203
                return nil
1✔
204
        }
1✔
205
        re, err := regexp.Compile(pattern)
1✔
206
        if err != nil {
2✔
207
                return fmt.Errorf("invalid commit pattern %q: %w", pattern, err)
1✔
208
        }
1✔
209
        subject := strings.SplitN(message, "\n", 2)[0]
1✔
210
        if !re.MatchString(subject) {
2✔
211
                return fmt.Errorf("commit message %q does not match required pattern %s", subject, pattern)
1✔
212
        }
1✔
213

214
        return nil
1✔
215
}
216

217
// SetSignCommits enables or disables GPG commit signing.
218
func (r *Repository) SetSignCommits(sign bool) {
×
219
        r.signCommits = sign
×
220
}
×
221

222
// IsSigningConfigured checks whether git commit signing is configured in the repository.
223
func (r *Repository) IsSigningConfigured(ctx context.Context) bool {
×
224
        out, err := r.run(ctx, "config", "commit.gpgsign")
×
225
        if err != nil {
×
226
                return false
×
227
        }
×
228

229
        return strings.TrimSpace(out) == "true"
×
230
}
231

232
func (r *Repository) Commit(ctx context.Context, message string) (string, error) {
1✔
233
        slog.Debug("git: committing", "message", message)
1✔
234
        args := []string{"commit"}
1✔
235
        if r.signCommits {
1✔
236
                args = append(args, "--gpg-sign")
×
237
        }
×
238
        args = append(args, "-m", message)
1✔
239
        err := r.runRetryable(ctx, args...)
1✔
240
        if err != nil {
1✔
241
                // Check if this looks like a pre-commit hook failure where files were modified
×
242
                // (formatters that fix files but reject the commit)
×
243
                if r.isHookFormatterFailure(ctx) {
×
244
                        slog.Info("git: pre-commit hook modified files, re-staging and retrying")
×
245
                        if stageErr := r.StageAll(ctx); stageErr != nil {
×
246
                                return "", fmt.Errorf("re-stage after hook: %w", stageErr)
×
247
                        }
×
248
                        retryErr := r.runRetryable(ctx, args...)
×
249
                        if retryErr != nil {
×
250
                                return "", fmt.Errorf("commit after hook retry: %w", retryErr)
×
251
                        }
×
252
                } else {
×
253
                        return "", err
×
254
                }
×
255
        }
256

257
        sha, err := r.CurrentCommit(ctx)
1✔
258
        if err != nil {
1✔
259
                slog.Warn("git: committed but failed to get SHA", "error", err)
×
260

×
261
                return "", fmt.Errorf("commit succeeded but failed to get SHA: %w", err)
×
262
        }
×
263
        if sha == "" {
1✔
264
                slog.Error("git: committed but empty SHA")
×
265

×
266
                return "", errors.New("commit succeeded but SHA is empty")
×
267
        }
×
268
        slog.Debug("git: committed", "sha", sha)
1✔
269

1✔
270
        return sha, nil
1✔
271
}
272

273
// isHookFormatterFailure checks if a commit failure was caused by a pre-commit hook
274
// that modified files (common with formatters like prettier, black, gofmt).
275
func (r *Repository) isHookFormatterFailure(ctx context.Context) bool {
×
276
        // After a failed commit, check if there are now unstaged changes
×
277
        // (which would indicate a formatter modified files)
×
278
        has, err := r.HasUncommittedChanges(ctx)
×
279
        if err != nil {
×
280
                return false
×
281
        }
×
282

283
        return has
×
284
}
285

286
func (r *Repository) Reset(ctx context.Context, commit string, hard bool) error {
1✔
287
        slog.Debug("git: resetting", "commit", commit, "hard", hard)
1✔
288
        args := []string{"reset"}
1✔
289
        if hard {
2✔
290
                args = append(args, "--hard")
1✔
291
        }
1✔
292
        args = append(args, commit)
1✔
293
        err := r.runRetryable(ctx, args...)
1✔
294

1✔
295
        return err
1✔
296
}
297

298
func (r *Repository) Stash(ctx context.Context) error {
1✔
299
        _, err := r.run(ctx, "stash")
1✔
300

1✔
301
        return err
1✔
302
}
1✔
303

304
func (r *Repository) StashPop(ctx context.Context) error {
1✔
305
        _, err := r.run(ctx, "stash", "pop")
1✔
306

1✔
307
        return err
1✔
308
}
1✔
309

310
// Push pushes to the remote repository.
311
func (r *Repository) Push(ctx context.Context, remote, branch string) error {
1✔
312
        slog.Debug("git: pushing", "remote", remote, "branch", branch)
1✔
313
        err := r.runRetryable(ctx, "push", remote, branch)
1✔
314

1✔
315
        return err
1✔
316
}
1✔
317

318
// PushDefault pushes to origin with the current branch.
319
func (r *Repository) PushDefault(ctx context.Context) error {
1✔
320
        branch, err := r.CurrentBranch(ctx)
1✔
321
        if err != nil {
1✔
322
                return err
×
323
        }
×
324

325
        return r.Push(ctx, "origin", branch)
1✔
326
}
327

328
// Pull pulls from the remote repository.
329
func (r *Repository) Pull(ctx context.Context) error {
×
330
        slog.Debug("git: pulling")
×
331
        err := r.runRetryable(ctx, "pull")
×
332

×
333
        return err
×
334
}
×
335

336
// Fetch fetches from the remote repository.
337
func (r *Repository) Fetch(ctx context.Context) error {
×
338
        slog.Debug("git: fetching")
×
339
        err := r.runRetryable(ctx, "fetch")
×
340

×
341
        return err
×
342
}
×
343

344
// CommitsBehind returns how many commits the current branch is behind the given ref.
345
// ref should be a full remote ref like "origin/main" or "upstream/develop".
346
func (r *Repository) CommitsBehind(ctx context.Context, ref string) (int, error) {
×
347
        current, err := r.CurrentBranch(ctx)
×
348
        if err != nil {
×
349
                return 0, err
×
350
        }
×
351

352
        // Count commits that are in ref but not in current
353
        out, err := r.run(ctx, "rev-list", "--count", fmt.Sprintf("%s..%s", current, ref))
×
354
        if err != nil {
×
355
                return 0, err
×
356
        }
×
357

358
        var count int
×
359
        if _, err := fmt.Sscanf(out, "%d", &count); err != nil {
×
360
                return 0, fmt.Errorf("parse count: %w", err)
×
361
        }
×
362

363
        return count, nil
×
364
}
365

366
func (r *Repository) Log(ctx context.Context, n int) ([]LogEntry, error) {
1✔
367
        format := "%H|%s|%an|%ai"
1✔
368
        out, err := r.run(ctx, "log", fmt.Sprintf("-n%d", n), "--format="+format)
1✔
369
        if err != nil {
1✔
370
                return nil, err
×
371
        }
×
372

373
        var entries []LogEntry
1✔
374
        for _, line := range strings.Split(out, "\n") {
2✔
375
                if line == "" {
1✔
376
                        continue
×
377
                }
378
                parts := strings.SplitN(line, "|", 4)
1✔
379
                if len(parts) < 4 {
1✔
380
                        continue
×
381
                }
382
                entries = append(entries, LogEntry{
1✔
383
                        SHA:     parts[0],
1✔
384
                        Message: parts[1],
1✔
385
                        Author:  parts[2],
1✔
386
                        Date:    parts[3],
1✔
387
                })
1✔
388
        }
389

390
        return entries, nil
1✔
391
}
392

393
// CommitInfo returns metadata for a single commit SHA.
394
func (r *Repository) CommitInfo(ctx context.Context, sha string) (LogEntry, error) {
1✔
395
        format := "%H|%s|%an|%ai"
1✔
396
        out, err := r.run(ctx, "log", "-1", "--format="+format, sha)
1✔
397
        if err != nil {
2✔
398
                return LogEntry{}, fmt.Errorf("commit %s not found: %w", sha, err)
1✔
399
        }
1✔
400
        if out == "" {
1✔
401
                return LogEntry{}, fmt.Errorf("commit %s not found", sha)
×
402
        }
×
403
        parts := strings.SplitN(out, "|", 4)
1✔
404
        if len(parts) < 4 {
1✔
405
                return LogEntry{}, fmt.Errorf("unexpected log format: %q", out)
×
406
        }
×
407

408
        return LogEntry{
1✔
409
                SHA:     parts[0],
1✔
410
                Message: parts[1],
1✔
411
                Author:  parts[2],
1✔
412
                Date:    parts[3],
1✔
413
        }, nil
1✔
414
}
415

416
func (r *Repository) Diff(ctx context.Context, cached bool) (string, error) {
1✔
417
        args := []string{"diff"}
1✔
418
        if cached {
1✔
419
                args = append(args, "--cached")
×
420
        }
×
421

422
        return r.run(ctx, args...)
1✔
423
}
424

425
// DiffAgainst shows the diff between a given commit and the current working tree (including
426
// uncommitted changes). When stat is true only the --stat summary is returned.
427
func (r *Repository) DiffAgainst(ctx context.Context, ref string, stat bool) (string, error) {
×
428
        args := []string{"diff", ref}
×
429
        if stat {
×
430
                args = append(args, "--stat")
×
431
        }
×
432

433
        return r.run(ctx, args...)
×
434
}
435

436
func (r *Repository) DiffFiles(ctx context.Context) ([]string, error) {
1✔
437
        out, err := r.run(ctx, "diff", "--name-only")
1✔
438
        if err != nil {
1✔
439
                return nil, err
×
440
        }
×
441
        if out == "" {
1✔
442
                return nil, nil
×
443
        }
×
444

445
        return strings.Split(out, "\n"), nil
1✔
446
}
447

448
type LogEntry struct {
449
        SHA     string
450
        Message string
451
        Author  string
452
        Date    string
453
}
454

455
// FileStatus holds a path and its change status from git.
456
type FileStatus struct {
457
        Path   string `json:"path"`
458
        Status string `json:"status"` // "added", "modified", "deleted", "renamed"
459
}
460

461
// parseNameStatusLine parses one line of `git diff --name-status` output.
462
//
463
//nolint:nonamedreturns // Named returns document the return values
464
func parseNameStatusLine(line string) (path, status string) {
1✔
465
        parts := strings.SplitN(line, "\t", 3)
1✔
466
        if len(parts) < 2 {
2✔
467
                return line, "modified"
1✔
468
        }
1✔
469
        code := parts[0]
1✔
470
        // Renames/copies have destination path in parts[2]
1✔
471
        if len(parts) == 3 {
2✔
472
                path = parts[2]
1✔
473
        } else {
2✔
474
                path = parts[1]
1✔
475
        }
1✔
476
        switch {
1✔
477
        case strings.HasPrefix(code, "A"):
1✔
478
                status = "added"
1✔
479
        case strings.HasPrefix(code, "D"):
1✔
480
                status = "deleted"
1✔
481
        case strings.HasPrefix(code, "R"), strings.HasPrefix(code, "C"):
1✔
482
                status = "renamed"
1✔
483
        default:
1✔
484
                status = "modified"
1✔
485
        }
486

487
        return path, status
1✔
488
}
489

490
// DiffFilesWithStatus returns changed files with their git change status.
491
func (r *Repository) DiffFilesWithStatus(ctx context.Context) ([]FileStatus, error) {
1✔
492
        out, err := r.run(ctx, "diff", "--name-status")
1✔
493
        if err != nil {
1✔
494
                return nil, err
×
495
        }
×
496
        if out == "" {
1✔
497
                return nil, nil
×
498
        }
×
499
        var result []FileStatus
1✔
500
        for _, line := range strings.Split(out, "\n") {
2✔
501
                if line == "" {
1✔
502
                        continue
×
503
                }
504
                path, status := parseNameStatusLine(line)
1✔
505
                result = append(result, FileStatus{Path: path, Status: status})
1✔
506
        }
507

508
        return result, nil
1✔
509
}
510

511
// DefaultBranch returns the default branch name.
512
// Detection order: remote HEAD symbolic ref (authoritative), then local
513
// main/master existence as fallback for offline or no-remote scenarios.
514
// Returns error if detection fails — callers should configure git.base_branch.
515
func (r *Repository) DefaultBranch(ctx context.Context) (string, error) {
1✔
516
        // 1. Try remote HEAD symbolic ref — this is authoritative when available.
1✔
517
        out, err := r.run(ctx, "symbolic-ref", "refs/remotes/origin/HEAD")
1✔
518
        if err == nil && out != "" {
1✔
519
                var name string
×
520
                if strings.HasPrefix(out, "refs/remotes/origin/") {
×
521
                        name = strings.TrimPrefix(out, "refs/remotes/origin/")
×
522
                } else if strings.HasPrefix(out, "refs/heads/") {
×
523
                        name = strings.TrimPrefix(out, "refs/heads/")
×
524
                }
×
525
                if name != "" {
×
526
                        return name, nil
×
527
                }
×
528
        }
529

530
        // 2. Fall back to local main/master for offline or no-remote repos.
531
        for _, name := range []string{"main", "master"} {
2✔
532
                if r.LocalBranchExists(ctx, name) {
2✔
533
                        return name, nil
1✔
534
                }
1✔
535
        }
536

537
        return "", errors.New("cannot detect default branch: origin/HEAD not set and no local main/master branch found; run 'kvelmo config set git.base_branch <branch>' to configure")
×
538
}
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