v0.6.0 d5cc8fe

compare.go RawHistoryFolder

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.HasRail = true
136	return Compare(l, p), http.StatusOK, nil
137}
138
139// commitsAhead lists the commits of the source that the target lacks, newest
140// first. exact is false when the walk gave up before finding where the source
141// meets the target. behind is true when the target has commits the source
142// lacks (the tip of the target is not an ancestor of the source).
143func (s *Server) commitsAhead(ctx context.Context, repo, srcTip, tgtTip string) (ahead []*pb.Commit, exact, behind bool, err error) {
144	if srcTip == tgtTip {
145		return nil, true, false, nil
146	}
147	onTarget := map[string]bool{}
148	tokenT := ""
149	for len(onTarget) < compareDepth {
150		res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
151			Repo: repo, Revision: tgtTip, PageSize: pageSize, PageToken: tokenT,
152		}))
153		if err != nil {
154			return nil, false, false, err
155		}
156		for _, c := range res.Msg.GetCommits() {
157			onTarget[c.GetId()] = true
158		}
159		if tokenT = res.Msg.GetNextPageToken(); tokenT == "" {
160			break
161		}
162	}
163	token := ""
164	for n := 0; n < compareDepth; {
165		res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
166			Repo: repo, Revision: srcTip, PageSize: pageSize, PageToken: token,
167		}))
168		if err != nil {
169			return nil, false, false, err
170		}
171		for _, c := range res.Msg.GetCommits() {
172			n++
173			if onTarget[c.GetId()] {
174				// The first commit of the source that the target has: where the
175				// branch left it. The target moved on when this is not its tip.
176				return ahead, true, c.GetId() != tgtTip, nil
177			}
178			ahead = append(ahead, c)
179		}
180		if token = res.Msg.GetNextPageToken(); token == "" {
181			// The whole history of the source is ahead: no common history.
182			return ahead, true, true, nil
183		}
184	}
185	return ahead, false, !slices.ContainsFunc(ahead, func(c *pb.Commit) bool { return c.GetId() == tgtTip }), nil
186}
187
188// openRequestFor finds the open merge request with this source and target.
189func (s *Server) openRequestFor(ctx context.Context, repo, source, target string) (*pb.MergeRequest, error) {
190	token := ""
191	for range maxOpenPages {
192		res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
193			Repo: repo, State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: token,
194		}))
195		if err != nil {
196			return nil, err
197		}
198		for _, m := range res.Msg.GetMergeRequests() {
199			if m.GetSourceBranch() == source && m.GetTargetBranch() == target {
200				return m, nil
201			}
202		}
203		if token = res.Msg.GetNextPageToken(); token == "" {
204			break
205		}
206	}
207	return nil, nil
208}
209
210// cutRunes shortens s to at most n characters.
211func cutRunes(s string, n int) string {
212	r := []rune(s)
213	if len(r) <= n {
214		return s
215	}
216	return string(r[:n])
217}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close