detached e188800

refactor(domain): own the revision resolution order in Revision.Candidates

gitcli and ermitegit iterate domain candidates and only perform the lookups.
commit
e188800252a9251d0e71ffe9e6ad068c2c894b52
author
brice · · 2026-10-19 19:27 UTC
committer
brice · · 2026-10-19 20:01 UTC
parent
ac21956
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +163 -83
M internal/adapters/ermitegit/convert.go +32 -31
@@ -119,55 +119,56 @@ func peelID(r *git.Repo, id git.ObjectID) (_ git.ObjectID, ok bool, _ error) {
119119 return commit, true, nil
120120 }
121121
122-// resolveRevision tries the candidates of gitcli in the same order, which
123-// the port documents: HEAD is HEAD; a full object id is an object before it
124-// is a ref; then the branch, then the tag; then a full ref name and an
125-// abbreviated id as written. It does not use Repo.Resolve, whose order is
126-// git's own (tags before branches, no ref for a missing full id).
127-//
128-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for revision resolution order; pending move to domain.Revision in a follow-up
122+// resolveRevision performs the lookups ordered by the domain, rather than
123+// Repo.Resolve, whose Git-specific resolution order differs from the port.
129124 func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) {
130- s := rev.String()
131- if rev.IsHead() || s == "HEAD" {
132- if c, ok, err := peelRef(r, "HEAD"); err != nil || ok {
133- return resolved{commit: c}, err
134- }
135- return resolved{}, notFound(rev)
136- }
137- if rev.IsObjectID() {
138- id, err := git.ParseID(s)
125+ for _, candidate := range rev.Candidates() {
126+ c, ok, err := lookupRevisionCandidate(r, candidate)
139127 if err != nil {
140128 return resolved{}, err
141129 }
142- if c, ok, err := peelID(r, id); err != nil || ok {
143- return resolved{commit: c}, err
130+ if ok {
131+ return resolved{commit: c, ref: candidateRef(candidate)}, nil
144132 }
145133 }
146- for _, name := range [...]string{headsPrefix + s, tagsPrefix + s} {
147- if c, ok, err := peelRef(r, name); err != nil || ok {
148- return resolved{commit: c, ref: name}, err
149- }
134+ return resolved{}, notFound(rev)
135+}
136+
137+// candidateRef translates a ref lookup into the resolved ref field.
138+func candidateRef(candidate domain.RevisionCandidate) string {
139+ if candidate.Kind == domain.RevisionCandidateRef {
140+ return candidate.Name
150141 }
151- switch {
152- case len(s) > 5 && s[:5] == "refs/":
153- if c, ok, err := peelRef(r, s); err != nil || ok {
154- return resolved{commit: c, ref: s}, err
142+ return ""
143+}
144+
145+// lookupRevisionCandidate translates one domain candidate to the library API.
146+func lookupRevisionCandidate(r *git.Repo, candidate domain.RevisionCandidate) (git.ObjectID, bool, error) {
147+ switch candidate.Kind {
148+ case domain.RevisionCandidateHead, domain.RevisionCandidateRef:
149+ return peelRef(r, candidate.Name)
150+ case domain.RevisionCandidateObject:
151+ id, err := git.ParseID(candidate.Name)
152+ if err != nil {
153+ return git.ObjectID{}, false, err
155154 }
156- case rev.IsAbbrevID():
157- id, err := r.ResolveCommitPrefix(s)
155+ return peelID(r, id)
156+ case domain.RevisionCandidateAbbrevID:
157+ id, err := r.ResolveCommitPrefix(candidate.Name)
158158 if err == nil {
159159 // The prefix may name an annotated tag: git peels it.
160160 id, err = r.PeelToCommit(id)
161161 }
162162 switch {
163163 case err == nil:
164- return resolved{commit: id}, nil
164+ return id, true, nil
165165 case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType):
166+ return git.ObjectID{}, false, nil
166167 default:
167- return resolved{}, err
168+ return git.ObjectID{}, false, err
168169 }
169170 }
170- return resolved{}, notFound(rev)
171+ return git.ObjectID{}, false, fmt.Errorf("unknown revision candidate kind %d", candidate.Kind)
171172 }
172173
173174 func notFound(rev domain.Revision) error {
@@ -119,55 +119,56 @@ func peelID(r *git.Repo, id git.ObjectID) (_ git.ObjectID, ok bool, _ error) {
119 return commit, true, nil119 return commit, true, nil
120 }120 }
121 121
122-// resolveRevision tries the candidates of gitcli in the same order, which122+// resolveRevision performs the lookups ordered by the domain, rather than
123-// the port documents: HEAD is HEAD; a full object id is an object before it123+// Repo.Resolve, whose Git-specific resolution order differs from the port.
124-// is a ref; then the branch, then the tag; then a full ref name and an
125-// abbreviated id as written. It does not use Repo.Resolve, whose order is
126-// git's own (tags before branches, no ref for a missing full id).
127-//
128-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for revision resolution order; pending move to domain.Revision in a follow-up
129 func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) {124 func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) {
130- s := rev.String()125+ for _, candidate := range rev.Candidates() {
131- if rev.IsHead() || s == "HEAD" {126+ c, ok, err := lookupRevisionCandidate(r, candidate)
132- if c, ok, err := peelRef(r, "HEAD"); err != nil || ok {
133- return resolved{commit: c}, err
134- }
135- return resolved{}, notFound(rev)
136- }
137- if rev.IsObjectID() {
138- id, err := git.ParseID(s)
139 if err != nil {127 if err != nil {
140 return resolved{}, err128 return resolved{}, err
141 }129 }
142- if c, ok, err := peelID(r, id); err != nil || ok {130+ if ok {
143- return resolved{commit: c}, err131+ return resolved{commit: c, ref: candidateRef(candidate)}, nil
144 }132 }
145 }133 }
146- for _, name := range [...]string{headsPrefix + s, tagsPrefix + s} {134+ return resolved{}, notFound(rev)
147- if c, ok, err := peelRef(r, name); err != nil || ok {135+}
148- return resolved{commit: c, ref: name}, err136+
149- }137+// candidateRef translates a ref lookup into the resolved ref field.
138+func candidateRef(candidate domain.RevisionCandidate) string {
139+ if candidate.Kind == domain.RevisionCandidateRef {
140+ return candidate.Name
150 }141 }
151- switch {142+ return ""
152- case len(s) > 5 && s[:5] == "refs/":143+}
153- if c, ok, err := peelRef(r, s); err != nil || ok {144+
154- return resolved{commit: c, ref: s}, err145+// lookupRevisionCandidate translates one domain candidate to the library API.
146+func lookupRevisionCandidate(r *git.Repo, candidate domain.RevisionCandidate) (git.ObjectID, bool, error) {
147+ switch candidate.Kind {
148+ case domain.RevisionCandidateHead, domain.RevisionCandidateRef:
149+ return peelRef(r, candidate.Name)
150+ case domain.RevisionCandidateObject:
151+ id, err := git.ParseID(candidate.Name)
152+ if err != nil {
153+ return git.ObjectID{}, false, err
155 }154 }
156- case rev.IsAbbrevID():155+ return peelID(r, id)
157- id, err := r.ResolveCommitPrefix(s)156+ case domain.RevisionCandidateAbbrevID:
157+ id, err := r.ResolveCommitPrefix(candidate.Name)
158 if err == nil {158 if err == nil {
159 // The prefix may name an annotated tag: git peels it.159 // The prefix may name an annotated tag: git peels it.
160 id, err = r.PeelToCommit(id)160 id, err = r.PeelToCommit(id)
161 }161 }
162 switch {162 switch {
163 case err == nil:163 case err == nil:
164- return resolved{commit: id}, nil164+ return id, true, nil
165 case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType):165 case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType):
166+ return git.ObjectID{}, false, nil
166 default:167 default:
167- return resolved{}, err168+ return git.ObjectID{}, false, err
168 }169 }
169 }170 }
170- return resolved{}, notFound(rev)171+ return git.ObjectID{}, false, fmt.Errorf("unknown revision candidate kind %d", candidate.Kind)
171 }172 }
172 173
173 func notFound(rev domain.Revision) error {174 func notFound(rev domain.Revision) error {
M internal/adapters/ermitegit/ermitegit.go +1 -1
@@ -291,7 +291,7 @@ func (a *Adapter) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.
291291
292292 // ResolveRevision turns a revision into a commit.
293293 //
294-//hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports
294+//hexcheck:ignore suspicious-business-logic-in-adapter selects the configured CLI or library backend, with a separate route for abbreviated IDs; resolution order comes from domain.Revision.Candidates
295295 func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
296296 cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) }
297297 route := a.routes.ResolveRevision
@@ -291,7 +291,7 @@ func (a *Adapter) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.
291 291
292 // ResolveRevision turns a revision into a commit.292 // ResolveRevision turns a revision into a commit.
293 //293 //
294-//hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports294+//hexcheck:ignore suspicious-business-logic-in-adapter selects the configured CLI or library backend, with a separate route for abbreviated IDs; resolution order comes from domain.Revision.Candidates
295 func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {295 func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
296 cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) }296 cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) }
297 route := a.routes.ResolveRevision297 route := a.routes.ResolveRevision
M internal/adapters/gitcli/read.go +38 -51
@@ -153,63 +153,50 @@ func readHead(dir string) (string, error) {
153153 return ref, nil
154154 }
155155
156-// revCandidate is one way to read a revision as a commit.
157-type revCandidate struct {
158- spec string // object expression for cat-file
159- ref string // full ref name it stands for, "" for ids and HEAD
160-}
161-
162-// candidates lists, in order, how a revision is tried. A full object id is an
163-// object id first: a branch or a tag that happens to be named like a commit
164-// must not redirect a link to that commit; refs are tried only when no such
165-// object exists. Otherwise it is the branch, then the tag, then (for "HEAD",
166-// full ref names and abbreviations) as written. Other forms Git understands,
167-// such as "main~2" or "FETCH_HEAD", are not revisions of ermite.
168-//
169-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for revision resolution order; pending move to domain.Revision in a follow-up
170-func candidates(rev domain.Revision) []revCandidate {
171- s := rev.String()
172- switch {
173- case rev.IsHead() || s == "HEAD":
174- return []revCandidate{{spec: "HEAD^{commit}"}}
175- }
176- refs := []revCandidate{
177- {spec: headsRefPrefix + s + "^{commit}", ref: headsRefPrefix + s},
178- {spec: tagsRefPrefix + s + "^{commit}", ref: tagsRefPrefix + s},
179- }
180- switch {
181- case rev.IsObjectID():
182- return append([]revCandidate{{spec: s + "^{commit}"}}, refs...)
183- case strings.HasPrefix(s, "refs/"):
184- return append(refs, revCandidate{spec: s + "^{commit}", ref: s})
185- case rev.IsAbbrevID():
186- return append(refs, revCandidate{spec: s + "^{commit}"})
187- }
188- return refs
189-}
190-
191-// resolve resolves rev on an open cat-file connection.
192-//
193-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for revision resolution order; pending move to domain.Revision in a follow-up
156+// resolve resolves the domain candidates on an open cat-file connection.
194157 func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) {
195- for _, cand := range candidates(rev) {
196- h, err := c.info(cand.spec)
197- if notFound(err) {
198- continue
199- }
158+ for _, cand := range rev.Candidates() {
159+ id, ok, err := lookupRevisionCandidate(c, cand)
200160 if err != nil {
201161 return domain.ResolvedRevision{}, err
202162 }
203- if h.Type != "commit" {
163+ if !ok {
204164 continue
205165 }
206- id, err := domain.ParseObjectID(h.ID)
207- if err != nil {
208- return domain.ResolvedRevision{}, c.fail(err)
209- }
210- return domain.ResolvedRevision{CommitID: id, Ref: cand.ref}, nil
166+ return domain.ResolvedRevision{CommitID: id, Ref: candidateRef(cand)}, nil
167+ }
168+ return domain.ResolvedRevision{}, revisionNotFound(rev)
169+}
170+
171+func revisionNotFound(rev domain.Revision) error {
172+ return fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)
173+}
174+
175+// lookupRevisionCandidate asks cat-file to peel a candidate to a commit.
176+func lookupRevisionCandidate(c *catConn, candidate domain.RevisionCandidate) (domain.ObjectID, bool, error) {
177+ h, err := c.info(candidate.Name + "^{commit}")
178+ if notFound(err) {
179+ return domain.ObjectID{}, false, nil
180+ }
181+ if err != nil {
182+ return domain.ObjectID{}, false, err
183+ }
184+ if h.Type != "commit" {
185+ return domain.ObjectID{}, false, nil
186+ }
187+ id, err := domain.ParseObjectID(h.ID)
188+ if err != nil {
189+ return domain.ObjectID{}, false, c.fail(err)
190+ }
191+ return id, true, nil
192+}
193+
194+// candidateRef translates a ref lookup into the resolved ref field.
195+func candidateRef(candidate domain.RevisionCandidate) string {
196+ if candidate.Kind == domain.RevisionCandidateRef {
197+ return candidate.Name
211198 }
212- return domain.ResolvedRevision{}, fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)
199+ return ""
213200 }
214201
215202 // ResolveRevision resolves a revision with `cat-file`.
@@ -261,7 +248,7 @@ type object struct {
261248 // request series on the repository's process. A missing path is
262249 // domain.ErrNotFound.
263250 //
264-//hexcheck:ignore suspicious-business-logic-in-adapter uses the business rule for revision resolution order, pending move to domain.Revision in a follow-up, before looking up a path
251+//hexcheck:ignore suspicious-business-logic-in-adapter translates a root path to Git tree syntax and a non-root path to Git colon syntax, then reads the cat-file header
265252 func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) {
266253 var obj object
267254 err := g.cat.do(ctx, dir, func(c *catConn) error {
@@ -153,63 +153,50 @@ func readHead(dir string) (string, error) {
153 return ref, nil153 return ref, nil
154 }154 }
155 155
156-// revCandidate is one way to read a revision as a commit.156+// resolve resolves the domain candidates on an open cat-file connection.
157-type revCandidate struct {
158- spec string // object expression for cat-file
159- ref string // full ref name it stands for, "" for ids and HEAD
160-}
161-
162-// candidates lists, in order, how a revision is tried. A full object id is an
163-// object id first: a branch or a tag that happens to be named like a commit
164-// must not redirect a link to that commit; refs are tried only when no such
165-// object exists. Otherwise it is the branch, then the tag, then (for "HEAD",
166-// full ref names and abbreviations) as written. Other forms Git understands,
167-// such as "main~2" or "FETCH_HEAD", are not revisions of ermite.
168-//
169-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for revision resolution order; pending move to domain.Revision in a follow-up
170-func candidates(rev domain.Revision) []revCandidate {
171- s := rev.String()
172- switch {
173- case rev.IsHead() || s == "HEAD":
174- return []revCandidate{{spec: "HEAD^{commit}"}}
175- }
176- refs := []revCandidate{
177- {spec: headsRefPrefix + s + "^{commit}", ref: headsRefPrefix + s},
178- {spec: tagsRefPrefix + s + "^{commit}", ref: tagsRefPrefix + s},
179- }
180- switch {
181- case rev.IsObjectID():
182- return append([]revCandidate{{spec: s + "^{commit}"}}, refs...)
183- case strings.HasPrefix(s, "refs/"):
184- return append(refs, revCandidate{spec: s + "^{commit}", ref: s})
185- case rev.IsAbbrevID():
186- return append(refs, revCandidate{spec: s + "^{commit}"})
187- }
188- return refs
189-}
190-
191-// resolve resolves rev on an open cat-file connection.
192-//
193-//hexcheck:ignore suspicious-business-logic-in-adapter business rule for revision resolution order; pending move to domain.Revision in a follow-up
194 func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) {157 func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) {
195- for _, cand := range candidates(rev) {158+ for _, cand := range rev.Candidates() {
196- h, err := c.info(cand.spec)159+ id, ok, err := lookupRevisionCandidate(c, cand)
197- if notFound(err) {
198- continue
199- }
200 if err != nil {160 if err != nil {
201 return domain.ResolvedRevision{}, err161 return domain.ResolvedRevision{}, err
202 }162 }
203- if h.Type != "commit" {163+ if !ok {
204 continue164 continue
205 }165 }
206- id, err := domain.ParseObjectID(h.ID)166+ return domain.ResolvedRevision{CommitID: id, Ref: candidateRef(cand)}, nil
207- if err != nil {167+ }
208- return domain.ResolvedRevision{}, c.fail(err)168+ return domain.ResolvedRevision{}, revisionNotFound(rev)
209- }169+}
210- return domain.ResolvedRevision{CommitID: id, Ref: cand.ref}, nil170+
171+func revisionNotFound(rev domain.Revision) error {
172+ return fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)
173+}
174+
175+// lookupRevisionCandidate asks cat-file to peel a candidate to a commit.
176+func lookupRevisionCandidate(c *catConn, candidate domain.RevisionCandidate) (domain.ObjectID, bool, error) {
177+ h, err := c.info(candidate.Name + "^{commit}")
178+ if notFound(err) {
179+ return domain.ObjectID{}, false, nil
180+ }
181+ if err != nil {
182+ return domain.ObjectID{}, false, err
183+ }
184+ if h.Type != "commit" {
185+ return domain.ObjectID{}, false, nil
186+ }
187+ id, err := domain.ParseObjectID(h.ID)
188+ if err != nil {
189+ return domain.ObjectID{}, false, c.fail(err)
190+ }
191+ return id, true, nil
192+}
193+
194+// candidateRef translates a ref lookup into the resolved ref field.
195+func candidateRef(candidate domain.RevisionCandidate) string {
196+ if candidate.Kind == domain.RevisionCandidateRef {
197+ return candidate.Name
211 }198 }
212- return domain.ResolvedRevision{}, fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)199+ return ""
213 }200 }
214 201
215 // ResolveRevision resolves a revision with `cat-file`.202 // ResolveRevision resolves a revision with `cat-file`.
@@ -261,7 +248,7 @@ type object struct {
261 // request series on the repository's process. A missing path is248 // request series on the repository's process. A missing path is
262 // domain.ErrNotFound.249 // domain.ErrNotFound.
263 //250 //
264-//hexcheck:ignore suspicious-business-logic-in-adapter uses the business rule for revision resolution order, pending move to domain.Revision in a follow-up, before looking up a path251+//hexcheck:ignore suspicious-business-logic-in-adapter translates a root path to Git tree syntax and a non-root path to Git colon syntax, then reads the cat-file header
265 func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) {252 func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) {
266 var obj object253 var obj object
267 err := g.cat.do(ctx, dir, func(c *catConn) error {254 err := g.cat.do(ctx, dir, func(c *catConn) error {
A internal/domain/revision.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "strings"
6+
7+// RevisionCandidateKind identifies the lookup needed to resolve a revision.
8+type RevisionCandidateKind uint8
9+
10+const (
11+ RevisionCandidateHead RevisionCandidateKind = iota
12+ RevisionCandidateObject
13+ RevisionCandidateRef
14+ RevisionCandidateAbbrevID
15+)
16+
17+// RevisionCandidate is one lookup to try when resolving a revision.
18+// Name is HEAD, an object id, a full ref name, or an abbreviated object id.
19+type RevisionCandidate struct {
20+ Kind RevisionCandidateKind
21+ Name string
22+}
23+
24+// Candidates returns the resolution order. HEAD is special; full object ids
25+// are objects before refs, so a ref cannot redirect an existing object link.
26+// Otherwise branches beat tags. Full ref names and abbreviated ids are tried
27+// as written only after the branch and tag forms.
28+func (r Revision) Candidates() []RevisionCandidate {
29+ s := r.String()
30+ if r.IsHead() || s == "HEAD" {
31+ return []RevisionCandidate{{Kind: RevisionCandidateHead, Name: "HEAD"}}
32+ }
33+ refs := []RevisionCandidate{
34+ {Kind: RevisionCandidateRef, Name: "refs/heads/" + s},
35+ {Kind: RevisionCandidateRef, Name: "refs/tags/" + s},
36+ }
37+ switch {
38+ case r.IsObjectID():
39+ return append([]RevisionCandidate{{Kind: RevisionCandidateObject, Name: s}}, refs...)
40+ case strings.HasPrefix(s, "refs/"):
41+ return append(refs, RevisionCandidate{Kind: RevisionCandidateRef, Name: s})
42+ case r.IsAbbrevID():
43+ return append(refs, RevisionCandidate{Kind: RevisionCandidateAbbrevID, Name: s})
44+ }
45+ return refs
46+}
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "strings"
6+
7+// RevisionCandidateKind identifies the lookup needed to resolve a revision.
8+type RevisionCandidateKind uint8
9+
10+const (
11+ RevisionCandidateHead RevisionCandidateKind = iota
12+ RevisionCandidateObject
13+ RevisionCandidateRef
14+ RevisionCandidateAbbrevID
15+)
16+
17+// RevisionCandidate is one lookup to try when resolving a revision.
18+// Name is HEAD, an object id, a full ref name, or an abbreviated object id.
19+type RevisionCandidate struct {
20+ Kind RevisionCandidateKind
21+ Name string
22+}
23+
24+// Candidates returns the resolution order. HEAD is special; full object ids
25+// are objects before refs, so a ref cannot redirect an existing object link.
26+// Otherwise branches beat tags. Full ref names and abbreviated ids are tried
27+// as written only after the branch and tag forms.
28+func (r Revision) Candidates() []RevisionCandidate {
29+ s := r.String()
30+ if r.IsHead() || s == "HEAD" {
31+ return []RevisionCandidate{{Kind: RevisionCandidateHead, Name: "HEAD"}}
32+ }
33+ refs := []RevisionCandidate{
34+ {Kind: RevisionCandidateRef, Name: "refs/heads/" + s},
35+ {Kind: RevisionCandidateRef, Name: "refs/tags/" + s},
36+ }
37+ switch {
38+ case r.IsObjectID():
39+ return append([]RevisionCandidate{{Kind: RevisionCandidateObject, Name: s}}, refs...)
40+ case strings.HasPrefix(s, "refs/"):
41+ return append(refs, RevisionCandidate{Kind: RevisionCandidateRef, Name: s})
42+ case r.IsAbbrevID():
43+ return append(refs, RevisionCandidate{Kind: RevisionCandidateAbbrevID, Name: s})
44+ }
45+ return refs
46+}
A internal/domain/revision_test.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestRevisionCandidates(t *testing.T) {
16+ full := strings.Repeat("a", 40)
17+ ref := func(s string) domain.RevisionCandidate {
18+ return domain.RevisionCandidate{Kind: domain.RevisionCandidateRef, Name: s}
19+ }
20+ branches := func(s string) []domain.RevisionCandidate {
21+ return []domain.RevisionCandidate{ref("refs/heads/" + s), ref("refs/tags/" + s)}
22+ }
23+ for _, tt := range []struct {
24+ name, text string
25+ want []domain.RevisionCandidate
26+ }{
27+ {"zero HEAD", "", []domain.RevisionCandidate{{Kind: domain.RevisionCandidateHead, Name: "HEAD"}}},
28+ {"explicit HEAD", "HEAD", []domain.RevisionCandidate{{Kind: domain.RevisionCandidateHead, Name: "HEAD"}}},
29+ {"full object first", full, append([]domain.RevisionCandidate{{Kind: domain.RevisionCandidateObject, Name: full}}, branches(full)...)},
30+ {"full branch ref last", "refs/heads/main", append(branches("refs/heads/main"), ref("refs/heads/main"))},
31+ {"full tag ref last", "refs/tags/v1", append(branches("refs/tags/v1"), ref("refs/tags/v1"))},
32+ {"other full ref last", "refs/custom/x", append(branches("refs/custom/x"), ref("refs/custom/x"))},
33+ {"minimum abbreviation last", "abc1234", append(branches("abc1234"), domain.RevisionCandidate{Kind: domain.RevisionCandidateAbbrevID, Name: "abc1234"})},
34+ {"maximum abbreviation last", full[:39], append(branches(full[:39]), domain.RevisionCandidate{Kind: domain.RevisionCandidateAbbrevID, Name: full[:39]})},
35+ {"short hex is ref", "abc123", branches("abc123")},
36+ {"uppercase hex is ref", "ABC1234", branches("ABC1234")},
37+ {"ordinary name", "main", branches("main")},
38+ {"slash name", "feature/x", branches("feature/x")},
39+ } {
40+ t.Run(tt.name, func(t *testing.T) {
41+ r, err := domain.ParseRevision(tt.text)
42+ require.NoError(t, err)
43+ assert.Equal(t, tt.want, r.Candidates())
44+ })
45+ }
46+}
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestRevisionCandidates(t *testing.T) {
16+ full := strings.Repeat("a", 40)
17+ ref := func(s string) domain.RevisionCandidate {
18+ return domain.RevisionCandidate{Kind: domain.RevisionCandidateRef, Name: s}
19+ }
20+ branches := func(s string) []domain.RevisionCandidate {
21+ return []domain.RevisionCandidate{ref("refs/heads/" + s), ref("refs/tags/" + s)}
22+ }
23+ for _, tt := range []struct {
24+ name, text string
25+ want []domain.RevisionCandidate
26+ }{
27+ {"zero HEAD", "", []domain.RevisionCandidate{{Kind: domain.RevisionCandidateHead, Name: "HEAD"}}},
28+ {"explicit HEAD", "HEAD", []domain.RevisionCandidate{{Kind: domain.RevisionCandidateHead, Name: "HEAD"}}},
29+ {"full object first", full, append([]domain.RevisionCandidate{{Kind: domain.RevisionCandidateObject, Name: full}}, branches(full)...)},
30+ {"full branch ref last", "refs/heads/main", append(branches("refs/heads/main"), ref("refs/heads/main"))},
31+ {"full tag ref last", "refs/tags/v1", append(branches("refs/tags/v1"), ref("refs/tags/v1"))},
32+ {"other full ref last", "refs/custom/x", append(branches("refs/custom/x"), ref("refs/custom/x"))},
33+ {"minimum abbreviation last", "abc1234", append(branches("abc1234"), domain.RevisionCandidate{Kind: domain.RevisionCandidateAbbrevID, Name: "abc1234"})},
34+ {"maximum abbreviation last", full[:39], append(branches(full[:39]), domain.RevisionCandidate{Kind: domain.RevisionCandidateAbbrevID, Name: full[:39]})},
35+ {"short hex is ref", "abc123", branches("abc123")},
36+ {"uppercase hex is ref", "ABC1234", branches("ABC1234")},
37+ {"ordinary name", "main", branches("main")},
38+ {"slash name", "feature/x", branches("feature/x")},
39+ } {
40+ t.Run(tt.name, func(t *testing.T) {
41+ r, err := domain.ParseRevision(tt.text)
42+ require.NoError(t, err)
43+ assert.Equal(t, tt.want, r.Candidates())
44+ })
45+ }
46+}

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