Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 23m ago 32290114
history blame raw 53.6 KiB
1// Package git wraps the git binary. Vole shells out rather than reimplementing
2// git: hooks, pack handling, and compare-and-swap ref updates all come from the
3// real thing.
4package git
5
6import (
7 "bufio"
8 "bytes"
9 "context"
10 "errors"
11 "fmt"
12 "io"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "sort"
17 "strconv"
18 "strings"
19)
20
21const ZeroSHA = "0000000000000000000000000000000000000000"
22
23// Entry is one record from `git ls-tree` or one line of `git mktree` input.
24type Entry struct {
25 Mode string
26 Type string
27 SHA string
28 Name string
29}
30
31type Repo struct {
32 Dir string
33 // ExtraEnv is appended to every git invocation. It exists for the one case
34 // where a repository-scoped variable must survive the scrub below: a hook
35 // inspecting the very repository being pushed, whose new objects are only
36 // visible through GIT_QUARANTINE_PATH.
37 ExtraEnv []string
38 // ctx, when set, bounds every git invocation this handle starts. A web page
39 // that shells out to git needs one: a repository can be slow, and the process
40 // is the thing that has to stop.
41 ctx context.Context
42}
43
44func Open(dir string) *Repo { return &Repo{Dir: dir} }
45
46// WithContext returns a handle whose git invocations are killed when ctx ends. It
47// is a copy, so a caller cannot change the deadline of a handle somebody else
48// holds.
49func (r *Repo) WithContext(ctx context.Context) *Repo {
50 cp := *r
51 cp.ctx = ctx
52 return &cp
53}
54
55func (r *Repo) run(stdin []byte, args ...string) ([]byte, string, error) {
56 return r.runEnv(nil, stdin, args...)
57}
58
59// runEnv is run with extra environment entries appended, so a single call can
60// carry an author without mutating process state. os/exec keeps the last value
61// for a duplicated key, which is what makes the override reliable.
62func (r *Repo) runEnv(extra []string, stdin []byte, args ...string) ([]byte, string, error) {
63 cmd := r.command(args...)
64 cmd.Env = append(cmd.Env, extra...)
65 if stdin != nil {
66 cmd.Stdin = bytes.NewReader(stdin)
67 }
68 var out, errb bytes.Buffer
69 cmd.Stdout = &out
70 cmd.Stderr = &errb
71 err := cmd.Run()
72 return out.Bytes(), strings.TrimSpace(errb.String()), err
73}
74
75// Run exposes raw git invocation for callers that need it.
76func (r *Repo) Run(stdin []byte, args ...string) ([]byte, error) {
77 out, errs, err := r.run(stdin, args...)
78 if err != nil {
79 return out, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, errs)
80 }
81 return out, nil
82}
83
84// command builds a git invocation with the environment every call shares.
85func (r *Repo) command(args ...string) *exec.Cmd {
86 var cmd *exec.Cmd
87 if r.ctx != nil {
88 cmd = exec.CommandContext(r.ctx, "git", args...)
89 } else {
90 cmd = exec.Command("git", args...)
91 }
92 cmd.Env = append(scrubbedEnv(),
93 "GIT_DIR="+r.Dir,
94 "GIT_TERMINAL_PROMPT=0",
95 "GIT_AUTHOR_NAME=vole", "GIT_AUTHOR_EMAIL=vole@localhost",
96 "GIT_COMMITTER_NAME=vole", "GIT_COMMITTER_EMAIL=vole@localhost",
97 )
98 cmd.Env = append(cmd.Env, r.ExtraEnv...)
99 return cmd
100}
101
102// runHead returns at most n bytes of a git command's stdout. `cat-file blob`
103// writes the whole object, and the caller only wants to sniff the first bytes of
104// a file that is about to be accepted, so the process is stopped once the
105// sample is read rather than after a needless 100 MiB copy.
106func (r *Repo) runHead(n int, args ...string) ([]byte, string, error) {
107 cmd := r.command(args...)
108 var errb bytes.Buffer
109 cmd.Stderr = &errb
110 stdout, err := cmd.StdoutPipe()
111 if err != nil {
112 return nil, "", err
113 }
114 if err := cmd.Start(); err != nil {
115 return nil, "", err
116 }
117 buf := make([]byte, n)
118 got, rerr := io.ReadFull(stdout, buf)
119 if rerr != nil && !errors.Is(rerr, io.ErrUnexpectedEOF) && !errors.Is(rerr, io.EOF) {
120 cmd.Process.Kill()
121 cmd.Wait()
122 return nil, strings.TrimSpace(errb.String()), rerr
123 }
124 // Short blob: the process has already finished. Otherwise stop it; git's
125 // exit status for a closed pipe is not a failure of this call.
126 if got == n {
127 cmd.Process.Kill()
128 }
129 cmd.Wait()
130 return buf[:got], strings.TrimSpace(errb.String()), nil
131}
132
133// InitBare creates a bare repo at dir with the given default branch.
134func InitBare(dir, branch string) error {
135 if err := os.MkdirAll(dir, 0o755); err != nil {
136 return err
137 }
138 cmd := exec.Command("git", "init", "--bare", "-q", "--initial-branch="+branch, dir)
139 var errb bytes.Buffer
140 cmd.Stderr = &errb
141 if err := cmd.Run(); err != nil {
142 return fmt.Errorf("git init: %w: %s", err, strings.TrimSpace(errb.String()))
143 }
144 return nil
145}
146
147// RevParse resolves a revision to a commit SHA, returning "" when it does not
148// exist (an unborn branch is not an error).
149func (r *Repo) RevParse(rev string) (string, error) {
150 out, _, err := r.run(nil, "rev-parse", "--verify", "--quiet", rev+"^{commit}")
151 if err != nil {
152 return "", nil
153 }
154 return strings.TrimSpace(string(out)), nil
155}
156
157// RefCommit resolves a branch ref to its commit. A loose ref is read directly,
158// which is how git stores every branch it updates, and anything else — a packed
159// ref, a symbolic ref, a ref that is not there — falls back to rev-parse. The
160// fast path exists because the index checks the metadata ref on every page and
161// every search refresh; a file read is far cheaper than a process.
162//
163// It is for branch refs. A tag ref can point at a tag object, which RevParse
164// peels and this does not.
165func (r *Repo) RefCommit(ref string) (string, error) {
166 name := strings.TrimPrefix(ref, "refs/heads/")
167 if name != ref && looseRefName(name) {
168 if data, err := os.ReadFile(filepath.Join(r.Dir, "refs", "heads", name)); err == nil {
169 if sha := strings.TrimSpace(string(data)); isObjectID(sha) {
170 return sha, nil
171 }
172 }
173 }
174 return r.RevParse(ref)
175}
176
177// looseRefName rejects a name that cannot be a branch path segment, so the fast
178// path never reads outside the ref store.
179func looseRefName(name string) bool {
180 if name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") ||
181 strings.Contains(name, "..") || strings.ContainsAny(name, "\\\x00\n") {
182 return false
183 }
184 return true
185}
186
187// isObjectID reports the form of a git object id: 40 hex digits for SHA-1, 64
188// for SHA-256. A ref file holding anything else (a symbolic ref, a comment) is
189// not answered by the fast path.
190func isObjectID(s string) bool {
191 if len(s) != 40 && len(s) != 64 {
192 return false
193 }
194 for _, r := range s {
195 if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
196 return false
197 }
198 }
199 return true
200}
201
202// RevParseRaw resolves a rev without peeling to a commit, returning "" when it
203// does not exist. Use this to detect a ref that points at the wrong object type.
204func (r *Repo) RevParseRaw(rev string) (string, error) {
205 out, errs, err := r.run(nil, "rev-parse", "--verify", "--quiet", rev)
206 if err != nil {
207 return "", nil
208 }
209 sha := strings.TrimSpace(string(out))
210 if sha == "" {
211 return "", fmt.Errorf("rev-parse %q: empty result: %s", rev, errs)
212 }
213 return sha, nil
214}
215
216// ResolveAny resolves a rev to an object of any type (tree, blob, commit).
217func (r *Repo) ResolveAny(rev string) (string, string, error) {
218 out, errs, err := r.run(nil, "cat-file", "-t", rev)
219 if err != nil {
220 return "", "", fmt.Errorf("resolve %q: %w: %s", rev, err, errs)
221 }
222 sha, err := r.RevParseAny(rev)
223 if err != nil {
224 return "", "", err
225 }
226 return sha, strings.TrimSpace(string(out)), nil
227}
228
229func (r *Repo) RevParseAny(rev string) (string, error) {
230 out, errs, err := r.run(nil, "rev-parse", "--verify", "--quiet", rev)
231 if err != nil {
232 return "", fmt.Errorf("rev-parse %q: %w: %s", rev, err, errs)
233 }
234 return strings.TrimSpace(string(out)), nil
235}
236
237// TreeOf resolves a revision to the tree it names. It is how a merge commit is
238// compared with the tree a clean merge of its parents would produce, without
239// reading either tree into memory.
240func (r *Repo) TreeOf(rev string) (string, error) {
241 out, errs, err := r.run(nil, "rev-parse", "--verify", "--quiet", rev+"^{tree}")
242 if err != nil {
243 return "", fmt.Errorf("rev-parse %q^{tree}: %w: %s", rev, err, errs)
244 }
245 return strings.TrimSpace(string(out)), nil
246}
247
248func (r *Repo) Exists() bool {
249 st, err := os.Stat(r.Dir)
250 return err == nil && st.IsDir()
251}
252
253// LsTree lists the entries at treeish (a commit, a tree, or commit:path).
254func (r *Repo) LsTree(treeish string) ([]Entry, error) {
255 return r.lsTree(false, false, treeish)
256}
257
258// LsTreeR lists every blob under treeish recursively, with full paths.
259func (r *Repo) LsTreeR(treeish string) ([]Entry, error) {
260 return r.lsTree(true, false, treeish)
261}
262
263// LsTreeRT lists every entry under treeish recursively, including the trees
264// themselves, with full paths. A writer uses it to find the subtree ids it can
265// reuse; a reader that only wants files uses LsTreeR.
266func (r *Repo) LsTreeRT(treeish string) ([]Entry, error) {
267 return r.lsTree(true, true, treeish)
268}
269
270func (r *Repo) lsTree(recursive, withTrees bool, treeish string) ([]Entry, error) {
271 args := []string{"ls-tree"}
272 if recursive {
273 args = append(args, "-r")
274 }
275 if withTrees {
276 args = append(args, "-t")
277 }
278 args = append(args, "-z", treeish)
279 out, errs, err := r.run(nil, args...)
280 if err != nil {
281 return nil, fmt.Errorf("ls-tree %q: %w: %s", treeish, err, errs)
282 }
283 var es []Entry
284 for _, rec := range bytes.Split(out, []byte{0}) {
285 if len(rec) == 0 {
286 continue
287 }
288 tab := bytes.IndexByte(rec, '\t')
289 if tab < 0 {
290 return nil, fmt.Errorf("bad ls-tree record %q", rec)
291 }
292 f := strings.Fields(string(rec[:tab]))
293 if len(f) != 3 {
294 return nil, fmt.Errorf("bad ls-tree fields %q", rec)
295 }
296 es = append(es, Entry{Mode: f[0], Type: f[1], SHA: f[2], Name: string(rec[tab+1:])})
297 }
298 return es, nil
299}
300
301// BlobBySHA returns a blob's contents by object id.
302func (r *Repo) BlobBySHA(sha string) ([]byte, error) {
303 out, errs, err := r.run(nil, "cat-file", "blob", sha)
304 if err != nil {
305 return nil, fmt.Errorf("cat-file blob %s: %w: %s", sha, err, errs)
306 }
307 return out, nil
308}
309
310// BlobsBySHA returns the contents of every named blob in one git call. A caller
311// that is about to read a whole tree uses it so the cost is one process, not one
312// per file. The result is keyed by the object id git reports, which is the id
313// that was asked for.
314func (r *Repo) BlobsBySHA(shas []string) (map[string][]byte, error) {
315 if len(shas) == 0 {
316 return map[string][]byte{}, nil
317 }
318 var in bytes.Buffer
319 for _, sha := range shas {
320 if strings.ContainsAny(sha, "\n\r\x00\t ") {
321 return nil, fmt.Errorf("cat-file: malformed object id %q", sha)
322 }
323 in.WriteString(sha + "\n")
324 }
325 out, errs, err := r.run(in.Bytes(), "cat-file", "--batch")
326 if err != nil {
327 return nil, fmt.Errorf("cat-file --batch: %w: %s", err, errs)
328 }
329 blobs := make(map[string][]byte, len(shas))
330 // The contents are sliced out of out rather than copied, so the map shares
331 // one buffer with the read: a large tree costs one copy, not two.
332 rest := out
333 for len(rest) > 0 {
334 i := bytes.IndexByte(rest, '\n')
335 if i < 0 {
336 return nil, fmt.Errorf("cat-file: unterminated object header")
337 }
338 header := string(rest[:i])
339 rest = rest[i+1:]
340 fields := strings.Fields(header)
341 if len(fields) == 2 && fields[1] == "missing" {
342 return nil, fmt.Errorf("cat-file: %s is missing", fields[0])
343 }
344 if len(fields) != 3 {
345 return nil, fmt.Errorf("cat-file: bad object header %q", header)
346 }
347 size, err := strconv.ParseInt(fields[2], 10, 64)
348 if err != nil || size < 0 || int64(len(rest)) < size+1 {
349 return nil, fmt.Errorf("cat-file: bad object size %q", fields[2])
350 }
351 data := rest[:size]
352 // Every object is followed by one newline. It is framing, not content: a
353 // blob that does not end in a newline still gets one here.
354 if rest[size] != '\n' {
355 return nil, fmt.Errorf("cat-file: object %s is not newline-terminated", fields[0])
356 }
357 blobs[fields[0]] = data
358 rest = rest[size+1:]
359 }
360 return blobs, nil
361}
362
363// SetConfig writes a repo-local git config value.
364func (r *Repo) SetConfig(key, value string) error {
365 _, err := r.Run(nil, "config", key, value)
366 return err
367}
368
369func (r *Repo) MkTree(es []Entry) (string, error) {
370 sorted := make([]Entry, len(es))
371 copy(sorted, es)
372 sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
373 var buf bytes.Buffer
374 for _, e := range sorted {
375 fmt.Fprintf(&buf, "%s %s %s\t%s\x00", e.Mode, e.Type, e.SHA, e.Name)
376 }
377 out, errs, err := r.run(buf.Bytes(), "mktree", "-z")
378 if err != nil {
379 return "", fmt.Errorf("mktree: %w: %s", err, errs)
380 }
381 return strings.TrimSpace(string(out)), nil
382}
383
384func (r *Repo) HashObject(payload []byte) (string, error) {
385 out, errs, err := r.run(payload, "hash-object", "-w", "--stdin")
386 if err != nil {
387 return "", fmt.Errorf("hash-object: %w: %s", err, errs)
388 }
389 return strings.TrimSpace(string(out)), nil
390}
391
392// CommitTreeAs commits with a specific author, which is the audit record for a
393// metadata change.
394func (r *Repo) CommitTreeAs(tree, parent, msg, author string) (string, error) {
395 return r.commitTree(tree, parent, msg, author)
396}
397
398func (r *Repo) commitTree(tree, parent, msg, author string) (string, error) {
399 parents := []string{}
400 if parent != "" {
401 parents = append(parents, parent)
402 }
403 return r.commitTreeParents(tree, parents, msg, author)
404}
405
406// CommitMergeTree writes a merge commit over an already-built tree with the given
407// parents, in order: the base tip first, then the approved head. The order matters
408// to the gate, which accepts a merge whose parents are the old tip and the approved
409// head in either order but nothing else.
410func (r *Repo) CommitMergeTree(tree string, parents []string, msg, author string) (string, error) {
411 if len(parents) != 2 {
412 return "", fmt.Errorf("commit-tree: a merge commit needs two parents, got %d", len(parents))
413 }
414 return r.commitTreeParents(tree, parents, msg, author)
415}
416
417func (r *Repo) commitTreeParents(tree string, parents []string, msg, author string) (string, error) {
418 args := []string{"commit-tree", tree}
419 for _, p := range parents {
420 if p == "" {
421 continue
422 }
423 args = append(args, "-p", p)
424 }
425 env := []string{}
426 if strings.TrimSpace(author) != "" {
427 name := strings.TrimSpace(author)
428 env = append(env,
429 "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+name+"@vole",
430 "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+name+"@vole")
431 }
432 out, errs, err := r.runEnv(env, []byte(msg), args...)
433 if err != nil {
434 return "", fmt.Errorf("commit-tree: %w: %s", err, errs)
435 }
436 return strings.TrimSpace(string(out)), nil
437}
438
439func (r *Repo) CommitTree(tree, parent, msg string) (string, error) {
440 return r.commitTree(tree, parent, msg, "")
441}
442
443// LastTouchedByPath reports, for every path under dir reachable from rev, the newest
444// commit that touched it. That commit is the one whose signature covers the bytes at
445// the tip, which is what makes it the right commit to verify.
446//
447// The first answer wins, not the last: `git log` walks newest first, and a path added,
448// deleted and re-added inside one push has several commits that "added" it. Verifying
449// the oldest of those would check a signature over bytes that are no longer there.
450//
451// One `git log` answers for a whole subtree, so a record's attestation costs one
452// process for all of them rather than one per file.
453func (r *Repo) LastTouchedByPath(rev, dir string) (map[string]string, error) {
454 return r.touchedByPath(rev, dir, "%H")
455}
456
457// TouchTimes is LastTouchedByPath for the question a list asks: when was each
458// path under dir last changed? Rev may be a commit range, so an incremental
459// reader only walks the commits it has not seen.
460//
461// The metadata format deliberately has no `updated` field — the commit that
462// changed a file already carries the time, and writing it back would add a
463// changed line to every edit. This is how the index turns one `git log` into a
464// per-file time, rather than one process per entity.
465func (r *Repo) TouchTimes(rev, dir string) (map[string]int64, error) {
466 touched, err := r.touchedByPath(rev, dir, "%at")
467 if err != nil {
468 return nil, err
469 }
470 if len(touched) == 0 {
471 return nil, nil
472 }
473 times := make(map[string]int64, len(touched))
474 for path, when := range touched {
475 t, err := strconv.ParseInt(when, 10, 64)
476 if err != nil {
477 continue
478 }
479 times[path] = t
480 }
481 return times, nil
482}
483
484// touchedByPath attributes every path under dir reachable from rev to the newest
485// commit that touched it, reading the commit field named by format.
486func (r *Repo) touchedByPath(rev, dir, format string) (map[string]string, error) {
487 if rev == "" || rev == ZeroSHA {
488 return nil, nil
489 }
490 out, errs, err := r.run(nil, "log", "--format=%x00"+format, "--name-only", rev, "--", dir)
491 if err != nil {
492 return nil, fmt.Errorf("log %s: %w: %s", dir, err, errs)
493 }
494 touched := map[string]string{}
495 // Each record starts with a NUL and the commit field, so paths that share a
496 // commit are attributed without guessing where one commit's list ends.
497 for _, chunk := range bytes.Split(out, []byte{0})[1:] {
498 lines := strings.Split(strings.TrimSpace(string(chunk)), "\n")
499 if len(lines) == 0 {
500 continue
501 }
502 value := strings.TrimSpace(lines[0])
503 if value == "" {
504 continue
505 }
506 for _, line := range lines[1:] {
507 path := strings.TrimSpace(line)
508 if path == "" {
509 continue
510 }
511 if _, seen := touched[path]; !seen {
512 touched[path] = value
513 }
514 }
515 }
516 return touched, nil
517}
518
519// UpdateRefCAS moves ref from old to new. Returns false when another writer won
520// the race, which the caller retries. An empty old means "must not exist".
521func (r *Repo) UpdateRefCAS(ref, new, old string) (bool, error) {
522 if old == "" {
523 old = ZeroSHA
524 }
525 _, errs, err := r.run(nil, "update-ref", ref, new, old)
526 if err == nil {
527 return true, nil
528 }
529 if strings.Contains(errs, "cannot lock ref") ||
530 strings.Contains(errs, "is at") ||
531 strings.Contains(errs, "unable to resolve") ||
532 strings.Contains(errs, "reference already exists") {
533 return false, nil
534 }
535 return false, fmt.Errorf("update-ref %s: %w: %s", ref, err, errs)
536}
537
538func (r *Repo) UpdateRef(ref, new string) error {
539 _, err := r.Run(nil, "update-ref", ref, new)
540 return err
541}
542
543func (r *Repo) DeleteRef(ref string) error {
544 _, err := r.Run(nil, "update-ref", "-d", ref)
545 return err
546}
547
548// MaxRefsListed bounds what a listing returns. A page that lists refs walks every
549// one of them, and a repository's ref count is something its contributors choose;
550// the *quota* check uses RefCount, which must see all of them, but nothing a
551// browser renders needs more than this.
552const MaxRefsListed = 500
553
554func (r *Repo) ListRefs(prefix string) (map[string]string, error) {
555 out, err := r.Run(nil, "for-each-ref", "--count="+strconv.Itoa(MaxRefsListed),
556 "--format=%(refname)%1f%(objectname)", prefix)
557 if err != nil {
558 return nil, err
559 }
560 m := map[string]string{}
561 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
562 if line == "" {
563 continue
564 }
565 f := strings.SplitN(line, "\x1f", 2)
566 if len(f) == 2 {
567 m[f[0]] = f[1]
568 }
569 }
570 return m, nil
571}
572
573// RefEntry is one ref and the creation time git reports for it. A branch's time
574// is the committer date of its tip, a tag's is its tagger or commit date, which
575// is what makes one listing enough to sort both.
576type RefEntry struct {
577 Name string
578 When int64
579}
580
581// Refs lists the refs under every given prefix in one call. The ref picker shows
582// branches and tags together, and asking for them separately is a second process
583// per page for no reason. Like ListRefs, the listing stops at MaxRefsListed; the
584// cap is shared by the prefixes, which is the same page-render bound the callers
585// applied one list at a time.
586func (r *Repo) Refs(prefixes ...string) ([]RefEntry, error) {
587 if len(prefixes) == 0 {
588 return nil, nil
589 }
590 args := make([]string, 0, len(prefixes)+3)
591 args = append(args, "for-each-ref", "--count="+strconv.Itoa(MaxRefsListed),
592 "--format=%(refname)%1f%(creatordate:unix)")
593 args = append(args, prefixes...)
594 out, err := r.Run(nil, args...)
595 if err != nil {
596 return nil, err
597 }
598 var refs []RefEntry
599 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
600 name, when, ok := strings.Cut(line, "\x1f")
601 if !ok || name == "" {
602 continue
603 }
604 n, _ := strconv.ParseInt(when, 10, 64)
605 refs = append(refs, RefEntry{Name: name, When: n})
606 }
607 return refs, nil
608}
609
610// BlobAt returns the contents of a blob at commit:path.
611func (r *Repo) BlobAt(commit, path string) ([]byte, error) {
612 out, errs, err := r.run(nil, "cat-file", "blob", commit+":"+path)
613 if err != nil {
614 return nil, fmt.Errorf("cat-file %s:%s: %w: %s", commit, path, err, errs)
615 }
616 return out, nil
617}
618
619// BlobSize returns the length of the object at rev without reading it. It is
620// what lets a reader decide to stream a 100 MiB file or to render only its first
621// page, instead of allocating the whole thing and measuring it afterwards.
622func (r *Repo) BlobSize(rev string) (int64, error) {
623 out, errs, err := r.run(nil, "cat-file", "-s", rev)
624 if err != nil {
625 return 0, fmt.Errorf("cat-file -s %s: %w: %s", rev, err, errs)
626 }
627 n, err := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
628 if err != nil {
629 return 0, fmt.Errorf("cat-file -s %s: bad size %q", rev, out)
630 }
631 return n, nil
632}
633
634// BlobSizes returns the size of every named blob in one git call. A loader that
635// is about to read a whole tree of files uses it to refuse the oversized ones
636// before any of them becomes a []byte.
637func (r *Repo) BlobSizes(shas []string) (map[string]int64, error) {
638 for _, sha := range shas {
639 if strings.ContainsAny(sha, "\n\r\x00\t ") {
640 return nil, fmt.Errorf("cat-file: malformed object id %q", sha)
641 }
642 }
643 return r.blobSizes(shas)
644}
645
646// ErrObjectTooLarge reports that a caller asked for an object that exceeds the
647// cap it named. It is a policy answer, not a broken repository.
648var ErrObjectTooLarge = errors.New("git: object exceeds size limit")
649
650// BlobStream is one object read from git as a stream, with its header already
651// parsed. It exists so a download can be copied to the wire without the object
652// first becoming a []byte: serving a 100 MiB blob costs one 64 KiB buffer no
653// matter how many readers ask for it at once.
654//
655// A stream holds a git process open, so every one of them must be closed. Close
656// on a partly read stream kills the process instead of draining the rest: the
657// caller stopped caring about the bytes.
658type BlobStream struct {
659 SHA string
660 Type string
661 Size int64
662
663 br *bufio.Reader
664 cmd *exec.Cmd
665 in io.WriteCloser
666 left int64
667 done bool
668 closed bool
669}
670
671// OpenBlob resolves rev (a revision, a commit:path pair, or an object id) and
672// returns its header with the reader positioned at the first payload byte.
673func (r *Repo) OpenBlob(rev string) (*BlobStream, error) {
674 if rev == "" || strings.ContainsAny(rev, "\n\r\x00") {
675 return nil, fmt.Errorf("cat-file: malformed revision %q", rev)
676 }
677 cmd := r.command("cat-file", "--batch")
678 in, err := cmd.StdinPipe()
679 if err != nil {
680 return nil, err
681 }
682 out, err := cmd.StdoutPipe()
683 if err != nil {
684 in.Close()
685 return nil, err
686 }
687 cmd.Stderr = io.Discard
688 if err := cmd.Start(); err != nil {
689 in.Close()
690 return nil, err
691 }
692 kill := func() {
693 in.Close()
694 cmd.Process.Kill()
695 cmd.Wait()
696 }
697 if _, err := io.WriteString(in, rev+"\n"); err != nil {
698 kill()
699 return nil, fmt.Errorf("cat-file %s: %w", rev, err)
700 }
701 br := bufio.NewReaderSize(out, 64<<10)
702 line, err := br.ReadString('\n')
703 if err != nil {
704 kill()
705 return nil, fmt.Errorf("cat-file %s: %w", rev, err)
706 }
707 fields := strings.Fields(strings.TrimRight(line, "\n"))
708 if len(fields) == 2 && fields[1] == "missing" {
709 kill()
710 return nil, fmt.Errorf("cat-file %s: object not found", rev)
711 }
712 if len(fields) != 3 {
713 kill()
714 return nil, fmt.Errorf("cat-file %s: unexpected header %q", rev, line)
715 }
716 size, err := strconv.ParseInt(fields[2], 10, 64)
717 if err != nil || size < 0 {
718 kill()
719 return nil, fmt.Errorf("cat-file %s: bad size in %q", rev, line)
720 }
721 return &BlobStream{SHA: fields[0], Type: fields[1], Size: size, br: br, cmd: cmd, in: in, left: size}, nil
722}
723
724// OpenBlobAt is OpenBlob for commit:path.
725func (r *Repo) OpenBlobAt(commit, path string) (*BlobStream, error) {
726 return r.OpenBlob(commit + ":" + path)
727}
728
729// Read implements io.Reader over the object's payload. It stops at the object's
730// own length; the trailing newline git writes after each object is eaten by the
731// last read.
732func (b *BlobStream) Read(p []byte) (int, error) {
733 if b.closed || b.left <= 0 {
734 return 0, io.EOF
735 }
736 if int64(len(p)) > b.left {
737 p = p[:b.left]
738 }
739 n, err := b.br.Read(p)
740 b.left -= int64(n)
741 if b.left == 0 {
742 b.finish()
743 }
744 return n, err
745}
746
747// finish eats the object's trailing newline and reaps the process. A failure
748// here is not the reader's business: the payload is already delivered.
749func (b *BlobStream) finish() {
750 if b.done {
751 return
752 }
753 b.done = true
754 b.br.Discard(1)
755 b.in.Close()
756 b.cmd.Wait()
757}
758
759// Close releases the process. It is safe to call more than once and after a
760// full read.
761func (b *BlobStream) Close() error {
762 if b.closed {
763 return nil
764 }
765 b.closed = true
766 if !b.done {
767 b.done = true
768 b.in.Close()
769 if b.cmd.Process != nil {
770 b.cmd.Process.Kill()
771 }
772 b.cmd.Wait()
773 }
774 return nil
775}
776
777// ReadPrefix returns at most max bytes of the stream and reports whether more
778// bytes were left behind. The rest is not read: the process is stopped.
779func (b *BlobStream) ReadPrefix(max int64) ([]byte, bool, error) {
780 if max < 0 {
781 max = 0
782 }
783 take := b.left
784 if take > max {
785 take = max
786 }
787 buf := make([]byte, take)
788 if _, err := io.ReadFull(b, buf); err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
789 return nil, false, err
790 }
791 return buf, b.left > 0, nil
792}
793
794// BlobPrefixAt returns at most max bytes of the blob at commit:path and whether
795// there were more. It is the read for anything that renders a file inline: a
796// caller that wants a preview never needs more than the preview.
797func (r *Repo) BlobPrefixAt(commit, path string, max int64) ([]byte, bool, error) {
798 stream, err := r.OpenBlobAt(commit, path)
799 if err != nil {
800 return nil, false, err
801 }
802 defer stream.Close()
803 return stream.ReadPrefix(max)
804}
805
806func (r *Repo) ObjectType(rev string) (string, error) {
807 out, _, err := r.run(nil, "cat-file", "-t", rev)
808 if err != nil {
809 return "", err
810 }
811 return strings.TrimSpace(string(out)), nil
812}
813
814// Symref returns the branch HEAD points at, if any.
815func (r *Repo) Symref() string {
816 out, _, err := r.run(nil, "symbolic-ref", "--short", "HEAD")
817 if err != nil {
818 return ""
819 }
820 return strings.TrimSpace(string(out))
821}
822
823// SetHEAD points HEAD at an existing branch. It does not create the branch; the
824// caller decides whether an unborn HEAD is acceptable.
825func (r *Repo) SetHEAD(branch string) error {
826 _, errs, err := r.run(nil, "symbolic-ref", "HEAD", "refs/heads/"+branch)
827 if err != nil {
828 return fmt.Errorf("set HEAD to %s: %w: %s", branch, err, errs)
829 }
830 return nil
831}
832
833// ValidTag reports whether name is a syntactically valid tag. The leading-dash
834// check keeps a name from being read as an option by git.
835func (r *Repo) ValidTag(name string) bool {
836 if name == "" || strings.HasPrefix(name, "-") || strings.Contains(name, "\x00") {
837 return false
838 }
839 _, _, err := r.run(nil, "check-ref-format", "refs/tags/"+name)
840 return err == nil
841}
842
843// WriteAnnotatedTag creates, or with force moves, an annotated tag. The tagger is
844// the account that asked for it, not the server process, so the tag explains who
845// made the release.
846func (r *Repo) WriteAnnotatedTag(name, target, message, tagger string, force bool) error {
847 if !r.ValidTag(name) {
848 return fmt.Errorf("%q is not a valid tag name", name)
849 }
850 args := []string{"tag", "-a"}
851 if force {
852 args = append(args, "-f")
853 }
854 args = append(args, name, "-m", message, target)
855 _, errs, err := r.runEnv([]string{
856 "GIT_COMMITTER_NAME=" + tagger,
857 "GIT_COMMITTER_EMAIL=" + tagger + "@vole.local",
858 }, nil, args...)
859 if err != nil {
860 return fmt.Errorf("tag %s: %w: %s", name, err, errs)
861 }
862 return nil
863}
864
865// ValidBranch reports whether name is a syntactically valid branch. It checks
866// the full ref rather than using --branch, which would expand @{-1} and similar
867// history syntax instead of validating the bytes the caller wrote.
868func (r *Repo) ValidBranch(name string) bool {
869 if name == "" || strings.HasPrefix(name, "-") || strings.Contains(name, "\x00") {
870 return false
871 }
872 _, _, err := r.run(nil, "check-ref-format", "refs/heads/"+name)
873 return err == nil
874}
875
876// commitFormat is one line per commit: sha, author, email, epoch, subject.
877// %x1f is a unit separator, which cannot appear in a subject the way a tab or a
878// newline can.
879const commitFormat = "%H%x1f%an%x1f%ae%x1f%at%x1f%s"
880
881type Commit struct {
882 SHA string
883 Author string
884 Email string
885 When int64
886 Subject string
887}
888
889// repositoryEnv lists the variables git sets to point at a specific repository.
890// They are inherited by hooks: pre-receive runs with GIT_QUARANTINE_PATH and
891// friends set for the repository being pushed, so a hook that shells out to git
892// for a *different* repository (the roster, for instance) would silently read the
893// pushed one instead. Every command we run names its repository explicitly, so
894// these are always dropped.
895var repositoryEnv = map[string]bool{
896 "GIT_DIR": true,
897 "GIT_WORK_TREE": true,
898 "GIT_COMMON_DIR": true,
899 "GIT_INDEX_FILE": true,
900 "GIT_OBJECT_DIRECTORY": true,
901 "GIT_ALTERNATE_OBJECT_DIRECTORIES": true,
902 "GIT_QUARANTINE_PATH": true,
903 "GIT_NAMESPACE": true,
904 "GIT_PREFIX": true,
905}
906
907func scrubbedEnv() []string {
908 env := os.Environ()
909 out := make([]string, 0, len(env))
910 for _, kv := range env {
911 name := kv
912 if i := strings.IndexByte(kv, '='); i >= 0 {
913 name = kv[:i]
914 }
915 if repositoryEnv[name] {
916 continue
917 }
918 out = append(out, kv)
919 }
920 return out
921}
922
923// Log returns up to n commits from rev.
924func (r *Repo) Log(rev string, n int) ([]Commit, error) {
925 args := []string{"log", "--format=" + commitFormat}
926 if n > 0 {
927 args = append(args, "-n", strconv.Itoa(n))
928 }
929 args = append(args, rev, "--")
930 out, errs, err := r.run(nil, args...)
931 if err != nil {
932 return nil, fmt.Errorf("log %q: %w: %s", rev, err, errs)
933 }
934 return parseCommits(out), nil
935}
936
937// LogRange returns the commits reachable from to but not from, oldest first. It is
938// what a reconciler reads to apply every step of a branch advance it missed, rather
939// than only the net difference between two tips.
940func (r *Repo) LogRange(from, to string) ([]Commit, error) {
941 args := []string{"log", "--reverse", "--format=" + commitFormat}
942 if from == "" || from == ZeroSHA {
943 args = append(args, to, "--")
944 } else {
945 args = append(args, from+".."+to, "--")
946 }
947 out, errs, err := r.run(nil, args...)
948 if err != nil {
949 return nil, fmt.Errorf("log %s..%s: %w: %s", from, to, err, errs)
950 }
951 return parseCommits(out), nil
952}
953
954// RevParseFull resolves a revision to a full commit id. It refuses ambiguous
955// short ids rather than picking one.
956func (r *Repo) RevParseFull(rev string) (string, error) {
957 out, errs, err := r.run(nil, "rev-parse", "--verify", "--quiet", "--end-of-options", rev+"^{commit}")
958 if err != nil {
959 return "", fmt.Errorf("rev-parse %s: %w: %s", rev, err, errs)
960 }
961 sha := strings.TrimSpace(string(out))
962 if len(sha) != 40 {
963 return "", fmt.Errorf("%q is not a commit", rev)
964 }
965 return sha, nil
966}
967
968// CurrentBranch returns the checked-out branch name, or "" when detached.
969func (r *Repo) CurrentBranch() (string, error) {
970 out, _, err := r.run(nil, "symbolic-ref", "--short", "-q", "HEAD")
971 if err != nil {
972 return "", nil // detached or unborn: nothing is checked out by name
973 }
974 return strings.TrimSpace(string(out)), nil
975}
976
977// DiffNameStatus lists the paths a branch changes relative to from, using the
978// merge base so unrelated history on from does not show up as changes.
979func (r *Repo) DiffNameStatus(from, to string) ([]string, error) {
980 out, errs, err := r.run(nil, "diff", "--name-status", from+"..."+to)
981 if err != nil {
982 return nil, fmt.Errorf("diff %s...%s: %w: %s", from, to, err, errs)
983 }
984 var lines []string
985 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
986 if strings.TrimSpace(line) != "" {
987 lines = append(lines, strings.Replace(line, "\t", " ", -1))
988 }
989 }
990 return lines, nil
991}
992
993// DiffStatus returns the change status of each path between two commits, as the
994// single letter git uses: A added, M modified, D deleted, T type changed, R
995// renamed, C copied. Callers that guard a path must treat every status except D
996// as a write, or a check that only looks at additions can be walked past by
997// editing a file that already exists.
998func (r *Repo) DiffStatus(old, new string) (map[string]string, error) {
999 return r.DiffStatusPaths(old, new)
1000}
1001
1002// DiffStatusPaths is DiffStatus limited to the given pathspecs. An incremental
1003// refresh only cares about the subtrees it indexes, so a range that also carries
1004// unrelated changes does not scan them.
1005func (r *Repo) DiffStatusPaths(old, new string, pathspecs ...string) (map[string]string, error) {
1006 args := []string{"diff-tree", "-r", "--no-commit-id", "--name-status", old, new}
1007 if len(pathspecs) > 0 {
1008 args = append(args, "--")
1009 args = append(args, pathspecs...)
1010 }
1011 out, errs, err := r.run(nil, args...)
1012 if err != nil {
1013 return nil, fmt.Errorf("diff-tree %s..%s: %w: %s", old, new, err, errs)
1014 }
1015 status := map[string]string{}
1016 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
1017 if line == "" {
1018 continue
1019 }
1020 parts := strings.SplitN(line, "\t", 2)
1021 if len(parts) != 2 {
1022 continue
1023 }
1024 // Rename and copy entries carry two paths; the destination is the one that
1025 // matters for a write check.
1026 status[strings.TrimSpace(parts[1])] = strings.TrimSpace(parts[0])[:1]
1027 }
1028 return status, nil
1029}
1030
1031// CommitParents returns a commit's parents, in order.
1032func (r *Repo) CommitParents(sha string) ([]string, error) {
1033 out, errs, err := r.run(nil, "rev-list", "--parents", "-n", "1", sha)
1034 if err != nil {
1035 return nil, fmt.Errorf("rev-list --parents %s: %w: %s", sha, err, errs)
1036 }
1037 f := strings.Fields(strings.TrimSpace(string(out)))
1038 if len(f) == 0 {
1039 return nil, fmt.Errorf("no such commit %s", sha)
1040 }
1041 return f[1:], nil
1042}
1043
1044// IsAncestor reports whether ancestor is reachable from descendant. Used to tell
1045// a fast-forward push from a rewrite.
1046func (r *Repo) IsAncestor(ancestor, descendant string) (bool, error) {
1047 if ancestor == "" || ancestor == ZeroSHA {
1048 return true, nil // a new branch cannot rewrite anything
1049 }
1050 _, _, err := r.run(nil, "merge-base", "--is-ancestor", ancestor, descendant)
1051 if err == nil {
1052 return true, nil
1053 }
1054 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
1055 return false, nil
1056 }
1057 return false, fmt.Errorf("merge-base --is-ancestor %s %s: %w", ancestor, descendant, err)
1058}
1059
1060// CountMerges counts merge commits in old..new, or in all of new when old is the
1061// zero SHA.
1062func (r *Repo) CountMerges(old, new string) (int, error) {
1063 args := []string{"rev-list", "--count", "--merges", new}
1064 if old != "" && old != ZeroSHA {
1065 args = []string{"rev-list", "--count", "--merges", old + ".." + new}
1066 }
1067 out, errs, err := r.run(nil, args...)
1068 if err != nil {
1069 if strings.Contains(errs, "bad revision") || strings.Contains(errs, "unknown revision") {
1070 return 0, nil // history we cannot read is not a merge we can prove
1071 }
1072 return 0, fmt.Errorf("rev-list --merges: %w: %s", err, errs)
1073 }
1074 n, err := strconv.Atoi(strings.TrimSpace(string(out)))
1075 if err != nil {
1076 return 0, nil
1077 }
1078 return n, nil
1079}
1080
1081// LogGrep returns commits whose message mentions pattern. Used for issue
1082// backlinks, so the search happens in git rather than in a second index.
1083func (r *Repo) LogGrep(rev, pattern string, n int) ([]Commit, error) {
1084 out, errs, err := r.run(nil, "log",
1085 "--grep="+pattern, "--fixed-strings", "--format="+commitFormat,
1086 "-n", strconv.Itoa(n), rev, "--")
1087 if err != nil {
1088 return nil, fmt.Errorf("log --grep %q: %w: %s", pattern, err, errs)
1089 }
1090 return parseCommits(out), nil
1091}
1092
1093func parseCommits(out []byte) []Commit {
1094 var cs []Commit
1095 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
1096 if line == "" {
1097 continue
1098 }
1099 f := strings.SplitN(line, "\x1f", 5)
1100 if len(f) != 5 {
1101 continue
1102 }
1103 ts, _ := strconv.ParseInt(f[3], 10, 64)
1104 cs = append(cs, Commit{SHA: f[0], Author: f[1], Email: f[2], When: ts, Subject: f[4]})
1105 }
1106 return cs
1107}
1108
1109// Grep returns fixed-string, case-insensitive, non-binary matches as raw
1110// path:line:text records. It stops reading after maxBytes, so a broad query
1111// cannot make the server hold an unbounded result set.
1112func (r *Repo) Grep(rev, pattern string, maxBytes int) ([]byte, bool, error) {
1113 if maxBytes < 1 {
1114 maxBytes = 1
1115 }
1116 out, _, err := r.runHead(maxBytes+1,
1117 "grep", "-n", "-I", "-i", "-F", "-e", pattern, rev, "--")
1118 if err != nil {
1119 return nil, false, fmt.Errorf("grep %q: %w", pattern, err)
1120 }
1121 truncated := len(out) > maxBytes
1122 if truncated {
1123 out = out[:maxBytes]
1124 }
1125 return out, truncated, nil
1126}
1127
1128// LogPath lists commits that touched one path. follow asks git to follow a
1129// rename; it is only valid for a single file, so directory history uses false.
1130func (r *Repo) LogPath(rev, path string, n int, follow bool) ([]Commit, error) {
1131 args := []string{"log", "--format=" + commitFormat, "-n", strconv.Itoa(n)}
1132 if follow {
1133 args = append(args, "--follow")
1134 }
1135 args = append(args, rev, "--", path)
1136 out, errs, err := r.run(nil, args...)
1137 if err != nil {
1138 return nil, fmt.Errorf("log %q -- %q: %w: %s", rev, path, err, errs)
1139 }
1140 return parseCommits(out), nil
1141}
1142
1143// addMarker starts each commit's block in AddedPathsPerCommit's output. It is a
1144// control character no path in a metadata tree can contain, so a path can never
1145// be mistaken for the separator. git takes it as a %x escape because a pretty
1146// format rejects a literal control byte.
1147const (
1148 addMarker = "\x01"
1149 addMarkerSpec = "%x01"
1150)
1151
1152// AddedPathsPerCommit lists, oldest first, the paths each commit added, limited
1153// to the given pathspecs. A commit that added nothing under them is absent from
1154// the result, so a caller counting blocks is counting only commits that changed
1155// the tree it asked about.
1156func (r *Repo) AddedPathsPerCommit(rev string, pathspecs ...string) ([][]string, error) {
1157 args := []string{"log", "--reverse", "--diff-filter=A", "--name-only", "--no-renames",
1158 "--format=" + addMarkerSpec, rev, "--"}
1159 args = append(args, pathspecs...)
1160 out, errs, err := r.run(nil, args...)
1161 if err != nil {
1162 return nil, fmt.Errorf("log %q added paths: %w: %s", rev, err, errs)
1163 }
1164 var groups [][]string
1165 var cur []string
1166 flush := func() {
1167 if len(cur) > 0 {
1168 groups = append(groups, cur)
1169 cur = nil
1170 }
1171 }
1172 for _, line := range strings.Split(string(out), "\n") {
1173 line = strings.TrimRight(line, "\r")
1174 switch line {
1175 case addMarker:
1176 flush()
1177 case "":
1178 // git separates the format line and the names with blank lines.
1179 default:
1180 cur = append(cur, line)
1181 }
1182 }
1183 flush()
1184 return groups, nil
1185}
1186
1187// BlameLine is one line of `git blame` output with the commit that last touched
1188// it. Line is the line number in the blamed revision.
1189type BlameLine struct {
1190 SHA string
1191 Author string
1192 When int64
1193 Line int
1194 Text string
1195}
1196
1197// Blame reads line attribution for one file, bounded by maxBytes. A cut result
1198// is reported rather than silently shown as the whole file.
1199func (r *Repo) Blame(rev, path string, maxBytes int) ([]BlameLine, bool, error) {
1200 if maxBytes < 1 {
1201 maxBytes = 1
1202 }
1203 out, _, err := r.runHead(maxBytes+1,
1204 "blame", "--line-porcelain", rev, "--", path)
1205 if err != nil {
1206 return nil, false, fmt.Errorf("blame %q -- %q: %w", rev, path, err)
1207 }
1208 truncated := len(out) > maxBytes
1209 if truncated {
1210 out = out[:maxBytes]
1211 }
1212 var lines []BlameLine
1213 var cur BlameLine
1214 have := false
1215 for _, line := range strings.Split(string(out), "\n") {
1216 switch {
1217 case strings.HasPrefix(line, "\t"):
1218 if have {
1219 cur.Text = line[1:]
1220 lines = append(lines, cur)
1221 have = false
1222 }
1223 case strings.HasPrefix(line, "author "):
1224 cur.Author = strings.TrimPrefix(line, "author ")
1225 case strings.HasPrefix(line, "author-time "):
1226 cur.When, _ = strconv.ParseInt(strings.TrimPrefix(line, "author-time "), 10, 64)
1227 default:
1228 fields := strings.Fields(line)
1229 if len(fields) >= 3 {
1230 sha := strings.TrimPrefix(fields[0], "^")
1231 if n, err := strconv.Atoi(fields[2]); err == nil && len(sha) == 40 {
1232 cur = BlameLine{SHA: sha, Line: n}
1233 have = true
1234 }
1235 }
1236 }
1237 }
1238 return lines, truncated, nil
1239}
1240
1241func (r *Repo) CountCommits(rev string) (int, error) {
1242 out, _, err := r.run(nil, "rev-list", "--count", rev)
1243 if err != nil {
1244 return 0, nil // unborrn branch
1245 }
1246 n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
1247 return n, nil
1248}
1249
1250// NewBlobSizes returns the size of every blob reachable from new but not from
1251// old. Used by the pre-receive hook to enforce object size limits without
1252// walking the entire history.
1253func (r *Repo) NewBlobSizes(old, new string) (map[string]int64, error) {
1254 blobs, err := r.NewBlobs(old, new)
1255 if err != nil {
1256 return nil, err
1257 }
1258 out := make(map[string]int64, len(blobs))
1259 for sha, b := range blobs {
1260 out[sha] = b.Size
1261 }
1262 return out, nil
1263}
1264
1265// Blob is one object a push introduces, with the path git reported for it.
1266// An object reachable from two paths is reported once, under one of them: the
1267// path is for the message, not for authorization or naming anything durable.
1268type Blob struct {
1269 SHA string
1270 Path string
1271 Size int64
1272}
1273
1274// NewBlobs lists the blobs a push adds, with sizes. rev-list --objects is the
1275// only git command here that reports path and object id together, so it is the
1276// one used for both the size check and LFS pointer scanning.
1277func (r *Repo) NewBlobs(old, new string) (map[string]Blob, error) {
1278 args := []string{"rev-list", "--objects", new}
1279 // A brand-new ref reports the zero SHA as its old value; passing that to
1280 // --not would make git error out and silently skip the check.
1281 if old != "" && old != ZeroSHA {
1282 args = append(args, "--not", old)
1283 }
1284 out, errs, err := r.run(nil, args...)
1285 if err != nil {
1286 return nil, fmt.Errorf("rev-list: %w: %s", err, errs)
1287 }
1288 paths := map[string]string{}
1289 var shas []string
1290 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
1291 if line == "" {
1292 continue
1293 }
1294 sha, path, _ := strings.Cut(line, " ")
1295 shas = append(shas, sha)
1296 paths[sha] = path
1297 }
1298 if len(shas) == 0 {
1299 return map[string]Blob{}, nil
1300 }
1301 sizes, err := r.blobSizes(shas)
1302 if err != nil {
1303 return nil, err
1304 }
1305 blobs := make(map[string]Blob, len(sizes))
1306 for sha, n := range sizes {
1307 blobs[sha] = Blob{SHA: sha, Path: paths[sha], Size: n}
1308 }
1309 return blobs, nil
1310}
1311
1312// TreeBlobs lists every blob in a commit's tree with its size. This is what the
1313// LFS check scans: what a ref serves after the push, not every object the push
1314// happens to carry. A commit that deletes a broken pointer must stay pushable,
1315// or there is no way out of the mistake.
1316func (r *Repo) TreeBlobs(treeish string) (map[string]Blob, error) {
1317 entries, err := r.LsTreeR(treeish)
1318 if err != nil {
1319 return nil, err
1320 }
1321 paths := map[string]string{}
1322 var shas []string
1323 for _, e := range entries {
1324 if e.Type != "blob" {
1325 continue
1326 }
1327 paths[e.SHA] = e.Name
1328 shas = append(shas, e.SHA)
1329 }
1330 sizes, err := r.blobSizes(shas)
1331 if err != nil {
1332 return nil, err
1333 }
1334 blobs := make(map[string]Blob, len(sizes))
1335 for sha, n := range sizes {
1336 blobs[sha] = Blob{SHA: sha, Path: paths[sha], Size: n}
1337 }
1338 return blobs, nil
1339}
1340
1341// Diff returns the unified diff git prints between two revisions. A range may
1342// be written with git's own syntax ("main...feature"), so the caller decides
1343// between two-dot and merge-base semantics.
1344//
1345// The patch is complete. A view that renders one file asks for that file
1346// (DiffPaths) and a view that lists files asks for their names and counts
1347// (DiffInventory), so no caller has to cut a patch to stay small: the size of a
1348// page follows the file it shows, not the size of the change.
1349func (r *Repo) Diff(rangeOrFrom, to string) (string, error) {
1350 args := append([]string{"diff", "--no-color", "--no-ext-diff", "--find-renames", "--unified=3"}, diffRange(rangeOrFrom, to)...)
1351 out, errs, err := r.run(nil, args...)
1352 if err != nil {
1353 return "", fmt.Errorf("diff: %w: %s", err, errs)
1354 }
1355 return string(out), nil
1356}
1357
1358// DiffPaths is Diff limited to the paths given, which is what a per-file view
1359// reads. A rename needs both of its paths named: filtered to the destination
1360// alone, git reports what is left as a new file and the rename is lost.
1361func (r *Repo) DiffPaths(rangeOrFrom, to string, paths []string) (string, error) {
1362 args := append([]string{"diff", "--no-color", "--no-ext-diff", "--find-renames", "--unified=3"}, diffRange(rangeOrFrom, to)...)
1363 args = append(append(args, "--"), paths...)
1364 out, errs, err := r.run(nil, args...)
1365 if err != nil {
1366 return "", fmt.Errorf("diff %s: %w: %s", strings.Join(paths, " "), err, errs)
1367 }
1368 return string(out), nil
1369}
1370
1371// DiffInventory lists what a change touches without generating its patch: the
1372// status, both paths, and the line counts of every file. It is what a changed-
1373// file tree, a landing summary and a file count read, so a change touching a
1374// thousand files costs one walk of the trees rather than a patch large enough to
1375// hold every hunk — and nothing is cut, so the inventory is the whole change.
1376//
1377// `--raw --numstat` prints the raw records first and the numstat records second,
1378// both NUL-separated because a path may hold a tab, a quote or a non-ASCII byte.
1379// The two sections are printed in the same order, which is what pairs a status
1380// with its counts.
1381func (r *Repo) DiffInventory(rangeOrFrom, to string) ([]PathChange, error) {
1382 args := append([]string{"diff", "--raw", "--numstat", "--find-renames", "-z"}, diffRange(rangeOrFrom, to)...)
1383 out, errs, err := r.run(nil, args...)
1384 if err != nil {
1385 return nil, fmt.Errorf("diff --raw --numstat: %w: %s", err, errs)
1386 }
1387 changes, err := parseInventory(string(out))
1388 if err != nil {
1389 return nil, err
1390 }
1391 return changes, nil
1392}
1393
1394// ShowPatchPath is a commit's patch against its parent (or the empty tree for a
1395// root commit), limited to the paths given. `git show` gets the root case right,
1396// which is why it is used here instead of assembling a range by hand.
1397//
1398// It is what a per-file commit view reads, so opening a broad commit costs the
1399// hunks of one file rather than the whole diff.
1400func (r *Repo) ShowPatchPath(rev string, paths []string) (string, error) {
1401 args := []string{"show", "--format=", "--patch", "--no-color", "--no-ext-diff", "--find-renames", "--unified=3", rev, "--"}
1402 args = append(args, paths...)
1403 out, errs, err := r.run(nil, args...)
1404 if err != nil {
1405 return "", fmt.Errorf("show %s (%s): %w: %s", rev, strings.Join(paths, " "), err, errs)
1406 }
1407 return string(out), nil
1408}
1409
1410// diffRange returns the revision arguments a diff call takes. One range string
1411// is git's own "a...b" syntax; two revisions are the endpoints git diffs when a
1412// landing record names the tip it landed on rather than a range.
1413func diffRange(rangeOrFrom, to string) []string {
1414 if to == "" {
1415 return []string{rangeOrFrom}
1416 }
1417 return []string{rangeOrFrom, to}
1418}
1419
1420// parseInventory pairs the two sections `git diff --raw --numstat -z` prints.
1421func parseInventory(out string) ([]PathChange, error) {
1422 changes, stats, err := parseInventorySections(strings.Split(out, "\x00"))
1423 if err != nil {
1424 return nil, err
1425 }
1426 if len(stats) != len(changes) {
1427 return nil, fmt.Errorf("diff --raw --numstat: %d files but %d count records", len(changes), len(stats))
1428 }
1429 for i := range changes {
1430 // The sections are printed in the same order; checking that they agree
1431 // is what keeps a count from landing on the wrong file.
1432 if pathLabel(changes[i]) != pathLabel(stats[i]) {
1433 return nil, fmt.Errorf("diff --raw --numstat: %q and %q disagree", pathLabel(changes[i]), pathLabel(stats[i]))
1434 }
1435 changes[i].Added, changes[i].Removed = stats[i].Added, stats[i].Removed
1436 changes[i].Binary = stats[i].Binary
1437 }
1438 return changes, nil
1439}
1440
1441// parseInventorySections reads the raw records, then returns them beside the
1442// numstat records that follow.
1443func parseInventorySections(fields []string) ([]PathChange, []PathChange, error) {
1444 var changes []PathChange
1445 // A raw record is ":<oldmode> <newmode> <oldsha> <newsha> <status>" and then
1446 // one path, or two for a rename or a copy. The status carries a similarity
1447 // score for those (R073), so only its first byte names the change.
1448 for len(fields) > 0 && strings.HasPrefix(fields[0], ":") {
1449 record := fields[0]
1450 fields = fields[1:]
1451 status := record[strings.LastIndexByte(record, ' ')+1:]
1452 if status == "" {
1453 return nil, nil, fmt.Errorf("diff --raw: %q has no status", record)
1454 }
1455 change := PathChange{Status: status[:1]}
1456 if change.Status == "R" || change.Status == "C" {
1457 if len(fields) < 2 {
1458 return nil, nil, fmt.Errorf("diff --raw: %q is missing a path", record)
1459 }
1460 change.Old, change.New = fields[0], fields[1]
1461 fields = fields[2:]
1462 } else {
1463 if len(fields) < 1 {
1464 return nil, nil, fmt.Errorf("diff --raw: %q is missing a path", record)
1465 }
1466 change.Old, change.New = fields[0], fields[0]
1467 fields = fields[1:]
1468 switch change.Status {
1469 case "A":
1470 change.Old = ""
1471 case "D":
1472 change.New = ""
1473 }
1474 }
1475 changes = append(changes, change)
1476 }
1477
1478 var stats []PathChange
1479 // A numstat record is "<added>\t<removed>\t<path>". Binary files have no
1480 // lines, and a rename leaves the path empty and prints its two paths as the
1481 // two records that follow the counts.
1482 for len(fields) > 0 {
1483 record := fields[0]
1484 fields = fields[1:]
1485 if record == "" {
1486 continue
1487 }
1488 added, rest, ok := strings.Cut(record, "\t")
1489 if !ok {
1490 return nil, nil, fmt.Errorf("diff --numstat: %q is not counts and a path", record)
1491 }
1492 removed, path, ok := strings.Cut(rest, "\t")
1493 if !ok {
1494 return nil, nil, fmt.Errorf("diff --numstat: %q is not counts and a path", record)
1495 }
1496 stat := PathChange{}
1497 if added == "-" || removed == "-" {
1498 stat.Binary = true
1499 } else {
1500 var err error
1501 if stat.Added, err = strconv.Atoi(added); err != nil {
1502 return nil, nil, fmt.Errorf("diff --numstat: %q has no added count", record)
1503 }
1504 if stat.Removed, err = strconv.Atoi(removed); err != nil {
1505 return nil, nil, fmt.Errorf("diff --numstat: %q has no removed count", record)
1506 }
1507 }
1508 if path == "" {
1509 if len(fields) < 2 {
1510 return nil, nil, fmt.Errorf("diff --numstat: %q names no paths", record)
1511 }
1512 stat.Old, stat.New = fields[0], fields[1]
1513 fields = fields[2:]
1514 } else {
1515 stat.New = path
1516 }
1517 stats = append(stats, stat)
1518 }
1519 return changes, stats, nil
1520}
1521
1522// pathLabel is the path a change is named by: the new one, or the old one for a
1523// deletion.
1524func pathLabel(c PathChange) string {
1525 if c.New != "" {
1526 return c.New
1527 }
1528 return c.Old
1529}
1530
1531// PathChange is one path a commit touched, as git's name-status reports it. Old
1532// is the path before the commit and New is the path after it, so Old is empty for
1533// a file the commit added and New is empty for a deletion. They differ only for a
1534// rename or a copy, and the patch for such a path is complete only when git is
1535// told both.
1536//
1537// Status is git's single letter: A added, M modified, D deleted, R renamed,
1538// C copied, T type changed.
1539type PathChange struct {
1540 Status string
1541 Old string
1542 New string
1543 // Added and Removed are the line counts of the change to this path, and
1544 // Binary says there are no lines to count. They are zero for a listing that
1545 // did not ask for them (ShowNameStatus).
1546 Added int
1547 Removed int
1548 Binary bool
1549}
1550
1551// ShowNameStatus lists the paths one commit touched without generating its
1552// patch. It is the cheap index a per-file viewer renders first, so the cost of
1553// opening a commit grows with the number of files it touched, not with the size
1554// of their diffs.
1555//
1556// The output is read NUL-separated because git otherwise quotes a path holding a
1557// quote, a tab or a non-ASCII byte, and these paths are what the view builds
1558// links and pathspecs from.
1559func (r *Repo) ShowNameStatus(rev string) ([]PathChange, error) {
1560 out, errs, err := r.run(nil, "show", "--format=", "--name-status", "--no-color", "--find-renames", "-z", rev)
1561 if err != nil {
1562 return nil, fmt.Errorf("show --name-status %s: %w: %s", rev, err, errs)
1563 }
1564 fields := strings.Split(string(out), "\x00")
1565 changes := make([]PathChange, 0, len(fields)/2)
1566 for i := 0; i < len(fields); {
1567 status := strings.TrimSpace(fields[i])
1568 i++
1569 if status == "" {
1570 continue
1571 }
1572 if i >= len(fields) {
1573 break
1574 }
1575 first := fields[i]
1576 i++
1577 c := PathChange{Status: status[:1], Old: first, New: first}
1578 if c.Status == "R" || c.Status == "C" {
1579 if i >= len(fields) {
1580 break
1581 }
1582 c.New = fields[i]
1583 i++
1584 }
1585 switch c.Status {
1586 case "A":
1587 c.Old = ""
1588 case "D":
1589 c.New = ""
1590 }
1591 changes = append(changes, c)
1592 }
1593 return changes, nil
1594}
1595
1596// CommitBody is a commit's full message, for the commit page.
1597func (r *Repo) CommitBody(rev string) (string, error) {
1598 out, errs, err := r.run(nil, "show", "-s", "--format=%B", rev)
1599 if err != nil {
1600 return "", fmt.Errorf("show -s %s: %w: %s", rev, err, errs)
1601 }
1602 return strings.TrimSpace(string(out)), nil
1603}
1604
1605// AllBlobs lists every blob reachable from any ref, with its size. It is the
1606// marking step of garbage collection, so it walks history rather than tips: an
1607// old commit still serves its files.
1608func (r *Repo) AllBlobs() (map[string]int64, error) {
1609 out, errs, err := r.run(nil, "rev-list", "--objects", "--all")
1610 if err != nil {
1611 return nil, fmt.Errorf("rev-list --all: %w: %s", err, errs)
1612 }
1613 var shas []string
1614 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
1615 if line == "" {
1616 continue
1617 }
1618 sha, _, _ := strings.Cut(line, " ")
1619 shas = append(shas, sha)
1620 }
1621 return r.blobSizes(shas)
1622}
1623
1624func (r *Repo) blobSizes(shas []string) (map[string]int64, error) {
1625 if len(shas) == 0 {
1626 return map[string]int64{}, nil
1627 }
1628 var in bytes.Buffer
1629 for _, s := range shas {
1630 in.WriteString(s + "\n")
1631 }
1632 bo, errs, err := r.run(in.Bytes(), "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)")
1633 if err != nil {
1634 return nil, fmt.Errorf("cat-file --batch-check: %w: %s", err, errs)
1635 }
1636 sizes := map[string]int64{}
1637 for _, line := range strings.Split(strings.TrimSpace(string(bo)), "\n") {
1638 f := strings.Fields(line)
1639 if len(f) != 3 || f[1] != "blob" {
1640 continue
1641 }
1642 n, err := strconv.ParseInt(f[2], 10, 64)
1643 if err != nil {
1644 continue
1645 }
1646 sizes[f[0]] = n
1647 }
1648 return sizes, nil
1649}
1650
1651// BlobPrefix reads at most n bytes from a blob. Sniffing for a pointer or for
1652// binary content should not cost a 100 MiB read of a blob that is about to be
1653// accepted anyway.
1654func (r *Repo) BlobPrefix(sha string, n int) ([]byte, error) {
1655 out, errs, err := r.runHead(n, "cat-file", "blob", sha)
1656 if err != nil {
1657 return nil, fmt.Errorf("cat-file blob %s: %w: %s", sha, err, errs)
1658 }
1659 return out, nil
1660}
1661