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

umputun / ralphex / 21841600020

09 Feb 2026 09:34PM UTC coverage: 80.502% (-0.3%) from 80.776%
21841600020

Pull #79

github

umputun
fix(git): address copilot review feedback on external backend

add -- separator before paths in Add, MoveFile, IsIgnored to prevent
filenames starting with - from being interpreted as flags. improve
run() error to wrap underlying error when git output is empty.
use checkout -B in tests for idempotent branch creation.
Pull Request #79: Add external git backend option

282 of 371 new or added lines in 5 files covered. (76.01%)

13 existing lines in 1 file now uncovered.

5165 of 6416 relevant lines covered (80.5%)

202.23 hits per line

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

78.86
/pkg/git/external.go
1
package git
2

3
import (
4
        "context"
5
        "errors"
6
        "fmt"
7
        "os"
8
        "os/exec"
9
        "path/filepath"
10
        "strconv"
11
        "strings"
12
)
13

14
// externalBackend implements the backend interface by shelling out to the git CLI.
15
// this provides native git behavior, avoiding go-git quirks with symlinks, gitignore, etc.
16
type externalBackend struct {
17
        path string // absolute path to repository root
18
}
19

20
// newExternalBackend creates an externalBackend that shells out to the git CLI.
21
// validates the path is inside a git repository using git rev-parse.
22
func newExternalBackend(path string) (*externalBackend, error) {
64✔
23
        absPath, err := filepath.Abs(path)
64✔
24
        if err != nil {
64✔
NEW
25
                return nil, fmt.Errorf("resolve path: %w", err)
×
NEW
26
        }
×
27

28
        // validate path is a git repo and get the toplevel
29
        cmd := exec.CommandContext(context.Background(), "git", "rev-parse", "--show-toplevel")
64✔
30
        cmd.Dir = absPath
64✔
31
        out, err := cmd.Output()
64✔
32
        if err != nil {
66✔
33
                var exitErr *exec.ExitError
2✔
34
                if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
4✔
35
                        return nil, fmt.Errorf("open git repository %s: %s", absPath, strings.TrimSpace(string(exitErr.Stderr)))
2✔
36
                }
2✔
NEW
37
                return nil, fmt.Errorf("open git repository %s: %w", absPath, err)
×
38
        }
39

40
        root := strings.TrimSpace(string(out))
62✔
41

62✔
42
        // resolve symlinks for consistent path comparison (macOS /var -> /private/var)
62✔
43
        root, err = filepath.EvalSymlinks(root)
62✔
44
        if err != nil {
62✔
NEW
45
                return nil, fmt.Errorf("eval symlinks: %w", err)
×
NEW
46
        }
×
47

48
        return &externalBackend{path: root}, nil
62✔
49
}
50

51
// run executes a git command and returns combined stdout+stderr with trailing whitespace removed.
52
// leading whitespace is preserved (important for porcelain format parsing).
53
// on failure, returns error with the combined output for diagnostics.
54
func (e *externalBackend) run(args ...string) (string, error) {
79✔
55
        cmd := exec.CommandContext(context.Background(), "git", args...)
79✔
56
        cmd.Dir = e.path
79✔
57
        out, err := cmd.CombinedOutput()
79✔
58
        if err != nil {
84✔
59
                msg := strings.TrimSpace(string(out))
5✔
60
                if msg != "" {
10✔
61
                        return "", fmt.Errorf("git %s: %s", args[0], msg)
5✔
62
                }
5✔
NEW
63
                return "", fmt.Errorf("git %s: %w", args[0], err)
×
64
        }
65
        return strings.TrimRight(string(out), " \t\n\r"), nil
74✔
66
}
67

68
// compile-time check: externalBackend must satisfy the backend interface
69
var _ backend = (*externalBackend)(nil)
70

71
// Root returns the absolute path to the repository root.
72
func (e *externalBackend) Root() string {
3✔
73
        return e.path
3✔
74
}
3✔
75

76
// headHash returns the current HEAD commit hash.
77
func (e *externalBackend) headHash() (string, error) {
13✔
78
        out, err := e.run("rev-parse", "HEAD")
13✔
79
        if err != nil {
14✔
80
                return "", fmt.Errorf("get HEAD: %w", err)
1✔
81
        }
1✔
82
        return out, nil
12✔
83
}
84

85
// HasCommits returns true if the repository has at least one commit.
86
func (e *externalBackend) HasCommits() (bool, error) {
5✔
87
        cmd := exec.CommandContext(context.Background(), "git", "rev-parse", "HEAD")
5✔
88
        cmd.Dir = e.path
5✔
89
        cmd.Env = append(os.Environ(), "LC_ALL=C") // force English stderr for reliable parsing
5✔
90
        if _, err := cmd.Output(); err != nil {
7✔
91
                var exitErr *exec.ExitError
2✔
92
                if errors.As(err, &exitErr) && exitErr.ExitCode() == 128 {
4✔
93
                        // git outputs "ambiguous argument 'HEAD'" when HEAD doesn't exist (empty repo);
2✔
94
                        // other exit-128 causes (corruption, permission errors) propagate as errors.
2✔
95
                        // note: must use cmd.Output() (not cmd.Run()) so ExitError.Stderr is populated.
2✔
96
                        stderr := strings.ToLower(string(exitErr.Stderr))
2✔
97
                        if strings.Contains(stderr, "ambiguous argument") {
3✔
98
                                return false, nil // no commits (empty repo, HEAD not found)
1✔
99
                        }
1✔
100
                        return false, fmt.Errorf("check HEAD: %s", strings.TrimSpace(string(exitErr.Stderr)))
1✔
101
                }
NEW
102
                return false, fmt.Errorf("check HEAD: %w", err) // unexpected exit code or exec failure
×
103
        }
104
        return true, nil
3✔
105
}
106

107
// CurrentBranch returns the name of the current branch, or empty string for detached HEAD.
108
func (e *externalBackend) CurrentBranch() (string, error) {
14✔
109
        cmd := exec.CommandContext(context.Background(), "git", "symbolic-ref", "--short", "HEAD")
14✔
110
        cmd.Dir = e.path
14✔
111
        cmd.Env = append(os.Environ(), "LC_ALL=C") // force English stderr for reliable parsing
14✔
112
        out, err := cmd.Output()
14✔
113
        if err != nil {
17✔
114
                var exitErr *exec.ExitError
3✔
115
                if errors.As(err, &exitErr) && exitErr.ExitCode() == 128 {
6✔
116
                        // only treat as "detached HEAD" when stderr indicates symbolic-ref failure;
3✔
117
                        // other exit-128 causes (corruption, permission errors) should propagate as errors
3✔
118
                        stderr := strings.ToLower(string(exitErr.Stderr))
3✔
119
                        if strings.Contains(stderr, "not a symbolic ref") {
5✔
120
                                return "", nil // detached HEAD (symbolic-ref fails when not on a branch)
2✔
121
                        }
2✔
122
                        return "", fmt.Errorf("get current branch: %s", strings.TrimSpace(string(exitErr.Stderr)))
1✔
123
                }
NEW
124
                return "", fmt.Errorf("get current branch: %w", err) // unexpected exit code or exec failure
×
125
        }
126
        return strings.TrimSpace(string(out)), nil
11✔
127
}
128

129
// IsMainBranch returns true if the current branch is "main" or "master".
130
func (e *externalBackend) IsMainBranch() (bool, error) {
5✔
131
        branch, err := e.CurrentBranch()
5✔
132
        if err != nil {
5✔
NEW
133
                return false, fmt.Errorf("get current branch: %w", err)
×
NEW
134
        }
×
135
        return branch == "main" || branch == "master", nil
5✔
136
}
137

138
// GetDefaultBranch returns the default branch name.
139
// detects from origin/HEAD symbolic reference, falls back to checking common branch names.
140
func (e *externalBackend) GetDefaultBranch() string {
4✔
141
        // try origin/HEAD first
4✔
142
        cmd := exec.CommandContext(context.Background(), "git", "symbolic-ref", "refs/remotes/origin/HEAD")
4✔
143
        cmd.Dir = e.path
4✔
144
        out, err := cmd.Output()
4✔
145
        if err == nil {
4✔
NEW
146
                ref := strings.TrimSpace(string(out))
×
NEW
147
                // ref is like "refs/remotes/origin/main"
×
NEW
148
                if strings.HasPrefix(ref, "refs/remotes/origin/") {
×
NEW
149
                        branchName := ref[len("refs/remotes/origin/"):]
×
NEW
150

×
NEW
151
                        // check if local branch exists
×
NEW
152
                        if e.refExists("refs/heads/" + branchName) {
×
NEW
153
                                return branchName
×
NEW
154
                        }
×
155
                        // local branch doesn't exist, return remote-tracking ref
NEW
156
                        return "origin/" + branchName
×
157
                }
158
        }
159

160
        // fallback: check which common branch names exist
161
        for _, name := range []string{"main", "master", "trunk", "develop"} {
13✔
162
                if e.refExists("refs/heads/" + name) {
12✔
163
                        return name
3✔
164
                }
3✔
165
        }
166

167
        return "master"
1✔
168
}
169

170
// BranchExists checks if a branch with the given name exists.
171
func (e *externalBackend) BranchExists(name string) bool {
7✔
172
        return e.refExists("refs/heads/" + name)
7✔
173
}
7✔
174

175
// CreateBranch creates a new branch and switches to it.
176
func (e *externalBackend) CreateBranch(name string) error {
11✔
177
        _, err := e.run("checkout", "-b", name)
11✔
178
        if err != nil {
12✔
179
                return fmt.Errorf("create branch: %w", err)
1✔
180
        }
1✔
181
        return nil
10✔
182
}
183

184
// CheckoutBranch switches to an existing branch.
185
func (e *externalBackend) CheckoutBranch(name string) error {
3✔
186
        _, err := e.run("checkout", name)
3✔
187
        if err != nil {
4✔
188
                return fmt.Errorf("checkout branch: %w", err)
1✔
189
        }
1✔
190
        return nil
2✔
191
}
192

193
// IsDirty returns true if the worktree has uncommitted changes (staged or modified tracked files).
194
func (e *externalBackend) IsDirty() (bool, error) {
10✔
195
        out, err := e.run("status", "--porcelain")
10✔
196
        if err != nil {
10✔
NEW
197
                return false, fmt.Errorf("get status: %w", err)
×
NEW
198
        }
×
199
        if out == "" {
14✔
200
                return false, nil
4✔
201
        }
4✔
202

203
        // check each line - only count tracked changes (not untracked files marked with ??)
204
        for line := range strings.SplitSeq(out, "\n") {
12✔
205
                if line == "" {
6✔
NEW
206
                        continue
×
207
                }
208
                // untracked files (lines starting with "??") don't count as dirty
209
                if strings.HasPrefix(line, "??") {
8✔
210
                        continue
2✔
211
                }
212
                return true, nil
4✔
213
        }
214
        return false, nil
2✔
215
}
216

217
// FileHasChanges returns true if the given file has uncommitted changes.
218
func (e *externalBackend) FileHasChanges(path string) (bool, error) {
8✔
219
        rel, err := e.toRelative(path)
8✔
220
        if err != nil {
8✔
NEW
221
                return false, err
×
NEW
222
        }
×
223

224
        // use -uall to list individual files, not collapsed directories
225
        out, err := e.run("status", "--porcelain", "-uall", "--", rel)
8✔
226
        if err != nil {
8✔
NEW
227
                return false, fmt.Errorf("check file status: %w", err)
×
NEW
228
        }
×
229
        return out != "", nil
8✔
230
}
231

232
// HasChangesOtherThan returns true if there are uncommitted changes to files other than the given file.
233
// this includes modified/deleted tracked files, staged changes, and untracked files (excluding gitignored).
234
func (e *externalBackend) HasChangesOtherThan(path string) (bool, error) {
8✔
235
        rel, err := e.toRelative(path)
8✔
236
        if err != nil {
8✔
NEW
237
                return false, err
×
NEW
238
        }
×
239

240
        // use -uall to list individual files, not collapsed directories
241
        out, err := e.run("status", "--porcelain", "-uall")
8✔
242
        if err != nil {
8✔
NEW
243
                return false, fmt.Errorf("get status: %w", err)
×
NEW
244
        }
×
245

246
        if out == "" {
11✔
247
                return false, nil
3✔
248
        }
3✔
249

250
        for line := range strings.SplitSeq(out, "\n") {
11✔
251
                if line == "" {
6✔
NEW
252
                        continue
×
253
                }
254
                // extract file path from porcelain output: "XY path" or "XY path -> newpath"
255
                filePath := e.extractPathFromPorcelain(line)
6✔
256
                if filePath == rel {
9✔
257
                        continue
3✔
258
                }
259
                return true, nil
3✔
260
        }
261
        return false, nil
2✔
262
}
263

264
// IsIgnored checks if a path is ignored by gitignore rules.
265
func (e *externalBackend) IsIgnored(path string) (bool, error) {
7✔
266
        cmd := exec.CommandContext(context.Background(), "git", "check-ignore", "-q", "--", path)
7✔
267
        cmd.Dir = e.path
7✔
268
        err := cmd.Run()
7✔
269
        if err == nil {
10✔
270
                return true, nil // exit 0 = ignored
3✔
271
        }
3✔
272
        // exit 1 = not ignored, other codes = error
273
        var exitErr *exec.ExitError
4✔
274
        if errors.As(err, &exitErr) {
8✔
275
                if exitErr.ExitCode() == 1 {
8✔
276
                        return false, nil
4✔
277
                }
4✔
278
        }
NEW
279
        return false, fmt.Errorf("check-ignore: %w", err)
×
280
}
281

282
// Add stages a file for commit.
283
func (e *externalBackend) Add(path string) error {
7✔
284
        rel, err := e.toRelative(path)
7✔
285
        if err != nil {
7✔
NEW
286
                return err
×
NEW
287
        }
×
288
        _, err = e.run("add", "--", rel)
7✔
289
        if err != nil {
7✔
NEW
290
                return fmt.Errorf("add file: %w", err)
×
NEW
291
        }
×
292
        return nil
7✔
293
}
294

295
// MoveFile moves a file using git mv.
296
func (e *externalBackend) MoveFile(src, dst string) error {
2✔
297
        srcRel, err := e.toRelative(src)
2✔
298
        if err != nil {
2✔
NEW
299
                return fmt.Errorf("invalid source path: %w", err)
×
NEW
300
        }
×
301
        dstRel, err := e.toRelative(dst)
2✔
302
        if err != nil {
2✔
NEW
303
                return fmt.Errorf("invalid destination path: %w", err)
×
NEW
304
        }
×
305
        _, err = e.run("mv", "--", srcRel, dstRel)
2✔
306
        if err != nil {
3✔
307
                return fmt.Errorf("move file: %w", err)
1✔
308
        }
1✔
309
        return nil
1✔
310
}
311

312
// Commit creates a commit with the given message.
313
func (e *externalBackend) Commit(msg string) error {
5✔
314
        _, err := e.run("commit", "-m", msg)
5✔
315
        if err != nil {
6✔
316
                return fmt.Errorf("commit: %w", err)
1✔
317
        }
1✔
318
        return nil
4✔
319
}
320

321
// CreateInitialCommit stages all non-ignored files and creates an initial commit.
322
func (e *externalBackend) CreateInitialCommit(msg string) error {
3✔
323
        // git add -A respects .gitignore natively
3✔
324
        _, err := e.run("add", "-A")
3✔
325
        if err != nil {
3✔
NEW
326
                return fmt.Errorf("stage files: %w", err)
×
NEW
327
        }
×
328

329
        // check if anything was staged
330
        out, err := e.run("status", "--porcelain")
3✔
331
        if err != nil {
3✔
NEW
332
                return fmt.Errorf("check status: %w", err)
×
NEW
333
        }
×
334
        if out == "" {
4✔
335
                return errors.New("no files to commit")
1✔
336
        }
1✔
337

338
        _, err = e.run("commit", "-m", msg)
2✔
339
        if err != nil {
2✔
NEW
340
                return fmt.Errorf("commit: %w", err)
×
NEW
341
        }
×
342
        return nil
2✔
343
}
344

345
// diffStats returns change statistics between baseBranch and HEAD.
346
// returns zero stats if baseBranch doesn't exist or HEAD equals baseBranch.
347
func (e *externalBackend) diffStats(baseBranch string) (DiffStats, error) {
8✔
348
        // check if base branch exists (try local, remote, origin/ prefix)
8✔
349
        baseRef := e.resolveRef(baseBranch)
8✔
350
        if baseRef == "" {
10✔
351
                return DiffStats{}, nil
2✔
352
        }
2✔
353

354
        // check if HEAD equals base
355
        headHash, err := e.headHash()
6✔
356
        if err != nil {
6✔
NEW
357
                return DiffStats{}, nil //nolint:nilerr // no HEAD means no stats
×
NEW
358
        }
×
359

360
        baseCmd := exec.CommandContext(context.Background(), "git", "rev-parse", baseRef) //nolint:gosec // baseRef from resolveRef, not user input
6✔
361
        baseCmd.Dir = e.path
6✔
362
        baseOut, err := baseCmd.Output()
6✔
363
        if err != nil {
6✔
NEW
364
                return DiffStats{}, nil //nolint:nilerr // can't resolve base, return zero
×
NEW
365
        }
×
366
        if strings.TrimSpace(string(baseOut)) == headHash {
8✔
367
                return DiffStats{}, nil
2✔
368
        }
2✔
369

370
        // get numstat
371
        out, err := e.run("diff", "--numstat", baseRef+"...HEAD")
4✔
372
        if err != nil {
4✔
NEW
373
                return DiffStats{}, fmt.Errorf("diff numstat: %w", err)
×
NEW
374
        }
×
375

376
        if out == "" {
4✔
NEW
377
                return DiffStats{}, nil
×
NEW
378
        }
×
379

380
        var result DiffStats
4✔
381
        for line := range strings.SplitSeq(out, "\n") {
9✔
382
                if line == "" {
5✔
NEW
383
                        continue
×
384
                }
385
                parts := strings.Fields(line)
5✔
386
                if len(parts) < 3 {
5✔
NEW
387
                        continue
×
388
                }
389
                // binary files show "-" for additions/deletions
390
                if parts[0] == "-" || parts[1] == "-" {
5✔
NEW
391
                        result.Files++
×
NEW
392
                        continue
×
393
                }
394
                additions, _ := strconv.Atoi(parts[0])
5✔
395
                deletions, _ := strconv.Atoi(parts[1])
5✔
396
                result.Files++
5✔
397
                result.Additions += additions
5✔
398
                result.Deletions += deletions
5✔
399
        }
400
        return result, nil
4✔
401
}
402

403
// resolveRef tries to resolve a branch name to a valid git ref.
404
// checks local branch, remote tracking (origin/<name>), and as-is for "origin/" prefixed names.
405
func (e *externalBackend) resolveRef(branchName string) string {
8✔
406
        // try local branch
8✔
407
        if e.refExists("refs/heads/" + branchName) {
14✔
408
                return branchName
6✔
409
        }
6✔
410

411
        // try remote tracking branch
412
        if e.refExists("refs/remotes/origin/" + branchName) {
2✔
NEW
413
                return "origin/" + branchName
×
NEW
414
        }
×
415

416
        // try as-is for "origin/" prefixed names
417
        if strings.HasPrefix(branchName, "origin/") {
2✔
NEW
418
                remoteName := branchName[7:]
×
NEW
419
                if e.refExists("refs/remotes/origin/" + remoteName) {
×
NEW
420
                        return branchName
×
NEW
421
                }
×
422
        }
423

424
        return ""
2✔
425
}
426

427
// refExists checks if a git reference exists.
428
func (e *externalBackend) refExists(ref string) bool {
26✔
429
        cmd := exec.CommandContext(context.Background(), "git", "show-ref", "--verify", "--quiet", ref)
26✔
430
        cmd.Dir = e.path
26✔
431
        return cmd.Run() == nil
26✔
432
}
26✔
433

434
// toRelative converts a path to be relative to the repository root.
435
func (e *externalBackend) toRelative(path string) (string, error) {
31✔
436
        if !filepath.IsAbs(path) {
52✔
437
                cleaned := filepath.Clean(path)
21✔
438
                if strings.HasPrefix(cleaned, "..") {
22✔
439
                        return "", fmt.Errorf("path %q escapes repository root", path)
1✔
440
                }
1✔
441
                return cleaned, nil
20✔
442
        }
443

444
        // resolve symlinks for consistent comparison (macOS /var -> /private/var)
445
        resolved, err := filepath.EvalSymlinks(filepath.Dir(path))
10✔
446
        if err != nil {
12✔
447
                // if can't resolve, use original path
2✔
448
                resolved = filepath.Dir(path)
2✔
449
        }
2✔
450
        path = filepath.Join(resolved, filepath.Base(path))
10✔
451

10✔
452
        rel, err := filepath.Rel(e.path, path)
10✔
453
        if err != nil {
10✔
NEW
454
                return "", fmt.Errorf("path outside repository: %w", err)
×
NEW
455
        }
×
456
        if strings.HasPrefix(rel, "..") {
11✔
457
                return "", fmt.Errorf("path %q is outside repository root %q", path, e.path)
1✔
458
        }
1✔
459
        return rel, nil
9✔
460
}
461

462
// extractPathFromPorcelain extracts file path from git status --porcelain output.
463
// format: "XY path" or "XY original -> renamed"
464
func (e *externalBackend) extractPathFromPorcelain(line string) string {
10✔
465
        if len(line) < 4 {
11✔
466
                return ""
1✔
467
        }
1✔
468
        // skip the 2-char status code and space
469
        path := line[3:]
9✔
470
        // handle renames: "XY old -> new"
9✔
471
        if idx := strings.Index(path, " -> "); idx >= 0 {
10✔
472
                path = path[idx+4:]
1✔
473
        }
1✔
474
        return path
9✔
475
}
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