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