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

internal/gitx/git.go

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