detached 7f0f2f2

refactor(domain): share blob sniffing, commit text, tree order and ref path splitting

The rules a RepoReader applies to what it reads (binary sniff, media
type, commit message, tree order and bounds, longest ref match, commit
query validation) move out of gitcli so that a second adapter applies
exactly the same ones.
commit
7f0f2f2e3e4bb5c8ed4cd1cb148a746d84799bdd
author
brice · · 2026-10-10 22:07 UTC
parent
7586fbd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +147 -96
M internal/adapters/gitcli/blob.go +2 -41
@@ -9,11 +9,8 @@ import (
99 "errors"
1010 "fmt"
1111 "io"
12- "net/http"
1312 "os"
1413 "os/exec"
15- "path"
16- "strings"
1714 "sync"
1815
1916 "bnema.dev/ermite/internal/domain"
@@ -77,7 +74,7 @@ func (g *Git) OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Rev
7774 info := domain.BlobInfo{ID: id, Path: p.String(), Size: h.Size, Mode: h.Mode}
7875
7976 if data != nil || h.Size == 0 {
80- fillMedia(&info, data)
77+ info.SniffContent(data)
8178 return info, io.NopCloser(bytes.NewReader(data)), nil
8279 }
8380
@@ -90,7 +87,7 @@ func (g *Git) OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Rev
9087 _ = s.Close()
9188 return domain.BlobInfo{}, nil, fmt.Errorf("blob %q of %q: %w", p, repo, ctxOr(ctx, err))
9289 }
93- fillMedia(&info, head)
90+ info.SniffContent(head)
9491 return info, s, nil
9592 }
9693
@@ -101,42 +98,6 @@ func ctxOr(ctx context.Context, err error) error {
10198 return err
10299 }
103100
104-// fillMedia sets Binary and MediaType from the first bytes of the content.
105-func fillMedia(info *domain.BlobInfo, content []byte) {
106- head := content[:min(len(content), domain.BinarySniffLen)]
107- info.Binary = bytes.IndexByte(head, 0) >= 0
108- info.MediaType = mediaType(info.Path, head, info.Binary)
109-}
110-
111-// textTypes maps the extensions whose media type is more precise than what
112-// the content shows. It is fixed here so that the answer does not depend on the
113-// MIME tables of the host.
114-var textTypes = map[string]string{
115- ".html": "text/html", ".htm": "text/html", ".css": "text/css",
116- ".js": "text/javascript", ".mjs": "text/javascript",
117- ".json": "application/json", ".xml": "application/xml",
118- ".svg": "image/svg+xml", ".md": "text/markdown", ".csv": "text/csv",
119-}
120-
121-// mediaType guesses a media type without parameters. What the content shows
122-// (images, archives, PDF…) wins; for plain text the file name may refine it
123-// from textTypes, but a name never makes binary content text.
124-func mediaType(name string, head []byte, binary bool) string {
125- sniffed, _, _ := strings.Cut(http.DetectContentType(head), ";")
126- switch {
127- case binary && strings.HasPrefix(sniffed, "text/"):
128- return "application/octet-stream"
129- case sniffed != "text/plain" && sniffed != "text/xml" && sniffed != "application/octet-stream":
130- return sniffed
131- case binary:
132- return "application/octet-stream"
133- }
134- if t, ok := textTypes[strings.ToLower(path.Ext(name))]; ok {
135- return t
136- }
137- return "text/plain"
138-}
139-
140101 // blobStream is the content of a blob read from its own git process.
141102 type blobStream struct {
142103 pool *catPool
@@ -9,11 +9,8 @@ import (
9 "errors"9 "errors"
10 "fmt"10 "fmt"
11 "io"11 "io"
12- "net/http"
13 "os"12 "os"
14 "os/exec"13 "os/exec"
15- "path"
16- "strings"
17 "sync"14 "sync"
18 15
19 "bnema.dev/ermite/internal/domain"16 "bnema.dev/ermite/internal/domain"
@@ -77,7 +74,7 @@ func (g *Git) OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Rev
77 info := domain.BlobInfo{ID: id, Path: p.String(), Size: h.Size, Mode: h.Mode}74 info := domain.BlobInfo{ID: id, Path: p.String(), Size: h.Size, Mode: h.Mode}
78 75
79 if data != nil || h.Size == 0 {76 if data != nil || h.Size == 0 {
80- fillMedia(&info, data)77+ info.SniffContent(data)
81 return info, io.NopCloser(bytes.NewReader(data)), nil78 return info, io.NopCloser(bytes.NewReader(data)), nil
82 }79 }
83 80
@@ -90,7 +87,7 @@ func (g *Git) OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Rev
90 _ = s.Close()87 _ = s.Close()
91 return domain.BlobInfo{}, nil, fmt.Errorf("blob %q of %q: %w", p, repo, ctxOr(ctx, err))88 return domain.BlobInfo{}, nil, fmt.Errorf("blob %q of %q: %w", p, repo, ctxOr(ctx, err))
92 }89 }
93- fillMedia(&info, head)90+ info.SniffContent(head)
94 return info, s, nil91 return info, s, nil
95 }92 }
96 93
@@ -101,42 +98,6 @@ func ctxOr(ctx context.Context, err error) error {
101 return err98 return err
102 }99 }
103 100
104-// fillMedia sets Binary and MediaType from the first bytes of the content.
105-func fillMedia(info *domain.BlobInfo, content []byte) {
106- head := content[:min(len(content), domain.BinarySniffLen)]
107- info.Binary = bytes.IndexByte(head, 0) >= 0
108- info.MediaType = mediaType(info.Path, head, info.Binary)
109-}
110-
111-// textTypes maps the extensions whose media type is more precise than what
112-// the content shows. It is fixed here so that the answer does not depend on the
113-// MIME tables of the host.
114-var textTypes = map[string]string{
115- ".html": "text/html", ".htm": "text/html", ".css": "text/css",
116- ".js": "text/javascript", ".mjs": "text/javascript",
117- ".json": "application/json", ".xml": "application/xml",
118- ".svg": "image/svg+xml", ".md": "text/markdown", ".csv": "text/csv",
119-}
120-
121-// mediaType guesses a media type without parameters. What the content shows
122-// (images, archives, PDF…) wins; for plain text the file name may refine it
123-// from textTypes, but a name never makes binary content text.
124-func mediaType(name string, head []byte, binary bool) string {
125- sniffed, _, _ := strings.Cut(http.DetectContentType(head), ";")
126- switch {
127- case binary && strings.HasPrefix(sniffed, "text/"):
128- return "application/octet-stream"
129- case sniffed != "text/plain" && sniffed != "text/xml" && sniffed != "application/octet-stream":
130- return sniffed
131- case binary:
132- return "application/octet-stream"
133- }
134- if t, ok := textTypes[strings.ToLower(path.Ext(name))]; ok {
135- return t
136- }
137- return "text/plain"
138-}
139-
140 // blobStream is the content of a blob read from its own git process.101 // blobStream is the content of a blob read from its own git process.
141 type blobStream struct {102 type blobStream struct {
142 pool *catPool103 pool *catPool
M internal/adapters/gitcli/read.go +13 -55
@@ -11,7 +11,6 @@ import (
1111 "io/fs"
1212 "os"
1313 "path/filepath"
14- "slices"
1514 "strconv"
1615 "strings"
1716 "sync"
@@ -232,49 +231,21 @@ func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev dom
232231 return res, nil
233232 }
234233
235-// maxRefPathTries bounds the number of leading segments tried as a ref name.
236-const maxRefPathTries = 32
237-
238234 // ResolveRefPath tries the leading segments of path as a revision (branch,
239235 // tag, full ref name, HEAD; a single segment may also be a commit id), longest
240236 // first, with up to one `cat-file` request per candidate on the repository's
241-// process. At most 32 segments are tried.
242-//
243-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
237+// process. At most domain.MaxRefPathTries segments are tried.
244238 func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
245239 dir, err := g.repoDir(repo)
246240 if err != nil {
247241 return domain.RefPath{}, err
248242 }
249- path = strings.Trim(path, "/")
250- segs := strings.Split(path, "/")
251- if path == "" || slices.Contains(segs, "") {
252- return domain.RefPath{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
253- }
254243 var out domain.RefPath
255- err = g.cat.do(ctx, dir, func(c *catConn) error {
256- out = domain.RefPath{}
257- for n := min(len(segs), maxRefPathTries); n >= 1; n-- {
258- name := strings.Join(segs[:n], "/")
259- rev, err := domain.ParseRevision(name)
260- if err != nil {
261- continue
262- }
263- res, err := resolve(c, rev)
264- if errors.Is(err, domain.ErrNotFound) {
265- continue
266- }
267- if err != nil {
268- return err
269- }
270- rest, err := domain.ParseTreePath(strings.Join(segs[n:], "/"))
271- if err != nil {
272- return err
273- }
274- out = domain.RefPath{Name: name, Ref: res.Ref, CommitID: res.CommitID, Rest: rest}
275- return nil
276- }
277- return fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
244+ err = g.cat.do(ctx, dir, func(c *catConn) (err error) {
245+ out, err = domain.SplitRefPath(path, func(rev domain.Revision) (domain.ResolvedRevision, error) {
246+ return resolve(c, rev)
247+ })
248+ return err
278249 })
279250 if err != nil {
280251 return domain.RefPath{}, fmt.Errorf("resolve path %q in %q: %w", path, repo, err)
@@ -355,20 +326,8 @@ func (g *Git) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revi
355326 if err != nil {
356327 return domain.Tree{}, fmt.Errorf("list tree %q of %q: %w", path, repo, err)
357328 }
358- slices.SortStableFunc(tree.Entries, func(a, b domain.TreeEntry) int {
359- ad, bd := a.Kind == domain.EntryDirectory, b.Kind == domain.EntryDirectory
360- switch {
361- case ad && !bd:
362- return -1
363- case !ad && bd:
364- return 1
365- }
366- return strings.Compare(a.Name, b.Name)
367- })
368-
369- if len(tree.Entries) > domain.MaxTreeEntries {
370- tree.Entries, tree.Truncated = tree.Entries[:domain.MaxTreeEntries], true
371- }
329+ domain.SortTreeEntries(tree.Entries)
330+ tree.BoundTree()
372331 if includeLast {
373332 if err := g.fillLastCommits(ctx, dir, &tree); err != nil {
374333 return domain.Tree{}, fmt.Errorf("last commits of %q in %q: %w", path, repo, err)
@@ -538,8 +497,7 @@ func readCommit(r *bufio.Reader) (domain.Commit, error) {
538497 if c.Committer, err = signature(f[5], f[6], f[7]); err != nil {
539498 return domain.Commit{}, fmt.Errorf("git log: committer of %s: %w", id, err)
540499 }
541- c.Message = strings.TrimRight(strings.ToValidUTF8(f[8], "\uFFFD"), "\n")
542- c.Summary, _, _ = strings.Cut(c.Message, "\n")
500+ c.Message, c.Summary = domain.SplitCommitMessage(f[8])
543501 return c, nil
544502 }
545503
@@ -549,8 +507,8 @@ func signature(name, email, unix string) (domain.Signature, error) {
549507 return domain.Signature{}, err
550508 }
551509 return domain.Signature{
552- Name: strings.ToValidUTF8(name, "\uFFFD"),
553- Email: strings.ToValidUTF8(email, "\uFFFD"),
510+ Name: domain.ValidGitText(name),
511+ Email: domain.ValidGitText(email),
554512 Time: time.Unix(sec, 0).UTC(),
555513 }, nil
556514 }
@@ -559,8 +517,8 @@ func signature(name, email, unix string) (domain.Signature, error) {
559517 //
560518 //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
561519 func (g *Git) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {
562- if q.Limit < 1 || q.Limit > ports.MaxCommitLimit || q.Skip < 0 {
563- return ports.CommitPage{}, fmt.Errorf("list commits: %w: limit %d, skip %d", domain.ErrInvalidPageSize, q.Limit, q.Skip)
520+ if err := q.Validate(); err != nil {
521+ return ports.CommitPage{}, err
564522 }
565523 dir, err := g.repoDir(repo)
566524 if err != nil {
@@ -11,7 +11,6 @@ import (
11 "io/fs"11 "io/fs"
12 "os"12 "os"
13 "path/filepath"13 "path/filepath"
14- "slices"
15 "strconv"14 "strconv"
16 "strings"15 "strings"
17 "sync"16 "sync"
@@ -232,49 +231,21 @@ func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev dom
232 return res, nil231 return res, nil
233 }232 }
234 233
235-// maxRefPathTries bounds the number of leading segments tried as a ref name.
236-const maxRefPathTries = 32
237-
238 // ResolveRefPath tries the leading segments of path as a revision (branch,234 // ResolveRefPath tries the leading segments of path as a revision (branch,
239 // tag, full ref name, HEAD; a single segment may also be a commit id), longest235 // tag, full ref name, HEAD; a single segment may also be a commit id), longest
240 // first, with up to one `cat-file` request per candidate on the repository's236 // first, with up to one `cat-file` request per candidate on the repository's
241-// process. At most 32 segments are tried.237+// process. At most domain.MaxRefPathTries segments are tried.
242-//
243-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
244 func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {238 func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
245 dir, err := g.repoDir(repo)239 dir, err := g.repoDir(repo)
246 if err != nil {240 if err != nil {
247 return domain.RefPath{}, err241 return domain.RefPath{}, err
248 }242 }
249- path = strings.Trim(path, "/")
250- segs := strings.Split(path, "/")
251- if path == "" || slices.Contains(segs, "") {
252- return domain.RefPath{}, fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
253- }
254 var out domain.RefPath243 var out domain.RefPath
255- err = g.cat.do(ctx, dir, func(c *catConn) error {244+ err = g.cat.do(ctx, dir, func(c *catConn) (err error) {
256- out = domain.RefPath{}245+ out, err = domain.SplitRefPath(path, func(rev domain.Revision) (domain.ResolvedRevision, error) {
257- for n := min(len(segs), maxRefPathTries); n >= 1; n-- {246+ return resolve(c, rev)
258- name := strings.Join(segs[:n], "/")247+ })
259- rev, err := domain.ParseRevision(name)248+ return err
260- if err != nil {
261- continue
262- }
263- res, err := resolve(c, rev)
264- if errors.Is(err, domain.ErrNotFound) {
265- continue
266- }
267- if err != nil {
268- return err
269- }
270- rest, err := domain.ParseTreePath(strings.Join(segs[n:], "/"))
271- if err != nil {
272- return err
273- }
274- out = domain.RefPath{Name: name, Ref: res.Ref, CommitID: res.CommitID, Rest: rest}
275- return nil
276- }
277- return fmt.Errorf("path %q: %w", path, domain.ErrNotFound)
278 })249 })
279 if err != nil {250 if err != nil {
280 return domain.RefPath{}, fmt.Errorf("resolve path %q in %q: %w", path, repo, err)251 return domain.RefPath{}, fmt.Errorf("resolve path %q in %q: %w", path, repo, err)
@@ -355,20 +326,8 @@ func (g *Git) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revi
355 if err != nil {326 if err != nil {
356 return domain.Tree{}, fmt.Errorf("list tree %q of %q: %w", path, repo, err)327 return domain.Tree{}, fmt.Errorf("list tree %q of %q: %w", path, repo, err)
357 }328 }
358- slices.SortStableFunc(tree.Entries, func(a, b domain.TreeEntry) int {329+ domain.SortTreeEntries(tree.Entries)
359- ad, bd := a.Kind == domain.EntryDirectory, b.Kind == domain.EntryDirectory330+ tree.BoundTree()
360- switch {
361- case ad && !bd:
362- return -1
363- case !ad && bd:
364- return 1
365- }
366- return strings.Compare(a.Name, b.Name)
367- })
368-
369- if len(tree.Entries) > domain.MaxTreeEntries {
370- tree.Entries, tree.Truncated = tree.Entries[:domain.MaxTreeEntries], true
371- }
372 if includeLast {331 if includeLast {
373 if err := g.fillLastCommits(ctx, dir, &tree); err != nil {332 if err := g.fillLastCommits(ctx, dir, &tree); err != nil {
374 return domain.Tree{}, fmt.Errorf("last commits of %q in %q: %w", path, repo, err)333 return domain.Tree{}, fmt.Errorf("last commits of %q in %q: %w", path, repo, err)
@@ -538,8 +497,7 @@ func readCommit(r *bufio.Reader) (domain.Commit, error) {
538 if c.Committer, err = signature(f[5], f[6], f[7]); err != nil {497 if c.Committer, err = signature(f[5], f[6], f[7]); err != nil {
539 return domain.Commit{}, fmt.Errorf("git log: committer of %s: %w", id, err)498 return domain.Commit{}, fmt.Errorf("git log: committer of %s: %w", id, err)
540 }499 }
541- c.Message = strings.TrimRight(strings.ToValidUTF8(f[8], "\uFFFD"), "\n")500+ c.Message, c.Summary = domain.SplitCommitMessage(f[8])
542- c.Summary, _, _ = strings.Cut(c.Message, "\n")
543 return c, nil501 return c, nil
544 }502 }
545 503
@@ -549,8 +507,8 @@ func signature(name, email, unix string) (domain.Signature, error) {
549 return domain.Signature{}, err507 return domain.Signature{}, err
550 }508 }
551 return domain.Signature{509 return domain.Signature{
552- Name: strings.ToValidUTF8(name, "\uFFFD"),510+ Name: domain.ValidGitText(name),
553- Email: strings.ToValidUTF8(email, "\uFFFD"),511+ Email: domain.ValidGitText(email),
554 Time: time.Unix(sec, 0).UTC(),512 Time: time.Unix(sec, 0).UTC(),
555 }, nil513 }, nil
556 }514 }
@@ -559,8 +517,8 @@ func signature(name, email, unix string) (domain.Signature, error) {
559 //517 //
560 //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port518 //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
561 func (g *Git) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {519 func (g *Git) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {
562- if q.Limit < 1 || q.Limit > ports.MaxCommitLimit || q.Skip < 0 {520+ if err := q.Validate(); err != nil {
563- return ports.CommitPage{}, fmt.Errorf("list commits: %w: limit %d, skip %d", domain.ErrInvalidPageSize, q.Limit, q.Skip)521+ return ports.CommitPage{}, err
564 }522 }
565 dir, err := g.repoDir(repo)523 dir, err := g.repoDir(repo)
566 if err != nil {524 if err != nil {
M internal/domain/git.go +61 -0
@@ -5,6 +5,7 @@ package domain
55 import (
66 "errors"
77 "fmt"
8+ "slices"
89 "strings"
910 "time"
1011 "unicode/utf8"
@@ -488,3 +489,63 @@ func ParseBranchName(s string) (string, error) {
488489 }
489490 return s, nil
490491 }
492+
493+// MaxRefPathTries bounds the number of leading segments of a path tried as a
494+// ref name by SplitRefPath.
495+const MaxRefPathTries = 32
496+
497+// SplitRefPath splits a path such as "feature/x/dir/file" into the longest
498+// leading part that resolve accepts as a revision and the rest. resolve
499+// reports domain.ErrNotFound for a name that is not a revision; any other
500+// error stops the search. At most MaxRefPathTries leading segments are tried.
501+// It returns ErrNotFound if nothing matches.
502+func SplitRefPath(path string, resolve func(Revision) (ResolvedRevision, error)) (RefPath, error) {
503+ path = strings.Trim(path, "/")
504+ segs := strings.Split(path, "/")
505+ if path == "" || slices.Contains(segs, "") {
506+ return RefPath{}, fmt.Errorf("path %q: %w", path, ErrNotFound)
507+ }
508+ for n := min(len(segs), MaxRefPathTries); n >= 1; n-- {
509+ name := strings.Join(segs[:n], "/")
510+ rev, err := ParseRevision(name)
511+ if err != nil {
512+ continue
513+ }
514+ res, err := resolve(rev)
515+ if errors.Is(err, ErrNotFound) {
516+ continue
517+ }
518+ if err != nil {
519+ return RefPath{}, err
520+ }
521+ rest, err := ParseTreePath(strings.Join(segs[n:], "/"))
522+ if err != nil {
523+ return RefPath{}, err
524+ }
525+ return RefPath{Name: name, Ref: res.Ref, CommitID: res.CommitID, Rest: rest}, nil
526+ }
527+ return RefPath{}, fmt.Errorf("path %q: %w", path, ErrNotFound)
528+}
529+
530+// SortTreeEntries orders the entries of a directory as the port promises:
531+// directories first, then the other entries, each group by name.
532+func SortTreeEntries(entries []TreeEntry) {
533+ slices.SortStableFunc(entries, func(a, b TreeEntry) int {
534+ ad, bd := a.Kind == EntryDirectory, b.Kind == EntryDirectory
535+ switch {
536+ case ad && !bd:
537+ return -1
538+ case !ad && bd:
539+ return 1
540+ }
541+ return strings.Compare(a.Name, b.Name)
542+ })
543+}
544+
545+// BoundTree keeps the first MaxTreeEntries entries of t and sets Truncated
546+// when there were more.
547+func (t *Tree) BoundTree() {
548+ if len(t.Entries) > MaxTreeEntries {
549+ t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true
550+ }
551+}
@@ -5,6 +5,7 @@ package domain
5 import (5 import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "slices"
8 "strings"9 "strings"
9 "time"10 "time"
10 "unicode/utf8"11 "unicode/utf8"
@@ -488,3 +489,63 @@ func ParseBranchName(s string) (string, error) {
488 }489 }
489 return s, nil490 return s, nil
490 }491 }
492+
493+// MaxRefPathTries bounds the number of leading segments of a path tried as a
494+// ref name by SplitRefPath.
495+const MaxRefPathTries = 32
496+
497+// SplitRefPath splits a path such as "feature/x/dir/file" into the longest
498+// leading part that resolve accepts as a revision and the rest. resolve
499+// reports domain.ErrNotFound for a name that is not a revision; any other
500+// error stops the search. At most MaxRefPathTries leading segments are tried.
501+// It returns ErrNotFound if nothing matches.
502+func SplitRefPath(path string, resolve func(Revision) (ResolvedRevision, error)) (RefPath, error) {
503+ path = strings.Trim(path, "/")
504+ segs := strings.Split(path, "/")
505+ if path == "" || slices.Contains(segs, "") {
506+ return RefPath{}, fmt.Errorf("path %q: %w", path, ErrNotFound)
507+ }
508+ for n := min(len(segs), MaxRefPathTries); n >= 1; n-- {
509+ name := strings.Join(segs[:n], "/")
510+ rev, err := ParseRevision(name)
511+ if err != nil {
512+ continue
513+ }
514+ res, err := resolve(rev)
515+ if errors.Is(err, ErrNotFound) {
516+ continue
517+ }
518+ if err != nil {
519+ return RefPath{}, err
520+ }
521+ rest, err := ParseTreePath(strings.Join(segs[n:], "/"))
522+ if err != nil {
523+ return RefPath{}, err
524+ }
525+ return RefPath{Name: name, Ref: res.Ref, CommitID: res.CommitID, Rest: rest}, nil
526+ }
527+ return RefPath{}, fmt.Errorf("path %q: %w", path, ErrNotFound)
528+}
529+
530+// SortTreeEntries orders the entries of a directory as the port promises:
531+// directories first, then the other entries, each group by name.
532+func SortTreeEntries(entries []TreeEntry) {
533+ slices.SortStableFunc(entries, func(a, b TreeEntry) int {
534+ ad, bd := a.Kind == EntryDirectory, b.Kind == EntryDirectory
535+ switch {
536+ case ad && !bd:
537+ return -1
538+ case !ad && bd:
539+ return 1
540+ }
541+ return strings.Compare(a.Name, b.Name)
542+ })
543+}
544+
545+// BoundTree keeps the first MaxTreeEntries entries of t and sets Truncated
546+// when there were more.
547+func (t *Tree) BoundTree() {
548+ if len(t.Entries) > MaxTreeEntries {
549+ t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true
550+ }
551+}
A internal/domain/media.go +61 -0
@@ -0,0 +1,61 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "bytes"
7+ "net/http"
8+ "path"
9+ "strings"
10+)
11+
12+// SniffContent sets Binary and MediaType from the first bytes of the content
13+// (Path must be set). Only the first BinarySniffLen bytes count, so a caller
14+// may pass the whole content or just its head. Every reader of blobs applies
15+// this one rule.
16+func (b *BlobInfo) SniffContent(content []byte) {
17+ head := content[:min(len(content), BinarySniffLen)]
18+ b.Binary = bytes.IndexByte(head, 0) >= 0
19+ b.MediaType = mediaType(b.Path, head, b.Binary)
20+}
21+
22+// textTypes maps the extensions whose media type is more precise than what
23+// the content shows. It is fixed here so that the answer does not depend on the
24+// MIME tables of the host.
25+var textTypes = map[string]string{
26+ ".html": "text/html", ".htm": "text/html", ".css": "text/css",
27+ ".js": "text/javascript", ".mjs": "text/javascript",
28+ ".json": "application/json", ".xml": "application/xml",
29+ ".svg": "image/svg+xml", ".md": "text/markdown", ".csv": "text/csv",
30+}
31+
32+// mediaType guesses a media type without parameters. What the content shows
33+// (images, archives, PDF…) wins; for plain text the file name may refine it
34+// from textTypes, but a name never makes binary content text.
35+func mediaType(name string, head []byte, binary bool) string {
36+ sniffed, _, _ := strings.Cut(http.DetectContentType(head), ";")
37+ switch {
38+ case binary && strings.HasPrefix(sniffed, "text/"):
39+ return "application/octet-stream"
40+ case sniffed != "text/plain" && sniffed != "text/xml" && sniffed != "application/octet-stream":
41+ return sniffed
42+ case binary:
43+ return "application/octet-stream"
44+ }
45+ if t, ok := textTypes[strings.ToLower(path.Ext(name))]; ok {
46+ return t
47+ }
48+ return "text/plain"
49+}
50+
51+// ValidGitText replaces the bytes of s that are not valid UTF-8 with U+FFFD:
52+// names, emails and messages of commits may hold any bytes.
53+func ValidGitText(s string) string { return strings.ToValidUTF8(s, "\uFFFD") }
54+
55+// SplitCommitMessage turns the raw message of a commit into Commit.Message
56+// (valid UTF-8, no trailing line breaks) and Commit.Summary (its first line).
57+func SplitCommitMessage(raw string) (message, summary string) {
58+ message = strings.TrimRight(ValidGitText(raw), "\n")
59+ summary, _, _ = strings.Cut(message, "\n")
60+ return message, summary
61+}
@@ -0,0 +1,61 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "bytes"
7+ "net/http"
8+ "path"
9+ "strings"
10+)
11+
12+// SniffContent sets Binary and MediaType from the first bytes of the content
13+// (Path must be set). Only the first BinarySniffLen bytes count, so a caller
14+// may pass the whole content or just its head. Every reader of blobs applies
15+// this one rule.
16+func (b *BlobInfo) SniffContent(content []byte) {
17+ head := content[:min(len(content), BinarySniffLen)]
18+ b.Binary = bytes.IndexByte(head, 0) >= 0
19+ b.MediaType = mediaType(b.Path, head, b.Binary)
20+}
21+
22+// textTypes maps the extensions whose media type is more precise than what
23+// the content shows. It is fixed here so that the answer does not depend on the
24+// MIME tables of the host.
25+var textTypes = map[string]string{
26+ ".html": "text/html", ".htm": "text/html", ".css": "text/css",
27+ ".js": "text/javascript", ".mjs": "text/javascript",
28+ ".json": "application/json", ".xml": "application/xml",
29+ ".svg": "image/svg+xml", ".md": "text/markdown", ".csv": "text/csv",
30+}
31+
32+// mediaType guesses a media type without parameters. What the content shows
33+// (images, archives, PDF…) wins; for plain text the file name may refine it
34+// from textTypes, but a name never makes binary content text.
35+func mediaType(name string, head []byte, binary bool) string {
36+ sniffed, _, _ := strings.Cut(http.DetectContentType(head), ";")
37+ switch {
38+ case binary && strings.HasPrefix(sniffed, "text/"):
39+ return "application/octet-stream"
40+ case sniffed != "text/plain" && sniffed != "text/xml" && sniffed != "application/octet-stream":
41+ return sniffed
42+ case binary:
43+ return "application/octet-stream"
44+ }
45+ if t, ok := textTypes[strings.ToLower(path.Ext(name))]; ok {
46+ return t
47+ }
48+ return "text/plain"
49+}
50+
51+// ValidGitText replaces the bytes of s that are not valid UTF-8 with U+FFFD:
52+// names, emails and messages of commits may hold any bytes.
53+func ValidGitText(s string) string { return strings.ToValidUTF8(s, "\uFFFD") }
54+
55+// SplitCommitMessage turns the raw message of a commit into Commit.Message
56+// (valid UTF-8, no trailing line breaks) and Commit.Summary (its first line).
57+func SplitCommitMessage(raw string) (message, summary string) {
58+ message = strings.TrimRight(ValidGitText(raw), "\n")
59+ summary, _, _ = strings.Cut(message, "\n")
60+ return message, summary
61+}
M internal/ports/ports.go +10 -0
@@ -6,6 +6,7 @@ package ports
66
77 import (
88 "context"
9+ "fmt"
910 "io"
1011 "time"
1112
@@ -102,6 +103,15 @@ type CommitQuery struct {
102103 // MaxCommitLimit is the most commits one CommitQuery may ask for.
103104 const MaxCommitLimit = 1000
104105
106+// Validate checks the page size and the offset of the query. It returns an
107+// error matching domain.ErrInvalidPageSize.
108+func (q CommitQuery) Validate() error {
109+ if q.Limit < 1 || q.Limit > MaxCommitLimit || q.Skip < 0 {
110+ return fmt.Errorf("list commits: %w: limit %d, skip %d", domain.ErrInvalidPageSize, q.Limit, q.Skip)
111+ }
112+ return nil
113+}
114+
105115 // CommitPage is a page of history, newest first.
106116 type CommitPage struct {
107117 // Head is the commit the revision resolved to.
@@ -6,6 +6,7 @@ package ports
6 6
7 import (7 import (
8 "context"8 "context"
9+ "fmt"
9 "io"10 "io"
10 "time"11 "time"
11 12
@@ -102,6 +103,15 @@ type CommitQuery struct {
102 // MaxCommitLimit is the most commits one CommitQuery may ask for.103 // MaxCommitLimit is the most commits one CommitQuery may ask for.
103 const MaxCommitLimit = 1000104 const MaxCommitLimit = 1000
104 105
106+// Validate checks the page size and the offset of the query. It returns an
107+// error matching domain.ErrInvalidPageSize.
108+func (q CommitQuery) Validate() error {
109+ if q.Limit < 1 || q.Limit > MaxCommitLimit || q.Skip < 0 {
110+ return fmt.Errorf("list commits: %w: limit %d, skip %d", domain.ErrInvalidPageSize, q.Limit, q.Skip)
111+ }
112+ return nil
113+}
114+
105 // CommitPage is a page of history, newest first.115 // CommitPage is a page of history, newest first.
106 type CommitPage struct {116 type CommitPage struct {
107 // Head is the commit the revision resolved to.117 // Head is the commit the revision resolved to.

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