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