Migration This is my new public git source viewer. Source coming soon. Please contact with concerns/bugs. Contact me

internal/gitx/git.go

c0427d5fc7c0fcf8606c546071085e8af231fb81 · 21.0 KB · 721 lines raw

1 // Package gitx is the only place that knows how git works.
2 // Everything above it sees plain Go values. Swap this for go-git or
3 // git2go later without touching a single module or template.
4 package gitx
5
6 import (
7 "bytes"
8 "context"
9 "fmt"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "regexp"
14 "strconv"
15 "strings"
16 "sync"
17 "time"
18 )
19
20 // Separators. exec() argv cannot contain NUL bytes, so the FORMAT strings
21 // use git's own hex escapes and git emits the real bytes; we split on those.
22 // (for-each-ref spells them %00, git-log spells them %x00.)
23 const (
24 sepF = "\x00" // field separator, as it appears in git's OUTPUT
25 sepR = "\x1e" // record separator, as it appears in git's OUTPUT
26
27 escF = "%00" // field separator, as written in a for-each-ref format
28 escLogF = "%x00" // field separator, as written in a git-log format
29 escLogR = "%x1e" // record separator, as written in a git-log format
30 )
31
32 type Repo struct {
33 Dir string // path to the git dir (bare repo, or <worktree>/.git)
34 Slug string // url-safe short name, derived from the directory
35 empty bool // no commits yet (unborn HEAD)
36
37 ctx context.Context
38 maxBytes int64
39
40 // gpgHome, once set by UseKeyring, is an isolated GNUPGHOME containing
41 // only the keys the operator imported for this repo. Its presence is
42 // also the signal to Log/LogAll to ask git for signature status at all.
43 gpgHome string
44
45 // keyringPath is the gsg.gpgkeyring value that produced gpgHome, so a
46 // caller can tell whether the configured keyring has changed without
47 // tracking that separately. Empty when no keyring is imported.
48 keyringPath string
49
50 // memo caches HeadRef/Refs for the lifetime of one request. It is a
51 // pointer, allocated fresh by WithContext, rather than a plain field:
52 // Repo is copied by value (see WithContext), so a mutex or map field
53 // here directly would either trip go vet's copylocks check or -- worse
54 // -- share its map header with the copy, leaking one request's cache
55 // into the long-lived Repo every Handle keeps between requests. nil on
56 // that long-lived Repo and in any Repo not built via WithContext (e.g.
57 // tests): HeadRef/Refs just skip the cache in that case.
58 memo *repoMemo
59 }
60
61 type repoMemo struct {
62 mu sync.Mutex
63 headRef *string
64 haveRefs bool
65 refs []Ref
66 refsErr error
67 }
68
69 // WithContext returns a view of the repo bound to a request context and an
70 // output cap. Serving means every git invocation is attacker-influenced
71 // work: without a deadline, one `git log` on a huge repo pins a core, and
72 // without a cap one diff of a 200MB blob eats the heap.
73 func (r *Repo) WithContext(ctx context.Context, maxBytes int64) *Repo {
74 c := *r
75 c.ctx = ctx
76 c.maxBytes = maxBytes
77 c.memo = &repoMemo{}
78 return &c
79 }
80
81 // Empty reports whether the repository has no commits. This is a normal
82 // state for a freshly created bare repo, not an error -- Log and Tree
83 // return nothing rather than failing, so a view still renders.
84 func (r *Repo) Empty() bool { return r.empty }
85
86 func Open(dir string) (*Repo, error) {
87 gitDir := dir
88 if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && st.IsDir() {
89 gitDir = filepath.Join(dir, ".git")
90 }
91 r := &Repo{
92 Dir: gitDir,
93 Slug: strings.TrimSuffix(filepath.Base(strings.TrimSuffix(dir, "/")), ".git"),
94 }
95 if _, err := r.run("rev-parse", "--git-dir"); err != nil {
96 return nil, fmt.Errorf("open %s: %w", dir, err)
97 }
98 _, err := r.run("rev-parse", "--verify", "--quiet", "HEAD")
99 r.empty = err != nil
100 return r, nil
101 }
102
103 // ErrTruncated is returned alongside partial output that hit maxBytes.
104 var ErrTruncated = fmt.Errorf("output truncated")
105
106 func (r *Repo) run(args ...string) ([]byte, error) {
107 ctx := r.ctx
108 if ctx == nil {
109 ctx = context.Background()
110 }
111 full := append([]string{"--git-dir", r.Dir}, args...)
112 cmd := exec.CommandContext(ctx, "git", full...)
113 // Refuse to inherit anything that alters git's behaviour.
114 cmd.Env = []string{"GIT_CONFIG_NOSYSTEM=1", "HOME=/nonexistent", "GIT_TERMINAL_PROMPT=0", "PATH=" + os.Getenv("PATH")}
115 if r.gpgHome != "" {
116 // git shells out to gpg to check %G?; gpg picks this up from its
117 // own environment, which it inherits from git's.
118 cmd.Env = append(cmd.Env, "GNUPGHOME="+r.gpgHome)
119 }
120
121 var out, errb bytes.Buffer
122 cmd.Stdout, cmd.Stderr = &out, &errb
123 err := cmd.Run()
124
125 if r.maxBytes > 0 && int64(out.Len()) > r.maxBytes {
126 return out.Bytes()[:r.maxBytes], ErrTruncated
127 }
128 if err != nil {
129 if ctx.Err() != nil {
130 return nil, fmt.Errorf("git %s: %w", args[0], ctx.Err())
131 }
132 return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(errb.String()))
133 }
134 return out.Bytes(), nil
135 }
136
137 // ------------------------------------------------------------------ config
138
139 // Config reads a key from the repository's own config file, the way cgit
140 // does with enable-git-config. A missing key is empty, not an error.
141 func (r *Repo) Config(key string) string {
142 out, err := r.run("config", "--get", key)
143 if err != nil {
144 return "" // exit 1 == key absent
145 }
146 return strings.TrimSpace(string(out))
147 }
148
149 // ConfigFirst returns the first key that has a value, for fallback chains
150 // such as gsg.description -> gitweb.description.
151 func (r *Repo) ConfigFirst(keys ...string) string {
152 for _, k := range keys {
153 if v := r.Config(k); v != "" {
154 return v
155 }
156 }
157 return ""
158 }
159
160 // -------------------------------------------------------------------- gpg
161
162 // UseKeyring points every subsequent git invocation on this Repo (via
163 // GNUPGHOME, see run) at an isolated homedir containing only the keys
164 // imported from path. Without this, %G? signature checks would fall back
165 // to the host's default keyring -- whatever happens to be trusted there,
166 // not what this repo's operator vetted.
167 //
168 // The homedir is created directly under /tmp rather than os.TempDir():
169 // gpg-agent's control socket is a Unix domain socket, capped at ~104
170 // bytes, and a long TMPDIR (macOS's is typically 50+ characters) pushes
171 // the full path over that limit and breaks key generation/import in a way
172 // that just prints "no agent running" with no useful exit code to catch.
173 func (r *Repo) UseKeyring(path string) error {
174 dir, err := os.MkdirTemp("/tmp", "gsg-gpg-")
175 if err != nil {
176 return fmt.Errorf("gpg keyring: %w", err)
177 }
178 if err := os.Chmod(dir, 0700); err != nil {
179 return fmt.Errorf("gpg keyring: %w", err)
180 }
181 cmd := exec.Command("gpg", "--batch", "--quiet", "--import", path)
182 cmd.Env = []string{"GNUPGHOME=" + dir, "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")}
183 var errb bytes.Buffer
184 cmd.Stderr = &errb
185 if err := cmd.Run(); err != nil {
186 os.RemoveAll(dir)
187 return fmt.Errorf("gpg keyring: import %s: %w: %s", path, err, strings.TrimSpace(errb.String()))
188 }
189
190 // Only swap the new homedir in -- and clean up the old one -- once the
191 // import has actually succeeded, so a bad reconfigure logs a warning and
192 // leaves the previously-working keyring active rather than breaking it.
193 old := r.gpgHome
194 r.gpgHome, r.keyringPath = dir, path
195 if old != "" {
196 os.RemoveAll(old)
197 }
198 return nil
199 }
200
201 // KeyringPath is the gsg.gpgkeyring value currently imported, or "" if no
202 // keyring is active. Callers use this to detect when the configured keyring
203 // has changed, on any call -- the first or the hundredth -- without needing
204 // to track that separately.
205 func (r *Repo) KeyringPath() string { return r.keyringPath }
206
207 // ClearKeyring reverts to unverified signatures, removing any imported
208 // keyring's isolated homedir.
209 func (r *Repo) ClearKeyring() {
210 if r.gpgHome != "" {
211 os.RemoveAll(r.gpgHome)
212 }
213 r.gpgHome, r.keyringPath = "", ""
214 }
215
216 const placeholderDesc = "Unnamed repository; edit this file 'description' to name the repository."
217
218 // DescriptionFile reads the legacy `description` file, ignoring git's placeholder.
219 func (r *Repo) DescriptionFile() string {
220 b, err := os.ReadFile(filepath.Join(r.Dir, "description"))
221 if err != nil {
222 return ""
223 }
224 s := strings.TrimSpace(string(b))
225 if s == placeholderDesc {
226 return ""
227 }
228 return s
229 }
230
231 // -------------------------------------------------------------------- refs
232
233 type Ref struct {
234 Name string
235 Kind string // "branch" | "tag"
236 Target string // commit sha
237 When time.Time
238 Title string
239 }
240
241 func (r *Repo) Refs() ([]Ref, error) {
242 if r.memo != nil {
243 r.memo.mu.Lock()
244 if r.memo.haveRefs {
245 refs, err := r.memo.refs, r.memo.refsErr
246 r.memo.mu.Unlock()
247 return refs, err
248 }
249 r.memo.mu.Unlock()
250 }
251
252 refs, err := r.refs()
253
254 if r.memo != nil {
255 r.memo.mu.Lock()
256 r.memo.refs, r.memo.refsErr, r.memo.haveRefs = refs, err, true
257 r.memo.mu.Unlock()
258 }
259 return refs, err
260 }
261
262 func (r *Repo) refs() ([]Ref, error) {
263 const f = "%(refname:short)" + escF + "%(objectname)" + escF +
264 "%(creatordate:iso-strict)" + escF + "%(subject)" + escF + "%(refname)"
265 out, err := r.run("for-each-ref", "--format="+f, "refs/heads", "refs/tags")
266 if err != nil {
267 return nil, err
268 }
269 var refs []Ref
270 for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
271 if line == "" {
272 continue
273 }
274 p := strings.Split(line, sepF)
275 if len(p) < 5 {
276 continue
277 }
278 kind := "branch"
279 if strings.HasPrefix(p[4], "refs/tags/") {
280 kind = "tag"
281 }
282 t, _ := time.Parse(time.RFC3339, p[2])
283 refs = append(refs, Ref{Name: p[0], Kind: kind, Target: p[1], When: t, Title: p[3]})
284 }
285 return refs, nil
286 }
287
288 var shaRE = regexp.MustCompile(`^[0-9a-fA-F]{7,40}$`)
289
290 // Resolve turns user-supplied ref text into a commit SHA.
291 //
292 // SECURITY: the input is never handed to `git rev-parse`. rev-parse accepts
293 // an expression language (`:/regex` searches history, `@{...}` reads the
294 // reflog) and git subcommands treat a leading `-` as an option. So input is
295 // either a hex SHA that must already exist, or an exact match against the
296 // repo's own ref list. Everything else is rejected.
297 func (r *Repo) Resolve(ref string) (string, error) {
298 if ref == "" || ref == "HEAD" {
299 ref = r.HeadRef()
300 }
301 if strings.ContainsAny(ref, "\x00\n") || strings.HasPrefix(ref, "-") {
302 return "", fmt.Errorf("invalid ref %q", ref)
303 }
304 if shaRE.MatchString(ref) {
305 out, err := r.run("rev-parse", "--verify", "--quiet", ref+"^{commit}")
306 if err != nil {
307 return "", fmt.Errorf("unknown commit %q", ref)
308 }
309 return strings.TrimSpace(string(out)), nil
310 }
311 refs, err := r.Refs()
312 if err != nil {
313 return "", err
314 }
315 for _, x := range refs {
316 if x.Name == ref {
317 out, err := r.run("rev-parse", "--verify", x.Target+"^{commit}")
318 if err != nil {
319 return "", err
320 }
321 return strings.TrimSpace(string(out)), nil
322 }
323 }
324 return "", fmt.Errorf("unknown ref %q", ref)
325 }
326
327 // SplitRefPath separates "feature/x/src/main.go" into ref "feature/x" and
328 // path "src/main.go" by matching the LONGEST prefix that is a real ref.
329 // Branch names contain slashes, so a naive split at the first "/" is wrong;
330 // cgit dodges this with ?h= query params, GitHub resolves against the ref
331 // list. This does the latter.
332 func (r *Repo) SplitRefPath(rest string) (ref, path string) {
333 rest = strings.Trim(rest, "/")
334 if rest == "" {
335 return r.HeadRef(), ""
336 }
337 names := map[string]bool{}
338 if refs, err := r.Refs(); err == nil {
339 for _, x := range refs {
340 names[x.Name] = true
341 }
342 }
343 segs := strings.Split(rest, "/")
344 for i := len(segs); i > 0; i-- {
345 cand := strings.Join(segs[:i], "/")
346 if names[cand] || shaRE.MatchString(cand) {
347 return cand, strings.Join(segs[i:], "/")
348 }
349 }
350 // No ref matched: treat the whole thing as a path at HEAD.
351 return r.HeadRef(), rest
352 }
353
354 // HeadRef resolves the repo's default branch, rather than guessing main/master.
355 func (r *Repo) HeadRef() string {
356 if r.memo != nil {
357 r.memo.mu.Lock()
358 cached := r.memo.headRef
359 r.memo.mu.Unlock()
360 if cached != nil {
361 return *cached
362 }
363 }
364
365 head := r.headRef()
366
367 if r.memo != nil {
368 r.memo.mu.Lock()
369 r.memo.headRef = &head
370 r.memo.mu.Unlock()
371 }
372 return head
373 }
374
375 func (r *Repo) headRef() string {
376 out, err := r.run("symbolic-ref", "--short", "HEAD")
377 if err != nil {
378 return "HEAD"
379 }
380 return strings.TrimSpace(string(out))
381 }
382
383 // ----------------------------------------------------------------- commits
384
385 type Commit struct {
386 SHA string
387 Short string
388 Author string
389 Email string
390 When time.Time
391 Subject string
392 Body string
393 Parents []string
394
395 // Signature is only populated when the caller asked for it (Log, when
396 // the repo has a keyring -- see Repo.UseKeyring). SigStatus is git's
397 // raw %G? code: G/U good, B bad, X/Y expired, R revoked, E unchecked,
398 // N unsigned or "" when signature checks weren't requested at all.
399 SigStatus string
400 Signer string
401 KeyID string
402 }
403
404 // Verified reports a good signature from a key in the repo's configured
405 // keyring. A bare gpg homedir has no ownertrust database, so a genuinely
406 // valid signature from a freshly-imported key comes back "U" (valid, trust
407 // unknown), not "G" -- trust here comes from the key being in the
408 // operator's keyring file, not from GPG's own web-of-trust bookkeeping.
409 func (c Commit) Verified() bool { return c.SigStatus == "G" || c.SigStatus == "U" }
410
411 // Signed reports whether the commit carries a signature at all, good or
412 // not -- lets a template tell "bad signature" apart from "no signature".
413 func (c Commit) Signed() bool { return c.SigStatus != "" && c.SigStatus != "N" }
414
415 // Unchecked reports a signature git could not evaluate against the
416 // keyring at all -- almost always because the signing key just isn't in
417 // it. That is not evidence of tampering the way B/X/Y/R are, so it should
418 // read as "we don't know", not as a failure.
419 func (c Commit) Unchecked() bool { return c.SigStatus == "E" }
420
421 // SigLabel is a short human-readable rendering of SigStatus, for the log
422 // view's badge.
423 func (c Commit) SigLabel() string {
424 switch c.SigStatus {
425 case "G", "U":
426 return "verified"
427 case "B":
428 return "bad signature"
429 case "X":
430 return "expired signature"
431 case "Y":
432 return "expired key"
433 case "R":
434 return "revoked key"
435 case "E":
436 return "signature not checked"
437 default:
438 return "unsigned"
439 }
440 }
441
442 // Log walks history from rev in ONE git process. n <= 0 means unbounded.
443 // Never call this once per commit; fork overhead dominates at scale.
444 // When verify is true, each commit also carries its signature status --
445 // only pass true for small, bounded logs: git forks gpg once per signed
446 // commit to check it.
447 func (r *Repo) Log(rev string, n, skip int, verify bool) ([]Commit, error) {
448 if r.empty {
449 return nil, nil
450 }
451 verify = verify && r.gpgHome != "" // no keyring configured: nothing to check against
452 fields := []string{"%H", "%an", "%ae", "%aI", "%P", "%s"}
453 if verify {
454 fields = append(fields, "%G?", "%GS", "%GK")
455 }
456 fields = append(fields, "%b")
457 format := strings.Join(fields, escLogF)
458 args := []string{"log", "--format=" + escLogR + format}
459 if n > 0 {
460 args = append(args, "-n", strconv.Itoa(n))
461 }
462 if skip > 0 {
463 args = append(args, "--skip", strconv.Itoa(skip))
464 }
465 args = append(args, rev)
466 out, err := r.run(args...)
467 return r.parseLog(out, err, verify)
468 }
469
470 func (r *Repo) parseLog(out []byte, err error, verify bool) ([]Commit, error) {
471 if err != nil {
472 return nil, err
473 }
474 nfields := 7
475 if verify {
476 nfields = 10
477 }
478 var commits []Commit
479 for _, rec := range strings.Split(string(out), sepR) {
480 rec = strings.TrimRight(rec, "\n")
481 if rec == "" {
482 continue
483 }
484 p := strings.SplitN(rec, sepF, nfields)
485 if len(p) < nfields {
486 continue
487 }
488 t, _ := time.Parse(time.RFC3339, p[3])
489 c := Commit{
490 SHA: p[0], Author: p[1], Email: p[2], When: t,
491 Subject: p[5],
492 }
493 if verify {
494 c.SigStatus, c.Signer, c.KeyID, c.Body = p[6], p[7], p[8], strings.TrimSpace(p[9])
495 } else {
496 c.Body = strings.TrimSpace(p[6])
497 }
498 if len(c.SHA) >= 8 {
499 c.Short = c.SHA[:8]
500 }
501 if p[4] != "" {
502 c.Parents = strings.Fields(p[4])
503 }
504 commits = append(commits, c)
505 }
506 return commits, nil
507 }
508
509 // MergeBase returns the common ancestor of two resolved commits.
510 func (r *Repo) MergeBase(a, b string) (string, error) {
511 out, err := r.run("merge-base", a, b)
512 if err != nil {
513 return "", err
514 }
515 return strings.TrimSpace(string(out)), nil
516 }
517
518 // LogAll walks every ref in topological order. This is the input to the
519 // branch graph: --topo-order guarantees a child always precedes its
520 // parents, which the lane assignment depends on.
521 func (r *Repo) LogAll(n int) ([]Commit, error) {
522 if r.empty {
523 return nil, nil
524 }
525 format := strings.Join([]string{"%H", "%an", "%ae", "%aI", "%P", "%s", "%b"}, escLogF)
526 // Never request signature status here: this walks every ref unbounded
527 // for the graph view, and git forks gpg per signed commit -- fine at
528 // Log's n=20, not fine here.
529 out, err := r.run("log", "--all", "--topo-order", "--date-order",
530 "-n", strconv.Itoa(n), "--format="+escLogR+format)
531 return r.parseLog(out, err, false)
532 }
533
534 // ------------------------------------------------------------------- trees
535
536 type Entry struct {
537 Mode string
538 Type string // "blob" | "tree" | "commit" (submodule)
539 SHA string
540 Size int64
541 Name string // basename
542 Path string // full path from repo root
543 }
544
545 func (e Entry) IsDir() bool { return e.Type == "tree" }
546 func (e Entry) IsSubmodule() bool { return e.Type == "commit" }
547
548 // Tree lists one directory level at rev.
549 func (r *Repo) Tree(rev, path string) ([]Entry, error) {
550 if r.empty {
551 return nil, nil
552 }
553 spec := rev + ":"
554 if path != "" && path != "." {
555 spec = rev + ":" + path
556 }
557 out, err := r.run("ls-tree", "-z", "--long", spec)
558 if err != nil {
559 return nil, err
560 }
561 var entries []Entry
562 for _, rec := range strings.Split(string(out), "\x00") {
563 if rec == "" {
564 continue
565 }
566 head, name, ok := strings.Cut(rec, "\t")
567 if !ok {
568 continue
569 }
570 f := strings.Fields(head)
571 if len(f) < 3 {
572 continue
573 }
574 e := Entry{Mode: f[0], Type: f[1], SHA: f[2], Name: name}
575 if len(f) > 3 {
576 e.Size, _ = strconv.ParseInt(f[3], 10, 64)
577 }
578 e.Path = strings.TrimPrefix(path+"/"+name, "/")
579 entries = append(entries, e)
580 }
581 return entries, nil
582 }
583
584 // Stat looks up a single path's own tree entry (its mode/type/sha as
585 // recorded by its parent tree), regardless of depth. Unlike Blob, this
586 // works for gitlinks (submodules), since it never tries to read object
587 // contents.
588 func (r *Repo) Stat(rev, path string) (Entry, bool, error) {
589 out, err := r.run("ls-tree", "-z", "--long", rev, "--", path)
590 if err != nil {
591 return Entry{}, false, err
592 }
593 rec := strings.TrimSuffix(string(out), "\x00")
594 if rec == "" {
595 return Entry{}, false, nil
596 }
597 head, name, ok := strings.Cut(rec, "\t")
598 if !ok {
599 return Entry{}, false, nil
600 }
601 f := strings.Fields(head)
602 if len(f) < 3 {
603 return Entry{}, false, nil
604 }
605 e := Entry{Mode: f[0], Type: f[1], SHA: f[2], Name: filepath.Base(name), Path: name}
606 if len(f) > 3 {
607 e.Size, _ = strconv.ParseInt(f[3], 10, 64)
608 }
609 return e, true, nil
610 }
611
612 // SubmoduleURL looks up the remote configured for a submodule path in
613 // .gitmodules at rev. Best-effort: a missing or unparsable file just means
614 // no URL is shown alongside the pinned commit.
615 func (r *Repo) SubmoduleURL(rev, path string) string {
616 raw, err := r.Blob(rev, ".gitmodules")
617 if err != nil {
618 return ""
619 }
620 var inSection, sectionMatches bool
621 for _, line := range strings.Split(string(raw), "\n") {
622 line = strings.TrimSpace(line)
623 if strings.HasPrefix(line, "[submodule") {
624 inSection = true
625 sectionMatches = false
626 continue
627 }
628 if !inSection {
629 continue
630 }
631 key, val, ok := strings.Cut(line, "=")
632 if !ok {
633 continue
634 }
635 key, val = strings.TrimSpace(key), strings.TrimSpace(val)
636 switch key {
637 case "path":
638 sectionMatches = val == path
639 case "url":
640 if sectionMatches {
641 return val
642 }
643 }
644 }
645 return ""
646 }
647
648 // ------------------------------------------------------------------ diffs
649
650 type FileStat struct {
651 Added, Deleted int
652 Path, OldPath string
653 Status string
654 }
655
656 type Diff struct {
657 Stats []FileStat
658 Patch string
659 Truncated bool
660 }
661
662 // DiffRange returns the patch between two resolved commits.
663 // Callers MUST pass SHAs from Resolve, never raw user input.
664 func (r *Repo) DiffRange(base, head string, threeDot bool) (Diff, error) {
665 sep := ".."
666 if threeDot {
667 sep = "..."
668 }
669 return r.diff("diff", "-p", "--no-color", "--no-ext-diff", "-M", "--numstat", base+sep+head)
670 }
671
672 // DiffCommit returns the patch for a single commit. --root makes the first
673 // commit diff against the empty tree instead of erroring.
674 func (r *Repo) DiffCommit(sha string) (Diff, error) {
675 return r.diff("diff-tree", "-p", "--root", "--no-color", "--no-ext-diff",
676 "-M", "--numstat", "--format=", sha)
677 }
678
679 func (r *Repo) diff(args ...string) (Diff, error) {
680 var d Diff
681 raw, err := r.run(args...)
682 if err == ErrTruncated {
683 d.Truncated = true
684 } else if err != nil {
685 return d, err
686 }
687 body := string(raw)
688 // numstat block comes first, then the patch; they are separated by the
689 // first line starting with "diff --git".
690 if i := strings.Index(body, "diff --git"); i >= 0 {
691 d.Stats = parseNumstat(body[:i])
692 d.Patch = body[i:]
693 } else {
694 d.Stats = parseNumstat(body)
695 }
696 return d, nil
697 }
698
699 func parseNumstat(s string) []FileStat {
700 var out []FileStat
701 for _, line := range strings.Split(s, "\n") {
702 f := strings.Split(strings.TrimSpace(line), "\t")
703 if len(f) < 3 {
704 continue
705 }
706 fs := FileStat{Path: f[2], Status: "M"}
707 if f[0] == "-" {
708 fs.Status = "B" // binary
709 } else {
710 fs.Added, _ = strconv.Atoi(f[0])
711 fs.Deleted, _ = strconv.Atoi(f[1])
712 }
713 out = append(out, fs)
714 }
715 return out
716 }
717
718 // Blob returns file contents at rev.
719 func (r *Repo) Blob(rev, path string) ([]byte, error) {
720 return r.run("cat-file", "blob", rev+":"+path)
721 }