Go · 3350 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package git
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9 "os/exec"
10 "strconv"
11 "strings"
12 )
13
14 // AheadBehind returns the number of commits unique to head (ahead)
15 // and unique to base (behind), computed via
16 // `git rev-list --left-right --count base...head`. Output shape is
17 // "<behind>\t<ahead>" — the left side is base, right is head.
18 //
19 // When base or head doesn't exist on the repo we surface the typed
20 // ErrRefNotFound so callers can render "—" instead of a number.
21 func AheadBehind(ctx context.Context, gitDir, base, head string) (ahead, behind int, err error) {
22 cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
23 "rev-list", "--left-right", "--count", base+"..."+head)
24 out, runErr := cmd.Output()
25 if runErr != nil {
26 var ee *exec.ExitError
27 if errors.As(runErr, &ee) {
28 stderr := string(ee.Stderr)
29 if strings.Contains(stderr, "unknown revision") || strings.Contains(stderr, "ambiguous argument") {
30 return 0, 0, ErrRefNotFound
31 }
32 }
33 return 0, 0, wrapExecErr(runErr)
34 }
35 parts := strings.Fields(strings.TrimSpace(string(out)))
36 if len(parts) != 2 {
37 return 0, 0, fmt.Errorf("rev-list: unexpected output %q", out)
38 }
39 behind, _ = strconv.Atoi(parts[0])
40 ahead, _ = strconv.Atoi(parts[1])
41 return ahead, behind, nil
42 }
43
44 // CommitsBetween returns the commits unique to head (the right side
45 // of the symmetric range). Used by the compare view's commits list.
46 func CommitsBetween(ctx context.Context, gitDir, base, head string, max int) ([]Commit, error) {
47 if max <= 0 {
48 max = 250
49 }
50 const sep = "\x1f"
51 const recordEnd = "\x1e"
52 format := strings.Join([]string{"%H", "%h", "%an", "%ae", "%at", "%s"}, sep) + sep + "%b" + recordEnd
53 cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
54 "log",
55 "--max-count="+strconv.Itoa(max),
56 "--format="+format,
57 base+".."+head,
58 )
59 out, err := cmd.Output()
60 if err != nil {
61 var ee *exec.ExitError
62 if errors.As(err, &ee) && strings.Contains(string(ee.Stderr), "unknown revision") {
63 return nil, ErrRefNotFound
64 }
65 return nil, wrapExecErr(err)
66 }
67 return parseLogOutput(out)
68 }
69
70 // IsAncestor reports whether commit a is an ancestor of commit b.
71 // Used by the pre-receive force-push detector: a fast-forward is
72 // `IsAncestor(old, new)`.
73 func IsAncestor(ctx context.Context, gitDir, a, b string) (bool, error) {
74 cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
75 "merge-base", "--is-ancestor", a, b)
76 err := cmd.Run()
77 if err == nil {
78 return true, nil
79 }
80 var ee *exec.ExitError
81 if errors.As(err, &ee) {
82 // Exit 1 = not an ancestor. Anything else = real error.
83 if ee.ExitCode() == 1 {
84 return false, nil
85 }
86 }
87 return false, wrapExecErr(err)
88 }
89
90 // SetSymbolicRef updates HEAD (or any other symbolic ref) atomically.
91 // Used by the default-branch change to point HEAD at the new branch.
92 func SetSymbolicRef(ctx context.Context, gitDir, ref, target string) error {
93 cmd := exec.CommandContext(ctx, "git", "-C", gitDir,
94 "symbolic-ref", ref, target)
95 if out, err := cmd.CombinedOutput(); err != nil {
96 return fmt.Errorf("symbolic-ref %s -> %s: %w (%s)", ref, target, err, out)
97 }
98 return nil
99 }
100
101 // ErrRefNotFound is returned when git can't resolve a ref or commit.
102 // Distinguished from generic exec failures so handlers can render a
103 // 404-leaning response.
104 var ErrRefNotFound = errors.New("git: ref not found")
105