Commit bc635c981b
Verified · cmc
Layout: unified · split
internal/gitutil/numstat.go added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "os/exec" | |
| 6 | "strconv" | |
| 7 | "strings" | |
| 8 | ||
| 9 | "gitbay.org/gitbay/internal/toolpath" | |
| 10 | ) | |
| 11 | ||
| 12 | // NumStat is one file's line counts between two commits. A binary file | |
| 13 | // counts -1 each way. Status is A, M, D or R. | |
| 14 | type NumStat struct { | |
| 15 | Path string | |
| 16 | Added int | |
| 17 | Deleted int | |
| 18 | Status string | |
| 19 | } | |
| 20 | ||
| 21 | // DiffNumstat lists the files changed from base to head with their line | |
| 22 | // counts and status, renames detected. A renamed file is listed under | |
| 23 | // its new path. | |
| 24 | func DiffNumstat(dir, base, head string) ([]NumStat, error) { | |
| 25 | run := func(args ...string) (string, error) { | |
| 26 | cmd := exec.Command(toolpath.Look("git"), append([]string{"-C", dir}, args...)...) | |
| 27 | out, err := cmd.Output() | |
| 28 | if err != nil { | |
| 29 | return "", fmt.Errorf("git %s %s: %w", args[0], args[1], err) | |
| 30 | } | |
| 31 | return string(out), nil | |
| 32 | } | |
| 33 | num, err := run("diff", "--numstat", "-z", "-M", "--end-of-options", base, head) | |
| 34 | if err != nil { | |
| 35 | return nil, err | |
| 36 | } | |
| 37 | names, err := run("diff", "--name-status", "-z", "-M", "--end-of-options", base, head) | |
| 38 | if err != nil { | |
| 39 | return nil, err | |
| 40 | } | |
| 41 | // --name-status -z: "M\0path\0", or "R100\0old\0new\0" for a rename | |
| 42 | // or copy. | |
| 43 | status := map[string]string{} | |
| 44 | f := strings.Split(strings.TrimSuffix(names, "\x00"), "\x00") | |
| 45 | for i := 0; i < len(f); { | |
| 46 | s := f[i] | |
| 47 | if s == "" { | |
| 48 | i++ | |
| 49 | continue | |
| 50 | } | |
| 51 | if s[0] == 'R' || s[0] == 'C' { | |
| 52 | if i+2 < len(f) { | |
| 53 | status[f[i+2]] = s[:1] | |
| 54 | } | |
| 55 | i += 3 | |
| 56 | continue | |
| 57 | } | |
| 58 | if i+1 < len(f) { | |
| 59 | status[f[i+1]] = s[:1] | |
| 60 | } | |
| 61 | i += 2 | |
| 62 | } | |
| 63 | // --numstat -z: "a\td\tpath\0", or "a\td\t\0old\0new\0" for a rename. | |
| 64 | var out []NumStat | |
| 65 | recs := strings.Split(strings.TrimSuffix(num, "\x00"), "\x00") | |
| 66 | for i := 0; i < len(recs); i++ { | |
| 67 | parts := strings.SplitN(recs[i], "\t", 3) | |
| 68 | if len(parts) != 3 { | |
| 69 | continue | |
| 70 | } | |
| 71 | path := parts[2] | |
| 72 | if path == "" && i+2 < len(recs) { | |
| 73 | path = recs[i+2] | |
| 74 | i += 2 | |
| 75 | } | |
| 76 | ns := NumStat{Path: path, Added: -1, Deleted: -1, Status: status[path]} | |
| 77 | if parts[0] != "-" { | |
| 78 | ns.Added, _ = strconv.Atoi(parts[0]) | |
| 79 | ns.Deleted, _ = strconv.Atoi(parts[1]) | |
| 80 | } | |
| 81 | out = append(out, ns) | |
| 82 | } | |
| 83 | return out, nil | |
| 84 | } | |
internal/gitutil/numstat_test.go added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "slices" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | func TestDiffNumstat(t *testing.T) { | |
| 12 | dir := t.TempDir() | |
| 13 | git(t, dir, "init", "-q", "-b", "main") | |
| 14 | write(t, dir, "a.txt", "one\ntwo\nthree\n") | |
| 15 | write(t, dir, "old.txt", strings.Repeat("line\n", 20)) | |
| 16 | git(t, dir, "add", ".") | |
| 17 | git(t, dir, "commit", "-qm", "base") | |
| 18 | git(t, dir, "tag", "base") | |
| 19 | ||
| 20 | write(t, dir, "a.txt", "one\n2\n3\nthree\n") | |
| 21 | write(t, dir, "new.txt", "hello\n") | |
| 22 | if err := os.WriteFile(filepath.Join(dir, "bin.dat"), []byte{0, 1, 2, 0, 3}, 0o644); err != nil { | |
| 23 | t.Fatal(err) | |
| 24 | } | |
| 25 | git(t, dir, "mv", "old.txt", "moved.txt") | |
| 26 | git(t, dir, "add", ".") | |
| 27 | git(t, dir, "commit", "-qm", "head") | |
| 28 | ||
| 29 | got, err := DiffNumstat(dir, "base", "HEAD") | |
| 30 | if err != nil { | |
| 31 | t.Fatal(err) | |
| 32 | } | |
| 33 | want := []NumStat{ | |
| 34 | {Path: "a.txt", Added: 2, Deleted: 1, Status: "M"}, | |
| 35 | {Path: "bin.dat", Added: -1, Deleted: -1, Status: "A"}, | |
| 36 | {Path: "moved.txt", Added: 0, Deleted: 0, Status: "R"}, | |
| 37 | {Path: "new.txt", Added: 1, Deleted: 0, Status: "A"}, | |
| 38 | } | |
| 39 | slices.SortFunc(got, func(a, b NumStat) int { return strings.Compare(a.Path, b.Path) }) | |
| 40 | if !slices.Equal(got, want) { | |
| 41 | t.Errorf("got %+v\nwant %+v", got, want) | |
| 42 | } | |
| 43 | } | |