internal/gitutil/lastcommit.go

e4fa4034379ae93e3cf6f1084ed577a7e02b530c
gitbay/internal/gitutil/lastcommit.go history · blame · raw

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}