internal/gitutil/lastcommit.go

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

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}