v0.18.0 0cea275

cli_mr_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"strings"
7	"testing"
8
9	"connectrpc.com/connect"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12	"google.golang.org/protobuf/proto"
13
14	"bnema.dev/ermite-api/gen/ermite"
15)
16
17var (
18	headA = strings.Repeat("a", 40)
19	mrOne = &ermite.MergeRequest{
20		Repo: "alpha", Number: 1, Title: "Add search", Description: "Finds things.", SourceBranch: "feat/x", TargetBranch: "main",
21		State: ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, Author: "agent-7",
22		CreatedAt: ts("2026-10-01T10:00:00Z"), UpdatedAt: ts("2026-10-02T11:30:00Z"), HeadId: headA,
23	}
24)
25
26func TestMRCreateMapsFlags(t *testing.T) {
27	f := &fakeAPI{createMR: func(*ermite.CreateMergeRequestRequest) (*ermite.CreateMergeRequestResponse, error) {
28		return &ermite.CreateMergeRequestResponse{MergeRequest: mrOne}, nil
29	}}
30	url := f.serve(t)
31	res := runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "Add search", "--description", "Finds things.",
32		"--source", "refs/heads/feat/x", "--target", " main ").ok(t)
33	assert.Equal(t, "opened alpha!1 (feat/x into main)\n", res.stdout)
34	_, reqs := f.snapshot()
35	got := reqs[0].(*ermite.CreateMergeRequestRequest)
36	assert.Equal(t, "alpha", got.GetRepo())
37	assert.Equal(t, "Add search", got.GetTitle())
38	assert.Equal(t, "Finds things.", got.GetDescription())
39	assert.Equal(t, "feat/x", got.GetSourceBranch(), "a full ref name is shortened")
40	assert.Equal(t, "main", got.GetTargetBranch())
41
42	// The target defaults to nothing, which is the default branch for the server;
43	// the description comes from stdin with -.
44	res = runCLI(t, envFor(url), "line one\nline two\n", "mr", "create", "alpha", "--title", "t", "--source", "feat/x", "--description", "-").ok(t)
45	_, reqs = f.snapshot()
46	got = reqs[1].(*ermite.CreateMergeRequestRequest)
47	assert.Empty(t, got.GetTargetBranch())
48	assert.Equal(t, "line one\nline two", got.GetDescription())
49	_ = res
50
51	res = runCLI(t, envFor(url), "", "mr", "create", "alpha", "--title", "t", "--source", "x", "--json").ok(t)
52	assert.JSONEq(t, `{"repo":"alpha","number":1,"title":"Add search","description":"Finds things.","source_branch":"feat/x","target_branch":"main","state":"open","author":"agent-7","created_at":"2026-10-01T10:00:00Z","updated_at":"2026-10-02T11:30:00Z","head_id":"`+headA+`","merged_commit_id":"","merge_mode":""}`, res.stdout)
53
54	for _, args := range [][]string{{"mr", "create", "alpha", "--source", "x"}, {"mr", "create", "alpha", "--title", "t"}, {"mr", "create", "--title", "t", "--source", "x"}} {
55		require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
56	}
57}
58
59func TestMRListTableJSONAndStates(t *testing.T) {
60	merged := &ermite.MergeRequest{
61		Repo: "alpha", Number: 2, Title: "Fix\tthe \x1b[31mbug", SourceBranch: "fix", TargetBranch: "main",
62		State: ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED, Author: "root", UpdatedAt: ts("2026-10-03T00:00:00Z"),
63		MergedCommitId: headA, MergeMode: ermite.MergeMode_MERGE_MODE_SQUASH,
64	}
65	f := &fakeAPI{listMR: func(r *ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) {
66		return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{merged, mrOne}}, nil
67	}}
68	url := f.serve(t)
69	res := runCLI(t, envFor(url), "", "mr", "list", "alpha", "--state", "all").ok(t)
70	assert.Equal(t, ""+
71		"NUMBER  STATE   BRANCHES        AUTHOR   UPDATED           TITLE\n"+
72		"!2      merged  fix -> main     root     2026-10-03 00:00  Fix the ?[31mbug\n"+
73		"!1      open    feat/x -> main  agent-7  2026-10-02 11:30  Add search\n", res.stdout)
74	assert.NotContains(t, res.stdout, "\x1b", "server text is made safe for a terminal")
75
76	_, reqs := f.snapshot()
77	assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, reqs[0].(*ermite.ListMergeRequestsRequest).GetState(), "all")
78	runCLI(t, envFor(url), "", "mr", "list", "alpha").ok(t)
79	_, reqs = f.snapshot()
80	assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN, reqs[1].(*ermite.ListMergeRequestsRequest).GetState(), "open by default")
81	runCLI(t, envFor(url), "", "mr", "list", "alpha", "--state", "closed").ok(t)
82	_, reqs = f.snapshot()
83	assert.Equal(t, ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, reqs[2].(*ermite.ListMergeRequestsRequest).GetState())
84
85	res = runCLI(t, envFor(url), "", "mr", "list", "alpha", "--json").ok(t)
86	assert.Equal(t, []string{"alpha", "alpha"}, []string{decode[[]mrOut](t, res.stdout)[0].Repo, decode[[]mrOut](t, res.stdout)[1].Repo})
87	assert.Equal(t, "squash", decode[[]mrOut](t, res.stdout)[0].MergeMode)
88}
89
90func TestMRListFollowsPages(t *testing.T) {
91	f := &fakeAPI{listMR: func(r *ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) {
92		if r.GetPageToken() == "" {
93			return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{mrOne}, NextPageToken: "p2"}, nil
94		}
95		m := proto.Clone(mrOne).(*ermite.MergeRequest)
96		m.Number = 7
97		return &ermite.ListMergeRequestsResponse{MergeRequests: []*ermite.MergeRequest{m}}, nil
98	}}
99	res := runCLI(t, envFor(f.serve(t)), "", "mr", "list", "alpha", "--json").ok(t)
100	got := decode[[]mrOut](t, res.stdout)
101	require.Len(t, got, 2)
102	assert.EqualValues(t, 7, got[1].Number)
103}
104
105func TestMRShow(t *testing.T) {
106	other := &ermite.Signature{Name: "Bob", Email: "bob@example.org"}
107	detail := &ermite.GetMergeRequestResponse{
108		MergeRequest: mrOne,
109		Commits:      []*ermite.Commit{{Id: headA, Author: other, Summary: "add search"}},
110		DiffBaseId:   strings.Repeat("b", 40),
111		Stats:        &ermite.DiffStats{FilesChanged: 1, Additions: 2, Deletions: 1},
112		Diff: &ermite.Diff{Files: []*ermite.FileDiff{{
113			OldPath: "a.txt", NewPath: "a.txt", Status: ermite.FileStatus_FILE_STATUS_MODIFIED, Additions: 2, Deletions: 1,
114			Hunks: []*ermite.Hunk{{OldStart: 4, OldLines: 2, NewStart: 4, NewLines: 3, Header: "func f()", Lines: []*ermite.Line{
115				{Kind: ermite.LineKind_LINE_KIND_CONTEXT, OldNumber: 4, NewNumber: 4, Text: "keep"},
116				{Kind: ermite.LineKind_LINE_KIND_DELETION, OldNumber: 5, Text: "old"},
117				{Kind: ermite.LineKind_LINE_KIND_ADDITION, NewNumber: 5, Text: "new \x1b[0m"},
118				{Kind: ermite.LineKind_LINE_KIND_ADDITION, NewNumber: 6, Text: "more"},
119			}}},
120		}}},
121		Comments: []*ermite.Comment{
122			{Id: "1", Author: "agent-7", Text: "general\nnote", CreatedAt: ts("2026-10-02T09:00:00Z")},
123			{Id: "2", Author: "root", Path: "a.txt", Line: 5, Side: ermite.DiffSide_DIFF_SIDE_NEW, Text: "why?", Resolved: true, CreatedAt: ts("2026-10-02T10:00:00Z")},
124		},
125		LastReadId: strings.Repeat("c", 40),
126	}
127	f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return detail, nil }}
128	url := f.serve(t)
129
130	res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "!1", "--diff").ok(t)
131	out := res.stdout
132	assert.Contains(t, out, "merge request  alpha!1")
133	assert.Contains(t, out, "branches       feat/x -> main")
134	assert.Contains(t, out, "commits (1)")
135	assert.Contains(t, out, "changes: 1 file(s), +2 -1, from bbbbbbbbbb")
136	assert.Contains(t, out, "modified +2 -1  a.txt")
137	assert.Contains(t, out, "  #1 agent-7")
138	assert.Contains(t, out, "\n    note\n", "comment lines are indented")
139	assert.Contains(t, out, "a.txt:5 (new) [resolved]")
140	assert.Contains(t, out, "\n          5 +new ?[0m\n", "an addition has a new number only")
141	assert.Contains(t, out, "\n    5       -old\n", "a deletion has an old number only")
142	assert.Contains(t, out, "\n    4     4  keep\n", "a context line has both")
143	assert.NotContains(t, out, "\x1b")
144	assert.Empty(t, res.stderr)
145	_, reqs := f.snapshot()
146	assert.EqualValues(t, 1, reqs[0].(*ermite.GetMergeRequestRequest).GetNumber())
147	assert.False(t, reqs[0].(*ermite.GetMergeRequestRequest).GetSinceLastRead())
148
149	// Without --diff the lines are left out of the human and the JSON output.
150	res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
151	assert.NotContains(t, res.stdout, "+new")
152	js := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
153	assert.NotContains(t, js.stdout, `"hunks"`)
154	js = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json", "--diff").ok(t)
155	show := decode[showOut](t, js.stdout)
156	require.Len(t, show.Files, 1)
157	require.Len(t, show.Files[0].Hunks, 1)
158	assert.Equal(t, "addition", show.Files[0].Hunks[0].Lines[2].Kind)
159	assert.Equal(t, 5, show.Files[0].Hunks[0].Lines[2].NewNumber)
160	require.Len(t, show.Comments, 2)
161	assert.Equal(t, "new", show.Comments[1].Side)
162	assert.Empty(t, show.Comments[0].Side)
163	assert.Equal(t, strings.Repeat("c", 40), show.LastReadID)
164
165	// --since-last-read asks for it, and says when the head is lost.
166	detail.LastReadLost = true
167	res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--since-last-read").ok(t)
168	_, reqs = f.snapshot()
169	assert.True(t, reqs[len(reqs)-1].(*ermite.GetMergeRequestRequest).GetSinceLastRead())
170	assert.Contains(t, res.stderr, "head you last read is gone")
171}
172
173func TestMRShowMarkRead(t *testing.T) {
174	f := &fakeAPI{
175		getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
176			return &ermite.GetMergeRequestResponse{MergeRequest: mrOne}, nil
177		},
178		markRead: func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error) {
179			return &ermite.MarkReadResponse{}, nil
180		},
181	}
182	url := f.serve(t)
183	runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
184	calls, _ := f.snapshot()
185	assert.Equal(t, []string{"GetMergeRequest"}, calls, "reading does not mark read")
186	runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--mark-read").ok(t)
187	calls, reqs := f.snapshot()
188	assert.Equal(t, []string{"GetMergeRequest", "GetMergeRequest", "MarkRead"}, calls)
189	assert.Equal(t, headA, reqs[2].(*ermite.MarkReadRequest).GetHeadId(), "marks the head that was shown")
190}
191
192func TestMRCommentMapsFlags(t *testing.T) {
193	f := &fakeAPI{addComment: func(r *ermite.AddCommentRequest) (*ermite.AddCommentResponse, error) {
194		return &ermite.AddCommentResponse{Comment: &ermite.Comment{Id: "3", Path: r.GetPath(), Line: r.GetLine(), Side: r.GetSide(), Text: r.GetText()}}, nil
195	}}
196	url := f.serve(t)
197
198	res := runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "hello").ok(t)
199	assert.Equal(t, "comment 3 added to alpha!1\n", res.stdout)
200	runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "on a line", "--path", "a.txt", "--line", "5").ok(t)
201	runCLI(t, envFor(url), "", "mr", "comment", "alpha", "1", "-m", "old side", "--path", "a.txt", "--line", "5", "--side", "OLD").ok(t)
202	runCLI(t, envFor(url), "text\n", "mr", "comment", "alpha", "!1", "-m", "-").ok(t)
203	_, reqs := f.snapshot()
204	general := reqs[0].(*ermite.AddCommentRequest)
205	assert.Empty(t, general.GetPath())
206	assert.Zero(t, general.GetLine())
207	assert.Equal(t, ermite.DiffSide_DIFF_SIDE_UNSPECIFIED, general.GetSide(), "a general comment has no side, though --side defaults to new")
208	onNew := reqs[1].(*ermite.AddCommentRequest)
209	assert.Equal(t, "a.txt", onNew.GetPath())
210	assert.EqualValues(t, 5, onNew.GetLine())
211	assert.Equal(t, ermite.DiffSide_DIFF_SIDE_NEW, onNew.GetSide(), "new is the default side")
212	assert.Equal(t, ermite.DiffSide_DIFF_SIDE_OLD, reqs[2].(*ermite.AddCommentRequest).GetSide())
213	assert.Equal(t, "text", reqs[3].(*ermite.AddCommentRequest).GetText())
214	assert.EqualValues(t, 1, reqs[3].(*ermite.AddCommentRequest).GetNumber())
215
216	before, _ := f.snapshot()
217	for _, args := range [][]string{
218		{"mr", "comment", "alpha", "1"},                                                            // no -m
219		{"mr", "comment", "alpha", "x", "-m", "t"},                                                 // bad number
220		{"mr", "comment", "alpha", "0", "-m", "t"},                                                 // bad number
221		{"mr", "comment", "alpha", "1", "-m", "t", "--path", "a"},                                  // no line
222		{"mr", "comment", "alpha", "1", "-m", "t", "--line", "3"},                                  // no path
223		{"mr", "comment", "alpha", "1", "-m", "t", "--side", "old"},                                // no path
224		{"mr", "comment", "alpha", "1", "-m", "t", "--path", "a", "--line", "1", "--side", "left"}, // bad side
225	} {
226		require.Error(t, runCLI(t, envFor(url), "", args...).err, "%v", args)
227	}
228	after, _ := f.snapshot()
229	assert.Equal(t, before, after, "nothing reaches the server")
230}
231
232func TestMRResolveMergeAndClose(t *testing.T) {
233	merged := proto.Clone(mrOne).(*ermite.MergeRequest)
234	merged.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED
235	merged.MergedCommitId, merged.MergeMode = strings.Repeat("d", 40), ermite.MergeMode_MERGE_MODE_SQUASH
236	closed := proto.Clone(mrOne).(*ermite.MergeRequest)
237	closed.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_CLOSED
238	f := &fakeAPI{
239		resolveCmt: func(r *ermite.ResolveCommentRequest) (*ermite.ResolveCommentResponse, error) {
240			return &ermite.ResolveCommentResponse{Comment: &ermite.Comment{Id: r.GetCommentId(), Resolved: r.GetResolved()}}, nil
241		},
242		getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
243			return &ermite.GetMergeRequestResponse{MergeRequest: mrOne}, nil
244		},
245		mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) {
246			return &ermite.MergeMergeRequestResponse{MergeRequest: merged}, nil
247		},
248		closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) {
249			return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil
250		},
251	}
252	url := f.serve(t)
253
254	res := runCLI(t, envFor(url), "", "mr", "resolve", "alpha", "1", "#4").ok(t)
255	assert.Equal(t, "comment 4 of alpha!1 resolved\n", res.stdout)
256	res = runCLI(t, envFor(url), "", "mr", "resolve", "alpha", "1", "4", "--undo").ok(t)
257	assert.Equal(t, "comment 4 of alpha!1 reopened\n", res.stdout)
258	_, reqs := f.snapshot()
259	assert.Equal(t, "4", reqs[0].(*ermite.ResolveCommentRequest).GetCommentId(), "a leading # is dropped")
260	assert.True(t, reqs[0].(*ermite.ResolveCommentRequest).GetResolved())
261	assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved())
262
263	// Merge with an explicit head and mode.
264	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t)
265	assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout)
266	assert.Empty(t, res.stderr)
267	calls, reqs := f.snapshot()
268	assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest"}, calls, "an explicit head needs no read first")
269	m := reqs[2].(*ermite.MergeMergeRequestRequest)
270	assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode())
271	assert.Equal(t, headA, m.GetHeadId())
272	assert.EqualValues(t, 1, m.GetNumber())
273
274	// The default mode is a fast-forward.
275	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
276	assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
277	calls, reqs = f.snapshot()
278	assert.Equal(t, "MergeMergeRequest", calls[len(calls)-1])
279	m = reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest)
280	assert.Equal(t, ermite.MergeMode_MERGE_MODE_FAST_FORWARD, m.GetMode(), "fast-forward by default")
281	assert.Equal(t, headA, m.GetHeadId())
282
283	res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1").ok(t)
284	assert.Equal(t, "closed alpha!1 (feat/x into main)\n", res.stdout)
285	res = runCLI(t, envFor(url), "", "mr", "close", "alpha", "1", "--json").ok(t)
286	assert.Equal(t, "closed", decode[mrOut](t, res.stdout).State)
287}
288
289// ADR 0017: the merge is of the commit that was reviewed, so --head is
290// required, and the CLI does not fill it in from the live head.
291func TestMRMergeNeedsTheHead(t *testing.T) {
292	f := &fakeAPI{}
293	url := f.serve(t)
294	for _, args := range [][]string{
295		{"mr", "merge", "alpha", "1"},
296		{"mr", "merge", "alpha", "1", "--squash"},
297		{"mr", "merge", "alpha", "1", "--head", "  "},
298	} {
299		res := runCLI(t, envFor(url), "", args...)
300		require.Error(t, res.err, args)
301		assert.Contains(t, res.err.Error(), "--head is required")
302		assert.Contains(t, res.err.Error(), "ermite mr show alpha 1", "tells where to get the head")
303	}
304	calls, _ := f.snapshot()
305	assert.Empty(t, calls, "nothing reaches the server")
306
307	res := runCLI(t, envFor(url), "", "mr", "merge", "--help").ok(t)
308	assert.Contains(t, res.stdout, "(required)")
309	assert.NotContains(t, res.stdout, "default: the current head")
310}
311
312func TestMRMergeDeleteBranch(t *testing.T) {
313	merged := proto.Clone(mrOne).(*ermite.MergeRequest)
314	merged.State = ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED
315	merged.MergedCommitId, merged.MergeMode = headA, ermite.MergeMode_MERGE_MODE_FAST_FORWARD
316	deleted := true
317	f := &fakeAPI{mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) {
318		return &ermite.MergeMergeRequestResponse{MergeRequest: merged, SourceBranchDeleted: deleted}, nil
319	}}
320	url := f.serve(t)
321
322	res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
323	assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleted branch feat/x\n", res.stdout)
324	assert.Empty(t, res.stderr)
325	_, reqs := f.snapshot()
326	assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
327
328	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t)
329	assert.Contains(t, res.stdout, `"source_branch_deleted": true`)
330
331	// The merge stands when the branch is kept: a warning, no failure.
332	deleted = false
333	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
334	assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout)
335	assert.Contains(t, res.stderr, "ermite: warning: the branch feat/x was kept")
336
337	// Without the flag nothing is asked and nothing is said.
338	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t)
339	assert.Empty(t, res.stderr)
340	_, reqs = f.snapshot()
341	assert.False(t, reqs[len(reqs)-1].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
342}
343
344func TestMRErrorsAreFriendly(t *testing.T) {
345	f := &fakeAPI{
346		listMR: func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error) {
347			return nil, connect.NewError(connect.CodeUnimplemented, nil)
348		},
349		mergeMR: func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error) {
350			return nil, connect.NewError(connect.CodeFailedPrecondition, assert.AnError)
351		},
352	}
353	url := f.serve(t)
354	res := runCLI(t, envFor(url), "", "mr", "list", "alpha")
355	require.Error(t, res.err)
356	assert.Contains(t, res.err.Error(), "does not implement")
357	res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA)
358	require.Error(t, res.err)
359	assert.Contains(t, res.err.Error(), "precondition failed")
360}
361
362func TestSigningKeyShowOutput(t *testing.T) {
363	key := &ermite.GetSigningKeyResponse{
364		PublicKey: "ssh-ed25519 AAAAC3Nz", Fingerprint: "SHA256:abc", CommitterName: "ermite", CommitterEmail: "ermite@git.example.org",
365		AllowedSigners: "ermite@git.example.org ssh-ed25519 AAAAC3Nz",
366	}
367	f := &fakeAPI{getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) { return key, nil }}
368	url := f.serve(t)
369
370	res := runCLI(t, map[string]string{"ERMITE_URL": url}, "", "signing-key", "show").ok(t)
371	assert.Equal(t, ""+
372		"fingerprint      SHA256:abc\n"+
373		"committer        ermite <ermite@git.example.org>\n"+
374		"public key       ssh-ed25519 AAAAC3Nz\n"+
375		"allowed signers  ermite@git.example.org ssh-ed25519 AAAAC3Nz\n", res.stdout)
376	assert.Empty(t, f.header[0].Values("Authorization"), "no token is needed")
377	res = runCLI(t, envFor(url), "", "signing-key", "show", "--public-key").ok(t)
378	assert.Equal(t, "ssh-ed25519 AAAAC3Nz\n", res.stdout)
379	res = runCLI(t, envFor(url), "", "signing-key", "show", "--allowed-signers").ok(t)
380	assert.Equal(t, "ermite@git.example.org ssh-ed25519 AAAAC3Nz\n", res.stdout)
381	res = runCLI(t, envFor(url), "", "signing-key", "show", "--json").ok(t)
382	assert.JSONEq(t, `{"public_key":"ssh-ed25519 AAAAC3Nz","fingerprint":"SHA256:abc","committer_name":"ermite","committer_email":"ermite@git.example.org","allowed_signers":"ermite@git.example.org ssh-ed25519 AAAAC3Nz"}`, res.stdout)
383}

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 2

Tags 44

↑↓ moveenter openesc close