detached e893110

feat(ci): list the commits of a tag that no branch has

commit
e8931100c1a8bb1a74bbb1328cdab16c7301107f
author
brice · · 2026-10-13 03:21 UTC
parent
f93ee06
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +181 -4
M internal/adapters/gitcli/changed.go +15 -0
@@ -58,3 +58,18 @@ func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head
5858 }
5959 return paths, truncated, nil
6060 }
61+
62+// CommitsOffBranches lists the commits reachable from head and from no branch
63+// (refs/heads/*), newest first, at most limit. It runs
64+// `git log --not --branches --not <head>`: tags do not count, so the commit of
65+// another tag that no branch has is listed.
66+//
67+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
68+func (g *Git) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
69+ if head.IsZero() {
70+ return nil, false, fmt.Errorf("commits off branches: %w: no head", domain.ErrInvalid)
71+ }
72+ // "--not --branches" excludes every branch, the second "--not" turns the
73+ // negation off again for the head that follows.
74+ return g.logCommits(ctx, repo, head, limit, []string{"--not", "--branches", "--not"}, nil)
75+}
@@ -58,3 +58,18 @@ func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head
58 }58 }
59 return paths, truncated, nil59 return paths, truncated, nil
60 }60 }
61+
62+// CommitsOffBranches lists the commits reachable from head and from no branch
63+// (refs/heads/*), newest first, at most limit. It runs
64+// `git log --not --branches --not <head>`: tags do not count, so the commit of
65+// another tag that no branch has is listed.
66+//
67+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
68+func (g *Git) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
69+ if head.IsZero() {
70+ return nil, false, fmt.Errorf("commits off branches: %w: no head", domain.ErrInvalid)
71+ }
72+ // "--not --branches" excludes every branch, the second "--not" turns the
73+ // negation off again for the head that follows.
74+ return g.logCommits(ctx, repo, head, limit, []string{"--not", "--branches", "--not"}, nil)
75+}
M internal/adapters/gitcli/changed_test.go +58 -0
@@ -67,3 +67,61 @@ func TestChangedPathsKeepsAWholeRenameAndOddNames(t *testing.T) {
6767 require.NoError(t, err)
6868 assert.ElementsMatch(t, []string{"c.txt", "dir with space/é.txt"}, got, "both sides of a rename, verbatim")
6969 }
70+
71+func TestCommitsOffBranches(t *testing.T) {
72+ g, data := newGit(t)
73+ ctx := context.Background()
74+ m := newMergeFixture(t, data)
75+ t.Cleanup(func() { _ = g.Close(ctx) })
76+
77+ msgs := func(cs []domain.Commit) []string {
78+ var out []string
79+ for _, c := range cs {
80+ out = append(out, c.Summary)
81+ }
82+ return out
83+ }
84+
85+ // Every commit of the fixture is on main or feat.
86+ got, more, err := g.CommitsOffBranches(ctx, m.f.Name, m.d, 10)
87+ require.NoError(t, err)
88+ assert.Empty(t, got, "feat has its tip")
89+ assert.False(t, more)
90+
91+ // A commit no branch has, on top of feat: listed alone, then with its
92+ // parent when that has no branch either (a branch deleted, a detached tip).
93+ m.f.Git("checkout", "--quiet", "--detach", m.d.String())
94+ m.f.Write("t1.txt", "one\n")
95+ t1 := oidOf(t, m.f.Commit("tagged one"))
96+ m.f.Write("t2.txt", "two\n")
97+ t2 := oidOf(t, m.f.Commit("tagged two"))
98+ m.f.Git("update-ref", "refs/tags/v1", t2.String())
99+
100+ got, more, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 10)
101+ require.NoError(t, err)
102+ assert.Equal(t, []string{"tagged two", "tagged one"}, msgs(got), "newest first, down to the commits a branch has")
103+ assert.False(t, more)
104+ got, _, err = g.CommitsOffBranches(ctx, m.f.Name, t1, 10)
105+ require.NoError(t, err)
106+ assert.Equal(t, []string{"tagged one"}, msgs(got))
107+
108+ // A bound cuts the list and says so.
109+ got, more, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 1)
110+ require.NoError(t, err)
111+ assert.Equal(t, []string{"tagged two"}, msgs(got))
112+ assert.True(t, more)
113+
114+ // Another tag is no branch: t2 stays listed although v1 points to it, and
115+ // moving a branch onto t1 takes t1 out of the list.
116+ m.f.Git("update-ref", "refs/heads/rel", t1.String())
117+ got, _, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 10)
118+ require.NoError(t, err)
119+ assert.Equal(t, []string{"tagged two"}, msgs(got))
120+
121+ _, _, err = g.CommitsOffBranches(ctx, m.f.Name, domain.ObjectID{}, 10)
122+ assert.ErrorIs(t, err, domain.ErrInvalid)
123+ _, _, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 0)
124+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
125+ _, _, err = g.CommitsOffBranches(ctx, name(t, "missing"), t2, 5)
126+ assert.ErrorIs(t, err, domain.ErrNotFound)
127+}
@@ -67,3 +67,61 @@ func TestChangedPathsKeepsAWholeRenameAndOddNames(t *testing.T) {
67 require.NoError(t, err)67 require.NoError(t, err)
68 assert.ElementsMatch(t, []string{"c.txt", "dir with space/é.txt"}, got, "both sides of a rename, verbatim")68 assert.ElementsMatch(t, []string{"c.txt", "dir with space/é.txt"}, got, "both sides of a rename, verbatim")
69 }69 }
70+
71+func TestCommitsOffBranches(t *testing.T) {
72+ g, data := newGit(t)
73+ ctx := context.Background()
74+ m := newMergeFixture(t, data)
75+ t.Cleanup(func() { _ = g.Close(ctx) })
76+
77+ msgs := func(cs []domain.Commit) []string {
78+ var out []string
79+ for _, c := range cs {
80+ out = append(out, c.Summary)
81+ }
82+ return out
83+ }
84+
85+ // Every commit of the fixture is on main or feat.
86+ got, more, err := g.CommitsOffBranches(ctx, m.f.Name, m.d, 10)
87+ require.NoError(t, err)
88+ assert.Empty(t, got, "feat has its tip")
89+ assert.False(t, more)
90+
91+ // A commit no branch has, on top of feat: listed alone, then with its
92+ // parent when that has no branch either (a branch deleted, a detached tip).
93+ m.f.Git("checkout", "--quiet", "--detach", m.d.String())
94+ m.f.Write("t1.txt", "one\n")
95+ t1 := oidOf(t, m.f.Commit("tagged one"))
96+ m.f.Write("t2.txt", "two\n")
97+ t2 := oidOf(t, m.f.Commit("tagged two"))
98+ m.f.Git("update-ref", "refs/tags/v1", t2.String())
99+
100+ got, more, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 10)
101+ require.NoError(t, err)
102+ assert.Equal(t, []string{"tagged two", "tagged one"}, msgs(got), "newest first, down to the commits a branch has")
103+ assert.False(t, more)
104+ got, _, err = g.CommitsOffBranches(ctx, m.f.Name, t1, 10)
105+ require.NoError(t, err)
106+ assert.Equal(t, []string{"tagged one"}, msgs(got))
107+
108+ // A bound cuts the list and says so.
109+ got, more, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 1)
110+ require.NoError(t, err)
111+ assert.Equal(t, []string{"tagged two"}, msgs(got))
112+ assert.True(t, more)
113+
114+ // Another tag is no branch: t2 stays listed although v1 points to it, and
115+ // moving a branch onto t1 takes t1 out of the list.
116+ m.f.Git("update-ref", "refs/heads/rel", t1.String())
117+ got, _, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 10)
118+ require.NoError(t, err)
119+ assert.Equal(t, []string{"tagged two"}, msgs(got))
120+
121+ _, _, err = g.CommitsOffBranches(ctx, m.f.Name, domain.ObjectID{}, 10)
122+ assert.ErrorIs(t, err, domain.ErrInvalid)
123+ _, _, err = g.CommitsOffBranches(ctx, m.f.Name, t2, 0)
124+ assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
125+ _, _, err = g.CommitsOffBranches(ctx, name(t, "missing"), t2, 5)
126+ assert.ErrorIs(t, err, domain.ErrNotFound)
127+}
M internal/adapters/gitcli/merge.go +17 -4
@@ -132,6 +132,19 @@ func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, de
132132 //
133133 //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
134134 func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
135+ var excluded []string
136+ if !exclude.IsZero() {
137+ excluded = []string{"^" + exclude.String()}
138+ }
139+ return g.logCommits(ctx, repo, head, limit, nil, excluded)
140+}
141+
142+// logCommits runs `git log` for the commits reachable from head: revArgs go
143+// before the head (options such as --not), excluded after it. Newest first,
144+// at most limit; more says there were others.
145+//
146+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
147+func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) {
135148 if limit < 1 || limit > ports.MaxCommitLimit {
136149 return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)
137150 }
@@ -139,10 +152,10 @@ func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head
139152 if err != nil {
140153 return nil, false, err
141154 }
142- args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat, "--end-of-options", head.String())
143- if !exclude.IsZero() {
144- args = append(args, "^"+exclude.String())
145- }
155+ args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat)
156+ args = append(args, revArgs...)
157+ args = append(args, "--end-of-options", head.String())
158+ args = append(args, excluded...)
146159 var (
147160 commits []domain.Commit
148161 more bool
@@ -132,6 +132,19 @@ func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, de
132 //132 //
133 //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port133 //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
134 func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {134 func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
135+ var excluded []string
136+ if !exclude.IsZero() {
137+ excluded = []string{"^" + exclude.String()}
138+ }
139+ return g.logCommits(ctx, repo, head, limit, nil, excluded)
140+}
141+
142+// logCommits runs `git log` for the commits reachable from head: revArgs go
143+// before the head (options such as --not), excluded after it. Newest first,
144+// at most limit; more says there were others.
145+//
146+//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
147+func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) {
135 if limit < 1 || limit > ports.MaxCommitLimit {148 if limit < 1 || limit > ports.MaxCommitLimit {
136 return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)149 return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)
137 }150 }
@@ -139,10 +152,10 @@ func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head
139 if err != nil {152 if err != nil {
140 return nil, false, err153 return nil, false, err
141 }154 }
142- args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat, "--end-of-options", head.String())155+ args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat)
143- if !exclude.IsZero() {156+ args = append(args, revArgs...)
144- args = append(args, "^"+exclude.String())157+ args = append(args, "--end-of-options", head.String())
145- }158+ args = append(args, excluded...)
146 var (159 var (
147 commits []domain.Commit160 commits []domain.Commit
148 more bool161 more bool
M internal/ports/ci.go +5 -0
@@ -173,6 +173,11 @@ type CIGit interface {
173173 // means the empty tree), in the order of the diff and without renames
174174 // detection, at most max; truncated is true when there were more.
175175 ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, max int) (paths []string, truncated bool, err error)
176+ // CommitsOffBranches returns the commits reachable from head and from no
177+ // branch (refs/heads/*; tags do not count), newest first, at most limit
178+ // (1 to MaxCommitLimit); more is true when there are others. It is empty
179+ // for a commit that a branch already has.
180+ CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) (commits []domain.Commit, more bool, err error)
176181 }
177182
178183 // --- inbound ---
@@ -173,6 +173,11 @@ type CIGit interface {
173 // means the empty tree), in the order of the diff and without renames173 // means the empty tree), in the order of the diff and without renames
174 // detection, at most max; truncated is true when there were more.174 // detection, at most max; truncated is true when there were more.
175 ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, max int) (paths []string, truncated bool, err error)175 ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, max int) (paths []string, truncated bool, err error)
176+ // CommitsOffBranches returns the commits reachable from head and from no
177+ // branch (refs/heads/*; tags do not count), newest first, at most limit
178+ // (1 to MaxCommitLimit); more is true when there are others. It is empty
179+ // for a commit that a branch already has.
180+ CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) (commits []domain.Commit, more bool, err error)
176 }181 }
177 182
178 // --- inbound ---183 // --- inbound ---
M internal/ports/mocks/CIGit.go +86 -0
@@ -138,3 +138,89 @@ func (_c *CIGit_ChangedPaths_Call) RunAndReturn(run func(ctx context.Context, re
138138 _c.Call.Return(run)
139139 return _c
140140 }
141+
142+// CommitsOffBranches provides a mock function for the type CIGit
143+func (_mock *CIGit) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
144+ ret := _mock.Called(ctx, repo, head, limit)
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for CommitsOffBranches")
148+ }
149+
150+ var r0 []domain.Commit
151+ var r1 bool
152+ var r2 error
153+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, int) ([]domain.Commit, bool, error)); ok {
154+ return returnFunc(ctx, repo, head, limit)
155+ }
156+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, int) []domain.Commit); ok {
157+ r0 = returnFunc(ctx, repo, head, limit)
158+ } else {
159+ if ret.Get(0) != nil {
160+ r0 = ret.Get(0).([]domain.Commit)
161+ }
162+ }
163+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, int) bool); ok {
164+ r1 = returnFunc(ctx, repo, head, limit)
165+ } else {
166+ r1 = ret.Get(1).(bool)
167+ }
168+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, int) error); ok {
169+ r2 = returnFunc(ctx, repo, head, limit)
170+ } else {
171+ r2 = ret.Error(2)
172+ }
173+ return r0, r1, r2
174+}
175+
176+// CIGit_CommitsOffBranches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommitsOffBranches'
177+type CIGit_CommitsOffBranches_Call struct {
178+ *mock.Call
179+}
180+
181+// CommitsOffBranches is a helper method to define mock.On call
182+// - ctx context.Context
183+// - repo domain.RepoName
184+// - head domain.ObjectID
185+// - limit int
186+func (_e *CIGit_Expecter) CommitsOffBranches(ctx any, repo any, head any, limit any) *CIGit_CommitsOffBranches_Call {
187+ return &CIGit_CommitsOffBranches_Call{Call: _e.mock.On("CommitsOffBranches", ctx, repo, head, limit)}
188+}
189+
190+func (_c *CIGit_CommitsOffBranches_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int)) *CIGit_CommitsOffBranches_Call {
191+ _c.Call.Run(func(args mock.Arguments) {
192+ var arg0 context.Context
193+ if args[0] != nil {
194+ arg0 = args[0].(context.Context)
195+ }
196+ var arg1 domain.RepoName
197+ if args[1] != nil {
198+ arg1 = args[1].(domain.RepoName)
199+ }
200+ var arg2 domain.ObjectID
201+ if args[2] != nil {
202+ arg2 = args[2].(domain.ObjectID)
203+ }
204+ var arg3 int
205+ if args[3] != nil {
206+ arg3 = args[3].(int)
207+ }
208+ run(
209+ arg0,
210+ arg1,
211+ arg2,
212+ arg3,
213+ )
214+ })
215+ return _c
216+}
217+
218+func (_c *CIGit_CommitsOffBranches_Call) Return(commits []domain.Commit, more bool, err error) *CIGit_CommitsOffBranches_Call {
219+ _c.Call.Return(commits, more, err)
220+ return _c
221+}
222+
223+func (_c *CIGit_CommitsOffBranches_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error)) *CIGit_CommitsOffBranches_Call {
224+ _c.Call.Return(run)
225+ return _c
226+}
@@ -138,3 +138,89 @@ func (_c *CIGit_ChangedPaths_Call) RunAndReturn(run func(ctx context.Context, re
138 _c.Call.Return(run)138 _c.Call.Return(run)
139 return _c139 return _c
140 }140 }
141+
142+// CommitsOffBranches provides a mock function for the type CIGit
143+func (_mock *CIGit) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
144+ ret := _mock.Called(ctx, repo, head, limit)
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for CommitsOffBranches")
148+ }
149+
150+ var r0 []domain.Commit
151+ var r1 bool
152+ var r2 error
153+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, int) ([]domain.Commit, bool, error)); ok {
154+ return returnFunc(ctx, repo, head, limit)
155+ }
156+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, int) []domain.Commit); ok {
157+ r0 = returnFunc(ctx, repo, head, limit)
158+ } else {
159+ if ret.Get(0) != nil {
160+ r0 = ret.Get(0).([]domain.Commit)
161+ }
162+ }
163+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, int) bool); ok {
164+ r1 = returnFunc(ctx, repo, head, limit)
165+ } else {
166+ r1 = ret.Get(1).(bool)
167+ }
168+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, int) error); ok {
169+ r2 = returnFunc(ctx, repo, head, limit)
170+ } else {
171+ r2 = ret.Error(2)
172+ }
173+ return r0, r1, r2
174+}
175+
176+// CIGit_CommitsOffBranches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommitsOffBranches'
177+type CIGit_CommitsOffBranches_Call struct {
178+ *mock.Call
179+}
180+
181+// CommitsOffBranches is a helper method to define mock.On call
182+// - ctx context.Context
183+// - repo domain.RepoName
184+// - head domain.ObjectID
185+// - limit int
186+func (_e *CIGit_Expecter) CommitsOffBranches(ctx any, repo any, head any, limit any) *CIGit_CommitsOffBranches_Call {
187+ return &CIGit_CommitsOffBranches_Call{Call: _e.mock.On("CommitsOffBranches", ctx, repo, head, limit)}
188+}
189+
190+func (_c *CIGit_CommitsOffBranches_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int)) *CIGit_CommitsOffBranches_Call {
191+ _c.Call.Run(func(args mock.Arguments) {
192+ var arg0 context.Context
193+ if args[0] != nil {
194+ arg0 = args[0].(context.Context)
195+ }
196+ var arg1 domain.RepoName
197+ if args[1] != nil {
198+ arg1 = args[1].(domain.RepoName)
199+ }
200+ var arg2 domain.ObjectID
201+ if args[2] != nil {
202+ arg2 = args[2].(domain.ObjectID)
203+ }
204+ var arg3 int
205+ if args[3] != nil {
206+ arg3 = args[3].(int)
207+ }
208+ run(
209+ arg0,
210+ arg1,
211+ arg2,
212+ arg3,
213+ )
214+ })
215+ return _c
216+}
217+
218+func (_c *CIGit_CommitsOffBranches_Call) Return(commits []domain.Commit, more bool, err error) *CIGit_CommitsOffBranches_Call {
219+ _c.Call.Return(commits, more, err)
220+ return _c
221+}
222+
223+func (_c *CIGit_CommitsOffBranches_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error)) *CIGit_CommitsOffBranches_Call {
224+ _c.Call.Return(run)
225+ return _c
226+}

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 42

↑↓ moveenter openesc close