| 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 |