internal/gitutil/lastcommit.go

v1.2.0
gitbay/internal/gitutil/lastcommit.go history · blame · raw

158 lines · 4482 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), 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", 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", 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}