Skip to content

Commit cd547e7

Browse files
committed
chore: isolate GitPatch
1 parent 1f5548f commit cd547e7

File tree

4 files changed

+196
-169
lines changed

4 files changed

+196
-169
lines changed

patch.go

+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package revgrep
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"os/exec"
10+
"regexp"
11+
"strconv"
12+
"strings"
13+
)
14+
15+
// GitPatch returns a patch from a git repository.
16+
// If no git repository was found and no errors occurred, nil is returned,
17+
// else an error is returned revisionFrom and revisionTo defines the git diff parameters,
18+
// if left blank and there are unstaged changes or untracked files,
19+
// only those will be returned else only check changes since HEAD~.
20+
// If revisionFrom is set but revisionTo is not,
21+
// untracked files will be included, to exclude untracked files set revisionTo to HEAD~.
22+
// It's incorrect to specify revisionTo without a revisionFrom.
23+
func GitPatch(ctx context.Context, revisionFrom, revisionTo string) (io.Reader, []string, error) {
24+
// check if git repo exists
25+
if err := exec.CommandContext(ctx, "git", "status", "--porcelain").Run(); err != nil {
26+
// don't return an error, we assume the error is not repo exists
27+
return nil, nil, nil
28+
}
29+
30+
// make a patch for untracked files
31+
ls, err := exec.CommandContext(ctx, "git", "ls-files", "--others", "--exclude-standard").CombinedOutput()
32+
if err != nil {
33+
return nil, nil, fmt.Errorf("error executing git ls-files: %w", err)
34+
}
35+
36+
var newFiles []string
37+
for _, file := range bytes.Split(ls, []byte{'\n'}) {
38+
if len(file) == 0 || bytes.HasSuffix(file, []byte{'/'}) {
39+
// ls-files was sometimes showing directories when they were ignored
40+
// I couldn't create a test case for this as I couldn't reproduce correctly for the moment,
41+
// just exclude files with trailing /
42+
continue
43+
}
44+
45+
newFiles = append(newFiles, string(file))
46+
}
47+
48+
if revisionFrom != "" {
49+
args := []string{revisionFrom}
50+
51+
if revisionTo != "" {
52+
args = append(args, revisionTo)
53+
}
54+
55+
args = append(args, "--")
56+
57+
patch, errDiff := gitDiff(ctx, args...)
58+
if errDiff != nil {
59+
return nil, nil, errDiff
60+
}
61+
62+
if revisionTo == "" {
63+
return patch, newFiles, nil
64+
}
65+
66+
return patch, nil, nil
67+
}
68+
69+
// make a patch for unstaged changes
70+
patch, err := gitDiff(ctx, "--")
71+
if err != nil {
72+
return nil, nil, err
73+
}
74+
75+
unstaged := patch.Len() > 0
76+
77+
// If there's unstaged changes OR untracked changes (or both),
78+
// then this is a suitable patch
79+
if unstaged || newFiles != nil {
80+
return patch, newFiles, nil
81+
}
82+
83+
// check for changes in recent commit
84+
patch, err = gitDiff(ctx, "HEAD~", "--")
85+
if err != nil {
86+
return nil, nil, err
87+
}
88+
89+
return patch, nil, nil
90+
}
91+
92+
func gitDiff(ctx context.Context, extraArgs ...string) (*bytes.Buffer, error) {
93+
cmd := exec.CommandContext(ctx, "git", "diff", "--color=never", "--no-ext-diff")
94+
95+
if isSupportedByGit(ctx, 2, 41, 0) {
96+
cmd.Args = append(cmd.Args, "--default-prefix")
97+
}
98+
99+
cmd.Args = append(cmd.Args, "--relative")
100+
cmd.Args = append(cmd.Args, extraArgs...)
101+
102+
patch := new(bytes.Buffer)
103+
errBuff := new(bytes.Buffer)
104+
105+
cmd.Stdout = patch
106+
cmd.Stderr = errBuff
107+
108+
if err := cmd.Run(); err != nil {
109+
return nil, fmt.Errorf("error executing %q: %w: %w", strings.Join(cmd.Args, " "), err, readAsError(errBuff))
110+
}
111+
112+
return patch, nil
113+
}
114+
115+
func readAsError(buff io.Reader) error {
116+
output, err := io.ReadAll(buff)
117+
if err != nil {
118+
return fmt.Errorf("read stderr: %w", err)
119+
}
120+
121+
return errors.New(string(output))
122+
}
123+
124+
func isSupportedByGit(ctx context.Context, major, minor, patch int) bool {
125+
output, err := exec.CommandContext(ctx, "git", "version").CombinedOutput()
126+
if err != nil {
127+
return false
128+
}
129+
130+
parts := bytes.Split(bytes.TrimSpace(output), []byte(" "))
131+
if len(parts) < 3 {
132+
return false
133+
}
134+
135+
v := string(parts[2])
136+
if v == "" {
137+
return false
138+
}
139+
140+
vp := regexp.MustCompile(`^(\d+)\.(\d+)(?:\.(\d+))?.*$`).FindStringSubmatch(v)
141+
if len(vp) < 4 {
142+
return false
143+
}
144+
145+
currentMajor, err := strconv.Atoi(vp[1])
146+
if err != nil {
147+
return false
148+
}
149+
150+
currentMinor, err := strconv.Atoi(vp[2])
151+
if err != nil {
152+
return false
153+
}
154+
155+
currentPatch, err := strconv.Atoi(vp[3])
156+
if err != nil {
157+
return false
158+
}
159+
160+
return currentMajor*1_000_000_000+currentMinor*1_000_000+currentPatch*1_000 >= major*1_000_000_000+minor*1_000_000+patch*1_000
161+
}

patch_test.go

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package revgrep
2+
3+
import (
4+
"context"
5+
"os"
6+
"testing"
7+
)
8+
9+
func TestGitPatch_nonGitDir(t *testing.T) {
10+
wd, err := os.Getwd()
11+
if err != nil {
12+
t.Fatalf("could not get current working dir: %v", err)
13+
}
14+
15+
// Change to non-git dir
16+
err = os.Chdir(t.TempDir())
17+
if err != nil {
18+
t.Fatalf("could not chdir: %v", err)
19+
}
20+
21+
t.Cleanup(func() { _ = os.Chdir(wd) })
22+
23+
patch, newFiles, err := GitPatch(context.Background(), "", "")
24+
if err != nil {
25+
t.Errorf("error expected nil, got: %v", err)
26+
}
27+
28+
if patch != nil {
29+
t.Errorf("patch expected nil, got: %v", patch)
30+
}
31+
32+
if newFiles != nil {
33+
t.Errorf("newFiles expected nil, got: %v", newFiles)
34+
}
35+
}

revgrep.go

-150
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@ package revgrep
33

44
import (
55
"bufio"
6-
"bytes"
76
"context"
87
"errors"
98
"fmt"
109
"io"
1110
"os"
12-
"os/exec"
1311
"path/filepath"
1412
"regexp"
1513
"strconv"
@@ -352,151 +350,3 @@ func (c *Checker) linesChanged() map[string][]pos {
352350

353351
return changes
354352
}
355-
356-
// GitPatch returns a patch from a git repository.
357-
// If no git repository was found and no errors occurred, nil is returned,
358-
// else an error is returned revisionFrom and revisionTo defines the git diff parameters,
359-
// if left blank and there are unstaged changes or untracked files,
360-
// only those will be returned else only check changes since HEAD~.
361-
// If revisionFrom is set but revisionTo is not,
362-
// untracked files will be included, to exclude untracked files set revisionTo to HEAD~.
363-
// It's incorrect to specify revisionTo without a revisionFrom.
364-
func GitPatch(ctx context.Context, revisionFrom, revisionTo string) (io.Reader, []string, error) {
365-
// check if git repo exists
366-
if err := exec.CommandContext(ctx, "git", "status", "--porcelain").Run(); err != nil {
367-
// don't return an error, we assume the error is not repo exists
368-
return nil, nil, nil
369-
}
370-
371-
// make a patch for untracked files
372-
ls, err := exec.CommandContext(ctx, "git", "ls-files", "--others", "--exclude-standard").CombinedOutput()
373-
if err != nil {
374-
return nil, nil, fmt.Errorf("error executing git ls-files: %w", err)
375-
}
376-
377-
var newFiles []string
378-
for _, file := range bytes.Split(ls, []byte{'\n'}) {
379-
if len(file) == 0 || bytes.HasSuffix(file, []byte{'/'}) {
380-
// ls-files was sometimes showing directories when they were ignored
381-
// I couldn't create a test case for this as I couldn't reproduce correctly for the moment,
382-
// just exclude files with trailing /
383-
continue
384-
}
385-
386-
newFiles = append(newFiles, string(file))
387-
}
388-
389-
if revisionFrom != "" {
390-
args := []string{revisionFrom}
391-
392-
if revisionTo != "" {
393-
args = append(args, revisionTo)
394-
}
395-
396-
args = append(args, "--")
397-
398-
patch, errDiff := gitDiff(ctx, args...)
399-
if errDiff != nil {
400-
return nil, nil, errDiff
401-
}
402-
403-
if revisionTo == "" {
404-
return patch, newFiles, nil
405-
}
406-
407-
return patch, nil, nil
408-
}
409-
410-
// make a patch for unstaged changes
411-
patch, err := gitDiff(ctx, "--")
412-
if err != nil {
413-
return nil, nil, err
414-
}
415-
416-
unstaged := patch.Len() > 0
417-
418-
// If there's unstaged changes OR untracked changes (or both),
419-
// then this is a suitable patch
420-
if unstaged || newFiles != nil {
421-
return patch, newFiles, nil
422-
}
423-
424-
// check for changes in recent commit
425-
patch, err = gitDiff(ctx, "HEAD~", "--")
426-
if err != nil {
427-
return nil, nil, err
428-
}
429-
430-
return patch, nil, nil
431-
}
432-
433-
func gitDiff(ctx context.Context, extraArgs ...string) (*bytes.Buffer, error) {
434-
cmd := exec.CommandContext(ctx, "git", "diff", "--color=never", "--no-ext-diff")
435-
436-
if isSupportedByGit(ctx, 2, 41, 0) {
437-
cmd.Args = append(cmd.Args, "--default-prefix")
438-
}
439-
440-
cmd.Args = append(cmd.Args, "--relative")
441-
cmd.Args = append(cmd.Args, extraArgs...)
442-
443-
patch := new(bytes.Buffer)
444-
errBuff := new(bytes.Buffer)
445-
446-
cmd.Stdout = patch
447-
cmd.Stderr = errBuff
448-
449-
if err := cmd.Run(); err != nil {
450-
return nil, fmt.Errorf("error executing %q: %w: %w", strings.Join(cmd.Args, " "), err, readAsError(errBuff))
451-
}
452-
453-
return patch, nil
454-
}
455-
456-
func readAsError(buff io.Reader) error {
457-
output, err := io.ReadAll(buff)
458-
if err != nil {
459-
return fmt.Errorf("read stderr: %w", err)
460-
}
461-
462-
return errors.New(string(output))
463-
}
464-
465-
func isSupportedByGit(ctx context.Context, major, minor, patch int) bool {
466-
output, err := exec.CommandContext(ctx, "git", "version").CombinedOutput()
467-
if err != nil {
468-
return false
469-
}
470-
471-
parts := bytes.Split(bytes.TrimSpace(output), []byte(" "))
472-
if len(parts) < 3 {
473-
return false
474-
}
475-
476-
v := string(parts[2])
477-
if v == "" {
478-
return false
479-
}
480-
481-
vp := regexp.MustCompile(`^(\d+)\.(\d+)(?:\.(\d+))?.*$`).FindStringSubmatch(v)
482-
if len(vp) < 4 {
483-
return false
484-
}
485-
486-
currentMajor, err := strconv.Atoi(vp[1])
487-
if err != nil {
488-
return false
489-
}
490-
491-
currentMinor, err := strconv.Atoi(vp[2])
492-
if err != nil {
493-
return false
494-
}
495-
496-
currentPatch, err := strconv.Atoi(vp[3])
497-
if err != nil {
498-
return false
499-
}
500-
501-
return currentMajor*1_000_000_000+currentMinor*1_000_000+currentPatch*1_000 >= major*1_000_000_000+minor*1_000_000+patch*1_000
502-
}

revgrep_test.go

-19
Original file line numberDiff line numberDiff line change
@@ -262,25 +262,6 @@ func rewriteAbs(line string) string {
262262
return strings.TrimPrefix(line, cwd+string(filepath.Separator))
263263
}
264264

265-
func TestGitPatchNonGitDir(t *testing.T) {
266-
// Change to non-git dir
267-
err := os.Chdir("/")
268-
if err != nil {
269-
t.Fatalf("could not chdir: %v", err)
270-
}
271-
272-
patch, newfiles, err := GitPatch(context.Background(), "", "")
273-
if err != nil {
274-
t.Errorf("error expected nil, got: %v", err)
275-
}
276-
if patch != nil {
277-
t.Errorf("patch expected nil, got: %v", patch)
278-
}
279-
if newfiles != nil {
280-
t.Errorf("newFiles expected nil, got: %v", newfiles)
281-
}
282-
}
283-
284265
func TestLinesChanged(t *testing.T) {
285266
diff := []byte(`--- a/file.go
286267
+++ b/file.go

0 commit comments

Comments
 (0)