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

valksor / kvelmo / 23471163553

24 Mar 2026 02:51AM UTC coverage: 49.867% (-1.4%) from 51.3%
23471163553

push

github

k0d3r1s
Update project config and gap analysis commands

Update CLAUDE.md with new CLI commands and package descriptions. Revise
AGENTS.md with current architecture guidance. Add CodeRabbit config
rule. Update lefthook pre-commit hook. Refresh all gap analysis
commands with current feature inventory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

811 of 1374 branches covered (59.02%)

Branch coverage included in aggregate %.

22001 of 44372 relevant lines covered (49.58%)

0.85 hits per line

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

66.76
/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
// StageFiles stages specific files for commit.
200
func (r *Repository) StageFiles(ctx context.Context, paths ...string) error {
1✔
201
        args := append([]string{"add", "--"}, paths...)
1✔
202

1✔
203
        return r.runRetryable(ctx, args...)
1✔
204
}
1✔
205

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

221
        return nil
1✔
222
}
223

224
// SetSignCommits enables or disables GPG commit signing.
225
func (r *Repository) SetSignCommits(sign bool) {
×
226
        r.signCommits = sign
×
227
}
×
228

229
// IsSigningConfigured checks whether git commit signing is configured in the repository.
230
func (r *Repository) IsSigningConfigured(ctx context.Context) bool {
×
231
        out, err := r.run(ctx, "config", "commit.gpgsign")
×
232
        if err != nil {
×
233
                return false
×
234
        }
×
235

236
        return strings.TrimSpace(out) == "true"
×
237
}
238

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

264
        sha, err := r.CurrentCommit(ctx)
1✔
265
        if err != nil {
1✔
266
                slog.Warn("git: committed but failed to get SHA", "error", err)
×
267

×
268
                return "", fmt.Errorf("commit succeeded but failed to get SHA: %w", err)
×
269
        }
×
270
        if sha == "" {
1✔
271
                slog.Error("git: committed but empty SHA")
×
272

×
273
                return "", errors.New("commit succeeded but SHA is empty")
×
274
        }
×
275
        slog.Debug("git: committed", "sha", sha)
1✔
276

1✔
277
        return sha, nil
1✔
278
}
279

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

290
        return has
×
291
}
292

293
func (r *Repository) Reset(ctx context.Context, commit string, hard bool) error {
1✔
294
        slog.Debug("git: resetting", "commit", commit, "hard", hard)
1✔
295
        args := []string{"reset"}
1✔
296
        if hard {
2✔
297
                args = append(args, "--hard")
1✔
298
        }
1✔
299
        args = append(args, commit)
1✔
300
        err := r.runRetryable(ctx, args...)
1✔
301

1✔
302
        return err
1✔
303
}
304

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

1✔
308
        return err
1✔
309
}
1✔
310

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

1✔
314
        return err
1✔
315
}
1✔
316

317
// Push pushes to the remote repository.
318
func (r *Repository) Push(ctx context.Context, remote, branch string) error {
1✔
319
        slog.Debug("git: pushing", "remote", remote, "branch", branch)
1✔
320
        err := r.runRetryable(ctx, "push", remote, branch)
1✔
321

1✔
322
        return err
1✔
323
}
1✔
324

325
// PushDefault pushes to origin with the current branch.
326
func (r *Repository) PushDefault(ctx context.Context) error {
1✔
327
        branch, err := r.CurrentBranch(ctx)
1✔
328
        if err != nil {
1✔
329
                return err
×
330
        }
×
331

332
        return r.Push(ctx, "origin", branch)
1✔
333
}
334

335
// Pull pulls from the remote repository.
336
func (r *Repository) Pull(ctx context.Context) error {
×
337
        slog.Debug("git: pulling")
×
338
        err := r.runRetryable(ctx, "pull")
×
339

×
340
        return err
×
341
}
×
342

343
// Fetch fetches from the remote repository.
344
func (r *Repository) Fetch(ctx context.Context) error {
×
345
        slog.Debug("git: fetching")
×
346
        err := r.runRetryable(ctx, "fetch")
×
347

×
348
        return err
×
349
}
×
350

351
// CommitsBehind returns how many commits the current branch is behind the given ref.
352
// ref should be a full remote ref like "origin/main" or "upstream/develop".
353
func (r *Repository) CommitsBehind(ctx context.Context, ref string) (int, error) {
×
354
        current, err := r.CurrentBranch(ctx)
×
355
        if err != nil {
×
356
                return 0, err
×
357
        }
×
358

359
        // Count commits that are in ref but not in current
360
        out, err := r.run(ctx, "rev-list", "--count", fmt.Sprintf("%s..%s", current, ref))
×
361
        if err != nil {
×
362
                return 0, err
×
363
        }
×
364

365
        var count int
×
366
        if _, err := fmt.Sscanf(out, "%d", &count); err != nil {
×
367
                return 0, fmt.Errorf("parse count: %w", err)
×
368
        }
×
369

370
        return count, nil
×
371
}
372

373
// logFormat is the git log format used by Log, CommitsSince, and CommitInfo.
374
const logFormat = "%H|%s|%an|%ai"
375

376
// parseLogOutput parses git log output produced with logFormat into LogEntry slices.
377
func parseLogOutput(out string) []LogEntry {
1✔
378
        var entries []LogEntry
1✔
379
        for _, line := range strings.Split(out, "\n") {
2✔
380
                if line == "" {
2✔
381
                        continue
1✔
382
                }
383
                parts := strings.SplitN(line, "|", 4)
1✔
384
                if len(parts) < 4 {
1✔
385
                        continue
×
386
                }
387
                entries = append(entries, LogEntry{
1✔
388
                        SHA:     parts[0],
1✔
389
                        Message: parts[1],
1✔
390
                        Author:  parts[2],
1✔
391
                        Date:    parts[3],
1✔
392
                })
1✔
393
        }
394

395
        return entries
1✔
396
}
397

398
func (r *Repository) Log(ctx context.Context, n int) ([]LogEntry, error) {
1✔
399
        out, err := r.run(ctx, "log", fmt.Sprintf("-n%d", n), "--format="+logFormat)
1✔
400
        if err != nil {
1✔
401
                return nil, err
×
402
        }
×
403

404
        return parseLogOutput(out), nil
1✔
405
}
406

407
// CommitsSince returns log entries for all commits between sinceRef (exclusive)
408
// and HEAD (inclusive). Useful for inspecting agent commits since the last checkpoint.
409
func (r *Repository) CommitsSince(ctx context.Context, sinceRef string) ([]LogEntry, error) {
1✔
410
        out, err := r.run(ctx, "log", "--format="+logFormat, sinceRef+"..HEAD")
1✔
411
        if err != nil {
1✔
412
                return nil, err
×
413
        }
×
414

415
        return parseLogOutput(out), nil
1✔
416
}
417

418
// CommitInfo returns metadata for a single commit SHA.
419
func (r *Repository) CommitInfo(ctx context.Context, sha string) (LogEntry, error) {
1✔
420
        out, err := r.run(ctx, "log", "-1", "--format="+logFormat, sha)
1✔
421
        if err != nil {
2✔
422
                return LogEntry{}, fmt.Errorf("commit %s not found: %w", sha, err)
1✔
423
        }
1✔
424
        if out == "" {
1✔
425
                return LogEntry{}, fmt.Errorf("commit %s not found", sha)
×
426
        }
×
427
        parts := strings.SplitN(out, "|", 4)
1✔
428
        if len(parts) < 4 {
1✔
429
                return LogEntry{}, fmt.Errorf("unexpected log format: %q", out)
×
430
        }
×
431

432
        return LogEntry{
1✔
433
                SHA:     parts[0],
1✔
434
                Message: parts[1],
1✔
435
                Author:  parts[2],
1✔
436
                Date:    parts[3],
1✔
437
        }, nil
1✔
438
}
439

440
func (r *Repository) Diff(ctx context.Context, cached bool) (string, error) {
1✔
441
        args := []string{"diff"}
1✔
442
        if cached {
1✔
443
                args = append(args, "--cached")
×
444
        }
×
445

446
        return r.run(ctx, args...)
1✔
447
}
448

449
// DiffAgainst shows the diff between a given commit and the current working tree (including
450
// uncommitted changes). When stat is true only the --stat summary is returned.
451
func (r *Repository) DiffAgainst(ctx context.Context, ref string, stat bool) (string, error) {
×
452
        args := []string{"diff", ref}
×
453
        if stat {
×
454
                args = append(args, "--stat")
×
455
        }
×
456

457
        return r.run(ctx, args...)
×
458
}
459

460
func (r *Repository) DiffFiles(ctx context.Context) ([]string, error) {
1✔
461
        out, err := r.run(ctx, "diff", "--name-only")
1✔
462
        if err != nil {
1✔
463
                return nil, err
×
464
        }
×
465
        if out == "" {
1✔
466
                return nil, nil
×
467
        }
×
468

469
        return strings.Split(out, "\n"), nil
1✔
470
}
471

472
type LogEntry struct {
473
        SHA     string
474
        Message string
475
        Author  string
476
        Date    string
477
}
478

479
// FileStatus holds a path and its change status from git.
480
type FileStatus struct {
481
        Path   string `json:"path"`
482
        Status string `json:"status"` // "added", "modified", "deleted", "renamed"
483
}
484

485
// parseNameStatusLine parses one line of `git diff --name-status` output.
486
//
487
//nolint:nonamedreturns // Named returns document the return values
488
func parseNameStatusLine(line string) (path, status string) {
1✔
489
        parts := strings.SplitN(line, "\t", 3)
1✔
490
        if len(parts) < 2 {
2✔
491
                return line, "modified"
1✔
492
        }
1✔
493
        code := parts[0]
1✔
494
        // Renames/copies have destination path in parts[2]
1✔
495
        if len(parts) == 3 {
2✔
496
                path = parts[2]
1✔
497
        } else {
2✔
498
                path = parts[1]
1✔
499
        }
1✔
500
        switch {
1✔
501
        case strings.HasPrefix(code, "A"):
1✔
502
                status = "added"
1✔
503
        case strings.HasPrefix(code, "D"):
1✔
504
                status = "deleted"
1✔
505
        case strings.HasPrefix(code, "R"), strings.HasPrefix(code, "C"):
1✔
506
                status = "renamed"
1✔
507
        default:
1✔
508
                status = "modified"
1✔
509
        }
510

511
        return path, status
1✔
512
}
513

514
// DiffFilesWithStatus returns changed files with their git change status.
515
func (r *Repository) DiffFilesWithStatus(ctx context.Context) ([]FileStatus, error) {
1✔
516
        out, err := r.run(ctx, "diff", "--name-status")
1✔
517
        if err != nil {
1✔
518
                return nil, err
×
519
        }
×
520
        if out == "" {
1✔
521
                return nil, nil
×
522
        }
×
523
        var result []FileStatus
1✔
524
        for _, line := range strings.Split(out, "\n") {
2✔
525
                if line == "" {
1✔
526
                        continue
×
527
                }
528
                path, status := parseNameStatusLine(line)
1✔
529
                result = append(result, FileStatus{Path: path, Status: status})
1✔
530
        }
531

532
        return result, nil
1✔
533
}
534

535
// DefaultBranch returns the default branch name.
536
// Detection order: remote HEAD symbolic ref (authoritative), then local
537
// main/master existence as fallback for offline or no-remote scenarios.
538
// Returns error if detection fails — callers should configure git.base_branch.
539
func (r *Repository) DefaultBranch(ctx context.Context) (string, error) {
1✔
540
        // 1. Try remote HEAD symbolic ref — this is authoritative when available.
1✔
541
        out, err := r.run(ctx, "symbolic-ref", "refs/remotes/origin/HEAD")
1✔
542
        if err == nil && out != "" {
1✔
543
                var name string
×
544
                if strings.HasPrefix(out, "refs/remotes/origin/") {
×
545
                        name = strings.TrimPrefix(out, "refs/remotes/origin/")
×
546
                } else if strings.HasPrefix(out, "refs/heads/") {
×
547
                        name = strings.TrimPrefix(out, "refs/heads/")
×
548
                }
×
549
                if name != "" {
×
550
                        return name, nil
×
551
                }
×
552
        }
553

554
        // 2. Fall back to local main/master for offline or no-remote repos.
555
        for _, name := range []string{"main", "master"} {
2✔
556
                if r.LocalBranchExists(ctx, name) {
2✔
557
                        return name, nil
1✔
558
                }
1✔
559
        }
560

561
        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")
×
562
}
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