Go · 3679 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package fork
4
5 import (
6 "context"
7 "errors"
8 "fmt"
9
10 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
11 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
12 )
13
14 // AheadBehindStats describes how the fork's default branch relates
15 // to the source's default branch. Both numbers are commit counts:
16 // `Ahead` = commits in fork not in source; `Behind` = commits in
17 // source not in fork.
18 //
19 // `Comparable` is false when either side's default ref doesn't
20 // exist (e.g. an empty fork before its first push, or a source
21 // that's never been initialized). The UI renders "—" in that case.
22 type AheadBehindStats struct {
23 Ahead int
24 Behind int
25 Comparable bool
26 }
27
28 // AheadBehind computes the stats by reading both default OIDs,
29 // then running `rev-list --left-right --count` inside the fork's
30 // bare repo. Because forks share object alternates with their
31 // source, the fork can resolve OIDs from the source without an
32 // explicit fetch — they're already reachable through the
33 // alternates link.
34 //
35 // This is the floor implementation. S36's perf-pass sprint adds
36 // caching keyed on `(fork_repo_id, fork_default_oid,
37 // upstream_default_oid)` so the rev-list shells out only on push
38 // (the deferral pointer is already in S36's spec).
39 func AheadBehind(ctx context.Context, deps Deps, forkRepoID int64) (AheadBehindStats, error) {
40 rq := reposdb.New()
41 fork, err := rq.GetRepoByID(ctx, deps.Pool, forkRepoID)
42 if err != nil {
43 return AheadBehindStats{}, fmt.Errorf("ahead-behind: load fork: %w", err)
44 }
45 if !fork.ForkOfRepoID.Valid {
46 return AheadBehindStats{}, ErrSourceNotFound
47 }
48 source, err := rq.GetRepoByID(ctx, deps.Pool, fork.ForkOfRepoID.Int64)
49 if err != nil {
50 return AheadBehindStats{}, ErrSourceNotFound
51 }
52 if source.DeletedAt.Valid {
53 return AheadBehindStats{}, ErrSourceDeleted
54 }
55
56 forkOwner, err := ownerUsername(ctx, deps, fork)
57 if err != nil {
58 return AheadBehindStats{}, err
59 }
60 sourceOwner, err := ownerUsername(ctx, deps, source)
61 if err != nil {
62 return AheadBehindStats{}, err
63 }
64 forkPath, err := deps.RepoFS.RepoPath(forkOwner, fork.Name)
65 if err != nil {
66 return AheadBehindStats{}, err
67 }
68 sourcePath, err := deps.RepoFS.RepoPath(sourceOwner, source.Name)
69 if err != nil {
70 return AheadBehindStats{}, err
71 }
72
73 // Resolve both default OIDs. Either side empty → not comparable.
74 forkOID, err := repogit.ResolveRefOID(ctx, forkPath, fork.DefaultBranch)
75 if err != nil {
76 if errors.Is(err, repogit.ErrRefNotFound) {
77 return AheadBehindStats{Comparable: false}, nil
78 }
79 return AheadBehindStats{}, fmt.Errorf("ahead-behind: resolve fork: %w", err)
80 }
81 sourceOID, err := repogit.ResolveRefOID(ctx, sourcePath, source.DefaultBranch)
82 if err != nil {
83 if errors.Is(err, repogit.ErrRefNotFound) {
84 return AheadBehindStats{Comparable: false}, nil
85 }
86 return AheadBehindStats{}, fmt.Errorf("ahead-behind: resolve source: %w", err)
87 }
88 if forkOID == sourceOID {
89 return AheadBehindStats{Comparable: true}, nil
90 }
91
92 // Run the count inside the fork's repo. The fork's alternates
93 // give it visibility into source's objects, so passing
94 // `<forkOID>...<sourceOID>` resolves both ends.
95 ahead, behind, err := repogit.AheadBehind(ctx, forkPath, sourceOID, forkOID)
96 if err != nil {
97 return AheadBehindStats{}, fmt.Errorf("ahead-behind: rev-list: %w", err)
98 }
99 // repogit.AheadBehind's argument order is (base, head): ahead
100 // = "commits in head not in base"; we asked it for forkOID's
101 // ahead-of sourceOID. So the returned ahead/behind are already
102 // from the fork's perspective.
103 return AheadBehindStats{Ahead: ahead, Behind: behind, Comparable: true}, nil
104 }
105