internal/gitutil/lastcommit.go
157 lines · 4422 bytes
1package gitutil
2
3import (
4 "bufio"
5 "os/exec"
6 "strconv"
7 "strings"
8 "time"
9)
10
11// EntryCommit is the newest commit touching one entry of a tree listing.
12type EntryCommit struct {
13 SHA string
14 Subject string
15 Author string
16 When time.Time
17}
18
19// lastCommitScan bounds the history walk. A directory whose entries are
20// all recently touched resolves in a handful of commits; this only caps
21// the pathological case, where the remaining entries are reported absent
22// rather than costing an unbounded scan on every page view.
23const lastCommitScan = 2000
24
25// LastCommits resolves the newest commit touching each of names directly
26// under path, for the tree at ref.
27//
28// One git log process serves the whole listing rather than one per entry:
29// the walk streams newest-first and is killed as soon as every name is
30// accounted for, so an active directory reads only the few commits it
31// needs no matter how deep the history goes. Names still unresolved when
32// the walk ends are absent from the map, and callers render them blank.
33func LastCommits(dir, ref, path string, names []string) map[string]EntryCommit {
34 if len(names) == 0 {
35 return nil
36 }
37 want := make(map[string]bool, len(names))
38 for _, n := range names {
39 want[n] = true
40 }
41 prefix := ""
42 if path != "" {
43 prefix = strings.TrimSuffix(path, "/") + "/"
44 }
45
46 args := []string{"-C", dir, "log", "--first-parent", "--name-only",
47 "--format=%x1e%H%x1f%ct%x1f%an%x1f%s", "-n", strconv.Itoa(lastCommitScan), ref}
48 if prefix != "" {
49 args = append(args, "--", strings.TrimSuffix(prefix, "/"))
50 }
51 cmd := exec.Command("git", args...)
52 stdout, err := cmd.StdoutPipe()
53 if err != nil {
54 return nil
55 }
56 if err := cmd.Start(); err != nil {
57 return nil
58 }
59 defer func() {
60 // The walk usually ends early; stop git rather than let it finish
61 // reading history nobody is going to look at.
62 cmd.Process.Kill()
63 cmd.Wait()
64 }()
65
66 out := make(map[string]EntryCommit, len(names))
67 var cur EntryCommit
68 sc := bufio.NewScanner(stdout)
69 sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
70 for sc.Scan() {
71 line := sc.Text()
72 if strings.HasPrefix(line, "\x1e") {
73 cur = parseCommitHeader(line[1:])
74 continue
75 }
76 if line == "" || cur.SHA == "" {
77 continue
78 }
79 name, ok := entryName(line, prefix)
80 if !ok || !want[name] {
81 continue
82 }
83 out[name] = cur
84 delete(want, name)
85 if len(want) == 0 {
86 break
87 }
88 }
89 return out
90}
91
92// parseCommitHeader reads sha, commit time, author, and subject, unit
93// separated so a subject containing spaces stays intact.
94func parseCommitHeader(s string) EntryCommit {
95 f := strings.SplitN(s, "\x1f", 4)
96 if len(f) != 4 {
97 return EntryCommit{}
98 }
99 c := EntryCommit{SHA: f[0], Author: f[2], Subject: f[3]}
100 if n, err := strconv.ParseInt(f[1], 10, 64); err == nil {
101 c.When = time.Unix(n, 0).UTC()
102 }
103 return c
104}
105
106// TipCommit is the commit at ref, for the bar above a tree listing that
107// answers "who touched this repository last".
108func TipCommit(dir, ref string) EntryCommit {
109 out, err := exec.Command("git", "-C", dir, "log", "-1",
110 "--format=%H%x1f%ct%x1f%an%x1f%s", ref).Output()
111 if err != nil {
112 return EntryCommit{}
113 }
114 return parseCommitHeader(strings.TrimRight(string(out), "\n"))
115}
116
117// entryName maps a changed path to the listing entry that contains it:
118// "internal/web/web.go" under prefix "internal/" is the entry "web".
119func entryName(changed, prefix string) (string, bool) {
120 if prefix != "" {
121 if !strings.HasPrefix(changed, prefix) {
122 return "", false
123 }
124 changed = changed[len(prefix):]
125 }
126 if changed == "" {
127 return "", false
128 }
129 if i := strings.IndexByte(changed, '/'); i >= 0 {
130 return changed[:i], true
131 }
132 return changed, true
133}
134
135// StatPath returns the tree entry for a single path at ref, so a blob
136// page can report the facts the file listing no longer carries: its size,
137// and whether it is executable or a symlink.
138func StatPath(dir, ref, path string) (TreeEntry, bool) {
139 out, err := exec.Command("git", "-C", dir, "ls-tree", "-l", ref, "--", path).Output()
140 if err != nil {
141 return TreeEntry{}, false
142 }
143 line := strings.TrimRight(string(out), "\n")
144 meta, name, ok := strings.Cut(line, "\t")
145 if !ok {
146 return TreeEntry{}, false
147 }
148 f := strings.Fields(meta)
149 if len(f) != 4 {
150 return TreeEntry{}, false
151 }
152 size := int64(-1)
153 if f[3] != "-" {
154 size, _ = strconv.ParseInt(f[3], 10, 64)
155 }
156 return TreeEntry{Mode: f[0], Type: f[1], SHA: f[2], Size: size, Name: name}, true
157}