1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/http"
8 "slices"
9
10 "connectrpc.com/connect"
11 "github.com/a-h/templ"
12
13 pb "bnema.dev/ermite-api/gen/ermite"
14)
15
16// compareDepth bounds the commits read on each side to find where a branch
17// left its target.
18const compareDepth = maxComparePages * pageSize
19
20// compare shows a branch against the default branch of the repository: the
21// commits it is ahead by and, for the owner, the form that opens a merge
22// request.
23//
24// The API has no "diff between two branches" call outside a merge request, so
25// the page shows the diff only when it is exact without one: the branch is
26// ahead by a single commit that sits on the tip of the target (that commit's
27// own diff is then the whole change). Otherwise it lists the commits, which
28// link to their own diffs, and says the whole diff comes with the merge
29// request.
30func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) {
31 d, err := s.loadRepo(ctx, r.PathValue("repo"))
32 if err != nil {
33 return nil, 0, err
34 }
35 src, rest, ok := resolveRef(d.branches, r.PathValue("rest"))
36 if !ok || rest != "" || d.head == nil {
37 return nil, 0, errNotFound
38 }
39 tgt := d.head
40 name := d.name()
41
42 p := ComparePage{
43 Source: visibleText(src.GetShortName()), Target: visibleText(tgt.GetShortName()),
44 SourceHref: repoHref(name, "tree", src.GetShortName()), TargetHref: repoHref(name, "tree", tgt.GetShortName()),
45 CLI: Clone{ID: "cli-create", Lines: []string{
46 "ermite mr create " + shellWord(name) + " --source " + shellWord(src.GetShortName()) + " --title <title>",
47 }},
48 }
49
50 same := src.GetShortName() == tgt.GetShortName()
51 var ahead []*pb.Commit
52 exact, behind := false, false
53 if same {
54 p.Note = "this is the default branch: there is nothing to compare it with."
55 p.AheadText = "0 commits"
56 } else {
57 ahead, exact, behind, err = s.commitsAhead(ctx, name, src.GetCommitId(), tgt.GetCommitId())
58 if err != nil {
59 return nil, 0, err
60 }
61 now := s.cfg.Now()
62 for _, c := range ahead {
63 p.Ahead = append(p.Ahead, commitRow(name, c, now))
64 }
65 p.AheadText = plural(len(ahead), "commit", "commits")
66 if !exact {
67 p.AheadText += " or more"
68 }
69 switch {
70 case !exact:
71 p.Note = "the branch left " + p.Target + " too long ago to tell where: only the newest commits are listed."
72 case len(ahead) == 0:
73 p.Note = "nothing to merge: " + p.Target + " already has every commit of " + p.Source + "."
74 case behind:
75 p.Note = p.Target + " has moved on since the branch started: a merge request can only be merged after " +
76 p.Source + " is rebased on " + p.Target + " and pushed again."
77 }
78 }
79
80 // The exact diff: one commit on top of the tip of the target.
81 if exact && !behind && len(ahead) == 1 {
82 res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: name, Revision: ahead[0].GetId()}))
83 if err != nil {
84 return nil, 0, err
85 }
86 p.HasDiff = true
87 p.Files = diffFiles(res.Msg.GetDiff().GetFiles())
88 p.NFiles, p.Adds, p.Dels = int(res.Msg.GetStats().GetFilesChanged()), int(res.Msg.GetStats().GetAdditions()), int(res.Msg.GetStats().GetDeletions())
89 if n := diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles); n != "" {
90 p.Note = n
91 }
92 lines, invisible := diffLines(p.Files)
93 p.Invisible = invisible
94 self := repoHref(name, "compare", src.GetShortName())
95 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
96 } else if len(ahead) > 1 && exact {
97 if p.Note == "" {
98 p.Note = "the whole diff of the branch is on its merge request; each commit links to its own."
99 }
100 }
101
102 // An open request for the same pair, or the form to open one.
103 var existing *pb.MergeRequest
104 if !same {
105 existing, err = s.openRequestFor(ctx, name, src.GetShortName(), tgt.GetShortName())
106 if err != nil {
107 return nil, 0, err
108 }
109 }
110 switch {
111 case existing != nil:
112 p.Existing = &Link{Label: "!" + itoa(int(existing.GetNumber())) + " " + visibleText(existing.GetTitle()), Href: mrHref(name, existing.GetNumber())}
113 case SignedIn(ctx) && !same && len(ahead) > 0:
114 title := ""
115 if len(ahead) == 1 {
116 title = ahead[0].GetSummary()
117 } else {
118 title = ahead[len(ahead)-1].GetSummary()
119 }
120 p.Create = &CreateForm{
121 Action: mrBase(name), Source: src.GetShortName(), Target: tgt.GetShortName(),
122 Title: cutRunes(title, 120),
123 }
124 }
125
126 l := s.repoLayout(ctx, d, "merge-requests")
127 l.Title = "compare " + p.Source + " · " + name + " · " + s.instance
128 pinRef(&l, target{rev: src.GetCommitId(), name: src.GetShortName()})
129 l.Crumbs[0].Current = false
130 l.Crumbs = append(l.Crumbs,
131 Crumb{Label: "merge requests", Href: mrBase(name)},
132 Crumb{Label: "compare", Current: false},
133 Crumb{Label: p.Source, Current: true},
134 )
135 l.Status = Status{
136 Mode: "compare", Context: p.Source + " → " + p.Target + " · " + p.AheadText,
137 Hints: "d split · u unified · x collapse all · ? help",
138 }
139 l.HasRail, l.RailSum = true, "commits · files"
140 return Compare(l, p), http.StatusOK, nil
141}
142
143// commitsAhead lists the commits of the source that the target lacks, newest
144// first. exact is false when the walk gave up before finding where the source
145// meets the target. behind is true when the target has commits the source
146// lacks (the tip of the target is not an ancestor of the source).
147func (s *Server) commitsAhead(ctx context.Context, repo, srcTip, tgtTip string) (ahead []*pb.Commit, exact, behind bool, err error) {
148 if srcTip == tgtTip {
149 return nil, true, false, nil
150 }
151 onTarget := map[string]bool{}
152 tokenT := ""
153 for len(onTarget) < compareDepth {
154 res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
155 Repo: repo, Revision: tgtTip, PageSize: pageSize, PageToken: tokenT,
156 }))
157 if err != nil {
158 return nil, false, false, err
159 }
160 for _, c := range res.Msg.GetCommits() {
161 onTarget[c.GetId()] = true
162 }
163 if tokenT = res.Msg.GetNextPageToken(); tokenT == "" {
164 break
165 }
166 }
167 token := ""
168 for n := 0; n < compareDepth; {
169 res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
170 Repo: repo, Revision: srcTip, PageSize: pageSize, PageToken: token,
171 }))
172 if err != nil {
173 return nil, false, false, err
174 }
175 for _, c := range res.Msg.GetCommits() {
176 n++
177 if onTarget[c.GetId()] {
178 // The first commit of the source that the target has: where the
179 // branch left it. The target moved on when this is not its tip.
180 return ahead, true, c.GetId() != tgtTip, nil
181 }
182 ahead = append(ahead, c)
183 }
184 if token = res.Msg.GetNextPageToken(); token == "" {
185 // The whole history of the source is ahead: no common history.
186 return ahead, true, true, nil
187 }
188 }
189 return ahead, false, !slices.ContainsFunc(ahead, func(c *pb.Commit) bool { return c.GetId() == tgtTip }), nil
190}
191
192// openRequestFor finds the open merge request with this source and target.
193func (s *Server) openRequestFor(ctx context.Context, repo, source, target string) (*pb.MergeRequest, error) {
194 token := ""
195 for range maxOpenPages {
196 res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
197 Repo: repo, State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: token,
198 }))
199 if err != nil {
200 return nil, err
201 }
202 for _, m := range res.Msg.GetMergeRequests() {
203 if m.GetSourceBranch() == source && m.GetTargetBranch() == target {
204 return m, nil
205 }
206 }
207 if token = res.Msg.GetNextPageToken(); token == "" {
208 break
209 }
210 }
211 return nil, nil
212}
213
214// cutRunes shortens s to at most n characters.
215func cutRunes(s string, n int) string {
216 r := []rune(s)
217 if len(r) <= n {
218 return s
219 }
220 return string(r[:n])
221}