Commit bc635c981b

bc635c981b93257c7182026597f741e1f5dda545

parent: ee77220399

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 03:20 UTC

gitutil: DiffNumstat

Ref #318

Layout: unified · split

internal/gitutil/numstat.go added +84
@@ -0,0 +1,84 @@
1package gitutil
2
3import (
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.
14type 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.
24func 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 @@
1package gitutil
2
3import (
4 "os"
5 "path/filepath"
6 "slices"
7 "strings"
8 "testing"
9)
10
11func 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}