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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +147 -96
M internal/adapters/gitcli/blob.go +2 -41
| @@ -9,11 +9,8 @@ import ( | |||
| 9 | 9 | "errors" | |
| 10 | 10 | "fmt" | |
| 11 | 11 | "io" | |
| 12 | - | "net/http" | |
| 13 | 12 | "os" | |
| 14 | 13 | "os/exec" | |
| 15 | - | "path" | |
| 16 | - | "strings" | |
| 17 | 14 | "sync" | |
| 18 | 15 | ||
| 19 | 16 | "bnema.dev/ermite/internal/domain" | |
| @@ -77,7 +74,7 @@ func (g *Git) OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Rev | |||
| 77 | 74 | info := domain.BlobInfo{ID: id, Path: p.String(), Size: h.Size, Mode: h.Mode} | |
| 78 | 75 | ||
| 79 | 76 | if data != nil || h.Size == 0 { | |
| 80 | - | fillMedia(&info, data) | |
| 77 | + | info.SniffContent(data) | |
| 81 | 78 | 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 | 87 | _ = s.Close() | |
| 91 | 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 | 91 | return info, s, nil | |
| 95 | 92 | } | |
| 96 | 93 | ||
| @@ -101,42 +98,6 @@ func ctxOr(ctx context.Context, err error) error { | |||
| 101 | 98 | 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 | 101 | // blobStream is the content of a blob read from its own git process. | |
| 141 | 102 | type blobStream struct { | |
| 142 | 103 | 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)), nil | 78 | 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, nil | 91 | return info, s, nil | ||
| 95 | } | 92 | } | ||
| 96 | 93 | ||||
| @@ -101,42 +98,6 @@ func ctxOr(ctx context.Context, err error) error { | |||||
| 101 | return err | 98 | 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 *catPool | 103 | pool *catPool | ||
M internal/adapters/gitcli/read.go +13 -55
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | 11 | "io/fs" | |
| 12 | 12 | "os" | |
| 13 | 13 | "path/filepath" | |
| 14 | - | "slices" | |
| 15 | 14 | "strconv" | |
| 16 | 15 | "strings" | |
| 17 | 16 | "sync" | |
| @@ -232,49 +231,21 @@ func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev dom | |||
| 232 | 231 | 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 | 234 | // ResolveRefPath tries the leading segments of path as a revision (branch, | |
| 239 | 235 | // tag, full ref name, HEAD; a single segment may also be a commit id), longest | |
| 240 | 236 | // 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. | |
| 244 | 238 | func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) { | |
| 245 | 239 | dir, err := g.repoDir(repo) | |
| 246 | 240 | if err != nil { | |
| 247 | 241 | 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 | 243 | 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 | |
| 278 | 249 | }) | |
| 279 | 250 | if err != nil { | |
| 280 | 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 | 326 | if err != nil { | |
| 356 | 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 { | |
| 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() | |
| 372 | 331 | if includeLast { | |
| 373 | 332 | if err := g.fillLastCommits(ctx, dir, &tree); err != nil { | |
| 374 | 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 | 497 | if c.Committer, err = signature(f[5], f[6], f[7]); err != nil { | |
| 539 | 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") | |
| 542 | - | c.Summary, _, _ = strings.Cut(c.Message, "\n") | |
| 500 | + | c.Message, c.Summary = domain.SplitCommitMessage(f[8]) | |
| 543 | 501 | return c, nil | |
| 544 | 502 | } | |
| 545 | 503 | ||
| @@ -549,8 +507,8 @@ func signature(name, email, unix string) (domain.Signature, error) { | |||
| 549 | 507 | return domain.Signature{}, err | |
| 550 | 508 | } | |
| 551 | 509 | 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), | |
| 554 | 512 | Time: time.Unix(sec, 0).UTC(), | |
| 555 | 513 | }, nil | |
| 556 | 514 | } | |
| @@ -559,8 +517,8 @@ func signature(name, email, unix string) (domain.Signature, error) { | |||
| 559 | 517 | // | |
| 560 | 518 | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |
| 561 | 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 { | |
| 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 | |
| 564 | 522 | } | |
| 565 | 523 | dir, err := g.repoDir(repo) | |
| 566 | 524 | 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, nil | 231 | 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), longest | 235 | // 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's | 236 | // 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{}, err | 241 | 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.RefPath | 243 | 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.EntryDirectory | 330 | + | 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, nil | 501 | return c, nil | ||
| 544 | } | 502 | } | ||
| 545 | 503 | ||||
| @@ -549,8 +507,8 @@ func signature(name, email, unix string) (domain.Signature, error) { | |||||
| 549 | return domain.Signature{}, err | 507 | 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 | }, nil | 513 | }, 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 port | 518 | //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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "slices" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "time" | |
| 10 | 11 | "unicode/utf8" | |
| @@ -488,3 +489,63 @@ func ParseBranchName(s string) (string, error) { | |||
| 488 | 489 | } | |
| 489 | 490 | 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 | + | } | |
| @@ -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, nil | 490 | 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 | |||
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | + | "fmt" | |
| 9 | 10 | "io" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| @@ -102,6 +103,15 @@ type CommitQuery struct { | |||
| 102 | 103 | // MaxCommitLimit is the most commits one CommitQuery may ask for. | |
| 103 | 104 | 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 | 115 | // CommitPage is a page of history, newest first. | |
| 106 | 116 | type CommitPage struct { | |
| 107 | 117 | // 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 = 1000 | 104 | 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. | ||