merge: hexmove/revision into chore/hexcheck-v0.4.0
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) { | |||
| 119 | 119 | return commit, true, nil | |
| 120 | 120 | } | |
| 121 | 121 | ||
| 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. | |
| 129 | 124 | 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) | |
| 139 | 127 | if err != nil { | |
| 140 | 128 | return resolved{}, err | |
| 141 | 129 | } | |
| 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 | |
| 144 | 132 | } | |
| 145 | 133 | } | |
| 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 | |
| 150 | 141 | } | |
| 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 | |
| 155 | 154 | } | |
| 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) | |
| 158 | 158 | if err == nil { | |
| 159 | 159 | // The prefix may name an annotated tag: git peels it. | |
| 160 | 160 | id, err = r.PeelToCommit(id) | |
| 161 | 161 | } | |
| 162 | 162 | switch { | |
| 163 | 163 | case err == nil: | |
| 164 | - | return resolved{commit: id}, nil | |
| 164 | + | return id, true, nil | |
| 165 | 165 | case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType): | |
| 166 | + | return git.ObjectID{}, false, nil | |
| 166 | 167 | default: | |
| 167 | - | return resolved{}, err | |
| 168 | + | 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 | 174 | 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, nil | 119 | return commit, true, nil | ||
| 120 | } | 120 | } | ||
| 121 | 121 | ||||
| 122 | - | // resolveRevision tries the candidates of gitcli in the same order, which | 122 | + | // 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 it | 123 | + | // 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{}, err | 128 | return resolved{}, err | ||
| 141 | } | 129 | } | ||
| 142 | - | if c, ok, err := peelID(r, id); err != nil || ok { | 130 | + | if ok { |
| 143 | - | return resolved{commit: c}, err | 131 | + | 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}, err | 136 | + | |
| 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}, err | 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 | |||
| 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}, nil | 164 | + | 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{}, err | 168 | + | 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. | |||
| 291 | 291 | ||
| 292 | 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 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 | |
| 295 | 295 | func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | |
| 296 | 296 | cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) } | |
| 297 | 297 | 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 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 |
| 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.ResolveRevision | 297 | route := a.routes.ResolveRevision | ||
M internal/adapters/gitcli/read.go +38 -51
| @@ -153,63 +153,50 @@ func readHead(dir string) (string, error) { | |||
| 153 | 153 | return ref, nil | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 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. | |
| 194 | 157 | 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) | |
| 200 | 160 | if err != nil { | |
| 201 | 161 | return domain.ResolvedRevision{}, err | |
| 202 | 162 | } | |
| 203 | - | if h.Type != "commit" { | |
| 163 | + | if !ok { | |
| 204 | 164 | continue | |
| 205 | 165 | } | |
| 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 | |
| 211 | 198 | } | |
| 212 | - | return domain.ResolvedRevision{}, fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound) | |
| 199 | + | return "" | |
| 213 | 200 | } | |
| 214 | 201 | ||
| 215 | 202 | // ResolveRevision resolves a revision with `cat-file`. | |
| @@ -261,7 +248,7 @@ type object struct { | |||
| 261 | 248 | // request series on the repository's process. A missing path is | |
| 262 | 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 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 | |
| 265 | 252 | func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) { | |
| 266 | 253 | var obj object | |
| 267 | 254 | err := g.cat.do(ctx, dir, func(c *catConn) error { | |
| @@ -153,63 +153,50 @@ func readHead(dir string) (string, error) { | |||||
| 153 | return ref, nil | 153 | 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{}, err | 161 | return domain.ResolvedRevision{}, err | ||
| 202 | } | 162 | } | ||
| 203 | - | if h.Type != "commit" { | 163 | + | if !ok { |
| 204 | continue | 164 | 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}, nil | 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 | |||
| 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 is | 248 | // 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 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 |
| 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 object | 253 | 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 | + | } | |||