detached 3483340

Merge 05fc636732aa61f7a2796c5e0dba9d9adb8883ec into 453499267f41ae8b3e4bd97a7445ec1c9de27425

commit
3483340ba3f364ca5f8397cb40fdfcbaf00abbf1
author
ermite · · 2026-10-19 20:02 UTC
merge of
4534992 + 05fc636 · diff against the first parent
signature
unsigned
102 files · +2489 -1405
M docs/merge-requests.md +13 -0
@@ -91,3 +91,16 @@ A root note can carry a verdict: `--approve` or `--request-changes`. The text ma
9191 - `mr show` lists the latest verdict of each author (`verdicts` in `--json`).
9292 - An approval is stale once the branch head differs from the head it was given on. Changes requested stay current until their author gives a new verdict, whatever is pushed.
9393 - Verdicts are immutable: editing or deleting the note keeps its verdict. Only a new verdict of the same author replaces it.
94+
95+## Shared read position and history queries
96+
97+The read position is shared, not per principal. `MergeRequestDetail.HeadUnread`
98+is a fact: the nonzero head differs from that position. The web presentation
99+still decides who sees the mark-read button.
100+
101+Repository comparisons return at most `ports.MaxCompareCommits` (500) commits.
102+Push summaries count at most `ports.MaxPushSummaryCommits` (100) commits and
103+report whether more exist; ancestry, rather than the bounded list, detects a
104+rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits`
105+(100) commits. These history queries remain on the merge-request service
106+because it owns the merge-git port.
M go.mod +1 -1
@@ -64,7 +64,7 @@ require (
6464 github.com/bep/godartsass/v2 v2.5.0 // indirect
6565 github.com/bep/golibsass v1.2.0 // indirect
6666 github.com/bits-and-blooms/bitset v1.24.5 // indirect
67- github.com/bnema/hexcheck v0.3.1 // indirect
67+ github.com/bnema/hexcheck v0.4.0 // indirect
6868 github.com/brunoga/deep v1.3.1 // indirect
6969 github.com/bufbuild/buf v1.73.0 // indirect
7070 github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201 // indirect
M go.sum +2 -2 generated file: collapsed
M internal/adapters/cilogs/cilogs.go +2 -0
@@ -244,6 +244,8 @@ func (s *Store) Count(_ context.Context, ref ports.CILogRef) (int64, error) {
244244 }
245245
246246 // Read returns lines from line number from.
247+//
248+//hexcheck:ignore suspicious-business-logic-in-adapter reads a bounded snapshot of stored log lines using sparse offsets and maps missing or out-of-range reads to port errors
247249 func (s *Store) Read(_ context.Context, ref ports.CILogRef, from int64, maxLines, maxBytes int) ([]domain.CILogLine, error) {
248250 if !validRef(ref) || from < 0 {
249251 return nil, domain.ErrNotFound
M internal/adapters/cisummaries/cisummaries.go +2 -0
@@ -56,6 +56,8 @@ func (s *Store) path(ref ports.CILogRef, step int32) string {
5656 }
5757
5858 // Put stores the summary of a step, or removes it when markdown is empty.
59+//
60+//hexcheck:ignore suspicious-business-logic-in-adapter validates storage coordinates and atomically writes a step summary, deleting its file when empty
5961 func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error {
6062 if !validRef(ref) || step < 1 || step > domain.CIMaxSteps {
6163 return domain.ErrNotFound
M internal/adapters/ermitegit/commitobjects.go +12 -11
@@ -22,10 +22,10 @@ var _ ports.CommitObjectReader = (*Adapter)(nil)
2222 // always serves it, and the wrapped reader only the repositories the library
2323 // cannot read.
2424 //
25-//hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain
25+//hexcheck:ignore suspicious-business-logic-in-adapter streams object headers and bytes; eligibility, deduplication and byte limits are decided by domain.CommitObjectBudget, request validation by domain.ValidateCommitObjects
2626 func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
27- if len(ids) > ports.MaxCommitLimit {
28- return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit)
27+ if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil {
28+ return nil, err
2929 }
3030 cli := func() (map[domain.ObjectID][]byte, error) {
3131 r, ok := a.cli.(ports.CommitObjectReader)
@@ -36,12 +36,12 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
3636 }
3737 return serve(ctx, a, repo, func(r *git.Repo) (map[domain.ObjectID][]byte, error) {
3838 out := make(map[domain.ObjectID][]byte, len(ids))
39- var total int64
39+ var budget domain.CommitObjectBudget
4040 for _, id := range ids {
4141 if err := ctx.Err(); err != nil {
4242 return nil, err
4343 }
44- if _, done := out[id]; done {
44+ if budget.Seen(id) {
4545 continue
4646 }
4747 gid, err := git.ParseID(id.String())
@@ -55,12 +55,13 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
5555 if err != nil {
5656 return nil, err
5757 }
58- if typ != git.TypeCommit || size > domain.MaxCommitObjectBytes {
59- continue
60- }
61- if total+size > domain.MaxCommitObjectsBytes {
58+ take, stop := budget.Admit(id, typ == git.TypeCommit, size)
59+ if stop {
6260 break
6361 }
62+ if !take {
63+ continue
64+ }
6465 typ, data, err := r.ReadObject(gid, nil)
6566 if errors.Is(err, git.ErrNotFound) {
6667 continue // removed meanwhile
@@ -68,9 +69,9 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
6869 if err != nil {
6970 return nil, err
7071 }
71- if typ == git.TypeCommit && len(data) <= domain.MaxCommitObjectBytes && total+int64(len(data)) <= domain.MaxCommitObjectsBytes {
72+ if take, _ := budget.Admit(id, typ == git.TypeCommit, int64(len(data))); take {
7273 out[id] = data
73- total += int64(len(data))
74+ budget.Charge(id, int64(len(data)))
7475 }
7576 }
7677 return out, nil
M internal/adapters/ermitegit/convert.go +25 -36
@@ -27,8 +27,6 @@ func domainID(id git.ObjectID) (domain.ObjectID, error) { return domain.ParseObj
2727 // listRefs lists HEAD, then the branches, then the tags, like gitcli: a tag
2828 // follows to its commit when it leads to one; HEAD takes the ids of its
2929 // branch or tag, and only the name of an unborn branch.
30-//
31-//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
3230 func listRefs(r *git.Repo) ([]domain.Ref, error) {
3331 var refs []domain.Ref
3432 byName := make(map[string]domain.Ref)
@@ -121,55 +119,48 @@ func peelID(r *git.Repo, id git.ObjectID) (_ git.ObjectID, ok bool, _ error) {
121119 return commit, true, nil
122120 }
123121
124-// resolveRevision tries the candidates of gitcli in the same order, which
125-// the port documents: HEAD is HEAD; a full object id is an object before it
126-// is a ref; then the branch, then the tag; then a full ref name and an
127-// abbreviated id as written. It does not use Repo.Resolve, whose order is
128-// git's own (tags before branches, no ref for a missing full id).
129-//
130-//hexcheck:ignore suspicious-business-logic-in-adapter tries the candidates of the port in its documented order, like gitcli; the rule is the port contract
122+// resolveRevision performs the lookups ordered by the domain, rather than
123+// Repo.Resolve, whose Git-specific resolution order differs from the port.
131124 func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) {
132- s := rev.String()
133- if rev.IsHead() || s == "HEAD" {
134- if c, ok, err := peelRef(r, "HEAD"); err != nil || ok {
135- return resolved{commit: c}, err
136- }
137- return resolved{}, notFound(rev)
138- }
139- if rev.IsObjectID() {
140- id, err := git.ParseID(s)
125+ for _, candidate := range rev.Candidates() {
126+ c, ok, err := lookupRevisionCandidate(r, candidate)
141127 if err != nil {
142128 return resolved{}, err
143129 }
144- if c, ok, err := peelID(r, id); err != nil || ok {
145- return resolved{commit: c}, err
146- }
147- }
148- for _, name := range [...]string{headsPrefix + s, tagsPrefix + s} {
149- if c, ok, err := peelRef(r, name); err != nil || ok {
150- return resolved{commit: c, ref: name}, err
130+ if ok {
131+ return resolved{commit: c, ref: candidate.ResolvedRef()}, nil
151132 }
152133 }
153- switch {
154- case len(s) > 5 && s[:5] == "refs/":
155- if c, ok, err := peelRef(r, s); err != nil || ok {
156- return resolved{commit: c, ref: s}, err
134+ return resolved{}, notFound(rev)
135+}
136+
137+// lookupRevisionCandidate translates one domain candidate to the library API.
138+func lookupRevisionCandidate(r *git.Repo, candidate domain.RevisionCandidate) (git.ObjectID, bool, error) {
139+ switch candidate.Kind {
140+ case domain.RevisionCandidateHead, domain.RevisionCandidateRef:
141+ return peelRef(r, candidate.Name)
142+ case domain.RevisionCandidateObject:
143+ id, err := git.ParseID(candidate.Name)
144+ if err != nil {
145+ return git.ObjectID{}, false, err
157146 }
158- case rev.IsAbbrevID():
159- id, err := r.ResolveCommitPrefix(s)
147+ return peelID(r, id)
148+ case domain.RevisionCandidateAbbrevID:
149+ id, err := r.ResolveCommitPrefix(candidate.Name)
160150 if err == nil {
161151 // The prefix may name an annotated tag: git peels it.
162152 id, err = r.PeelToCommit(id)
163153 }
164154 switch {
165155 case err == nil:
166- return resolved{commit: id}, nil
156+ return id, true, nil
167157 case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType):
158+ return git.ObjectID{}, false, nil
168159 default:
169- return resolved{}, err
160+ return git.ObjectID{}, false, err
170161 }
171162 }
172- return resolved{}, notFound(rev)
163+ return git.ObjectID{}, false, fmt.Errorf("unknown revision candidate kind %d", candidate.Kind)
173164 }
174165
175166 func notFound(rev domain.Revision) error {
@@ -477,8 +468,6 @@ func fillLastCommits(ctx context.Context, r *git.Repo, commit domain.ObjectID, d
477468 // The ids, the names, the emails and the message share one backing string, cut
478469 // into substrings: one allocation for the text of a commit instead of one per
479470 // field.
480-//
481-//hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain
482471 func convertCommit(c *git.Commit) (domain.Commit, error) {
483472 if len(c.Encoding) > 0 && !isUTF8Label(c.Encoding) {
484473 return domain.Commit{}, errFallback
M internal/adapters/ermitegit/ermitegit.go +1 -1
@@ -291,7 +291,7 @@ func (a *Adapter) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.
291291
292292 // ResolveRevision turns a revision into a commit.
293293 //
294-//hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports
294+//hexcheck:ignore suspicious-business-logic-in-adapter selects the configured CLI or library backend, with a separate route for abbreviated IDs; resolveRevision calls domain.Revision.Candidates for resolution order
295295 func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
296296 cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) }
297297 route := a.routes.ResolveRevision
M internal/adapters/gitcli/archive.go +0 -2
@@ -28,8 +28,6 @@ var _ ports.GitArchive = (*Git)(nil)
2828 // not by a gzip binary. A client that goes away ends ctx, which kills git.
2929 // Nothing is written to w when the commit does not exist
3030 // (domain.ErrNotFound).
31-//
32-//hexcheck:ignore suspicious-business-logic-in-adapter runs git archive and compresses its output: a process concern, access and naming are the use case's
3331 func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error {
3432 dir, err := g.repoDir(repo)
3533 if err != nil {
M internal/adapters/gitcli/changed.go +0 -4
@@ -21,8 +21,6 @@ const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
2121 // renames not detected (a rename lists both paths, as GitHub's push filters
2222 // see it), and stops reading after max paths: truncated tells there were more.
2323 // A zero base diffs against the empty tree.
24-//
25-//hexcheck:ignore suspicious-business-logic-in-adapter reads the path list git prints; the bound comes from the port
2624 func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limit int) ([]string, bool, error) {
2725 if limit < 1 || head.IsZero() {
2826 return nil, false, fmt.Errorf("changed paths: %w: limit %d", domain.ErrInvalid, limit)
@@ -63,8 +61,6 @@ func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head
6361 // (refs/heads/*), newest first, at most limit. It runs
6462 // `git log --not --branches --not <head>`: tags do not count, so the commit of
6563 // another tag that no branch has is listed.
66-//
67-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
6864 func (g *Git) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
6965 if head.IsZero() {
7066 return nil, false, fmt.Errorf("commits off branches: %w: no head", domain.ErrInvalid)
M internal/adapters/gitcli/commitobjects.go +11 -15
@@ -19,15 +19,10 @@ var _ ports.CommitObjectReader = (*Git)(nil)
1919 // that would take the total over domain.MaxCommitObjectsBytes (known from
2020 // `info`, before any content is read, so the stream stays consistent).
2121 //
22-//hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain
22+//hexcheck:ignore suspicious-business-logic-in-adapter streams object headers and bytes; eligibility, deduplication and byte limits are decided by domain.CommitObjectBudget, request validation by domain.ValidateCommitObjects
2323 func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
24- if len(ids) > ports.MaxCommitLimit {
25- return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit)
26- }
27- for _, id := range ids {
28- if id.IsZero() {
29- return nil, fmt.Errorf("%w: empty commit id", domain.ErrInvalidObjectID)
30- }
24+ if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil {
25+ return nil, err
3126 }
3227 dir, err := g.repoDir(repo)
3328 if err != nil {
@@ -36,12 +31,12 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [
3631 out := make(map[domain.ObjectID][]byte, len(ids))
3732 err = g.cat.do(ctx, dir, func(c *catConn) error {
3833 clear(out) // a retry on a fresh process starts over
39- var total int64
34+ var budget domain.CommitObjectBudget
4035 for _, id := range ids {
4136 if err := ctx.Err(); err != nil {
4237 return err
4338 }
44- if _, done := out[id]; done {
39+ if budget.Seen(id) {
4540 continue
4641 }
4742 h, err := c.info(id.String())
@@ -51,18 +46,19 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [
5146 if err != nil {
5247 return err
5348 }
54- if h.Type != "commit" || h.Size > domain.MaxCommitObjectBytes {
55- continue
56- }
57- if total+h.Size > domain.MaxCommitObjectsBytes {
49+ take, stop := budget.Admit(id, h.Type == "commit", h.Size)
50+ if stop {
5851 break
5952 }
53+ if !take {
54+ continue
55+ }
6056 _, data, err := c.contents(id.String(), domain.MaxCommitObjectBytes)
6157 if err != nil {
6258 return err
6359 }
6460 out[id] = data
65- total += int64(len(data))
61+ budget.Charge(id, int64(len(data)))
6662 }
6763 return nil
6864 })
M internal/adapters/gitcli/diff.go +15 -47
@@ -32,8 +32,6 @@ const maxDiffLine = 64 << 10
3232 // Statistics come from the line counts, binary files from their "-" counts.
3333 // The patch is parsed as it streams: nothing beyond the limits is kept, and
3434 // git is stopped once nothing more is needed.
35-//
36-//hexcheck:ignore suspicious-business-logic-in-adapter parses git diff-tree output into the domain diff, applying the limits the port passes in
3735 func (g *Git) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {
3836 dir, err := g.repoDir(repo)
3937 if err != nil {
@@ -91,7 +89,7 @@ type rawEntry struct {
9189
9290 // readDiff parses the output of diff-tree --raw --numstat -p.
9391 //
94-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values, applying the limits the port passes in
92+//hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process
9593 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {
9694 entries, err := readRaw(r)
9795 if err != nil {
@@ -107,23 +105,17 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
107105 stats.Deletions += entries[i].del
108106 }
109107 var diff domain.Diff
110- if limits.MaxFiles > 0 && len(entries) > limits.MaxFiles {
111- entries = entries[:limits.MaxFiles]
112- diff.Truncated = true
113- }
114- // Only the files of the window [from, to) get their hunks; the others are
115- // listed as they are, neither truncated nor counted in the budgets.
116- from, to := max(limits.From, 0), len(entries)
117- if limits.To > 0 {
118- to = min(limits.To, len(entries))
119- }
108+ budget := domain.NewDiffBudget(limits)
109+ kept, from, to, truncated := budget.Window(len(entries))
110+ entries = entries[:kept]
111+ diff.Truncated = truncated
120112
121113 // The patch holds one section per file, two for a type change, in the
122114 // order of the entries. Entries without lines to show (binary, pure
123115 // rename, mode change) have a section without hunks.
124116 lastRead := -1
125117 for i := from; i < to; i++ {
126- if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) {
118+ if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) {
127119 lastRead = i
128120 }
129121 }
@@ -133,8 +125,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
133125 _, _ = r.Discard(1)
134126 }
135127 }
136- pr := &patchReader{lr: newLineReader(r, maxDiffLine), bytesLeft: limits.MaxTotalBytes}
137- budget := limits.MaxTotalLines
128+ pr := &patchReader{lr: newLineReader(r, maxDiffLine), budget: budget}
138129 diff.Files = make([]domain.FileDiff, 0, len(entries))
139130 for i := range entries {
140131 e := &entries[i]
@@ -144,21 +135,19 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
144135 }
145136 inWindow := i >= from && i < to
146137 switch {
147- case !inWindow, !changedLines(e):
138+ case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del):
148139 // Nothing to show.
149- case !wantsHunks(e, limits, budget, pr.bytesLeft):
140+ case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del):
150141 e.file.Truncated = true
151142 default:
152- bytesBefore := pr.bytesLeft
153- hunks, lines, truncated, err := pr.file(sections, min(limits.MaxFileLines, budget))
143+ before := pr.budget
144+ hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines())
154145 if err != nil {
155146 return domain.Diff{}, domain.DiffStats{}, err
156147 }
157148 e.file.Hunks, e.file.Truncated = hunks, truncated
158149 if truncated {
159- pr.bytesLeft = bytesBefore // nothing of this file is kept
160- } else {
161- budget -= lines
150+ pr.budget = before // nothing of this file is kept
162151 }
163152 sections = 0
164153 }
@@ -177,20 +166,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
177166 return diff, stats, nil
178167 }
179168
180-// changedLines reports whether the file has text lines in its patch.
181-func changedLines(e *rawEntry) bool { return !e.file.Binary && e.added+e.del > 0 }
182-
183-// wantsHunks reports whether the lines of e can still be shown: the changed
184-// lines alone, a lower bound of the hunk lines, must fit the per-file limit
185-// and what remains of the total, and some text bytes must remain.
186-func wantsHunks(e *rawEntry, limits domain.DiffLimits, linesLeft, bytesLeft int) bool {
187- n := e.added + e.del
188- return changedLines(e) && n <= limits.MaxFileLines && n <= linesLeft && bytesLeft > 0
189-}
190-
191169 // readRaw reads the `--raw -z` records, which start with ':'.
192-//
193-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
194170 func readRaw(r *bufio.Reader) ([]rawEntry, error) {
195171 var entries []rawEntry
196172 for {
@@ -241,8 +217,6 @@ func unexpectedEOF(err error) error {
241217
242218 // parseRawMeta parses ":<old mode> <new mode> <old id> <new id> <status>" and
243219 // returns the number of paths that follow.
244-//
245-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
246220 func parseRawMeta(meta string) (rawEntry, int, error) {
247221 f := strings.Fields(strings.TrimPrefix(meta, ":"))
248222 if len(f) != 5 || f[4] == "" {
@@ -296,8 +270,6 @@ func parseRawMeta(meta string) (rawEntry, int, error) {
296270
297271 // readNumstat reads one `--numstat -z` record per entry. A binary file has
298272 // "-" counts.
299-//
300-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
301273 func readNumstat(r *bufio.Reader, entries []rawEntry) error {
302274 for i := range entries {
303275 tok, err := readToken(r)
@@ -341,7 +313,7 @@ type patchReader struct {
341313 eof bool
342314 cut bool // the last line read was cut
343315
344- bytesLeft int // text bytes still allowed in the diff
316+ budget domain.DiffBudget
345317 }
346318
347319 const sectionMarker = "diff --git "
@@ -434,8 +406,6 @@ func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, t
434406
435407 // section reads the hunks of the current section up to the next marker, which
436408 // stays pending. It gives up (truncated) after more than max lines.
437-//
438-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values, applying the limits the port passes in
439409 func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
440410 var cur *domain.Hunk
441411 var oldN, newN int
@@ -490,8 +460,8 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
490460 continue
491461 }
492462 lines++
493- p.bytesLeft -= len(text)
494- if lines > max || p.bytesLeft < 0 {
463+ fits := p.budget.ChargeLine(len(text))
464+ if lines > max || !fits {
495465 return nil, lines, true, nil
496466 }
497467 cur.Lines = append(cur.Lines, l)
@@ -499,8 +469,6 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
499469 }
500470
501471 // parseHunkHeader parses "@@ -a[,b] +c[,d] @@[ header]".
502-//
503-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
504472 func parseHunkHeader(s string) (domain.Hunk, error) {
505473 bad := func() (domain.Hunk, error) {
506474 return domain.Hunk{}, fmt.Errorf("git diff-tree: unexpected hunk header %q", truncateText(s))
M internal/adapters/gitcli/export_test.go +0 -6
@@ -110,15 +110,9 @@ func (g *Git) CatFileWaiting() int {
110110 return n
111111 }
112112
113-// MaxPushedCommits is the bound of the commits of a push, for the tests.
114-const MaxPushedCommits = maxPushedCommits
115-
116113 // ParseRevList reads rev-list output with the bounds of the guard replaced, and
117114 // returns the number of commits kept.
118115 func ParseRevList(r io.Reader, maxCommits, maxIDs int) (int, error) {
119116 c, err := parseRevList(bufio.NewReaderSize(r, 64*1024), maxCommits, maxIDs)
120117 return len(c), err
121118 }
122-
123-// MaxPushedObjects is the bound of the ids of the commits of a push.
124-const MaxPushedObjects = maxPushedObjects
M internal/adapters/gitcli/merge.go +0 -16
@@ -39,8 +39,6 @@ func parseID(out string) (domain.ObjectID, error) {
3939 }
4040
4141 // BranchHead resolves refs/heads/<branch>^{commit} with `cat-file`.
42-//
43-//hexcheck:ignore suspicious-business-logic-in-adapter maps a branch name to a git object name
4442 func (g *Git) BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) {
4543 dir, err := g.repoDir(repo)
4644 if err != nil {
@@ -95,8 +93,6 @@ func (g *Git) HasCommit(ctx context.Context, repo domain.RepoName, id domain.Obj
9593 }
9694
9795 // MergeBase runs `git merge-base`, which exits 1 when there is none.
98-//
99-//hexcheck:ignore suspicious-business-logic-in-adapter maps git exit codes to the domain not found error
10096 func (g *Git) MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error) {
10197 dir, err := g.repoDir(repo)
10298 if err != nil {
@@ -129,8 +125,6 @@ func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, de
129125 }
130126
131127 // ListRange runs `git log <head> ^<exclude>`.
132-//
133-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
134128 func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
135129 var excluded []string
136130 if !exclude.IsZero() {
@@ -142,8 +136,6 @@ func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head
142136 // logCommits runs `git log` for the commits reachable from head: revArgs go
143137 // before the head (options such as --not), excluded after it. Newest first,
144138 // at most limit; more says there were others.
145-//
146-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
147139 func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) {
148140 if limit < 1 || limit > ports.MaxCommitLimit {
149141 return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)
@@ -183,8 +175,6 @@ func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.
183175 }
184176
185177 // DiffCommits reads the diff between two commits like GetCommit does.
186-//
187-//hexcheck:ignore suspicious-business-logic-in-adapter reads a git diff into the domain diff with the limits the port passes in
188178 func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {
189179 dir, err := g.repoDir(repo)
190180 if err != nil {
@@ -198,8 +188,6 @@ func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head
198188 }
199189
200190 // CommitTree runs `git rev-parse <commit>^{tree}`.
201-//
202-//hexcheck:ignore suspicious-business-logic-in-adapter parses the id git printed
203191 func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) {
204192 dir, err := g.repoDir(repo)
205193 if err != nil {
@@ -229,8 +217,6 @@ func (g *Git) WriteCommit(ctx context.Context, repo domain.RepoName, object []by
229217 // UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which
230218 // takes the ref lock and refuses when the ref is not at old. A failure is
231219 // told from a moved branch by reading the branch again.
232-//
233-//hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port
234220 func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error {
235221 dir, err := g.repoDir(repo)
236222 if err != nil {
@@ -252,8 +238,6 @@ func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch str
252238 // DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes
253239 // the ref lock and refuses when the ref is not at old. A failure is told from
254240 // a moved or deleted branch by reading the branch again.
255-//
256-//hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port
257241 func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
258242 dir, err := g.repoDir(repo)
259243 if err != nil {
M internal/adapters/gitcli/pull.go +0 -2
@@ -18,8 +18,6 @@ var _ ports.PullGit = (*Git)(nil)
1818 // database and touches no ref or index: it prints the tree of the result and
1919 // exits 0, or exits 1 when the merge conflicts. Commits with no common history
2020 // do not merge (GitHub makes no test merge for them either).
21-//
22-//hexcheck:ignore suspicious-business-logic-in-adapter maps git exit codes to the clean/conflict answer of the port
2321 func (g *Git) TestMerge(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.ObjectID, bool, error) {
2422 dir, err := g.repoDir(repo)
2523 if err != nil {
A internal/adapters/gitcli/push_trees.go +400 -0
@@ -0,0 +1,400 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bufio"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "path/filepath"
12+ "strconv"
13+ "strings"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+var _ ports.PushTreeReader = (*Git)(nil)
20+
21+const (
22+ maxDiffPath = 4096
23+ maxRevListLine = 8192
24+)
25+
26+// PushSubtrees peels push endpoints, walks new commits, and reads entries at path.
27+func (g *Git) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) {
28+ out := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Subtrees: map[string]domain.Subtree{}}
29+ dir, err := g.repoDir(repo)
30+ if err != nil {
31+ return out, err
32+ }
33+ extra, err := quarantineEnv(dir, objects)
34+ if err != nil {
35+ return out, err
36+ }
37+ var kept []domain.RefUpdate
38+ for _, up := range updates {
39+ if up.New != domain.ZeroOID {
40+ kept = append(kept, up)
41+ }
42+ }
43+ if len(kept) == 0 {
44+ return out, nil
45+ }
46+ return g.readPushSubtrees(ctx, dir, extra, kept, path, bounds, out)
47+}
48+
49+// readPushSubtrees batches the endpoint and walked-object cat-file queries.
50+func (g *Git) readPushSubtrees(ctx context.Context, dir string, extra []string, kept []domain.RefUpdate, path string, bounds domain.PushWalkBounds, out domain.PushSubtrees) (domain.PushSubtrees, error) {
51+ types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)")
52+ if err != nil {
53+ return out, err
54+ }
55+ tips := pushCommitTips(kept, types, out.Ends)
56+ if len(tips) != 0 {
57+ out.Commits, err = g.newCommits(ctx, dir, extra, tips, bounds)
58+ if err != nil {
59+ return out, err
60+ }
61+ }
62+ ids := commitAndParentIDs(out.Commits)
63+ ws, err := g.subtreesAt(ctx, dir, extra, ids, path)
64+ if err != nil {
65+ return out, err
66+ }
67+ for id, w := range ws {
68+ out.Subtrees[id] = w
69+ }
70+ ids = pushEndSubtreeIDs(kept, out.Ends)
71+ ws, err = g.subtreesAt(ctx, dir, extra, ids, path)
72+ if err != nil {
73+ return out, err
74+ }
75+ for id, w := range ws {
76+ out.Subtrees[id] = w
77+ }
78+ return out, nil
79+}
80+
81+// pushCommitTips translates peeled endpoint answers and deduplicates rev-list tips.
82+func pushCommitTips(kept []domain.RefUpdate, types []string, ends map[string]domain.PushObjectType) []string {
83+ var tips []string
84+ seen := map[string]bool{}
85+ for i, up := range kept {
86+ for j, id := range []string{up.Old, up.New} {
87+ if id != domain.ZeroOID {
88+ ends[id] = domain.PushObjectType(types[2*i+j])
89+ }
90+ }
91+ if types[2*i+1] == "commit" && !seen[up.New] {
92+ seen[up.New] = true
93+ tips = append(tips, up.New)
94+ }
95+ }
96+ return tips
97+}
98+
99+// commitAndParentIDs deduplicates the object names for a cat-file batch.
100+func commitAndParentIDs(commits []domain.PushedCommit) []string {
101+ var ids []string
102+ asked := map[string]bool{}
103+ for _, c := range commits {
104+ for _, id := range append([]domain.ObjectID{c.ID}, c.Parents...) {
105+ key := id.String()
106+ if !asked[key] {
107+ asked[key] = true
108+ ids = append(ids, key)
109+ }
110+ }
111+ }
112+ return ids
113+}
114+
115+// pushEndSubtreeIDs omits blobs (not tree-ish) and includes HEAD only for creates.
116+func pushEndSubtreeIDs(kept []domain.RefUpdate, ends map[string]domain.PushObjectType) []string {
117+ var ids []string
118+ for _, up := range kept {
119+ for _, id := range []string{up.Old, up.New} {
120+ if id != domain.ZeroOID && ends[id] != domain.PushObjectBlob {
121+ ids = append(ids, id)
122+ }
123+ }
124+ }
125+ // HEAD is needed only for a new tree-ish ref. Keep it in the end batch.
126+ for _, up := range kept {
127+ if up.Old == domain.ZeroOID && ends[up.New] != domain.PushObjectBlob {
128+ ids = append(ids, domain.HeadKey)
129+ break
130+ }
131+ }
132+ return ids
133+}
134+
135+// newCommits lists the commits reachable from the tips and from no ref
136+// (`rev-list --parents <tips> --not --all`), at most bounds.Commits: more
137+// fails closed.
138+func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string, bounds domain.PushWalkBounds) ([]domain.PushedCommit, error) {
139+ if err := revListLimitsError(bounds); err != nil {
140+ return nil, err
141+ }
142+ args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(bounds.Commits+1)}, tips...), "--not", "--all")...)
143+ var out []domain.PushedCommit
144+ err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error {
145+ var err error
146+ out, err = parseRevList(r, bounds.Commits, bounds.IDs)
147+ return err
148+ })
149+ if err != nil {
150+ return nil, fmt.Errorf("commits of a push: %w", err)
151+ }
152+ return out, nil
153+}
154+
155+// revListLimitsError checks the numeric range of rev-list and parser limits.
156+func revListLimitsError(bounds domain.PushWalkBounds) error {
157+ if bounds.Commits < 0 || bounds.IDs < 0 || bounds.Commits == int(^uint(0)>>1) {
158+ return fmt.Errorf("%w: invalid push walk bounds", domain.ErrInvalid)
159+ }
160+ return nil
161+}
162+
163+// parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."):
164+// at most maxCommits lines and maxIDs ids in all (a commit and each of its
165+// parents count, repeats too). A line over maxRevListLine, a limit crossed or an
166+// id that is not one is an error, and it is raised as soon as the line that
167+// crosses it is read: nothing past it is read or kept.
168+func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]domain.PushedCommit, error) {
169+ var out []domain.PushedCommit
170+ ids := 0
171+ for {
172+ line, err := r.ReadSlice('\n')
173+ switch {
174+ case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine:
175+ return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid)
176+ case errors.Is(err, io.EOF) && len(line) == 0:
177+ return out, nil
178+ case err != nil && !errors.Is(err, io.EOF):
179+ return nil, err
180+ }
181+ f := strings.Fields(string(line))
182+ if len(f) == 0 {
183+ continue
184+ }
185+ if len(out) == maxCommits {
186+ return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits)
187+ }
188+ if ids += len(f); ids > maxIDs {
189+ return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs)
190+ }
191+ for _, id := range f {
192+ if _, err := domain.ParseObjectID(id); err != nil {
193+ return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid)
194+ }
195+ }
196+ id, _ := domain.ParseObjectID(f[0])
197+ c := domain.PushedCommit{ID: id}
198+ for _, p := range f[1:] {
199+ id, _ := domain.ParseObjectID(p)
200+ c.Parents = append(c.Parents, id)
201+ }
202+ out = append(out, c)
203+ }
204+}
205+
206+// subtreesAt reads the mode, id and type of entries without traversing them.
207+func (g *Git) subtreesAt(ctx context.Context, dir string, extra []string, ids []string, path string) (map[string]domain.Subtree, error) {
208+ out := make(map[string]domain.Subtree, len(ids))
209+ if len(ids) == 0 {
210+ return out, nil
211+ }
212+ specs := make([]string, len(ids))
213+ for i, id := range ids {
214+ specs[i] = id + ":" + path
215+ }
216+ args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)")
217+ err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error {
218+ for _, key := range ids {
219+ line, err := r.ReadSlice('\n')
220+ if err != nil {
221+ return fmt.Errorf("read object answers: %w", err)
222+ }
223+ f := strings.Fields(string(line))
224+ if len(f) != 3 {
225+ continue
226+ } // missing entry
227+ mode, err := strconv.ParseUint(f[0], 8, 32)
228+ if err != nil {
229+ continue
230+ }
231+ id, err := domain.ParseObjectID(f[1])
232+ if err != nil {
233+ return err
234+ }
235+ kind := domain.EntryKindOf(uint32(mode))
236+ if kind == domain.EntryDirectory && (f[0] != "040000" || f[2] != "tree") {
237+ kind = ""
238+ }
239+ out[key] = domain.Subtree{Kind: kind, ID: id}
240+ }
241+ return nil
242+ })
243+ if err != nil {
244+ return nil, fmt.Errorf("subtrees of a push: %w", err)
245+ }
246+ return out, nil
247+}
248+
249+// FirstDifferentEntry streams the first differing top-level entry of two trees.
250+func (g *Git) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, oldID, newID domain.ObjectID) (string, error) {
251+ dir, err := g.repoDir(repo)
252+ if err != nil {
253+ return "", err
254+ }
255+ extra, err := quarantineEnv(dir, objects)
256+ if err != nil {
257+ return "", err
258+ }
259+ from := oldID.String()
260+ if from == "" {
261+ from = emptyTree
262+ }
263+ to := newID.String()
264+ args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to)
265+ var name string
266+ err = g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error {
267+ n, found, err := firstPath(r)
268+ name = n
269+ if err != nil || !found {
270+ return err // no name: git's exit status is checked, so a failure refuses
271+ }
272+ return errStop // the first name is all that is needed: stop git
273+ })
274+ if err != nil {
275+ return "", fmt.Errorf("different tree entries %s..%s: %w", from, to, err)
276+ }
277+ return name, nil
278+}
279+
280+// firstPath reads the first NUL-terminated path of r; found is false at the end
281+// of the output without one. A path longer than maxDiffPath is an error.
282+func firstPath(r *bufio.Reader) (path string, found bool, err error) {
283+ var b []byte
284+ for {
285+ c, err := r.ReadByte()
286+ switch {
287+ case err != nil:
288+ return "", false, nil // the end of the output; a failing exit status is reported by pipe
289+ case c == 0:
290+ return string(b), true, nil
291+ case len(b) >= maxDiffPath:
292+ return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxDiffPath)
293+ }
294+ b = append(b, c)
295+ }
296+}
297+
298+// batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the
299+// old (unless the ref is created) and the new end of each update and returns
300+// the answers in that order, two per update, "" for the old end of a created
301+// ref. With suffix "^{}" an object that is missing, malformed or neither a
302+// commit, a tree nor a blob is an error (the push is refused); with any other
303+// suffix an answer "<input> missing" is kept as the answer.
304+func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) {
305+ var in strings.Builder
306+ want := 0
307+ for _, up := range ups {
308+ for _, oid := range []string{up.Old, up.New} {
309+ if oid == domain.ZeroOID {
310+ continue
311+ }
312+ if _, err := domain.ParseObjectID(oid); err != nil {
313+ return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err)
314+ }
315+ in.WriteString(oid + suffix + "\n")
316+ want++
317+ }
318+ }
319+ got := make([]string, 0, want)
320+ args := readArgs(dir, "cat-file", "--batch-check="+format)
321+ err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error {
322+ for range want {
323+ line, err := r.ReadSlice('\n')
324+ if err != nil {
325+ if errors.Is(err, bufio.ErrBufferFull) {
326+ return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid)
327+ }
328+ return fmt.Errorf("read object answers: %w", err)
329+ }
330+ got = append(got, strings.TrimSuffix(string(line), "\n"))
331+ }
332+ return nil
333+ })
334+ if err != nil {
335+ return nil, fmt.Errorf("objects of a push: %w", err)
336+ }
337+ out := make([]string, 0, 2*len(ups))
338+ i := 0
339+ for _, up := range ups {
340+ for _, oid := range []string{up.Old, up.New} {
341+ if oid == domain.ZeroOID {
342+ out = append(out, "")
343+ continue
344+ }
345+ if suffix == "^{}" {
346+ switch got[i] {
347+ case "commit", "tree", "blob":
348+ default: // "<input> missing", or a type that cannot be compared
349+ return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid)
350+ }
351+ }
352+ out = append(out, got[i])
353+ i++
354+ }
355+ }
356+ return out, nil
357+}
358+
359+// quarantineEnv returns the environment that makes git read the objects of a
360+// push in quarantine. Each directory must exist and, once symbolic links are
361+// resolved, be inside the objects directory of the repository dir (itself
362+// resolved): a hook request cannot send git to read anything else. The resolved
363+// paths are the ones given to git.
364+func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) {
365+ if o.Dir == "" && o.Alternates == "" {
366+ return nil, nil
367+ }
368+ root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects"))
369+ if err != nil {
370+ return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err)
371+ }
372+ inside := func(p string) (string, bool) {
373+ if !filepath.IsAbs(p) {
374+ return "", false
375+ }
376+ real, err := filepath.EvalSymlinks(p)
377+ if err != nil {
378+ return "", false
379+ }
380+ rel, err := filepath.Rel(root, real)
381+ return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
382+ }
383+ dir, ok := inside(o.Dir)
384+ if o.Dir == "" || !ok {
385+ return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid)
386+ }
387+ env := []string{"GIT_OBJECT_DIRECTORY=" + dir}
388+ if o.Alternates != "" {
389+ var alts []string
390+ for _, a := range filepath.SplitList(o.Alternates) {
391+ real, ok := inside(a)
392+ if !ok {
393+ return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid)
394+ }
395+ alts = append(alts, real)
396+ }
397+ env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator)))
398+ }
399+ return env, nil
400+}
R internal/adapters/gitcli/workflow_guard_test.go → internal/adapters/gitcli/push_trees_test.go +158 -217 renamed, 53% similar
@@ -51,7 +51,7 @@ func TestChangedWorkflow(t *testing.T) {
5151 return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"}
5252 }
5353 changed := func(ups ...domain.RefUpdate) string {
54- p, err := g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
54+ p, err := changedWorkflow(g, ctx, f.Name, ups, ports.PushObjects{})
5555 require.NoError(t, err)
5656 return p
5757 }
@@ -68,11 +68,11 @@ func TestChangedWorkflow(t *testing.T) {
6868 assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one")
6969 assert.Equal(t, "", changed(), "no update")
7070
71- _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{})
71+ _, err := changedWorkflow(g, ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{})
7272 assert.Error(t, err, "an object git does not have is a failure, not a pass")
7373 }
7474
75-func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) {
75+func TestPushSubtreesReadsTheQuarantineOfThePushOnly(t *testing.T) {
7676 g, data := newGit(t)
7777 ctx := context.Background()
7878 f := readertest.NewFixture(t, data, "proj")
@@ -90,23 +90,23 @@ func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) {
9090 "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"},
9191 "alternates without a dir": {Alternates: objects},
9292 } {
93- _, err := g.ChangedWorkflow(ctx, f.Name, up, o)
93+ _, err := g.PushSubtrees(ctx, f.Name, up, o, domain.WorkflowDir, domain.DefaultPushWalkBounds())
9494 assert.ErrorIs(t, err, domain.ErrInvalid, name)
9595 }
9696 // An existing directory inside the repository is accepted as a location
9797 // (the object is missing: a refusal of the object, not of the path).
9898 require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700))
99- _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects})
99+ _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
100100 require.Error(t, err)
101101 assert.NotContains(t, err.Error(), "outside the repository")
102102 // One that does not exist is refused.
103- _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"})
103+ _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
104104 assert.ErrorIs(t, err, domain.ErrInvalid)
105105 }
106106
107107 // A symbolic link in the way cannot take the objects of a push out of the
108108 // repository, and a data directory reached through links still works.
109-func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) {
109+func TestPushSubtreesResolvesSymbolicLinks(t *testing.T) {
110110 g, data := newGit(t)
111111 ctx := context.Background()
112112 f := readertest.NewFixture(t, data, "proj")
@@ -119,20 +119,20 @@ func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) {
119119 // Inside objects/, a link to elsewhere is outside once resolved.
120120 elsewhere := t.TempDir()
121121 require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link")))
122- _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")})
122+ _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
123123 assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/")
124- _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")})
124+ _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
125125 assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/")
126126
127127 // A link to somewhere inside objects/ is fine.
128128 require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700))
129129 require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link")))
130- _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")})
130+ _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
131131 require.Error(t, err)
132132 assert.NotContains(t, err.Error(), "outside the repository")
133133 }
134134
135-func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) {
135+func TestPushSubtreesThroughALinkedDataDirectory(t *testing.T) {
136136 _, data := newGit(t)
137137 ctx := context.Background()
138138 f := readertest.NewFixture(t, data, "proj")
@@ -152,10 +152,10 @@ func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) {
152152 q := filepath.Join(f.Bare, "objects", "incoming-9")
153153 require.NoError(t, os.Mkdir(q, 0o700))
154154 for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} {
155- p, err := g2.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}},
156- ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")})
155+ p, err := g2.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}},
156+ ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
157157 require.NoError(t, err, dir)
158- assert.Equal(t, ".github/workflows/ci.yml", p, dir)
158+ assert.Equal(t, domain.EntryDirectory, p.Subtrees[oidOf(t, wf).String()].Kind, dir)
159159 }
160160 }
161161
@@ -182,17 +182,7 @@ func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...strin
182182 return strings.TrimSpace(string(out))
183183 }
184184
185-// withWorkflows is a commit whose .github/workflows is the tree given.
186-func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string {
187- t.Helper()
188- gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows")
189- return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github"))
190-}
191-
192-// The trees of a push are the pusher's: a workflows directory that fans out
193-// into ten million paths is answered with its first path, at once, by a git
194-// that is stopped, without the diff ever being held in memory.
195-func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
185+func TestFirstDifferentEntryDoesNotBufferTheDiff(t *testing.T) {
196186 g, data := newGit(t)
197187 ctx := context.Background()
198188 f := readertest.NewFixture(t, data, "proj")
@@ -206,17 +196,17 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
206196 }
207197 level = treeOf(t, f, entries...)
208198 }
209- amp := withWorkflows(t, f, level)
199+ amp := level
210200
211201 var before, after runtime.MemStats
212202 runtime.GC()
213203 runtime.ReadMemStats(&before)
214204 start := time.Now()
215- p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{})
205+ p, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level))
216206 took := time.Since(start)
217207 runtime.ReadMemStats(&after)
218208 require.NoError(t, err)
219- assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p)
209+ assert.True(t, strings.HasPrefix(p, "d"), p)
220210 assert.Less(t, took, 2*time.Second, "answered from the first path")
221211 assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory")
222212 // And no git is left behind.
@@ -225,69 +215,50 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
225215 }
226216
227217 // A path longer than the bound fails closed: the push is refused, not read on.
228-func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) {
218+func TestFirstDifferentEntryRefusesAnEndlessPath(t *testing.T) {
229219 g, data := newGit(t)
230220 ctx := context.Background()
231221 f := readertest.NewFixture(t, data, "proj")
232222 t.Cleanup(func() { _ = g.Close(ctx) })
233223 level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf")
234224 level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes
235- _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{})
225+ _, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level))
236226 assert.ErrorIs(t, err, domain.ErrInvalid)
237227 }
238228
239229 // A tag may point to a blob, a tree or a commit; only the commits and trees can
240230 // hold a workflow, and an object that is not there refuses the push.
241-func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) {
231+func TestPushSubtreesPeelsAndClassifiesBothEnds(t *testing.T) {
242232 g, data := newGit(t)
243233 ctx := context.Background()
244234 f := readertest.NewFixture(t, data, "proj")
245235 t.Cleanup(func() { _ = g.Close(ctx) })
246236 f.Write("README", "r\n")
247- plain := f.Commit("plain")
248- f.Write(".github/workflows/ci.yml", "on: push\n")
249- wf := f.Commit("workflow")
250- f.Write(".github/workflows/ci.yml", "on: [push]\n")
251- f.Commit("the default branch moves on to other workflows") // HEAD is not wf
237+ plain := oidOf(t, f.Commit("plain")).String()
252238 blob := f.WriteObject("blob", "hello\n")
253- annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a <a@b> 1 +0000\n\nm\n")
254- annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n")
255- rootTree := f.Git("rev-parse", wf+"^{tree}")
256- ref := func(old, new string) []domain.RefUpdate {
257- return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}}
258- }
259- changed := func(ups []domain.RefUpdate) (string, error) {
260- return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
261- }
262- for name, ups := range map[string][]domain.RefUpdate{
263- "create a lightweight tag on a blob": ref(domain.ZeroOID, blob),
264- "create an annotated tag on a blob": ref(domain.ZeroOID, annotatedBlob),
265- "a tag from a blob to another blob": ref(blob, annotatedBlob),
266- "a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob),
267- } {
268- p, err := changed(ups)
269- require.NoError(t, err, name)
270- assert.Empty(t, p, name)
239+ tag := func(id, kind string) string {
240+ return f.WriteObject("tag", "object "+id+"\ntype "+kind+"\ntag t\ntagger a <a@b> 1 +0000\n\nm\n")
271241 }
272- for name, ups := range map[string][]domain.RefUpdate{
273- "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()),
274- "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()),
275- "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF),
276- "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree),
277- "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF),
278- } {
279- p, err := changed(ups)
280- require.NoError(t, err, name)
281- assert.Equal(t, ".github/workflows/ci.yml", p, name)
242+ tree := f.Git("rev-parse", plain+"^{tree}")
243+ for _, r := range []struct {
244+ id string
245+ kind domain.PushObjectType
246+ }{{plain, domain.PushObjectCommit}, {tag(plain, "commit"), domain.PushObjectCommit}, {blob, domain.PushObjectBlob}, {tag(blob, "blob"), domain.PushObjectBlob}, {tree, domain.PushObjectTree}, {tag(tag(plain, "commit"), "tag"), domain.PushObjectCommit}} {
247+ got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: r.id}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
248+ require.NoError(t, err)
249+ assert.Equal(t, r.kind, got.Ends[r.id])
250+ if r.kind == domain.PushObjectBlob {
251+ assert.NotContains(t, got.Subtrees, r.id)
252+ }
282253 }
283- for name, ups := range map[string][]domain.RefUpdate{
284- "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"),
285- "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()),
286- "a malformed object": ref(domain.ZeroOID, "ZZZZ"),
287- } {
288- _, err := changed(ups)
289- assert.Error(t, err, name)
254+ for _, r := range []domain.RefUpdate{{Old: domain.ZeroOID, New: strings.Repeat("1", 40)}, {Old: strings.Repeat("2", 40), New: plain}, {Old: domain.ZeroOID, New: "ZZZZ"}} {
255+ _, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{r}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
256+ assert.ErrorIs(t, err, domain.ErrInvalid)
290257 }
258+ got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: "invalid but deleted", New: domain.ZeroOID}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
259+ require.NoError(t, err)
260+ assert.Empty(t, got.Ends)
261+ assert.Empty(t, got.Commits)
291262 }
292263
293264 // rawTree writes a tree object by hand: entries are "mode name hexid", in the
@@ -308,73 +279,35 @@ func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string {
308279 // mode 040000 below a directory .github of mode 040000. The same tree id as a
309280 // gitlink, a symbolic link or a file is nothing, and turning it into a
310281 // directory activates it; the object id alone does not tell.
311-func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) {
282+func TestPushSubtreesEntryKinds(t *testing.T) {
312283 g, data := newGit(t)
313284 ctx := context.Background()
314285 f := readertest.NewFixture(t, data, "proj")
315286 t.Cleanup(func() { _ = g.Close(ctx) })
316- f.Write("README", "r\n")
317- f.Commit("base")
318- yml := f.WriteObject("blob", "on: push\n")
319- wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows
320- link := f.WriteObject("blob", ".github/other")
321- file := f.WriteObject("blob", "not a directory\n")
322- readme := f.WriteObject("blob", "r\n")
323-
324- // A commit whose .github/workflows has the mode and id given.
325- withWorkflows := func(mode, id string) string {
326- gh := rawTree(t, f, [3]string{mode, "workflows", id})
327- return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh}))
328- }
329- // A commit whose .github has the mode and id given.
330- withGithub := func(mode, id string) string {
331- return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id}))
332- }
333- asDir := withWorkflows("040000", wfTree)
334- asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule
335- asSymlink := withWorkflows("120000", link)
336- asFile := withWorkflows("100644", file)
337- none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme}))
338- ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree})
339- githubDir := withGithub("040000", ghDir)
340- githubGitlink := withGithub("160000", ghDir) // the same id, a submodule
341- githubFile := withGithub("100644", file)
342- githubLink := withGithub("120000", link)
343-
344- for name, c := range map[string]struct {
345- old, new string
346- want string
347- }{
348- "gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"},
349- "a directory to a gitlink removes it": {asDir, asGitlink, ""},
350- "symlink to a directory": {asSymlink, asDir, ".github/workflows/ci.yml"},
351- "a directory to a symlink removes it": {asDir, asSymlink, ""},
352- "file to a directory": {asFile, asDir, ".github/workflows/ci.yml"},
353- "a directory to a file removes it": {asDir, asFile, ""},
354- "nothing to a directory": {none, asDir, ".github/workflows/ci.yml"},
355- "a directory to nothing": {asDir, none, ""},
356- "the same gitlink": {asGitlink, asGitlink, ""},
357- "gitlink to symlink": {asGitlink, asSymlink, ""},
358- ".github a gitlink to a directory, same id": {githubGitlink, githubDir, ".github/workflows/ci.yml"},
359- ".github a directory to a gitlink": {githubDir, githubGitlink, ""},
360- ".github a file to a directory": {githubFile, githubDir, ".github/workflows/ci.yml"},
361- ".github a symlink to a directory": {githubLink, githubDir, ".github/workflows/ci.yml"},
362- ".github a directory to a symlink": {githubDir, githubLink, ""},
363- "a new ref with a gitlink": {domain.ZeroOID, asGitlink, ""},
364- "a new ref with a symlink": {domain.ZeroOID, asSymlink, ""},
365- "a new ref with .github a gitlink": {domain.ZeroOID, githubGitlink, ""},
366- "a new ref with a directory": {domain.ZeroOID, asDir, ".github/workflows/ci.yml"},
367- } {
368- p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{})
369- require.NoError(t, err, name)
370- assert.Equal(t, c.want, p, name)
287+ blob := f.WriteObject("blob", "x\n")
288+ tree := rawTree(t, f, [3]string{"100644", "ci.yml", blob})
289+ for _, r := range []struct {
290+ mode, id string
291+ kind domain.EntryKind
292+ }{{"040000", tree, domain.EntryDirectory}, {"160000", tree, domain.EntrySubmodule}, {"120000", blob, domain.EntrySymlink}, {"100644", blob, domain.EntryFile}, {"100755", blob, domain.EntryExecutable}} {
293+ t.Run(r.mode, func(t *testing.T) {
294+ gh := rawTree(t, f, [3]string{r.mode, "workflows", r.id})
295+ root := rawTree(t, f, [3]string{"040000", ".github", gh})
296+ oid := commitOf(t, f, root)
297+ got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
298+ require.NoError(t, err)
299+ assert.Equal(t, r.kind, got.Subtrees[oid].Kind)
300+ assert.Equal(t, r.id, got.Subtrees[oid].ID.String())
301+ assert.NotContains(t, got.Subtrees, domain.HeadKey)
302+ if r.mode != "040000" {
303+ root = rawTree(t, f, [3]string{r.mode, ".github", r.id})
304+ oid = commitOf(t, f, root)
305+ got, err = g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
306+ require.NoError(t, err)
307+ assert.NotContains(t, got.Subtrees, oid)
308+ }
309+ })
371310 }
372-
373- // A tag that peels to the commit is judged the same way.
374- tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a <a@b> 1 +0000\n\nm\n")
375- p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{})
376- require.NoError(t, err)
377- assert.Equal(t, ".github/workflows/ci.yml", p)
378311 }
379312
380313 // A history for the rules on the commits a push brings: main holds workflows A,
@@ -418,91 +351,37 @@ func (h *guardHistory) commit(workflows, _ string, parents ...string) string {
418351 return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...)
419352 }
420353
421-func TestChangedWorkflowJudgesThePushedCommits(t *testing.T) {
422- g, data := newGit(t)
423- ctx := context.Background()
424- h := newGuardHistory(t, g, data)
425- t.Cleanup(func() { _ = g.Close(ctx) })
426- f := h.f
427- run := func(ups ...domain.RefUpdate) (string, error) {
428- return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
429- }
430- create := func(ref, to string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: to, Ref: ref} }
431- move := func(ref, from, to string) domain.RefUpdate { return domain.RefUpdate{Old: from, New: to, Ref: ref} }
432- const ci = ".github/workflows/ci.yml"
433-
434- allowed := func(name string, ups ...domain.RefUpdate) {
435- t.Helper()
436- p, err := run(ups...)
437- require.NoError(t, err, name)
438- assert.Empty(t, p, name)
439- }
440- refused := func(name string, ups ...domain.RefUpdate) {
441- t.Helper()
442- p, err := run(ups...)
443- require.NoError(t, err, name)
444- assert.Equal(t, ci, p, name)
445- }
446-
447- // The staging repro: a new branch off main with a commit that touches no workflow.
448- allowed("new branch off main with a plain commit", create("refs/heads/smoke", h.commit("A", "", h.m)))
449- allowed("a new branch at the head of main", create("refs/heads/same", h.m))
450- allowed("a new tag at the head of main", create("refs/tags/v1", h.m))
451- allowed("a new branch whose commits carry no workflows at all", create("refs/heads/bare", h.commit("", "")))
452- allowed("a fast-forward with plain commits", move("refs/heads/main", h.m, h.commit("A", "", h.commit("A", "", h.m))))
453- allowed("a commit that removes the workflows", move("refs/heads/main", h.m, h.commit("", "", h.m)))
454-
455- // A pushed commit that changes them is refused wherever it goes.
456- refused("new branch with a commit that changes workflows", create("refs/heads/w", h.commit("C", "", h.m)))
457- refused("a root commit that carries workflows", create("refs/heads/root", h.commit("C", "")))
458- refused("an update with a commit that changes workflows", move("refs/heads/main", h.m, h.commit("C", "", h.m)))
459- n4 := h.commit("C", "", h.m)
460- n5 := h.commit("A", "", n4) // reverts the change of the commit before
461- refused("a fast-forward through a commit that changes them and a later one that reverts it", move("refs/heads/main", h.m, n5))
462- refused("a new branch through the same", create("refs/heads/through", n5))
463- n6 := h.commit("A", "", h.commit("C", "", h.o))
464- refused("a commit that changes them below plain ones, on an old base", create("refs/heads/below", h.commit("C", "", n6)))
465-
466- // A ref cannot be moved onto existing commits with other workflows.
467- refused("a new branch at an old commit whose workflows differ from main", create("refs/heads/b", h.o))
468- refused("a new tag at that commit", create("refs/tags/old", h.o))
469- refused("an update moving back to an older commit with other workflows", move("refs/heads/main", h.m, h.o))
470- allowed("an update to a commit with the same workflows", move("refs/heads/old", h.o, h.commit("B", "", h.o)))
471- allowed("an update that moves a ref to a commit without workflows (removes them)", move("refs/heads/old", h.o, h.commit("", "", h.o)))
472-
473- // Merges: a change only if it differs from every parent.
474- allowed("a merge of existing commits that keeps main's workflows", move("refs/heads/main", h.m, h.commit("A", "", h.m, h.o)))
475- allowed("a new branch with a merge keeping one parent's workflows", create("refs/heads/merge", h.commit("A", "", h.m, h.o)))
476- refused("a merge that differs from both parents", create("refs/heads/merge2", h.commit("D", "", h.m, h.o)))
477- refused("a merge of a pushed side that changes them (the side commit is itself new)", create("refs/heads/merge3", h.commit("A", "", h.m, h.commit("C", "", h.o))))
478- // One that takes the old side's workflows is a change of the ref, not of a commit.
479- refused("a merge taking the other parent's workflows, onto main", move("refs/heads/main", h.m, h.commit("B", "", h.m, h.o)))
480-
481- // Several updates: the first refused refuses the push.
482- refused("a plain ref and a refused one", create("refs/heads/ok", h.commit("A", "", h.m)), create("refs/heads/w2", h.commit("C", "", h.m)))
483- allowed("deletes", move("refs/heads/old", h.o, domain.ZeroOID))
484-}
485-
486-// With no default branch head, a new ref is compared with no workflows at all.
487-func TestChangedWorkflowOfANewRefWithoutADefaultBranchHead(t *testing.T) {
354+func TestPushSubtreesCommitsAndParents(t *testing.T) {
488355 g, data := newGit(t)
489356 ctx := context.Background()
490357 h := newGuardHistory(t, g, data)
491358 t.Cleanup(func() { _ = g.Close(ctx) })
492- h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") // the default branch does not exist
493- run := func(to string) string {
494- p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: to, Ref: "refs/tags/t"}}, ports.PushObjects{})
495- require.NoError(t, err)
496- return p
359+ side := h.commit("B", "", h.o)
360+ merge := h.commit("A", "", h.m, side)
361+ got, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}, {Old: domain.ZeroOID, New: merge}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
362+ require.NoError(t, err)
363+ want := h.f.Git("rev-list", "--parents", merge, "--not", "--all")
364+ var lines []string
365+ for _, c := range got.Commits {
366+ line := c.ID.String()
367+ for _, p := range c.Parents {
368+ line += " " + p.String()
369+ }
370+ lines = append(lines, line)
497371 }
498- assert.Equal(t, ".github/workflows/ci.yml", run(h.m), "an existing commit with workflows")
499- plain := h.commit("", "")
500- h.f.Git("update-ref", "refs/heads/plain", plain)
501- assert.Empty(t, run(plain), "an existing commit without")
372+ assert.Equal(t, strings.TrimSpace(want), strings.Join(lines, "\n"))
373+ assert.Len(t, got.Commits, 2)
374+ assert.Equal(t, h.trees["A"], got.Subtrees[domain.HeadKey].ID.String())
375+ assert.Equal(t, h.trees["B"], got.Subtrees[h.o].ID.String())
376+ // Any directory path is accepted; the reader has no workflow path policy.
377+ other, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}}, ports.PushObjects{}, "README", domain.DefaultPushWalkBounds())
378+ require.NoError(t, err)
379+ assert.Equal(t, domain.EntryFile, other.Subtrees[merge].Kind)
380+ assert.NotContains(t, other.Subtrees, domain.HeadKey, "HEAD is lazy for non-creates")
502381 }
503382
504383 // The commits of a push are bounded: more than the bound fails closed.
505-func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) {
384+func TestPushSubtreesBoundsThePushedCommits(t *testing.T) {
506385 g, data := newGit(t)
507386 ctx := context.Background()
508387 h := newGuardHistory(t, g, data)
@@ -525,10 +404,10 @@ func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) {
525404 }
526405 // Plain commits carry the workflows of main (the fast-import tree has none,
527406 // so the chain is "removing" them): allowed, whatever their number, up to the bound.
528- p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits), Ref: "refs/heads/main"}}, ports.PushObjects{})
407+ p, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(20), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80})
529408 require.NoError(t, err)
530- assert.Empty(t, p)
531- _, err = g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits + 1), Ref: "refs/heads/main"}}, ports.PushObjects{})
409+ assert.Len(t, p.Commits, 20)
410+ _, err = g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(21), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80})
532411 assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push")
533412 }
534413
@@ -565,18 +444,18 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) {
565444 b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small
566445 }
567446 b.WriteString("\n")
568- r := &countingReader{lines: gitcli.MaxPushedCommits, line: []byte(b.String())}
569- kept, err := gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects)
447+ r := &countingReader{lines: domain.MaxPushedCommits, line: []byte(b.String())}
448+ kept, err := gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs)
570449 require.ErrorIs(t, err, domain.ErrInvalid)
571450 assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length")
572451 assert.Zero(t, kept, "nothing is returned with the error")
573- total := gitcli.MaxPushedCommits * len(b.String())
452+ total := domain.MaxPushedCommits * len(b.String())
574453 assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total)
575454 assert.Less(t, r.read*20, total)
576455
577456 // Within the bounds the same lines are kept: 100 commits of 198 parents.
578457 r = &countingReader{lines: 100, line: []byte(b.String())}
579- kept, err = gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects)
458+ kept, err = gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs)
580459 require.NoError(t, err)
581460 assert.Equal(t, 100, kept)
582461
@@ -590,3 +469,65 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) {
590469 _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000)
591470 assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound")
592471 }
472+
473+func changedWorkflow(g *gitcli.Git, ctx context.Context, repo domain.RepoName, ups []domain.RefUpdate, objects ports.PushObjects) (string, error) {
474+ t, err := g.PushSubtrees(ctx, repo, ups, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds())
475+ if err != nil {
476+ return "", err
477+ }
478+ c, found, err := domain.WorkflowChangeOf(ups, t)
479+ if err != nil || !found {
480+ return "", err
481+ }
482+ entry, err := g.FirstDifferentEntry(ctx, repo, objects, c.Old, c.New)
483+ if err != nil {
484+ return "", err
485+ }
486+ if entry == "" {
487+ return domain.WorkflowDir, nil
488+ }
489+ return domain.WorkflowDir + "/" + entry, nil
490+}
491+
492+func TestFirstDifferentEntry(t *testing.T) {
493+ g, data := newGit(t)
494+ ctx := context.Background()
495+ f := readertest.NewFixture(t, data, "proj")
496+ t.Cleanup(func() { _ = g.Close(ctx) })
497+ tree := oidOf(t, treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tci.yml"))
498+ entry, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, tree)
499+ require.NoError(t, err)
500+ assert.Equal(t, "ci.yml", entry)
501+ entry, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, tree, tree)
502+ require.NoError(t, err)
503+ assert.Empty(t, entry)
504+ _, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{Dir: t.TempDir()}, domain.ObjectID{}, tree)
505+ assert.ErrorIs(t, err, domain.ErrInvalid)
506+}
507+
508+func TestChangedWorkflowPushHistories(t *testing.T) {
509+ g, data := newGit(t)
510+ ctx := context.Background()
511+ h := newGuardHistory(t, g, data)
512+ t.Cleanup(func() { _ = g.Close(ctx) })
513+ run := func(old, tip, want string) {
514+ t.Helper()
515+ got, err := changedWorkflow(g, ctx, h.f.Name, []domain.RefUpdate{{Old: old, New: tip, Ref: "refs/heads/pushed"}}, ports.PushObjects{})
516+ require.NoError(t, err)
517+ assert.Equal(t, want, got)
518+ }
519+ run(domain.ZeroOID, h.commit("A", "", h.m), "")
520+ change := h.commit("C", "", h.m)
521+ revert := h.commit("A", "", change)
522+ run(h.m, revert, ".github/workflows/ci.yml")
523+ run(domain.ZeroOID, h.commit("D", "", h.m, h.o), ".github/workflows/ci.yml")
524+ h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing")
525+ run(domain.ZeroOID, h.m, ".github/workflows/ci.yml")
526+ ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: h.m, Ref: "refs/tags/t"}}
527+ trees, err := g.PushSubtrees(ctx, h.f.Name, ups, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
528+ require.NoError(t, err)
529+ assert.NotContains(t, trees.Subtrees, domain.HeadKey)
530+ plain := h.commit("", "")
531+ h.f.Git("update-ref", "refs/heads/plain", plain)
532+ run(domain.ZeroOID, plain, "")
533+}
M internal/adapters/gitcli/read.go +31 -60
@@ -35,8 +35,6 @@ func notFound(err error) bool { return errors.Is(err, errObjectMissing) }
3535 // ListRefs lists HEAD, the branches and the tags with `git for-each-ref`.
3636 // An annotated tag is followed to its commit; a tag of a tag is followed with
3737 // one `cat-file` request each.
38-//
39-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain refs
4038 func (g *Git) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {
4139 dir, err := g.repoDir(repo)
4240 if err != nil {
@@ -155,66 +153,45 @@ func readHead(dir string) (string, error) {
155153 return ref, nil
156154 }
157155
158-// revCandidate is one way to read a revision as a commit.
159-type revCandidate struct {
160- spec string // object expression for cat-file
161- ref string // full ref name it stands for, "" for ids and HEAD
162-}
163-
164-// candidates lists, in order, how a revision is tried. A full object id is an
165-// object id first: a branch or a tag that happens to be named like a commit
166-// must not redirect a link to that commit; refs are tried only when no such
167-// object exists. Otherwise it is the branch, then the tag, then (for "HEAD",
168-// full ref names and abbreviations) as written. Other forms Git understands,
169-// such as "main~2" or "FETCH_HEAD", are not revisions of ermite.
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 maps a revision to git object names, a git detail of the reader
156+// resolve resolves the domain candidates on an open cat-file connection.
194157 func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) {
195- for _, cand := range candidates(rev) {
196- h, err := c.info(cand.spec)
197- if notFound(err) {
198- continue
199- }
158+ for _, cand := range rev.Candidates() {
159+ id, ok, err := lookupRevisionCandidate(c, cand)
200160 if err != nil {
201161 return domain.ResolvedRevision{}, err
202162 }
203- if h.Type != "commit" {
163+ if !ok {
204164 continue
205165 }
206- id, err := domain.ParseObjectID(h.ID)
207- if err != nil {
208- return domain.ResolvedRevision{}, c.fail(err)
209- }
210- return domain.ResolvedRevision{CommitID: id, Ref: cand.ref}, nil
166+ return domain.ResolvedRevision{CommitID: id, Ref: cand.ResolvedRef()}, nil
211167 }
212- return domain.ResolvedRevision{}, fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound)
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
213192 }
214193
215194 // ResolveRevision resolves a revision with `cat-file`.
216-//
217-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
218195 func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
219196 dir, err := g.repoDir(repo)
220197 if err != nil {
@@ -235,8 +212,6 @@ func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev dom
235212 // tag, full ref name, HEAD; a single segment may also be a commit id), longest
236213 // first, with up to one `cat-file` request per candidate on the repository's
237214 // process. At most domain.MaxRefPathTries segments are tried.
238-//
239-//hexcheck:ignore suspicious-business-logic-in-adapter the split is domain.SplitRefPath; this only runs it on a cat-file connection
240215 func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
241216 dir, err := g.repoDir(repo)
242217 if err != nil {
@@ -264,6 +239,8 @@ type object struct {
264239 // lookup resolves rev and reads the header of the object at path, in one
265240 // request series on the repository's process. A missing path is
266241 // domain.ErrNotFound.
242+//
243+//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
267244 func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) {
268245 var obj object
269246 err := g.cat.do(ctx, dir, func(c *catConn) error {
@@ -392,8 +369,6 @@ func (g *Git) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revi
392369 }
393370
394371 // parseTreeEntry parses "<mode> <type> <id> <size>\t<name>" of ls-tree -l.
395-//
396-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
397372 func parseTreeEntry(rec string, parent domain.TreePath) (domain.TreeEntry, error) {
398373 meta, name, ok := strings.Cut(rec, "\t")
399374 f := strings.Fields(meta)
@@ -487,8 +462,6 @@ feed:
487462
488463 // lastCommit returns the newest commit reachable from head that changed path;
489464 // an empty path means head itself.
490-//
491-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
492465 func (g *Git) lastCommit(ctx context.Context, dir string, head domain.ObjectID, path string) (domain.Commit, error) {
493466 var c domain.Commit
494467 found := false
@@ -520,8 +493,6 @@ const commitFormat = "%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B"
520493
521494 // readCommit reads one commit in commitFormat. It returns io.EOF at the end
522495 // of the output.
523-//
524-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port
525496 func readCommit(r *bufio.Reader) (domain.Commit, error) {
526497 var f [9]string
527498 for i := range f {
M internal/adapters/gitcli/repos.go +1 -3
@@ -102,7 +102,7 @@ func (g *Git) Discard(_ context.Context, id ports.StagedID) error {
102102
103103 // PurgeStaging removes everything under repos/.staging/.
104104 //
105-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
105+//hexcheck:ignore suspicious-business-logic-in-adapter removes staging leftovers from disk, tolerates a missing directory, and logs each removal
106106 func (g *Git) PurgeStaging(ctx context.Context) error {
107107 entries, err := os.ReadDir(g.stagingDir())
108108 if err != nil {
@@ -280,8 +280,6 @@ func (g *Git) DefaultBranch(ctx context.Context, name domain.RepoName) (string,
280280 // HasBranch checks refs/heads/<branch> with `git for-each-ref` after
281281 // `git check-ref-format`, which rejects the glob characters for-each-ref
282282 // would otherwise interpret.
283-//
284-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
285283 func (g *Git) HasBranch(ctx context.Context, name domain.RepoName, branch string) (bool, error) {
286284 dir, err := g.repoDir(name)
287285 if err != nil {
M internal/adapters/gitcli/stats.go +0 -6
@@ -18,8 +18,6 @@ import (
1818
1919 // CountCommits runs `git rev-list --count`, from the commit graph when the
2020 // repository has one.
21-//
22-//hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints
2321 func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
2422 if head.IsZero() {
2523 return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid)
@@ -45,8 +43,6 @@ func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domai
4543 // CommitTimes runs `git rev-list --since-as-filter --format=%ct`. The filter
4644 // looks at every reachable commit: --since stops the walk at the first older
4745 // one, and misses newer commits behind it when dates are out of order.
48-//
49-//hexcheck:ignore suspicious-business-logic-in-adapter reads the dates git prints; the bound comes from the port
5046 func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
5147 if head.IsZero() {
5248 return nil, false, fmt.Errorf("commit times: %w: no head", domain.ErrInvalid)
@@ -91,8 +87,6 @@ func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain
9187 }
9288
9389 // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags.
94-//
95-//hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints
9690 func (g *Git) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) {
9791 dir, err := g.repoDir(repo)
9892 if err != nil {
D internal/adapters/gitcli/workflow_guard.go +0 -438
@@ -1,438 +0,0 @@
1-// SPDX-License-Identifier: AGPL-3.0-or-later
2-
3-package gitcli
4-
5-import (
6- "bufio"
7- "context"
8- "errors"
9- "fmt"
10- "io"
11- "path/filepath"
12- "strconv"
13- "strings"
14-
15- "bnema.dev/ermite/internal/domain"
16- "bnema.dev/ermite/internal/ports"
17-)
18-
19-var _ ports.WorkflowGuard = (*Git)(nil)
20-
21-// workflowsDir is where workflow files live, as a literal pathspec directory.
22-const workflowsDir = ".github/workflows/"
23-
24-// maxWorkflowPath bounds the one path the guard reads from git: a longer one
25-// fails closed (the push is refused), whatever the trees hold.
26-const maxWorkflowPath = 4096
27-
28-// Bounds of the walk over the commits a push brings: more than that, or a
29-// commit with more parents than a line of rev-list holds, fails closed (the
30-// push is refused), whatever the commits hold. maxPushedObjects counts every
31-// commit and every parent listed, repeats included (so it bounds the distinct
32-// ids too), and is enforced as the lines are read, before anything more is
33-// kept: a few thousand commits with two hundred parents each are refused after
34-// the first lines, not after they have been held.
35-const (
36- maxPushedCommits = 10000
37- maxPushedObjects = 4 * maxPushedCommits
38- maxRevListLine = 8192
39-)
40-
41-// ChangedWorkflow tells whether a push changes the workflows of a repository.
42-// It returns the path of the first change, "" for none. Deleting a ref changes
43-// nothing, and a workflows directory that disappears is not a change.
44-//
45-// Two things are judged, as GitHub does for the token of a job (it refuses a
46-// push when the commits pushed change workflow files, not when a new branch is
47-// cut from a commit that has them):
48-//
49-// 1. Every commit the push brings (`rev-list <new> --not --all`, so the commits
50-// no ref has yet) must leave .github/workflows as it found it: it is
51-// compared with each of its parents (a root commit with nothing) and is a
52-// change when it is present and differs from every one of them. A merge of
53-// existing commits is thus no change; a side that brings a change is itself
54-// a new commit, and judged as one.
55-// 2. The directory a ref ends with must not be one it did not have: for an
56-// update the final .github/workflows must be the old one or absent, for a
57-// new ref the one of the default branch (HEAD) or absent. This keeps a job
58-// token from moving a ref onto existing commits that carry other workflows
59-// (an older commit, a commit of a branch with different ones).
60-//
61-// Presence is the directory the workflow loader lists (CI.workflowFiles, on both
62-// git readers): .github and workflows both entries of mode 040000 and type
63-// tree. A path through a gitlink (160000), a symbolic link (120000, which the
64-// loader does not follow) or a file is no workflows, so the same tree id that is
65-// no workflows as a gitlink and becomes workflows as a directory is a change,
66-// which the object id alone would not tell. Mode and id of
67-// "<commit>:.github/workflows" are what is compared: equal means the same
68-// content, whatever it holds, so nothing is read.
69-//
70-// Nothing is walked recursively: the trees of a push are the pusher's, and a
71-// recursive diff of trees that share subtrees fans out into more paths than the
72-// push has bytes (git builds the whole diff before it prints a line, so reading
73-// it as a stream would not help). The shallow diff that names the path reads one
74-// directory, as large as its tree object, as a stream, up to its first name, and
75-// git is stopped and reaped then; a name longer than maxWorkflowPath fails
76-// closed.
77-//
78-// The ends of each update are peeled first (`cat-file --batch-check` on
79-// `<oid>^{}`): a commit or a tree is compared, a blob (a tag may point to one)
80-// holds no workflows; a missing or malformed object fails closed. Git reads the
81-// objects of a push still in quarantine through the environment of the hook
82-// (ports.PushObjects), accepted only inside the repository's objects directory.
83-//
84-//hexcheck:ignore suspicious-business-logic-in-adapter reads the object ids and the path git prints for the compared trees; the rule that refuses is the use case's
85-func (g *Git) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) {
86- dir, err := g.repoDir(repo)
87- if err != nil {
88- return "", err
89- }
90- extra, err := quarantineEnv(dir, objects)
91- if err != nil {
92- return "", err
93- }
94- kept := make([]domain.RefUpdate, 0, len(updates))
95- for _, up := range updates {
96- if !up.IsDelete() {
97- kept = append(kept, up)
98- }
99- }
100- if len(kept) == 0 {
101- return "", nil
102- }
103- types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)")
104- if err != nil {
105- return "", err
106- }
107- if p, err := g.changedByNewCommits(ctx, dir, extra, kept, types); p != "" || err != nil {
108- return p, err
109- }
110- trees, err := g.batchCheck(ctx, dir, extra, kept, ":"+strings.TrimSuffix(workflowsDir, "/"), "%(objectmode) %(objectname) %(objecttype)")
111- if err != nil {
112- return "", err
113- }
114- headW, headRead := "", false
115- for i, up := range kept {
116- if types[2*i+1] == "blob" {
117- continue // a blob holds no workflow, whatever it replaces
118- }
119- base := workflowsOf(types[2*i], trees[2*i])
120- if up.IsCreate() {
121- if !headRead {
122- w, err := g.workflowsAt(ctx, dir, extra, []string{"HEAD:" + strings.TrimSuffix(workflowsDir, "/")})
123- if err != nil {
124- return "", err
125- }
126- headW, headRead = w["HEAD:"+strings.TrimSuffix(workflowsDir, "/")], true
127- }
128- base = headW // a new ref may end with the workflows of the default branch
129- }
130- newW := workflowsOf(types[2*i+1], trees[2*i+1])
131- if base == newW || newW == "" {
132- continue // the same directory, or none left
133- }
134- return g.firstChanged(ctx, dir, extra, base, newW)
135- }
136- return "", nil
137-}
138-
139-// changedByNewCommits judges the commits the updates bring (rule 1 of
140-// ChangedWorkflow).
141-func (g *Git) changedByNewCommits(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, types []string) (string, error) {
142- var tips []string
143- seen := map[string]bool{}
144- for i, up := range ups {
145- if types[2*i+1] == "commit" && !seen[up.New] {
146- seen[up.New] = true
147- tips = append(tips, up.New)
148- }
149- }
150- if len(tips) == 0 {
151- return "", nil
152- }
153- commits, err := g.newCommits(ctx, dir, extra, tips)
154- if err != nil {
155- return "", err
156- }
157- const suffix = ":.github/workflows"
158- var specs []string
159- asked := map[string]bool{}
160- for _, c := range commits {
161- for _, id := range append([]string{c.id}, c.parents...) {
162- if !asked[id] {
163- asked[id] = true
164- specs = append(specs, id+suffix)
165- }
166- }
167- }
168- ws, err := g.workflowsAt(ctx, dir, extra, specs)
169- if err != nil {
170- return "", err
171- }
172- for _, c := range commits {
173- cur := ws[c.id+suffix]
174- if cur == "" {
175- continue // none: nothing new is active
176- }
177- first, same := "", false
178- for i, p := range c.parents {
179- if i == 0 {
180- first = ws[p+suffix]
181- }
182- if ws[p+suffix] == cur {
183- same = true
184- }
185- }
186- if !same {
187- return g.firstChanged(ctx, dir, extra, first, cur)
188- }
189- }
190- return "", nil
191-}
192-
193-type pushedCommit struct {
194- id string
195- parents []string
196-}
197-
198-// newCommits lists the commits reachable from the tips and from no ref
199-// (`rev-list --parents <tips> --not --all`), at most maxPushedCommits: more
200-// fails closed.
201-func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string) ([]pushedCommit, error) {
202- args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(maxPushedCommits+1)}, tips...), "--not", "--all")...)
203- var out []pushedCommit
204- err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error {
205- var err error
206- out, err = parseRevList(r, maxPushedCommits, maxPushedObjects)
207- return err
208- })
209- if err != nil {
210- return nil, fmt.Errorf("commits of a push: %w", err)
211- }
212- return out, nil
213-}
214-
215-// parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."):
216-// at most maxCommits lines and maxIDs ids in all (a commit and each of its
217-// parents count, repeats too). A line over maxRevListLine, a limit crossed or an
218-// id that is not one is an error, and it is raised as soon as the line that
219-// crosses it is read: nothing past it is read or kept.
220-func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]pushedCommit, error) {
221- var out []pushedCommit
222- ids := 0
223- for {
224- line, err := r.ReadSlice('\n')
225- switch {
226- case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine:
227- return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid)
228- case errors.Is(err, io.EOF) && len(line) == 0:
229- return out, nil
230- case err != nil && !errors.Is(err, io.EOF):
231- return nil, err
232- }
233- f := strings.Fields(string(line))
234- if len(f) == 0 {
235- continue
236- }
237- if len(out) == maxCommits {
238- return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits)
239- }
240- if ids += len(f); ids > maxIDs {
241- return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs)
242- }
243- for _, id := range f {
244- if _, err := domain.ParseObjectID(id); err != nil {
245- return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid)
246- }
247- }
248- out = append(out, pushedCommit{id: f[0], parents: f[1:]})
249- }
250-}
251-
252-// workflowsAt returns, for each spec ("<rev>:.github/workflows"), the id of the
253-// directory it names, or "" when there is none (see workflowsOf).
254-func (g *Git) workflowsAt(ctx context.Context, dir string, extra []string, specs []string) (map[string]string, error) {
255- out := make(map[string]string, len(specs))
256- if len(specs) == 0 {
257- return out, nil
258- }
259- args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)")
260- err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error {
261- for _, spec := range specs {
262- line, err := r.ReadSlice('\n')
263- if err != nil {
264- return fmt.Errorf("read object answers: %w", err)
265- }
266- out[spec] = workflowsOf("", strings.TrimSuffix(string(line), "\n"))
267- }
268- return nil
269- })
270- if err != nil {
271- return nil, fmt.Errorf("workflows of a push: %w", err)
272- }
273- return out, nil
274-}
275-
276-// workflowsOf is the id of the tree that is the .github/workflows of a commit
277-// or a tree, "" when there is none: the answer for "<it>:.github/workflows" must
278-// be a tree of mode 040000, and the object peeled to a tree-ish, not a blob.
279-func workflowsOf(objType, answer string) string {
280- if objType == "blob" {
281- return ""
282- }
283- mode, rest, _ := strings.Cut(answer, " ")
284- id, typ, _ := strings.Cut(rest, " ")
285- if mode != "040000" || typ != "tree" {
286- return ""
287- }
288- return id
289-}
290-
291-// firstChanged names the first top-level entry of .github/workflows that
292-// differs between two trees (oldID may be "": none, the empty tree).
293-func (g *Git) firstChanged(ctx context.Context, dir string, extra []string, oldID, newID string) (string, error) {
294- from := oldID
295- if from == "" {
296- from = emptyTree
297- }
298- to := newID
299- args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to)
300- var name string
301- err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error {
302- n, found, err := firstPath(r)
303- name = n
304- if err != nil || !found {
305- return err // no name: git's exit status is checked, so a failure refuses
306- }
307- return errStop // the first name is all that is needed: stop git
308- })
309- if err != nil {
310- return "", fmt.Errorf("changed workflows %s..%s: %w", from, to, err)
311- }
312- if name == "" {
313- return strings.TrimSuffix(workflowsDir, "/"), nil
314- }
315- return workflowsDir + name, nil
316-}
317-
318-// firstPath reads the first NUL-terminated path of r; found is false at the end
319-// of the output without one. A path longer than maxWorkflowPath is an error.
320-func firstPath(r *bufio.Reader) (path string, found bool, err error) {
321- var b []byte
322- for {
323- c, err := r.ReadByte()
324- switch {
325- case err != nil:
326- return "", false, nil // the end of the output; a failing exit status is reported by pipe
327- case c == 0:
328- return string(b), true, nil
329- case len(b) >= maxWorkflowPath:
330- return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxWorkflowPath)
331- }
332- b = append(b, c)
333- }
334-}
335-
336-// batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the
337-// old (unless the ref is created) and the new end of each update and returns
338-// the answers in that order, two per update, "" for the old end of a created
339-// ref. With suffix "^{}" an object that is missing, malformed or neither a
340-// commit, a tree nor a blob is an error (the push is refused); with any other
341-// suffix an answer "<input> missing" is kept as the answer.
342-func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) {
343- var in strings.Builder
344- want := 0
345- for _, up := range ups {
346- for _, oid := range []string{up.Old, up.New} {
347- if oid == domain.ZeroOID {
348- continue
349- }
350- if _, err := domain.ParseObjectID(oid); err != nil {
351- return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err)
352- }
353- in.WriteString(oid + suffix + "\n")
354- want++
355- }
356- }
357- got := make([]string, 0, want)
358- args := readArgs(dir, "cat-file", "--batch-check="+format)
359- err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error {
360- for range want {
361- line, err := r.ReadSlice('\n')
362- if err != nil {
363- if errors.Is(err, bufio.ErrBufferFull) {
364- return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid)
365- }
366- return fmt.Errorf("read object answers: %w", err)
367- }
368- got = append(got, strings.TrimSuffix(string(line), "\n"))
369- }
370- return nil
371- })
372- if err != nil {
373- return nil, fmt.Errorf("objects of a push: %w", err)
374- }
375- out := make([]string, 0, 2*len(ups))
376- i := 0
377- for _, up := range ups {
378- for _, oid := range []string{up.Old, up.New} {
379- if oid == domain.ZeroOID {
380- out = append(out, "")
381- continue
382- }
383- if suffix == "^{}" {
384- switch got[i] {
385- case "commit", "tree", "blob":
386- default: // "<input> missing", or a type that cannot be compared
387- return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid)
388- }
389- }
390- out = append(out, got[i])
391- i++
392- }
393- }
394- return out, nil
395-}
396-
397-// quarantineEnv returns the environment that makes git read the objects of a
398-// push in quarantine. Each directory must exist and, once symbolic links are
399-// resolved, be inside the objects directory of the repository dir (itself
400-// resolved): a hook request cannot send git to read anything else. The resolved
401-// paths are the ones given to git.
402-func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) {
403- if o.Dir == "" && o.Alternates == "" {
404- return nil, nil
405- }
406- root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects"))
407- if err != nil {
408- return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err)
409- }
410- inside := func(p string) (string, bool) {
411- if !filepath.IsAbs(p) {
412- return "", false
413- }
414- real, err := filepath.EvalSymlinks(p)
415- if err != nil {
416- return "", false
417- }
418- rel, err := filepath.Rel(root, real)
419- return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
420- }
421- dir, ok := inside(o.Dir)
422- if o.Dir == "" || !ok {
423- return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid)
424- }
425- env := []string{"GIT_OBJECT_DIRECTORY=" + dir}
426- if o.Alternates != "" {
427- var alts []string
428- for _, a := range filepath.SplitList(o.Alternates) {
429- real, ok := inside(a)
430- if !ok {
431- return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid)
432- }
433- alts = append(alts, real)
434- }
435- env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator)))
436- }
437- return env, nil
438-}
M internal/adapters/githttp/handler.go +4 -4
@@ -174,6 +174,8 @@ var gitSuffixes = []string{"/info/refs", "/" + serviceUpload, "/" + serviceRecei
174174 // and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of
175175 // the forge. info/refs without a service is the dumb protocol, which ermite
176176 // does not serve.
177+//
178+//hexcheck:ignore suspicious-business-logic-in-adapter decodes smart-HTTP repository routes and owner aliases, validating methods and service query parameters
177179 func (h *Handler) parseRoute(r *http.Request) (route, int) {
178180 p := strings.TrimPrefix(r.URL.Path, "/")
179181 var head, rest string
@@ -224,8 +226,6 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) {
224226 }
225227
226228 // ServeHTTP implements http.Handler.
227-//
228-//hexcheck:ignore suspicious-business-logic-in-adapter transport orchestration: the authorization decisions are the use cases'
229229 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
230230 var rt route
231231 var name domain.RepoName
@@ -328,6 +328,8 @@ func credentials(r *http.Request) (secret string, present bool) {
328328 // denied answers an authorization failure. A caller without valid
329329 // credentials is asked for them wherever the repository does not show itself,
330330 // which keeps a private repository and a missing one indistinguishable.
331+//
332+//hexcheck:ignore suspicious-business-logic-in-adapter implements the smart-HTTP protocol visibility/auth challenge policy (401 vs 404) for hidden or missing repositories
331333 func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) {
332334 switch {
333335 case errors.Is(err, domain.ErrUnauthenticated):
@@ -418,8 +420,6 @@ func (h *Handler) acquire(ctx context.Context, w http.ResponseWriter, rt route,
418420 }
419421
420422 // serveBackend runs git http-backend for an authorized request.
421-//
422-//hexcheck:ignore suspicious-business-logic-in-adapter process and CGI plumbing: no business rule is decided here
423423 func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) {
424424 ctx := r.Context()
425425 logger := zerowrap.FromCtx(ctx)
M internal/adapters/githttp/hooks.go +0 -2
@@ -175,8 +175,6 @@ func (s *HookServer) Close(ctx context.Context) {
175175 }
176176
177177 // handle serves one connection: one request line, one reply line.
178-//
179-//hexcheck:ignore suspicious-business-logic-in-adapter hook protocol dispatch: the decisions are the use cases'
180178 func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
181179 log := zerowrap.FromCtx(ctx)
182180 _ = conn.SetReadDeadline(time.Now().Add(readTimeout))
M internal/adapters/kvstore/ci.go +11 -18
@@ -310,6 +310,9 @@ func decodeRun(item kv.Item) (domain.CIRun, error) {
310310 }, nil
311311 }
312312
313+// encodeJob serializes a job for storage.
314+//
315+//hexcheck:ignore suspicious-business-logic-in-adapter encodes a job as protobuf, rejecting an empty repository and omitting an absent token hash
313316 func encodeJob(j domain.CIJob) ([]byte, error) {
314317 if j.Repo.IsZero() {
315318 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -346,7 +349,6 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
346349 return mustMarshal(pb), nil
347350 }
348351
349-//hexcheck:ignore suspicious-business-logic-in-adapter maps the stored record to the domain job field by field: no rule is decided here
350352 func decodeJob(item kv.Item) (domain.CIJob, error) {
351353 var pb storagepb.CIJob
352354 if err := proto.Unmarshal(item.Value, &pb); err != nil {
@@ -530,8 +532,6 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu
530532 }
531533
532534 // Apply writes the records in one batch.
533-//
534-//hexcheck:ignore suspicious-business-logic-in-adapter stages records and their derived indexes under version preconditions: a storage concern, no policy
535535 func (c *CI) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) {
536536 var b kv.Batch
537537 guarded, err := c.stageRecords(ctx, &b, w)
@@ -576,18 +576,14 @@ func stageSeq(b *kv.Batch, key []byte, item kv.Item, ok bool, last int64) {
576576
577577 const createRunTries = 20
578578
579-// CreateRun takes the next run id and number and stores the draft.
579+// CreateRun takes the next run id and number and stores the draft. The batch
580+// requires the workflow's disabled key absent, so a disable that races the
581+// caller's check fails the batch and is reported as domain.ErrWorkflowDisabled.
580582 //
581-//hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy
583+//hexcheck:ignore suspicious-business-logic-in-adapter atomic precondition only: maps a conflict on the disabled-workflow key to its domain error; the usecase decides
582584 func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
583585 offKey := workflowOffKey(repo, workflowPath)
584586 for range createRunTries {
585- // A workflow the repository disabled gets no run, whatever started it.
586- // The key is also required absent by the batch below, so a disable that
587- // lands after this read fails the batch, and the next try sees it.
588- if _, off := c.db.Get(ctx, offKey); off {
589- return ports.CIRunDraft{}, domain.ErrWorkflowDisabled
590- }
591587 lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey))
592588 if err != nil {
593589 return ports.CIRunDraft{}, err
@@ -620,6 +616,10 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s
620616 }
621617 v, err := c.db.Apply(ctx, &b)
622618 if errors.Is(err, kv.ErrConflict) {
619+ // Translate a failed disabled-workflow precondition; other conflicts retry.
620+ if _, off := c.db.Get(ctx, offKey); off {
621+ return ports.CIRunDraft{}, domain.ErrWorkflowDisabled
622+ }
623623 continue
624624 }
625625 if err != nil {
@@ -669,8 +669,6 @@ func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource,
669669 }
670670
671671 // ListRuns returns the runs of the repository, newest first.
672-//
673-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only: reads a key range and reverses it
674672 func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) {
675673 start := []byte(runsPrefix(repo))
676674 end := []byte(runPrefix + repo.String() + "0") // '/' + 1
@@ -906,8 +904,6 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by
906904 const sweepChunk = 500
907905
908906 // Sweep removes the runs completed before the time.
909-//
910-//hexcheck:ignore suspicious-business-logic-in-adapter chunked delete of expired records: a storage concern, the retention is decided by the caller
911907 func (c *CI) Sweep(ctx context.Context, before time.Time) ([]int64, error) {
912908 snap := c.db.Snapshot(ctx)
913909 var ids []int64
@@ -955,8 +951,6 @@ func runnerNameKey(n string) []byte { return []byte(runnerName + stri
955951 func runnerTokenKey(h domain.TokenHash) []byte { return []byte(runnerTokPref + h.String()) }
956952
957953 // CreateRunner stores the runner, its name index and its token index.
958-//
959-//hexcheck:ignore suspicious-business-logic-in-adapter replaces a runner of the same name in one batch: a storage concern, the rule is decided by the caller
960954 func (c *CI) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) {
961955 for range createRunTries {
962956 var b kv.Batch
@@ -1115,7 +1109,6 @@ func encodeCall(c *domain.CICall) *storagepb.CICall {
11151109 }
11161110 }
11171111
1118-//hexcheck:ignore suspicious-business-logic-in-adapter maps the stored call to the domain call field by field: no rule is decided here
11191112 func decodeCall(pb *storagepb.CICall) *domain.CICall {
11201113 if pb == nil {
11211114 return nil
M internal/adapters/kvstore/ci_artifact.go +3 -5
@@ -79,6 +79,9 @@ var artifactStates = []struct {
7979 {domain.CIArtifactFinal, storagepb.CIArtifactState_CI_ARTIFACT_STATE_FINAL},
8080 }
8181
82+// encodeArtifact serializes an artifact for storage.
83+//
84+//hexcheck:ignore suspicious-business-logic-in-adapter validates artifact storage identifiers, UTF-8 and enum encoding before protobuf serialization
8285 func encodeArtifact(a domain.CIArtifact) ([]byte, error) {
8386 if a.Repo.IsZero() || a.ID < 1 || a.RunID < 1 {
8487 return nil, fmt.Errorf("%w: artifact without id, run or repository", domain.ErrInvalid)
@@ -106,7 +109,6 @@ func encodeArtifact(a domain.CIArtifact) ([]byte, error) {
106109 return mustMarshal(pb), nil
107110 }
108111
109-//hexcheck:ignore suspicious-business-logic-in-adapter decodes a record into the domain type: a storage concern, no policy
110112 func decodeArtifact(item kv.Item) (domain.CIArtifact, error) {
111113 var pb storagepb.CIArtifact
112114 if err := proto.Unmarshal(item.Value, &pb); err != nil {
@@ -141,8 +143,6 @@ func decodeArtifact(item kv.Item) (domain.CIArtifact, error) {
141143 const artifactIDTries = 1000
142144
143145 // NextArtifactID takes the next id in its own batch.
144-//
145-//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
146146 func (c *CIArtifacts) NextArtifactID(ctx context.Context) (int64, error) {
147147 ci := &CI{db: c.db}
148148 for range artifactIDTries {
@@ -274,8 +274,6 @@ func (c *CIArtifacts) BlockArtifactIDs(ctx context.Context) ([]int64, error) {
274274 // --- writes ---
275275
276276 // ApplyArtifacts writes w in one batch.
277-//
278-//hexcheck:ignore suspicious-business-logic-in-adapter stages records and their derived keys under version preconditions: a storage concern, no policy
279277 func (c *CIArtifacts) ApplyArtifacts(ctx context.Context, w ports.CIArtifactWrite) error {
280278 var b kv.Batch
281279 guard := false
M internal/adapters/kvstore/ci_concurrency.go +0 -4
@@ -103,7 +103,6 @@ func encodeConcurrencyRecord(r domain.CIConcurrencyRecord) []byte {
103103 return mustMarshal(pb)
104104 }
105105
106-//hexcheck:ignore suspicious-business-logic-in-adapter maps the stored record to the domain record field by field: no rule is decided here
107106 func decodeConcurrencyRecord(val []byte) (domain.CIConcurrencyRecord, error) {
108107 var pb storagepb.CIConcurrencyRecord
109108 if err := proto.Unmarshal(val, &pb); err != nil {
@@ -146,7 +145,6 @@ func encodeConcurrencyMark(m domain.CIConcurrencyMark) []byte {
146145 return mustMarshal(pb)
147146 }
148147
149-//hexcheck:ignore suspicious-business-logic-in-adapter maps the stored mark to the domain mark field by field: no rule is decided here
150148 func decodeConcurrencyMark(val []byte) (domain.CIConcurrencyMark, error) {
151149 var pb storagepb.CIConcurrencyMark
152150 if err := proto.Unmarshal(val, &pb); err != nil {
@@ -257,8 +255,6 @@ func (c *CI) UpdateConcurrency(ctx context.Context, repo domain.RepoName, group
257255 }
258256
259257 // ListConcurrency returns every stored group with its marks.
260-//
261-//hexcheck:ignore suspicious-business-logic-in-adapter lists and decodes records and marks: no rule is decided here
262258 func (c *CI) ListConcurrency(ctx context.Context) ([]ports.CIConcurrencyGroup, error) {
263259 type id struct{ repo, group string }
264260 snap := c.db.Snapshot(ctx)
M internal/adapters/kvstore/ci_settings.go +6 -4
@@ -85,6 +85,8 @@ func settingsKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName)
8585
8686 // BindSecretsKey stores the key check when there is none and compares it
8787 // otherwise.
88+//
89+//hexcheck:ignore suspicious-business-logic-in-adapter initializes or compares the persisted encryption-key check with compare-and-swap retries
8890 func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error {
8991 key := []byte(secretsKeyCheck)
9092 for range updateAttempts {
@@ -111,6 +113,8 @@ func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error {
111113
112114 // stage makes the batch depend on the scope: the repository key is guarded
113115 // and, for an environment, its record is required at the version read.
116+//
117+//hexcheck:ignore suspicious-business-logic-in-adapter validates a settings scope and adds repository and environment existence/version preconditions to a storage batch
114118 func (s *Settings) stage(ctx context.Context, b *kv.Batch, scope domain.SettingsScope) error {
115119 if !scope.Valid() {
116120 return fmt.Errorf("%w: scope", domain.ErrInvalid)
@@ -203,6 +207,8 @@ func (s *Settings) GetSealedSecret(ctx context.Context, scope domain.SettingsSco
203207 }
204208
205209 // existsIn is exists on a snapshot, so a listing and its check agree.
210+//
211+//hexcheck:ignore suspicious-business-logic-in-adapter validates a settings scope and checks repository and environment keys in the same snapshot as a listing
206212 func (s *Settings) existsIn(ctx context.Context, snap *kv.Snapshot, scope domain.SettingsScope) error {
207213 if !scope.Valid() {
208214 return fmt.Errorf("%w: scope", domain.ErrInvalid)
@@ -309,8 +315,6 @@ func decodeEnvironment(val []byte) (domain.Environment, error) {
309315 }
310316
311317 // CreateEnvironment implements ports.CISettingsStore.
312-//
313-//hexcheck:ignore suspicious-business-logic-in-adapter create-if-absent under a version precondition: a storage concern, no policy
314318 func (s *Settings) CreateEnvironment(ctx context.Context, repo domain.RepoName, name string, at time.Time) (domain.Environment, error) {
315319 key := envKey(repo, name)
316320 for range updateAttempts {
@@ -353,8 +357,6 @@ func (s *Settings) ListEnvironments(ctx context.Context, repo domain.RepoName) (
353357 }
354358
355359 // DeleteEnvironment implements ports.CISettingsStore.
356-//
357-//hexcheck:ignore suspicious-business-logic-in-adapter removes the keys of an environment in one batch: a storage concern, no policy
358360 func (s *Settings) DeleteEnvironment(ctx context.Context, repo domain.RepoName, name string) error {
359361 key := envKey(repo, name)
360362 scope := domain.EnvironmentScope(repo, name)
M internal/adapters/kvstore/issues.go +0 -8
@@ -84,8 +84,6 @@ func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][
8484 // Create takes the next number of the repository from mrseq/<repo>, the
8585 // sequence of the merge requests, and stores the issue under it; see
8686 // createNumbered. A missing repository gives domain.ErrNotFound.
87-//
88-//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
8987 func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
9088 err := createNumbered(ctx, i.db, i.locks, issue.Repo, func(b *kv.Batch, n int64) error {
9189 issue.Number = n
@@ -118,8 +116,6 @@ func (i *Issues) Get(ctx context.Context, repo domain.RepoName, number int64) (d
118116
119117 // List returns a page of the issues of the repository, highest number first. The
120118 // page token is the last number of the previous page.
121-//
122-//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
123119 func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
124120 before := int64(0) // 0: from the highest
125121 if params.PageToken != "" {
@@ -225,8 +221,6 @@ func (i *Issues) ListOpenAll(ctx context.Context) ([]domain.Issue, error) {
225221 // Update replaces the issue with a version precondition and moves its index key
226222 // in the same batch. The repository must exist (see guardRepoKey), so that a
227223 // concurrent delete of the repository leaves no index key behind.
228-//
229-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
230224 func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
231225 val, err := encodeIssue(issue)
232226 if err != nil {
@@ -330,7 +324,6 @@ func encodeIssue(issue domain.Issue) ([]byte, error) {
330324 }), nil
331325 }
332326
333-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
334327 func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
335328 var pb storagepb.Issue
336329 if err := proto.Unmarshal(item.Value, &pb); err != nil {
@@ -373,7 +366,6 @@ func encodeIssueComment(c domain.IssueComment) ([]byte, error) {
373366 }), nil
374367 }
375368
376-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
377369 func decodeIssueComment(item kv.Item) (domain.IssueComment, error) {
378370 var pb storagepb.IssueComment
379371 if err := proto.Unmarshal(item.Value, &pb); err != nil {
M internal/adapters/kvstore/issues_pending.go +0 -5
@@ -94,8 +94,6 @@ func (s *PendingIssues) Get(ctx context.Context, repo domain.RepoName, id domain
9494 // creation time then repository then id. There are at most
9595 // domain.MaxPendingIssues of them (a little more under a race), so they are
9696 // read and sorted. The page token is the position of the last item returned.
97-//
98-//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
9997 func (s *PendingIssues) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
10098 prefix := pendingPrefix
10199 if !repo.IsZero() {
@@ -216,8 +214,6 @@ func (s *PendingIssues) Count(ctx context.Context) (int, error) {
216214 // Approve numbers the pending issue from mrseq/<repo> (see createNumbered),
217215 // writes the open issue and its index key and deletes the pending record, in
218216 // one batch that requires the pending record to be unchanged.
219-//
220-//hexcheck:ignore suspicious-business-logic-in-adapter only sets the number under a version precondition; the issue itself is built by domain.ApprovedIssue
221217 func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) {
222218 var number int64
223219 err := createNumbered(ctx, s.db, s.locks, repo, func(b *kv.Batch, n int64) error {
@@ -291,7 +287,6 @@ func encodePending(p domain.PendingIssue) ([]byte, error) {
291287 }), nil
292288 }
293289
294-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
295290 func decodePending(repo domain.RepoName, id domain.PendingIssueID, item kv.Item) (domain.PendingIssue, error) {
296291 var pb storagepb.PendingIssue
297292 if err := proto.Unmarshal(item.Value, &pb); err != nil {
M internal/adapters/kvstore/mergerequests.go +3 -9
@@ -96,6 +96,8 @@ func indexKey(prefix string, repo domain.RepoName, branch string, n int64) []byt
9696 // when old is not known, as for a new request or a comment: the keys of mr are
9797 // then put or deleted, whatever they were). Deleting a key that is not there is
9898 // not an error.
99+//
100+//hexcheck:ignore suspicious-business-logic-in-adapter maintains source and target indexes to match the stored merge request open state and branch names
99101 func indexOpen(b *kv.Batch, old *domain.MergeRequest, mr domain.MergeRequest) {
100102 if old != nil && old.IsOpen() {
101103 if !mr.IsOpen() || old.Source != mr.Source {
@@ -204,8 +206,6 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
204206 // rewrites it (see guardRepo), so that a repository deleted meanwhile is not
205207 // left with a stray merge request (a push recording pushed_at makes it retry). A missing repository gives
206208 // domain.ErrNotFound.
207-//
208-//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
209209 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
210210 err := createNumbered(ctx, m.db, m.locks, mr.Repo, func(b *kv.Batch, n int64) error {
211211 mr.Number = n
@@ -451,7 +451,7 @@ func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (in
451451
452452 // Update replaces the merge request with a version precondition.
453453 //
454-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
454+//hexcheck:ignore suspicious-business-logic-in-adapter replaces a versioned record and maintains open-request indexes and the derived mergeability record
455455 func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) error {
456456 val, err := encodeMergeRequest(mr)
457457 if err != nil {
@@ -564,8 +564,6 @@ func (m *MergeRequests) GetRead(ctx context.Context, repo domain.RepoName, numbe
564564
565565 // SetRead writes mrread/<repo>/<number>. The repository must exist (see
566566 // guardRepo): domain.ErrNotFound otherwise.
567-//
568-//hexcheck:ignore suspicious-business-logic-in-adapter retries a write that lost a race with the repository key: a storage concern, no policy
569567 func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error {
570568 if head.IsZero() {
571569 return fmt.Errorf("%w: empty read position", domain.ErrInvalidObjectID)
@@ -748,7 +746,6 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
748746 }), nil
749747 }
750748
751-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
752749 func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest, error) {
753750 var pb storagepb.MergeRequest
754751 if err := proto.Unmarshal(item.Value, &pb); err != nil {
@@ -815,7 +812,6 @@ func encodeComment(c domain.Comment) ([]byte, error) {
815812 }), nil
816813 }
817814
818-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
819815 func decodeComment(item kv.Item) (domain.Comment, error) {
820816 var pb storagepb.Comment
821817 if err := proto.Unmarshal(item.Value, &pb); err != nil {
@@ -931,8 +927,6 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
931927
932928 // decodeSettings returns the defaults for a repository stored before merge
933929 // requests existed, and for settings left with no mode that this build knows.
934-//
935-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding with the documented defaults
936930 func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
937931 if pb == nil {
938932 return domain.DefaultRepoSettings()
M internal/adapters/kvstore/passkeys.go +0 -2
@@ -134,8 +134,6 @@ func (s *Passkeys) Create(ctx context.Context, p domain.Passkey) error {
134134 }
135135
136136 // Update replaces a stored passkey, or returns domain.ErrNotFound.
137-//
138-//hexcheck:ignore suspicious-business-logic-in-adapter version-checked replace, not a policy
139137 func (s *Passkeys) Update(ctx context.Context, p domain.Passkey) error {
140138 val, err := encodePasskey(p)
141139 if err != nil {
M internal/adapters/kvstore/repos.go +2 -10
@@ -67,8 +67,6 @@ func (s *Store) List(ctx context.Context) ([]domain.Repo, error) {
6767 const repoMutationTries = 8
6868
6969 // Mutate applies a retry-safe mutation under the version it read.
70-//
71-//hexcheck:ignore suspicious-business-logic-in-adapter optimistic concurrency is a storage concern
7270 func (s *Store) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
7371 key := repoKey(name)
7472 for range repoMutationTries {
@@ -158,8 +156,6 @@ const deleteTries = 32
158156 // got in after the listing makes the batch fail and the keys are listed again,
159157 // and a write after the batch finds no repository: no key outlives the
160158 // repository to attach itself to a repository created again.
161-//
162-//hexcheck:ignore suspicious-business-logic-in-adapter chunked delete under a version precondition: a storage concern, no policy
163159 func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
164160 for range deleteTries {
165161 snap := s.db.Snapshot(ctx)
@@ -211,12 +207,8 @@ func encodeRepo(r domain.Repo) ([]byte, error) {
211207 if err != nil {
212208 return nil, err
213209 }
214- settings := r.Settings
215- if settings.IsZero() {
216- // Settings are always stored explicitly, so that a later change of
217- // the defaults never alters a stored repository.
218- settings = domain.DefaultRepoSettings()
219- }
210+ // Store defaults explicitly so future default changes do not alter stored repositories.
211+ settings := r.EffectiveSettings()
220212 pbSettings, err := encodeSettings(settings)
221213 if err != nil {
222214 return nil, err
M internal/adapters/kvstore/store.go +0 -2
@@ -186,8 +186,6 @@ var preIssueScopes = []domain.Scope{
186186 // for, so it gets each of them if missing (this is no escalation). Every other
187187 // token is left as it is. The batch requires each token to be at the version it
188188 // was read at.
189-//
190-//hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy
191189 func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
192190 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
193191 pb := &storagepb.Token{}
M internal/adapters/kvstore/tokens.go +0 -6
@@ -153,8 +153,6 @@ func (s *Tokens) Revoke(ctx context.Context, id string) error {
153153
154154 // Touch sets last_used_at when the given time is later than the stored one,
155155 // so that racing writers never move it back.
156-//
157-//hexcheck:ignore suspicious-business-logic-in-adapter monotonic write guard, not a policy
158156 func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
159157 if !validTokenID(id) {
160158 return domain.ErrNotFound
@@ -194,8 +192,6 @@ func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
194192 // encodeToken refuses what the use case already refused (scopes, branch
195193 // prefixes): a malformed prefix must never reach the store, where it would
196194 // limit a token wrongly or not at all.
197-//
198-//hexcheck:ignore suspicious-business-logic-in-adapter schema encoding: it re-checks the syntax the use case enforced, it decides no policy
199195 func encodeToken(t domain.Token) ([]byte, error) {
200196 if !validTokenID(t.ID) {
201197 return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
@@ -237,8 +233,6 @@ func encodeToken(t domain.Token) ([]byte, error) {
237233
238234 // decodeToken drops scopes it does not know (written by a newer ermite): a
239235 // token never gains rights by being read by an older build.
240-//
241-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
242236 func decodeToken(val []byte) (domain.Token, error) {
243237 var pb storagepb.Token
244238 if err := proto.Unmarshal(val, &pb); err != nil {
M internal/adapters/rpc/api.go +2 -4
@@ -87,9 +87,7 @@ type Config struct {
8787 Secrets ports.SecretService
8888 Variables ports.VariableService
8989 Environments ports.EnvironmentService
90- // OIDCKeys rotates the key of the ID tokens of CI jobs; nil when the server
91- // issues none (GetVersion then does not list "ci.oidc"). Same rule as
92- // Sessions about typed nil pointers.
90+ // OIDCKeys manages ID-token signing keys, including when OIDC is disabled.
9391 OIDCKeys ports.OIDCKeyService
9492 // JobArtifacts and Artifacts serve JobArtifactService and ArtifactService,
9593 // and GetVersion lists "ci.artifacts", when both are set and Runner is.
@@ -138,7 +136,7 @@ func New(cfg Config) *API {
138136 if artifacts {
139137 features = append(features, FeatureCIArtifacts)
140138 }
141- if cfg.OIDCKeys != nil {
139+ if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() {
142140 features = append(features, FeatureCIOIDC)
143141 }
144142 if cfg.TrustedKeys != nil {
M internal/adapters/rpc/convert.go +1 -4
@@ -58,10 +58,7 @@ func visibilityFromPB(v pb.Visibility) (domain.Visibility, error) {
5858 }
5959
6060 func repoToPB(v ports.RepoView) *pb.Repo {
61- settings := v.Settings
62- if settings.IsZero() {
63- settings = domain.DefaultRepoSettings()
64- }
61+ settings := v.EffectiveSettings()
6562 return &pb.Repo{
6663 Name: v.Name.String(),
6764 Description: v.Description,
M internal/adapters/rpc/git.go +0 -1
@@ -187,7 +187,6 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC
187187 return connect.NewResponse(commitDetailToPB(detail)), nil
188188 }
189189
190-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
191190 func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
192191 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
193192 if err != nil {
M internal/adapters/rpc/mergerequest.go +0 -2
@@ -212,8 +212,6 @@ func commentID(s string) int64 {
212212 }
213213
214214 // headID parses a commit id of a request; the empty string is the zero id.
215-//
216-//hexcheck:ignore suspicious-business-logic-in-adapter parses a request field into the domain id and names the field in the error; no rule is decided here
217215 func headID(s string) (domain.ObjectID, error) {
218216 if s == "" {
219217 return domain.ObjectID{}, nil
M internal/adapters/rpc/repo.go +0 -2
@@ -83,8 +83,6 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
8383 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
8484 // nothing else; the optional issues field stands outside the mask, and alone
8585 // it is a valid update.
86-//
87-//hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here
8886 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
8987 var params ports.UpdateRepoParams
9088 paths := msg.GetUpdateMask().GetPaths()
M internal/adapters/rpc/server.go +5 -8
@@ -10,7 +10,6 @@ import (
1010 "connectrpc.com/connect"
1111
1212 pb "bnema.dev/ermite-api/gen/ermite"
13- "bnema.dev/ermite/internal/domain"
1413 "bnema.dev/ermite/internal/ports"
1514 )
1615
@@ -19,7 +18,7 @@ type serverService struct {
1918 version string
2019 features []string
2120 key ports.SigningKeyService
22- // oidc rotates the key of the ID tokens; nil when the server issues none.
21+ // oidc manages key rotation, including the disabled-provider response.
2322 oidc ports.OIDCKeyService
2423 }
2524
@@ -41,15 +40,13 @@ func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSi
4140 }
4241
4342 // RotateOIDCKey replaces the key that signs the ID tokens of CI jobs. Only the
44-// owner may; failed_precondition when the server issues no ID tokens.
43+// owner may; failed_precondition when the server issues no ID tokens. An API
44+// built without an OIDC key service answers unimplemented.
4545 func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) {
46- p := caller(ctx)
4746 if s.oidc == nil {
48- if !p.HasOwnerRights() {
49- return nil, connectError(ctx, domain.ErrForbidden)
50- }
51- return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY"))
47+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("this API serves no OIDC key service"))
5248 }
49+ p := caller(ctx)
5350 r, err := s.oidc.RotateKey(ctx, p)
5451 if err != nil {
5552 return nil, connectError(ctx, err)
M internal/adapters/rpc/server_test.go +11 -0
@@ -49,6 +49,17 @@ func TestServerServiceGetSigningKey(t *testing.T) {
4949 assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners())
5050 }
5151
52+// An API built without an OIDC key service answers unimplemented instead of
53+// panicking, for the owner and for anyone else.
54+func TestRotateOIDCKeyWithoutService(t *testing.T) {
55+ t.Parallel()
56+ for _, p := range []domain.Principal{domain.Owner(), {}} {
57+ ctx := domain.ContextWithPrincipal(context.Background(), p)
58+ _, err := (&serverService{}).RotateOIDCKey(ctx, connect.NewRequest(&pb.RotateOIDCKeyRequest{}))
59+ assert.Equal(t, connect.CodeUnimplemented, connect.CodeOf(err))
60+ }
61+}
62+
5263 // GetVersion lists "passkeys" only when the API is built with passkeys.
5364 func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) {
5465 t.Parallel()
M internal/adapters/sigverify/gpg.go +0 -2
@@ -41,8 +41,6 @@ var _ ports.SignatureVerifier = GPG{}
4141
4242 // Verify implements ports.SignatureVerifier. signedAt is ignored: see the time
4343 // policy of GPG.
44-//
45-//hexcheck:ignore suspicious-business-logic-in-adapter this is the OpenPGP protocol check the port delegates to the adapter; the trust and email rules stay in domain
4644 func (GPG) Verify(payload, signature []byte, committerEmail string, _ int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
4745 v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature}
4846 defer func() { // a parser bug must not take the server down
M internal/adapters/sigverify/ssh.go +0 -2
@@ -49,8 +49,6 @@ type sshSigBlob struct {
4949 }
5050
5151 // Verify implements ports.SignatureVerifier.
52-//
53-//hexcheck:ignore suspicious-business-logic-in-adapter this is the SSHSIG protocol check the port delegates to the adapter; the trust and email rules stay in domain
5452 func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
5553 v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature}
5654 defer func() { // a parser bug must not take the server down
M internal/adapters/sigverify/verifier.go +0 -2
@@ -42,8 +42,6 @@ func (Verifier) Verify(payload, signature []byte, committerEmail string, signedA
4242 // trusted completes the verification of a signature whose cryptography is
4343 // valid and whose key is trusted: verified when the key may sign for the
4444 // committer email, email_mismatch otherwise.
45-//
46-//hexcheck:ignore suspicious-business-logic-in-adapter the email rule is domain.TrustedKey.TrustsEmail; this only maps it to the port's status
4745 func trusted(v domain.CommitVerification, key domain.TrustedKey, committerEmail string) domain.CommitVerification {
4846 v.KeyID, v.KeyTitle = key.ID, key.Title
4947 if key.TrustsEmail(committerEmail) {
M internal/adapters/web/actions_artifacts.go +0 -4
@@ -53,8 +53,6 @@ type ArtifactsView struct {
5353 // runArtifacts is the artifacts section of a run, nil when there is nothing to
5454 // show: no artifacts on this server, a visitor who cannot list them, no
5555 // artifact, or a failed read, which is logged; the run page never fails for it.
56-//
57-//hexcheck:ignore suspicious-business-logic-in-adapter builds the artifacts section from the use case results; which errors hide it is presentation, no rule is decided here
5856 func (s *Server) runArtifacts(ctx context.Context, repo domain.RepoName, p RunPage) *ArtifactsView {
5957 if s.cfg.Artifacts == nil || !SignedIn(ctx) {
6058 return nil
@@ -112,8 +110,6 @@ func parseArtifactPath(r *http.Request) (int64, error) {
112110 // is never served: a zip is application/zip, anything else
113111 // application/octet-stream, and the answer is sandboxed and not sniffed, so
114112 // that nothing uploaded by a job can run in the origin of the forge.
115-//
116-//hexcheck:ignore suspicious-business-logic-in-adapter streams what the use case sends; the headers are about the response, no rule is decided here
117113 func (s *Server) artifactDownload(w http.ResponseWriter, r *http.Request) {
118114 ctx, cancel := context.WithTimeout(r.Context(), artifactTimeout)
119115 defer cancel()
M internal/adapters/web/browse.go +0 -2
@@ -71,8 +71,6 @@ func (s *Server) openBlobAt(ctx context.Context, repo domain.RepoName, rev, path
7171 }
7272
7373 // resolveRevision resolves rev to the commit it designates.
74-//
75-//hexcheck:ignore suspicious-business-logic-in-adapter parses the URL text and calls the use case; no rule is decided here
7674 func (s *Server) resolveRevision(ctx context.Context, repo domain.RepoName, rev string) (domain.ResolvedRevision, error) {
7775 r, err := domain.ParseRevision(rev)
7876 if err != nil {
M internal/adapters/web/checks.go +2 -0
@@ -54,6 +54,8 @@ func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string
5454 }
5555
5656 // checksSummary reduces checks to the mark a list shows next to a commit.
57+//
58+//hexcheck:ignore suspicious-business-logic-in-adapter renders domain check outcomes as a commit-list mark, passed count, label and actions link
5759 func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary {
5860 if len(checks) == 0 {
5961 return ChecksSummary{}
M internal/adapters/web/commit.go +0 -2
@@ -96,8 +96,6 @@ func diffLines(files []DiffFile) (lines int, invisible bool) {
9696
9797 // commit shows a commit: its message, its totals, the changed files and the
9898 // diff against the first parent. ?view=split selects the split layout.
99-//
100-//hexcheck:ignore suspicious-business-logic-in-adapter builds the commit page from the use case results; no rule is decided here
10199 func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {
102100 sha := r.PathValue("sha")
103101 if !isCommitID(sha) {
M internal/adapters/web/compare.go +3 -55
@@ -5,7 +5,6 @@ package web
55 import (
66 "context"
77 "net/http"
8- "slices"
98
109 "github.com/a-h/templ"
1110
@@ -13,10 +12,6 @@ import (
1312 "bnema.dev/ermite/internal/ports"
1413 )
1514
16-// compareDepth bounds the commits read on each side to find where a branch
17-// left its target.
18-const compareDepth = maxComparePages * pageSize
19-
2015 // compare shows a branch against the default branch of the repository: the
2116 // commits it is ahead by and, for the owner, the form that opens a merge
2217 // request.
@@ -27,8 +22,6 @@ const compareDepth = maxComparePages * pageSize
2722 // own diff is then the whole change). Otherwise it lists the commits, which
2823 // link to their own diffs, and says the whole diff comes with the merge
2924 // request.
30-//
31-//hexcheck:ignore suspicious-business-logic-in-adapter builds the compare page from the use case results; the merge rules are the use case's
3225 func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) {
3326 d, err := s.loadRepo(ctx, r.PathValue("repo"))
3427 if err != nil {
@@ -56,7 +49,9 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
5649 p.Note = "this is the default branch: there is nothing to compare it with."
5750 p.AheadText = "0 commits"
5851 } else {
59- ahead, exact, behind, err = s.commitsAhead(ctx, d.repo.Name, src.CommitID.String(), tgt.CommitID.String())
52+ var comparison ports.CompareResult
53+ comparison, err = s.cfg.MergeRequests.Compare(ctx, principal(ctx), d.repo.Name, tgt.CommitID, src.CommitID)
54+ ahead, exact, behind = comparison.Ahead, comparison.Exact, comparison.Behind
6055 if err != nil {
6156 return nil, 0, err
6257 }
@@ -139,53 +134,6 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
139134 return Compare(l, p), http.StatusOK, nil
140135 }
141136
142-// commitsAhead lists the commits of the source that the target lacks, newest
143-// first. exact is false when the walk gave up before finding where the source
144-// meets the target. behind is true when the target has commits the source
145-// lacks (the tip of the target is not an ancestor of the source).
146-//
147-//hexcheck:ignore suspicious-business-logic-in-adapter walks the history pages the use case returns to find where the branch left its target; the page needs it, the forge decides nothing
148-func (s *Server) commitsAhead(ctx context.Context, repo domain.RepoName, srcTip, tgtTip string) (ahead []domain.Commit, exact, behind bool, err error) {
149- if srcTip == tgtTip {
150- return nil, true, false, nil
151- }
152- onTarget := map[string]bool{}
153- tokenT := ""
154- for len(onTarget) < compareDepth {
155- res, err := s.commitList(ctx, repo, tgtTip, "", pageSize, tokenT)
156- if err != nil {
157- return nil, false, false, err
158- }
159- for _, c := range res.Commits {
160- onTarget[c.ID.String()] = true
161- }
162- if tokenT = res.NextPageToken; tokenT == "" {
163- break
164- }
165- }
166- token := ""
167- for n := 0; n < compareDepth; {
168- res, err := s.commitList(ctx, repo, srcTip, "", pageSize, token)
169- if err != nil {
170- return nil, false, false, err
171- }
172- for _, c := range res.Commits {
173- n++
174- if onTarget[c.ID.String()] {
175- // The first commit of the source that the target has: where the
176- // branch left it. The target moved on when this is not its tip.
177- return ahead, true, c.ID.String() != tgtTip, nil
178- }
179- ahead = append(ahead, c)
180- }
181- if token = res.NextPageToken; token == "" {
182- // The whole history of the source is ahead: no common history.
183- return ahead, true, true, nil
184- }
185- }
186- return ahead, false, !slices.ContainsFunc(ahead, func(c domain.Commit) bool { return c.ID.String() == tgtTip }), nil
187-}
188-
189137 // openRequestFor finds the open merge request with this source and target.
190138 func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) {
191139 token := ""
M internal/adapters/web/context.go +1 -1
@@ -30,7 +30,7 @@ type ContextRows struct {
3030 // the numbers of the first hidden line on each side, which move together (the
3131 // lines are unchanged); n the number of lines, 0 for the rest of the file.
3232 //
33-//hexcheck:ignore suspicious-business-logic-in-adapter cuts a file the use case opened into rows of the diff; no rule is decided here
33+//hexcheck:ignore suspicious-business-logic-in-adapter validates diff-context HTTP parameters, rejects binary or oversized files, and caps rendered context rows
3434 func (s *Server) diffContext(ctx context.Context, r *http.Request) (templ.Component, int, error) {
3535 sha := r.PathValue("sha")
3636 if !isFullID(sha) {
M internal/adapters/web/diff_highlight.go +0 -2
@@ -370,8 +370,6 @@ func computeFile(ctx context.Context, lx chroma.Lexer, hunks []domain.Hunk) (d *
370370
371371 // applyFile puts the colours on the lines of a file; false when the data is
372372 // not the one of this file (another number of lines), nothing is changed.
373-//
374-//hexcheck:ignore suspicious-business-logic-in-adapter puts coloured runs on the lines of a diff for the page; no rule is decided here
375373 func applyFile(d *hlData, f *DiffFile, hunks []domain.Hunk) bool {
376374 n := 0
377375 for _, h := range hunks {
M internal/adapters/web/events.go +3 -1
@@ -342,6 +342,8 @@ func (s *Server) canSee(ctx context.Context, rawName string) error {
342342
343343 // issuesOn is domain.ErrNotFound unless the repository has issues on and
344344 // s.cfg.Issues is set: the same answer as for a repository the visitor cannot see.
345+//
346+//hexcheck:ignore suspicious-business-logic-in-adapter maps domain RepoSettings.IssuesEnabled to a not-found stream; decides nothing itself
345347 func (s *Server) issuesOn(ctx context.Context, rawName string) error {
346348 if s.cfg.Issues == nil {
347349 return domain.ErrNotFound
@@ -354,7 +356,7 @@ func (s *Server) issuesOn(ctx context.Context, rawName string) error {
354356 if err != nil {
355357 return err
356358 }
357- if v.Settings.Issues.OrDefault() == domain.IssuesOff {
359+ if !v.EffectiveSettings().IssuesEnabled() {
358360 return domain.ErrNotFound
359361 }
360362 return nil
M internal/adapters/web/events_test.go +13 -0
@@ -465,3 +465,16 @@ func TestEventsIssuesViewsAreDeniedLikeAMissingRepo(t *testing.T) {
465465 resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil)
466466 assert.Equal(t, http.StatusOK, resp.StatusCode)
467467 }
468+
469+// Without the merge request service the commits page still gets a banner when
470+// its branch moves; it only cannot count the new commits.
471+func TestCommitsBannerWithoutMergeRequests(t *testing.T) {
472+ t.Parallel()
473+ srv := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)})
474+ old, tip := strings.Repeat("a", 40), strings.Repeat("b", 40)
475+ ls := &liveStream{s: srv, repo: "x", view: "commits", ref: "main", head: old}
476+ got, err := ls.banner(t.Context(), domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/main", Old: old, New: tip}}})
477+ require.NoError(t, err)
478+ require.Len(t, got, 1)
479+ assert.Contains(t, got[0].html, "the branch moved — reload")
480+}
M internal/adapters/web/fragments.go +43 -32
@@ -356,7 +356,7 @@ func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error))
356356 // banner tells the commits page how many commits its branch got since the
357357 // head it lists.
358358 //
359-//hexcheck:ignore suspicious-business-logic-in-adapter counts the commits of a page for the banner text; display only
359+//hexcheck:ignore suspicious-business-logic-in-adapter picks the update of the listed branch from the push event; the count and rewrite come from MergeRequests.PushSummary
360360 func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
361361 if l.ref == "" || !isFullID(l.head) {
362362 return nil, nil
@@ -376,37 +376,9 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
376376 case tip == "":
377377 return gap, nil // the branch was deleted
378378 }
379- text := ""
380- if tip != l.head {
381- name, err := domain.ParseRepoName(l.repo)
382- if err != nil {
383- return nil, err
384- }
385- res, err := l.s.commitList(ctx, name, tip, "", pageSize, "")
386- if err != nil {
387- return nil, err
388- }
389- // The count is approximate for a history with merges: the log is
390- // ordered by date, not as "head..tip", so commits that are not new may
391- // be counted before the head is met. And one page of the log being
392- // all that is read, "100+" cannot be told from a rewritten branch
393- // whose old head is further back: the use cases have no "is ancestor".
394- n, found := 0, false
395- for _, c := range res.Commits {
396- if c.ID.String() == l.head {
397- found = true
398- break
399- }
400- n++
401- }
402- switch {
403- case found:
404- text = newCommits(strconv.Itoa(n), n)
405- case res.NextPageToken != "":
406- text = newCommits(strconv.Itoa(n)+"+", 2)
407- default:
408- text = "the branch was rewritten — reload"
409- }
379+ text, err := l.bannerText(ctx, tip)
380+ if err != nil {
381+ return nil, err
410382 }
411383 c := LiveSlot("commits-head")
412384 if text != "" {
@@ -416,6 +388,45 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
416388 return []fragment{f}, err
417389 }
418390
391+func (l *liveStream) bannerText(ctx context.Context, tip string) (string, error) {
392+ if tip == l.head {
393+ return "", nil
394+ }
395+ if l.s.cfg.MergeRequests == nil {
396+ // Without the merge request service there is no history summary: the
397+ // banner only says the branch moved.
398+ return "the branch moved — reload", nil
399+ }
400+ name, err := domain.ParseRepoName(l.repo)
401+ if err != nil {
402+ return "", err
403+ }
404+ oldHead, err := domain.ParseObjectID(l.head)
405+ if err != nil {
406+ return "", err
407+ }
408+ newTip, err := domain.ParseObjectID(tip)
409+ if err != nil {
410+ return "", err
411+ }
412+ res, err := l.s.cfg.MergeRequests.PushSummary(ctx, principal(ctx), name, oldHead, newTip)
413+ if err != nil {
414+ return "", err
415+ }
416+ return pushSummaryText(res), nil
417+}
418+
419+func pushSummaryText(res ports.PushSummary) string {
420+ switch {
421+ case res.Rewritten:
422+ return "the branch was rewritten — reload"
423+ case res.More:
424+ return newCommits(strconv.Itoa(res.Count)+"+", 2)
425+ default:
426+ return newCommits(strconv.Itoa(res.Count), res.Count)
427+ }
428+}
429+
419430 // newCommits is the text of the banner: "1 new commit — reload".
420431 func newCommits(count string, n int) string {
421432 if n == 1 {
M internal/adapters/web/issues.go +3 -5
@@ -97,7 +97,7 @@ func (s *Server) issueRepo(ctx context.Context, rawName string) (*repoData, erro
9797 if err != nil {
9898 return nil, err
9999 }
100- if d.repo.Settings.Issues.OrDefault() == domain.IssuesOff {
100+ if !d.repo.Settings.IssuesEnabled() {
101101 return nil, errNotFound
102102 }
103103 return d, nil
@@ -129,7 +129,7 @@ func (s *Server) issueRow(repo string, i domain.Issue) IssueRow {
129129
130130 // issueList lists the issues of one state, a page at a time.
131131 //
132-//hexcheck:ignore suspicious-business-logic-in-adapter builds the list page from the use case results; whether the New issue button shows only reads the mode
132+//hexcheck:ignore suspicious-business-logic-in-adapter renders issue-list navigation; signed-in users see New issue, while anonymous users need a configured form and the domain AcceptsAnonymousIssues predicate
133133 func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
134134 d, err := s.issueRepo(ctx, r.PathValue("repo"))
135135 if err != nil {
@@ -179,7 +179,7 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
179179 if token != "" {
180180 p.Newest = link("")
181181 }
182- if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) {
182+ if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.AcceptsAnonymousIssues()) {
183183 p.NewHref = issuesBase(d.name()) + "/new"
184184 }
185185
@@ -216,8 +216,6 @@ func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component
216216 }
217217
218218 // issueShow shows an issue with its comments.
219-//
220-//hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here
221219 func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
222220 n, err := mrNumber(r)
223221 if err != nil {
M internal/adapters/web/issues_form.go +3 -1
@@ -245,6 +245,8 @@ func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.R
245245 }
246246
247247 // issueSubmittedPage is GET /<repo>/issues/submitted.
248+//
249+//hexcheck:ignore suspicious-business-logic-in-adapter maps domain RepoSettings.ShowsIssueSubmissionReceipt to a 404; decides nothing itself
248250 func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
249251 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
250252 defer cancel()
@@ -253,7 +255,7 @@ func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
253255 s.fail(ctx, w, r, err)
254256 return
255257 }
256- if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic {
258+ if !d.repo.Settings.ShowsIssueSubmissionReceipt() {
257259 s.fail(ctx, w, r, errNotFound)
258260 return
259261 }
M internal/adapters/web/issues_post.go +0 -1
@@ -67,7 +67,6 @@ func (s *Server) issueReopen(ctx context.Context, r *http.Request) (string, stri
6767 return s.issueTransition(ctx, r, s.cfg.Issues.Reopen)
6868 }
6969
70-//hexcheck:ignore suspicious-business-logic-in-adapter closes or reopens through the use case that owns the transition; the form only picks which
7170 func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) {
7271 repo := r.PathValue("repo")
7372 n, err := number(r)
M internal/adapters/web/mr.go +96 -88
@@ -62,9 +62,6 @@ const (
6262 // maxOpenPages bounds the pages read for the open merge requests, which
6363 // the first list page shows in full.
6464 maxOpenPages = 5
65- // maxComparePages bounds how many pages of history the compare page reads
66- // to find where the branch left the target.
67- maxComparePages = 5
6865 // railBranches is how many branches the list offers to compare.
6966 railBranches = 15
7067 )
@@ -546,16 +543,14 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
546543 return s.mrPage(ctx, r, true)
547544 }
548545
549-//hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here
550546 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
547+ if since && !SignedIn(ctx) {
548+ return nil, 0, errNotFound
549+ }
551550 n, err := mrNumber(r)
552551 if err != nil {
553552 return nil, 0, err
554553 }
555- if since && !SignedIn(ctx) {
556- // The read position is the owner's: there is nothing to show others.
557- return nil, 0, errNotFound
558- }
559554 d, err := s.loadRepo(ctx, r.PathValue("repo"))
560555 if err != nil {
561556 return nil, 0, err
@@ -574,7 +569,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
574569 return nil, 0, errNotFound
575570 }
576571 m := res.MergeRequest
577- now := s.cfg.Now()
578572 signed := SignedIn(ctx)
579573 open := m.State == domain.MRStateOpen
580574 head := m.HeadID.String()
@@ -595,13 +589,93 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
595589 Files: s.hl.files(ctx, res.Diff.Files),
596590 }
597591 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
592+ if err := s.mrFacts(ctx, r, d, &p, res); err != nil {
593+ return nil, 0, err
594+ }
595+
596+ p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
597+ countNotes(p.Files, res.Comments)
598+ if !res.DiffBaseID.IsZero() {
599+ lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
600+ }
601+ p.From = from
602+
603+ s.mrReadPresentation(&p, d.name(), n, res, since, signed)
604+
605+ // Comments: under their line, else in the list below the diff. The forms
606+ // are the owner's, on the whole diff of an open request.
607+ canWrite := signed && open && !since
608+ var on *lineRef
609+ if canWrite {
610+ on = parseOn(r.URL.Query().Get("on"))
611+ }
612+ s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
613+ lines, invisible := diffLines(p.Files)
614+ p.Invisible = invisible
615+ p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
616+ if signed && open {
617+ p.Box = s.mrBox(ctx, d, res)
618+ }
619+
620+ if mergeErrorOf(ctx) != "" {
621+ p.Canonical = self
622+ }
623+ if p.Box == nil {
624+ p.MergeError = mergeErrorOf(ctx) // the request is not open any more
625+ }
626+ if canWrite {
627+ p.GeneralForm = &CommentForm{
628+ ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
629+ }
630+ }
631+
632+ l := s.repoLayout(ctx, d, "merge-requests")
633+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
634+ if head != "" {
635+ pinRef(&l, target{rev: head, name: m.Source})
636+ if !d.hasBranch(m.Source) {
637+ pinRef(&l, target{rev: head, name: short(head), detached: true})
638+ }
639+ }
640+ l.Crumbs[0].Current = false
641+ l.Crumbs = append(l.Crumbs,
642+ Crumb{Label: "merge requests", Href: mrBase(d.name())},
643+ Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
644+ )
645+ if since {
646+ l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
647+ }
648+ p.Live = true
649+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
650+ l.HasRail = true
651+ l.MR = true
652+ return MergeRequest(l, p), http.StatusOK, nil
653+}
654+
655+func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestDetail) *MergeBox {
656+ m := res.MergeRequest
657+ box := s.mergeBox(d, m.Number, m.Source, m.HeadID.String(), res.HeadUnread, mergeabilityView(res.Mergeability, m.Target))
658+ box.Error = mergeErrorOf(ctx)
659+ if m.IsDraft() {
660+ box.ReadyAction = mrHref(d.name(), m.Number, "ready")
661+ }
662+ return box
663+}
664+
665+// mrFacts formats the description, metadata, branch links and checks.
666+func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error {
667+ m := res.MergeRequest
668+ head := m.HeadID.String()
669+ signed := SignedIn(ctx)
670+ open := m.State == domain.MRStateOpen
671+ now := s.cfg.Now()
598672 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
599673 // Relative links point into the source branch as it is now (its head
600674 // commit); the heading ids carry their own prefix, apart from the
601675 // ids of the diff.
602676 h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
603677 if err != nil {
604- return nil, 0, err
678+ return err
605679 }
606680 p.Desc = h
607681 }
@@ -644,45 +718,30 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
644718 p.Checks.Note = ""
645719 }
646720 }
647- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
648- countNotes(p.Files, res.Comments)
649- if !res.DiffBaseID.IsZero() {
650- lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
651- }
652- p.From = from
721+ return nil
722+}
653723
724+// mrReadPresentation maps usecase read tracking to commit rows and navigation.
725+func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.MergeRequestDetail, since, signed bool) {
726+ now := s.cfg.Now()
727+ head := res.MergeRequest.HeadID.String()
654728 // The commits of the request, newest first.
655- for _, c := range res.Commits {
656- row := commitRow(d.name(), c, now)
729+ for i, c := range res.Commits {
730+ row := commitRow(name, c, now)
657731 p.Commits = append(p.Commits, MRCommit{
658732 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
659- Sig: row.Sig,
733+ Sig: row.Sig, New: signed && i < res.NewCommits,
660734 })
661735 }
662736 lastRead := res.LastReadID.String()
663- if signed && lastRead != "" {
664- // The commits are newest first: those before the last read head are new.
665- // A head that is not among them (the branch was rewritten) marks none.
666- for i, c := range res.Commits {
667- if c.ID.String() != lastRead {
668- continue
669- }
670- for j := 0; j < i; j++ {
671- p.Commits[j].New = true
672- }
673- }
674- }
675737 if res.CommitsTruncated {
676738 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
677739 }
678740
679741 // Since the last read: the navigation, and what the page says about it.
680- if !signed {
681- lastRead = "" // the read position is the owner's
682- }
683742 if signed && lastRead != "" {
684743 p.Since = &SinceNav{
685- AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
744+ AllHref: mrHref(name, n), SinceHref: mrHref(name, n, "since"),
686745 ReadShort: short(lastRead), HeadShort: short(head), Current: since,
687746 }
688747 }
@@ -702,57 +761,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
702761 p.Since.Files = len(p.Files)
703762 }
704763
705- // Comments: under their line, else in the list below the diff. The forms
706- // are the owner's, on the whole diff of an open request.
707- canWrite := signed && open && !since
708- var on *lineRef
709- if canWrite {
710- on = parseOn(r.URL.Query().Get("on"))
711- }
712- s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
713- lines, invisible := diffLines(p.Files)
714- p.Invisible = invisible
715- p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
716- if signed && open {
717- p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target))
718- p.Box.Error = mergeErrorOf(ctx)
719- if m.IsDraft() {
720- p.Box.ReadyAction = mrHref(d.name(), n, "ready")
721- }
722- }
723- if mergeErrorOf(ctx) != "" {
724- p.Canonical = self
725- }
726- if p.Box == nil {
727- p.MergeError = mergeErrorOf(ctx) // the request is not open any more
728- }
729- if canWrite {
730- p.GeneralForm = &CommentForm{
731- ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
732- }
733- }
734-
735- l := s.repoLayout(ctx, d, "merge-requests")
736- l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
737- if head != "" {
738- pinRef(&l, target{rev: head, name: m.Source})
739- if !d.hasBranch(m.Source) {
740- pinRef(&l, target{rev: head, name: short(head), detached: true})
741- }
742- }
743- l.Crumbs[0].Current = false
744- l.Crumbs = append(l.Crumbs,
745- Crumb{Label: "merge requests", Href: mrBase(d.name())},
746- Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
747- )
748- if since {
749- l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
750- }
751- p.Live = true
752- l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
753- l.HasRail = true
754- l.MR = true
755- return MergeRequest(l, p), http.StatusOK, nil
756764 }
757765
758766 // hasBranch tells whether the repository has the branch.
@@ -766,11 +774,11 @@ func (d *repoData) hasBranch(name string) bool {
766774 }
767775
768776 // mergeBox is what the owner can do with an open request.
769-func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox {
777+func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead bool, mg MergeabilityView) *MergeBox {
770778 b := &MergeBox{
771779 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
772780 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
773- CanMarkRead: head != "" && head != lastRead,
781+ CanMarkRead: canMarkRead,
774782 CanDeleteBranch: source != d.repo.DefaultBranch,
775783 Mergeability: mg,
776784 }
M internal/adapters/web/mr_files.go +0 -2
@@ -157,8 +157,6 @@ func splitPath(p string) (name, dir string) {
157157 // the files of a window with their hunks and their comments, for a
158158 // placeholder of the page. A base or a head that is not the one of the diff
159159 // any more answers 409: the page says to reload.
160-//
161-//hexcheck:ignore suspicious-business-logic-in-adapter builds a window of the merge request page from the use case results; no rule is decided here
162160 func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
163161 n, err := mrNumber(r)
164162 if err != nil {
M internal/adapters/web/mr_post.go +0 -2
@@ -295,8 +295,6 @@ func commentID(s string) int64 {
295295 }
296296
297297 // headID parses a commit id of a form; the empty string is the zero id.
298-//
299-//hexcheck:ignore suspicious-business-logic-in-adapter parses a form field into the domain id and names the field in the error; no rule is decided here
300298 func headID(s string) (domain.ObjectID, error) {
301299 if s == "" {
302300 return domain.ObjectID{}, nil
M internal/adapters/web/mr_test.go +4 -4
@@ -63,7 +63,7 @@ func TestMergeBoxModes(t *testing.T) {
6363 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
6464 s := &Server{}
6565
66- behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
66+ behind := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
6767 require.Len(t, behind.Modes, 3)
6868 assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
6969 assert.NotEmpty(t, behind.Modes[0].Reason)
@@ -73,7 +73,7 @@ func TestMergeBoxModes(t *testing.T) {
7373 assert.True(t, behind.CanMerge())
7474 assert.True(t, behind.Signed)
7575
76- conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
76+ conflict := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
7777 assert.False(t, conflict.CanMerge())
7878 assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
7979 assert.Equal(t, 0, conflict.Checked())
@@ -84,7 +84,7 @@ func TestMergeBoxModes(t *testing.T) {
8484 func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
8585 t.Parallel()
8686 d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
87- b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
87+ b := (&Server{}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
8888 assert.False(t, b.Signed, "no commit is created, nothing to explain")
8989 assert.Len(t, b.Modes, 1)
9090 }
@@ -94,7 +94,7 @@ func TestMergeBoxNamesTheSigningKey(t *testing.T) {
9494 key := mocks.NewSigningKeyService(t)
9595 key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
9696 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
97- b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main"))
97+ b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{}, "main"))
9898 assert.Equal(t, "SHA256:abc", b.Fingerprint)
9999 }
100100
M internal/adapters/web/paths.go +0 -2
@@ -33,8 +33,6 @@ type pathList struct {
3333 // an ETag that makes it a 304 (answered before the list is loaded, so it never
3434 // waits for a build); the owner's copy is private and never stored. The ETag
3535 // differs for the gzip and the plain variant.
36-//
37-//hexcheck:ignore suspicious-business-logic-in-adapter serves a cached list after the use case authorized it; the headers are about the response, no rule is decided here
3836 func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
3937 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
4038 defer cancel()
M internal/adapters/web/raw.go +0 -2
@@ -31,8 +31,6 @@ const rawCSP = "default-src 'none'; sandbox"
3131 // image types (by their bytes, never SVG) as images, and anything else as an
3232 // attachment of unknown type. A file is never served as HTML or SVG, so what
3333 // a repository holds cannot run in the origin of the forge.
34-//
35-//hexcheck:ignore suspicious-business-logic-in-adapter streams a file the use case opened; the headers are about the response, no rule is decided here
3634 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
3735 ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)
3836 defer cancel()
M internal/adapters/web/refs.go +0 -4
@@ -15,8 +15,6 @@ import (
1515 // that is the short name of a branch or tag wins. A branch wins over a tag of
1616 // the same name. rest has no leading slash and is "" when the path is the ref
1717 // alone. ok is false when no ref matches; the caller may then try a commit id.
18-//
19-//hexcheck:ignore suspicious-business-logic-in-adapter finds the ref a URL path starts with; URL routing, no rule is decided here
2018 func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) {
2119 names := refsByName(refs)
2220 p = strings.Trim(p, "/")
@@ -97,8 +95,6 @@ func (t target) commitID() string {
9795 // resolveCommit sets the full id of a target named by an abbreviated commit id,
9896 // with one ResolveRevision. The page renders without it (it only lacks the file
9997 // finder), so a failure is not one.
100-//
101-//hexcheck:ignore suspicious-business-logic-in-adapter learns the full id of the commit a URL names for the finder links; no rule is decided here
10298 func (s *Server) resolveCommit(ctx context.Context, repo domain.RepoName, t *target) {
10399 if !t.detached || isFullID(t.rev) {
104100 return
M internal/adapters/web/repo.go +3 -7
@@ -44,16 +44,12 @@ func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.Repo
4444
4545 // repoRecord reads the repository record only, without Git: its name,
4646 // visibility and settings (the defaults when it has none).
47-//
48-//hexcheck:ignore suspicious-business-logic-in-adapter reads the repository from the use case; filling in the default settings for display decides no rule
4947 func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
5048 repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
5149 if err != nil {
5250 return domain.Repo{}, err
5351 }
54- if repo.Settings.IsZero() {
55- repo.Settings = domain.DefaultRepoSettings()
56- }
52+ repo.Settings = repo.EffectiveSettings()
5753 return repo, nil
5854 }
5955
@@ -132,7 +128,7 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
132128 // switcher on the head ref and the ":" targets. The caller sets the title,
133129 // the crumbs, the status and the rail.
134130 //
135-//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here
131+//hexcheck:ignore suspicious-business-logic-in-adapter renders repository navigation; the issues tab uses the domain IssuesEnabled predicate
136132 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
137133 l := s.base()
138134 l.Repo = d.name()
@@ -157,7 +153,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
157153 mrs.Count = s.openMRCount(ctx, d.repo.Name)
158154 l.Sections = append(l.Sections, mrs)
159155 }
160- if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
156+ if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {
161157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162158 }
163159 l.Sections = s.withActions(l.Sections, d.name(), section)
M internal/adapters/web/tree.go +0 -2
@@ -72,8 +72,6 @@ func (p TreePage) Unit() string {
7272
7373 // tree lists a directory of a ref, with the last commit of each entry. With
7474 // ?rows it answers the entries alone, for a directory opened in place.
75-//
76-//hexcheck:ignore suspicious-business-logic-in-adapter builds the directory page from the use case results; no rule is decided here
7775 func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {
7876 d, err := s.loadRepo(ctx, r.PathValue("repo"))
7977 if err != nil {
M internal/adapters/webauthn/webauthn.go +0 -2
@@ -137,8 +137,6 @@ func (v *Verifier) BeginLogin() ([]byte, []byte, error) {
137137
138138 // FinishLogin implements ports.PasskeyVerifier. The library compares the user
139139 // handle of the response with userHandle.
140-//
141-//hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; it decides nothing: the use case refuses a counter that does not advance
142140 func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {
143141 var session gowebauthn.SessionData
144142 if err := json.Unmarshal(ceremony, &session); err != nil {
M internal/app/forge.go +3 -8
@@ -151,8 +151,7 @@ type forge struct {
151151 CI *usecase.CI
152152 // Settings are the secrets, variables and environments of CI.
153153 Settings *usecase.CISettings
154- // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets
155- // key.
154+ // OIDC is the provider of ID tokens, disabled without a secrets key.
156155 OIDC *usecase.OIDC
157156 API *rpc.API
158157 Bus ports.EventBus
@@ -313,7 +312,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
313312 opts.ci(&ciCfg)
314313 }
315314 f.CI = usecase.NewCI(ctx, ciCfg)
316- f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI())
315+ f.Pushes.WithWorkflowTrigger(f.CI).WithPushTreeReader(git).WithRefTips(store.CI())
317316 f.Repos.WithCIRunCleaner(f.CI)
318317 f.CI.Start()
319318 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
@@ -394,10 +393,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
394393 if f.Passkeys != nil {
395394 apiPasskeys = f.Passkeys
396395 }
397- var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys
398- if f.OIDC != nil {
399- apiOIDC = f.OIDC
400- }
401396 var jobArtifacts ports.JobArtifactService
402397 var artifacts ports.ArtifactService
403398 if cfg.CIArtifacts {
@@ -421,7 +416,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
421416 Secrets: f.Settings,
422417 Variables: f.Settings,
423418 Environments: f.Settings,
424- OIDCKeys: apiOIDC,
419+ OIDCKeys: f.OIDC,
425420 JobArtifacts: jobArtifacts,
426421 Artifacts: artifacts,
427422 Version: serverVersion(),
A internal/domain/budget.go +85 -0
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "fmt"
6+
7+// ValidateCommitObjects checks a request before any repository I/O. max is the
8+// caller's commit-count limit.
9+func ValidateCommitObjects(ids []ObjectID, max int) error {
10+ if len(ids) > max {
11+ return fmt.Errorf("%w: %d commit objects asked, at most %d", ErrInvalid, len(ids), max)
12+ }
13+ for _, id := range ids {
14+ if id.IsZero() {
15+ return fmt.Errorf("%w: empty commit id", ErrInvalidObjectID)
16+ }
17+ }
18+ return nil
19+}
20+
21+// CommitObjectBudget bounds a batch of raw commits. Its zero value is ready to
22+// use. Admission is a preview; Charge records only successfully read content.
23+type CommitObjectBudget struct {
24+ total int64
25+ seen map[ObjectID]bool
26+}
27+
28+func (b *CommitObjectBudget) Seen(id ObjectID) bool { return b.seen[id] }
29+
30+func (b *CommitObjectBudget) Admit(id ObjectID, commit bool, size int64) (take, stop bool) {
31+ if b.Seen(id) || !commit || size < 0 || size > MaxCommitObjectBytes {
32+ return false, false
33+ }
34+ if size > MaxCommitObjectsBytes-b.total {
35+ return false, true
36+ }
37+ return true, false
38+}
39+
40+func (b *CommitObjectBudget) Charge(id ObjectID, size int64) {
41+ if b.seen == nil {
42+ b.seen = make(map[ObjectID]bool)
43+ }
44+ b.seen[id] = true
45+ b.total += size
46+}
47+
48+// DiffBudget owns file selection and retained hunk accounting. Truncated files
49+// consume no budget; streaming readers may use a copy while attempting a file.
50+type DiffBudget struct {
51+ limits DiffLimits
52+ linesLeft, bytesLeft int
53+}
54+
55+func NewDiffBudget(limits DiffLimits) DiffBudget {
56+ return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes}
57+}
58+
59+func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) {
60+ kept = files
61+ if b.limits.MaxFiles > 0 && kept > b.limits.MaxFiles {
62+ kept = b.limits.MaxFiles
63+ truncated = true
64+ }
65+ from, to = max(b.limits.From, 0), kept
66+ if b.limits.To > 0 {
67+ to = min(b.limits.To, kept)
68+ }
69+ return
70+}
71+
72+func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 }
73+
74+func (b DiffBudget) WantsHunks(binary bool, n int) bool {
75+ return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0
76+}
77+
78+func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) }
79+
80+// ChargeLine accounts for one decoded hunk line and reports whether it fits.
81+func (b *DiffBudget) ChargeLine(bytes int) bool {
82+ b.linesLeft--
83+ b.bytesLeft -= bytes
84+ return b.linesLeft >= 0 && b.bytesLeft >= 0
85+}
A internal/domain/budget_test.go +141 -0
@@ -0,0 +1,141 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "testing"
8+)
9+
10+func budgetID(t *testing.T, s string) ObjectID {
11+ t.Helper()
12+ id, err := ParseObjectID(s)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ return id
17+}
18+
19+func TestValidateCommitObjects(t *testing.T) {
20+ id := budgetID(t, "1111111111111111111111111111111111111111")
21+ for _, tt := range []struct {
22+ name string
23+ ids []ObjectID
24+ max int
25+ want error
26+ }{
27+ {"empty", nil, 1, nil}, {"boundary", []ObjectID{id}, 1, nil},
28+ {"too many", []ObjectID{id, id}, 1, ErrInvalid}, {"zero", []ObjectID{{}}, 1, ErrInvalidObjectID},
29+ } {
30+ t.Run(tt.name, func(t *testing.T) {
31+ if err := ValidateCommitObjects(tt.ids, tt.max); !errors.Is(err, tt.want) {
32+ t.Fatalf("got %v, want %v", err, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+func TestCommitObjectBudget(t *testing.T) {
39+ id := budgetID(t, "1111111111111111111111111111111111111111")
40+ for _, tt := range []struct {
41+ name string
42+ commit bool
43+ size, total int64
44+ seen, take, stop bool
45+ }{
46+ {"non commit", false, 1, 0, false, false, false},
47+ {"oversize", true, MaxCommitObjectBytes + 1, 0, false, false, false},
48+ {"exact object", true, MaxCommitObjectBytes, 0, false, true, false},
49+ {"exact total", true, 1, MaxCommitObjectsBytes - 1, false, true, false},
50+ {"over total", true, 2, MaxCommitObjectsBytes - 1, false, false, true},
51+ {"duplicate", true, 1, MaxCommitObjectsBytes, true, false, false},
52+ {"negative", true, -1, 0, false, false, false},
53+ } {
54+ t.Run(tt.name, func(t *testing.T) {
55+ b := CommitObjectBudget{total: tt.total}
56+ if tt.seen {
57+ b.seen = map[ObjectID]bool{id: true}
58+ }
59+ take, stop := b.Admit(id, tt.commit, tt.size)
60+ if take != tt.take || stop != tt.stop {
61+ t.Fatalf("got %v,%v", take, stop)
62+ }
63+ if take {
64+ b.Charge(id, tt.size)
65+ if !b.Seen(id) || b.total != tt.total+tt.size {
66+ t.Fatal("charge not recorded")
67+ }
68+ }
69+ })
70+ }
71+}
72+
73+func TestDiffBudgetWindow(t *testing.T) {
74+ for _, tt := range []struct {
75+ name string
76+ limits DiffLimits
77+ kept, from, to int
78+ truncated bool
79+ }{
80+ {"unlimited", DiffLimits{}, 5, 0, 5, false},
81+ {"cap", DiffLimits{MaxFiles: 3}, 3, 0, 3, true},
82+ {"window", DiffLimits{From: 1, To: 4}, 5, 1, 4, false},
83+ {"clamped", DiffLimits{From: -1, To: 9, MaxFiles: 3}, 3, 0, 3, true},
84+ {"past end", DiffLimits{From: 8}, 5, 8, 5, false},
85+ {"reversed", DiffLimits{From: 4, To: 2}, 5, 4, 2, false},
86+ } {
87+ t.Run(tt.name, func(t *testing.T) {
88+ b := NewDiffBudget(tt.limits)
89+ k, f, to, tr := b.Window(5)
90+ if k != tt.kept || f != tt.from || to != tt.to || tr != tt.truncated {
91+ t.Fatalf("got %d,%d,%d,%v", k, f, to, tr)
92+ }
93+ })
94+ }
95+}
96+
97+func TestDiffBudgetHunks(t *testing.T) {
98+ for _, tt := range []struct {
99+ name string
100+ binary bool
101+ n, lines, bytes int
102+ want bool
103+ }{
104+ {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false},
105+ {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false},
106+ {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false},
107+ } {
108+ t.Run(tt.name, func(t *testing.T) {
109+ b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes})
110+ if got := b.WantsHunks(tt.binary, tt.n); got != tt.want {
111+ t.Fatalf("got %v", got)
112+ }
113+ })
114+ }
115+}
116+
117+func TestDiffBudgetCharge(t *testing.T) {
118+ for _, tt := range []struct {
119+ name string
120+ bytes int
121+ fits bool
122+ }{{"exact", 3, true}, {"over", 4, false}} {
123+ t.Run(tt.name, func(t *testing.T) {
124+ b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3})
125+ before := b
126+ if b.FileLines() != 1 {
127+ t.Fatal("wrong file limit")
128+ }
129+ if b.ChargeLine(tt.bytes) != tt.fits {
130+ t.Fatal("wrong charge result")
131+ }
132+ if b.ChargeLine(0) {
133+ t.Fatal("line budget exceeded")
134+ }
135+ b = before
136+ if !b.WantsHunks(false, 1) {
137+ t.Fatal("discarded file consumed budget")
138+ }
139+ })
140+ }
141+}
A internal/domain/ci_workflow_guard.go +123 -0
@@ -0,0 +1,123 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "fmt"
6+
7+// PushObjectType is the type of a peeled Git object.
8+type PushObjectType string
9+
10+// Peeled object types accepted by the push tree reader.
11+const (
12+ PushObjectCommit PushObjectType = "commit"
13+ PushObjectTree PushObjectType = "tree"
14+ PushObjectBlob PushObjectType = "blob"
15+)
16+
17+// PushWalkBounds bounds the walk over the commits of a push.
18+type PushWalkBounds struct{ Commits, IDs int }
19+
20+// Bounds fail closed and count repeated parent IDs too.
21+const (
22+ MaxPushedCommits = 10000
23+ MaxPushedIDs = 4 * MaxPushedCommits
24+)
25+
26+// DefaultPushWalkBounds returns the job-token push walk limits.
27+func DefaultPushWalkBounds() PushWalkBounds { return PushWalkBounds{MaxPushedCommits, MaxPushedIDs} }
28+
29+// Subtree is the kind and identity of an entry at a tree path.
30+type Subtree struct {
31+ Kind EntryKind
32+ ID ObjectID
33+}
34+
35+// PushedCommit is a newly reachable commit with its ordered parents.
36+type PushedCommit struct {
37+ ID ObjectID
38+ Parents []ObjectID
39+}
40+
41+// HeadKey identifies the default branch head in PushSubtrees.Subtrees.
42+const HeadKey = "HEAD"
43+
44+// PushSubtrees contains endpoint types and entries keyed by original object ID
45+// (or HeadKey), and newly reachable commits in rev-list order. Absent entries
46+// mean there is no subtree at the requested path.
47+type PushSubtrees struct {
48+ Ends map[string]PushObjectType
49+ Commits []PushedCommit
50+ Subtrees map[string]Subtree
51+}
52+
53+// WorkflowTreeChange is the first forbidden workflow-tree replacement.
54+// A zero Old denotes no workflow directory.
55+type WorkflowTreeChange struct{ Old, New ObjectID }
56+
57+// WorkflowChangeOf applies the job-token workflow policy. Every new commit must
58+// keep the workflows of at least one parent; then each ref must keep its old
59+// workflows (HEAD's for a new ref). Disappearance and ref deletion are allowed.
60+// New commits are judged before ref ends, in their supplied order. Missing ends
61+// are a reader contract breach and fail closed with ErrInvalid.
62+func WorkflowChangeOf(updates []RefUpdate, t PushSubtrees) (WorkflowTreeChange, bool, error) {
63+ for _, up := range updates {
64+ if up.IsDelete() {
65+ continue
66+ }
67+ ids := []string{up.New}
68+ if !up.IsCreate() {
69+ ids = append(ids, up.Old)
70+ }
71+ for _, id := range ids {
72+ if _, ok := t.Ends[id]; !ok {
73+ return WorkflowTreeChange{}, false, fmt.Errorf("%w: missing push end %s", ErrInvalid, id)
74+ }
75+ }
76+ }
77+ dirOf := func(key string) ObjectID {
78+ s := t.Subtrees[key]
79+ if s.Kind != EntryDirectory {
80+ return ObjectID{}
81+ }
82+ return s.ID
83+ }
84+ for _, c := range t.Commits {
85+ cur := dirOf(c.ID.String())
86+ if cur.IsZero() {
87+ continue
88+ }
89+ var first ObjectID
90+ same := false
91+ for i, p := range c.Parents {
92+ w := dirOf(p.String())
93+ if i == 0 {
94+ first = w
95+ }
96+ if w == cur {
97+ same = true
98+ }
99+ }
100+ if !same {
101+ return WorkflowTreeChange{first, cur}, true, nil
102+ }
103+ }
104+ for _, up := range updates {
105+ if up.IsDelete() || t.Ends[up.New] == PushObjectBlob {
106+ continue
107+ }
108+ cur := dirOf(up.New)
109+ if cur.IsZero() {
110+ continue
111+ }
112+ var base ObjectID
113+ if up.IsCreate() {
114+ base = dirOf(HeadKey)
115+ } else if t.Ends[up.Old] != PushObjectBlob {
116+ base = dirOf(up.Old)
117+ }
118+ if base != cur {
119+ return WorkflowTreeChange{base, cur}, true, nil
120+ }
121+ }
122+ return WorkflowTreeChange{}, false, nil
123+}
A internal/domain/ci_workflow_guard_test.go +120 -0
@@ -0,0 +1,120 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "fmt"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestWorkflowChangeOf(t *testing.T) {
15+ id := func(n int) domain.ObjectID {
16+ v, err := domain.ParseObjectID(fmt.Sprintf("%040x", n))
17+ require.NoError(t, err)
18+ return v
19+ }
20+ a, b, c, d := id(1), id(2), id(3), id(4)
21+ x, y, z := id(11), id(12), id(13)
22+ dir := func(v domain.ObjectID) domain.Subtree { return domain.Subtree{Kind: domain.EntryDirectory, ID: v} }
23+ up := func(old, new domain.ObjectID) domain.RefUpdate {
24+ o, n := old.String(), new.String()
25+ if old.IsZero() {
26+ o = domain.ZeroOID
27+ }
28+ if new.IsZero() {
29+ n = domain.ZeroOID
30+ }
31+ return domain.RefUpdate{Old: o, New: n}
32+ }
33+ zero := domain.ObjectID{}
34+ commit := func(v domain.ObjectID, parents ...domain.ObjectID) domain.PushedCommit {
35+ return domain.PushedCommit{ID: v, Parents: parents}
36+ }
37+ type row struct {
38+ name string
39+ ups []domain.RefUpdate
40+ commits []domain.PushedCommit
41+ trees map[string]domain.Subtree
42+ treeEnds []domain.ObjectID
43+ blobs []domain.ObjectID
44+ want domain.WorkflowTreeChange
45+ found bool
46+ missing bool
47+ }
48+ change := func(old, new domain.ObjectID) domain.WorkflowTreeChange {
49+ return domain.WorkflowTreeChange{Old: old, New: new}
50+ }
51+ rows := []row{
52+ {name: "no updates"},
53+ {name: "delete", ups: []domain.RefUpdate{up(a, zero)}},
54+ {name: "new blob ignores even a misleading subtree", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{b}},
55+ {name: "old blob is not a directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{a}, want: change(zero, y), found: true},
56+ {name: "new ref at HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), a.String(): dir(x)}},
57+ {name: "new ref same directory as HEAD", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(x)}},
58+ {name: "new ref old workflows", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(y)}, want: change(x, y), found: true},
59+ {name: "unborn HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
60+ {name: "HEAD has no workflows", ups: []domain.RefUpdate{up(zero, a)}},
61+ {name: "same workflow directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
62+ {name: "move back or remove one file from surviving directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, want: change(x, y), found: true},
63+ {name: "directory disappears", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
64+ {name: "lookalike path absent", ups: []domain.RefUpdate{up(a, b)}},
65+ {name: "root with workflows", commits: []domain.PushedCommit{commit(a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
66+ {name: "root without workflows", commits: []domain.PushedCommit{commit(a)}},
67+ {name: "plain pushed commit", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
68+ {name: "pushed directory disappears", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
69+ {name: "merge keeps second parent", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}},
70+ {name: "merge differs from all parents", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z)}, want: change(x, z), found: true},
71+ {name: "merge new side changes workflows", commits: []domain.PushedCommit{commit(d, a, c), commit(c, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z), d.String(): dir(x)}, want: change(y, z), found: true},
72+ {name: "merge adopts other parent refused by ref rule", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}, want: change(x, y), found: true},
73+ {name: "change then revert", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, b), commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(x)}, want: change(y, x), found: true},
74+ {name: "change below plain tip", commits: []domain.PushedCommit{commit(d, c), commit(c, b)}, trees: map[string]domain.Subtree{b.String(): dir(x), c.String(): dir(y), d.String(): dir(y)}, want: change(x, y), found: true},
75+ {name: "all new commits before any ref", ups: []domain.RefUpdate{up(a, b)}, commits: []domain.PushedCommit{commit(d, c)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true},
76+ {name: "first ref wins", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(x, y), found: true},
77+ {name: "plain ref then changed ref", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true},
78+ {name: "missing ends fail closed", ups: []domain.RefUpdate{up(a, b)}, missing: true},
79+ }
80+ rows = append(rows,
81+ row{name: "new tree end with workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
82+ row{name: "new tree end without workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}},
83+ row{name: "new blob ref", ups: []domain.RefUpdate{up(zero, a)}, blobs: []domain.ObjectID{a}},
84+ )
85+
86+ for _, kind := range []domain.EntryKind{domain.EntrySubmodule, domain.EntrySymlink, domain.EntryFile, domain.EntryExecutable} {
87+ rows = append(rows,
88+ row{name: string(kind) + " to directory same id", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): dir(x)}, want: change(zero, x), found: true},
89+ row{name: "directory to " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): {Kind: kind, ID: x}}},
90+ row{name: "same " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): {Kind: kind, ID: x}}},
91+ )
92+ }
93+ rows = append(rows, row{name: "gitlink to symlink", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: domain.EntrySubmodule, ID: x}, b.String(): {Kind: domain.EntrySymlink, ID: y}}})
94+ for _, r := range rows {
95+ t.Run(r.name, func(t *testing.T) {
96+ data := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Commits: r.commits, Subtrees: r.trees}
97+ if !r.missing {
98+ for _, u := range r.ups {
99+ for _, oid := range []string{u.Old, u.New} {
100+ data.Ends[oid] = domain.PushObjectCommit
101+ }
102+ }
103+ }
104+ for _, tree := range r.treeEnds {
105+ data.Ends[tree.String()] = domain.PushObjectTree
106+ }
107+ for _, b := range r.blobs {
108+ data.Ends[b.String()] = domain.PushObjectBlob
109+ }
110+ got, found, err := domain.WorkflowChangeOf(r.ups, data)
111+ if r.missing {
112+ require.ErrorIs(t, err, domain.ErrInvalid)
113+ return
114+ }
115+ require.NoError(t, err)
116+ assert.Equal(t, r.found, found)
117+ assert.Equal(t, r.want, got)
118+ })
119+ }
120+}
M internal/domain/mergerequest.go +9 -0
@@ -569,3 +569,12 @@ func BoundBehind(n int) (count int, capped bool) {
569569 }
570570 return n, false
571571 }
572+
573+// IssuesEnabled reports whether the repository exposes issue streams and pages.
574+func (s RepoSettings) IssuesEnabled() bool { return s.Issues.OrDefault() != IssuesOff }
575+
576+// ShowsIssueSubmissionReceipt reports whether submissions await public moderation.
577+func (s RepoSettings) ShowsIssueSubmissionReceipt() bool { return s.Issues.OrDefault() == IssuesPublic }
578+
579+// AcceptsAnonymousIssues reports whether anyone may submit an issue.
580+func (s RepoSettings) AcceptsAnonymousIssues() bool { return s.Issues.OrDefault() == IssuesPublic }
M internal/domain/repo.go +8 -0
@@ -92,3 +92,11 @@ func validTopic(t string) bool {
9292 }
9393 return true
9494 }
95+
96+// EffectiveSettings returns the repository settings, using defaults for an unset record.
97+func (r Repo) EffectiveSettings() RepoSettings {
98+ if r.Settings.IsZero() {
99+ return DefaultRepoSettings()
100+ }
101+ return r.Settings
102+}
M internal/domain/repo_test.go +34 -0
@@ -60,3 +60,37 @@ func TestNormalizeTopics(t *testing.T) {
6060 })
6161 }
6262 }
63+
64+func TestRepoEffectiveSettings(t *testing.T) {
65+ custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeMode("squash")}, Issues: domain.IssuesPublic}
66+ for _, tc := range []struct {
67+ name string
68+ settings, want domain.RepoSettings
69+ }{
70+ {"unset", domain.RepoSettings{}, domain.DefaultRepoSettings()},
71+ {"custom", custom, custom},
72+ } {
73+ t.Run(tc.name, func(t *testing.T) {
74+ repo := domain.Repo{Settings: tc.settings}
75+ assert.Equal(t, tc.want, repo.EffectiveSettings())
76+ assert.Equal(t, tc.settings, repo.Settings)
77+ })
78+ }
79+}
80+
81+func TestRepoIssuePresentationRules(t *testing.T) {
82+ for _, tc := range []struct {
83+ mode domain.IssuesMode
84+ enabled, receipt bool
85+ }{
86+ {"", false, false}, {domain.IssuesOff, false, false},
87+ {domain.IssuesOwner, true, false}, {domain.IssuesPublic, true, true},
88+ } {
89+ t.Run(string(tc.mode), func(t *testing.T) {
90+ s := domain.RepoSettings{Issues: tc.mode}
91+ assert.Equal(t, tc.enabled, s.IssuesEnabled())
92+ assert.Equal(t, tc.receipt, s.ShowsIssueSubmissionReceipt())
93+ assert.Equal(t, tc.receipt, s.AcceptsAnonymousIssues())
94+ })
95+ }
96+}
A internal/domain/revision.go +54 -0
@@ -0,0 +1,54 @@
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+}
47+
48+// ResolvedRef returns the full ref name for a ref candidate, or empty otherwise.
49+func (c RevisionCandidate) ResolvedRef() string {
50+ if c.Kind == RevisionCandidateRef {
51+ return c.Name
52+ }
53+ return ""
54+}
A internal/domain/revision_test.go +58 -0
@@ -0,0 +1,58 @@
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+}
47+
48+func TestRevisionCandidateResolvedRef(t *testing.T) {
49+ for _, tc := range []struct {
50+ kind domain.RevisionCandidateKind
51+ want string
52+ }{
53+ {domain.RevisionCandidateHead, ""}, {domain.RevisionCandidateObject, ""},
54+ {domain.RevisionCandidateRef, "refs/heads/main"}, {domain.RevisionCandidateAbbrevID, ""},
55+ } {
56+ assert.Equal(t, tc.want, (domain.RevisionCandidate{Kind: tc.kind, Name: "refs/heads/main"}).ResolvedRef())
57+ }
58+}
M internal/ports/ci.go +41 -15
@@ -407,21 +407,47 @@ type PushObjects struct {
407407 Alternates string
408408 }
409409
410-// WorkflowGuard finds the changes of a push to the workflow files of a
411-// repository.
412-type WorkflowGuard interface {
413- // ChangedWorkflow returns the first path under .github/workflows/ that the
414- // updates change, "" for none. Two checks, as GitHub judges the token of a
415- // job: (1) every commit the push brings (those no ref has yet) is compared
416- // with each of its parents (a root commit with nothing) and is a change when
417- // it has workflows that differ from every parent's; (2) the workflows a ref
418- // ends with must be its old ones or none for an update, and the default
419- // branch head's or none for a new ref (none when there is no head). A
420- // workflows directory that disappears is no change, and a deleted ref changes
421- // nothing. objects are those of the push in progress; a directory that is not
422- // inside the repository is refused. A push beyond the bounds of the check
423- // (commits, parents) is an error that matches domain.ErrInvalid.
424- ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects) (path string, err error)
410+// PushTreeReader reads, for a push still in quarantine, the objects the
411+// workflow policy (domain.WorkflowChangeOf) judges. It knows nothing of the
412+// policy: it peels, classifies, walks and compares trees.
413+type PushTreeReader interface {
414+ // PushSubtrees reads what a push brings, and the entry at path (a
415+ // directory path without leading or trailing slash, e.g.
416+ // domain.WorkflowDir) in each object concerned.
417+ //
418+ // Updates that delete (New == ZeroOID) have no objects: they are skipped.
419+ // For every other update, Old (unless ZeroOID) and New are peeled
420+ // (`<oid>^{}`): the result is in Ends keyed by the oid as given. An end
421+ // that is missing, malformed, or peels to neither commit, tree nor blob is
422+ // an error matching domain.ErrInvalid.
423+ //
424+ // Commits is `rev-list --parents <tips> --not --all` where tips are the
425+ // distinct New ends that peel to a commit: the commits no ref has yet,
426+ // each with its parents, in rev-list order. More than bounds.Commits
427+ // commits, more than bounds.IDs ids (commits and parents, repeats
428+ // included) or a commit whose parent line exceeds the adapter's line
429+ // buffer is an error matching domain.ErrInvalid, raised as soon as the
430+ // line crossing the bound is read.
431+ //
432+ // Subtrees holds the entry at path for every end, every commit, every
433+ // parent, and, when a create has a non-blob New, the default branch head
434+ // (key domain.HeadKey, absent when HEAD is unborn or has no such entry). An object that peels to a blob, or an
435+ // object with no entry at path (including a path that crosses a
436+ // gitlink, symlink or file), has no key. Kind is EntryDirectory only for
437+ // mode 040000 and type tree.
438+ //
439+ // objects are the quarantine directories of the push; one outside the
440+ // repository's objects directory (symlinks resolved) is an error matching
441+ // domain.ErrInvalid, checked before anything is read.
442+ PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error)
443+
444+ // FirstDifferentEntry returns the name of the first top-level entry that
445+ // differs between the trees old and new (`diff-tree --name-only -z
446+ // --no-renames`, read as a stream up to the first name, then git is
447+ // stopped). A zero old is the empty tree. It returns "" when nothing
448+ // differs. A name longer than the adapter's path bound is an error
449+ // matching domain.ErrInvalid. objects as above.
450+ FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects PushObjects, old, new domain.ObjectID) (name string, err error)
425451 }
426452
427453 // --- inbound ---
M internal/ports/dto.go +28 -0
@@ -249,9 +249,27 @@ type IssueDetail struct {
249249 Comments []domain.IssueComment
250250 }
251251
252+// CompareResult describes source commits absent from target, newest first.
253+type CompareResult struct {
254+ Ahead []domain.Commit
255+ Behind bool
256+ Exact bool
257+}
258+
259+// PushSummary counts a bounded range; More means additional commits exist.
260+type PushSummary struct {
261+ Count int
262+ More bool
263+ Rewritten bool
264+}
265+
252266 // MergeRequestDetail is a merge request with its review data.
253267 type MergeRequestDetail struct {
254268 MergeRequest domain.MergeRequest
269+ // NewCommits is the unread prefix of Commits; zero if the read head is absent.
270+ NewCommits int
271+ // HeadUnread reports whether the nonzero head differs from the shared read position.
272+ HeadUnread bool
255273 // Commits are the commits of the head that the target lacks, newest
256274 // first, at most MaxMergeRequestCommits.
257275 Commits []domain.Commit
@@ -357,3 +375,13 @@ type InboxOverview struct {
357375 // whatever the filter: they feed the filter itself.
358376 Repos []domain.RepoName
359377 }
378+
379+// History query limits.
380+const (
381+ // MaxMergeRequestCommits bounds the commits returned with a merge request.
382+ MaxMergeRequestCommits = 100
383+ // MaxCompareCommits bounds a repository comparison.
384+ MaxCompareCommits = 500
385+ // MaxPushSummaryCommits bounds the commits counted in a push summary.
386+ MaxPushSummaryCommits = 100
387+)
M internal/ports/mocks/MergeRequestService.go +168 -0
@@ -210,6 +210,90 @@ func (_c *MergeRequestService_Close_Call) RunAndReturn(run func(ctx context.Cont
210210 return _c
211211 }
212212
213+// Compare provides a mock function for the type MergeRequestService
214+func (_mock *MergeRequestService) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error) {
215+ ret := _mock.Called(ctx, p, repo, target, source)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for Compare")
219+ }
220+
221+ var r0 ports.CompareResult
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.CompareResult, error)); ok {
224+ return returnFunc(ctx, p, repo, target, source)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.CompareResult); ok {
227+ r0 = returnFunc(ctx, p, repo, target, source)
228+ } else {
229+ r0 = ret.Get(0).(ports.CompareResult)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
232+ r1 = returnFunc(ctx, p, repo, target, source)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// MergeRequestService_Compare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Compare'
240+type MergeRequestService_Compare_Call struct {
241+ *mock.Call
242+}
243+
244+// Compare is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - repo domain.RepoName
248+// - target domain.ObjectID
249+// - source domain.ObjectID
250+func (_e *MergeRequestService_Expecter) Compare(ctx any, p any, repo any, target any, source any) *MergeRequestService_Compare_Call {
251+ return &MergeRequestService_Compare_Call{Call: _e.mock.On("Compare", ctx, p, repo, target, source)}
252+}
253+
254+func (_c *MergeRequestService_Compare_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID)) *MergeRequestService_Compare_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ var arg0 context.Context
257+ if args[0] != nil {
258+ arg0 = args[0].(context.Context)
259+ }
260+ var arg1 domain.Principal
261+ if args[1] != nil {
262+ arg1 = args[1].(domain.Principal)
263+ }
264+ var arg2 domain.RepoName
265+ if args[2] != nil {
266+ arg2 = args[2].(domain.RepoName)
267+ }
268+ var arg3 domain.ObjectID
269+ if args[3] != nil {
270+ arg3 = args[3].(domain.ObjectID)
271+ }
272+ var arg4 domain.ObjectID
273+ if args[4] != nil {
274+ arg4 = args[4].(domain.ObjectID)
275+ }
276+ run(
277+ arg0,
278+ arg1,
279+ arg2,
280+ arg3,
281+ arg4,
282+ )
283+ })
284+ return _c
285+}
286+
287+func (_c *MergeRequestService_Compare_Call) Return(compareResult ports.CompareResult, err error) *MergeRequestService_Compare_Call {
288+ _c.Call.Return(compareResult, err)
289+ return _c
290+}
291+
292+func (_c *MergeRequestService_Compare_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error)) *MergeRequestService_Compare_Call {
293+ _c.Call.Return(run)
294+ return _c
295+}
296+
213297 // CountOpen provides a mock function for the type MergeRequestService
214298 func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
215299 ret := _mock.Called(ctx, p, name)
@@ -1020,6 +1104,90 @@ func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx conte
10201104 return _c
10211105 }
10221106
1107+// PushSummary provides a mock function for the type MergeRequestService
1108+func (_mock *MergeRequestService) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error) {
1109+ ret := _mock.Called(ctx, p, repo, oldHead, newTip)
1110+
1111+ if len(ret) == 0 {
1112+ panic("no return value specified for PushSummary")
1113+ }
1114+
1115+ var r0 ports.PushSummary
1116+ var r1 error
1117+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.PushSummary, error)); ok {
1118+ return returnFunc(ctx, p, repo, oldHead, newTip)
1119+ }
1120+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.PushSummary); ok {
1121+ r0 = returnFunc(ctx, p, repo, oldHead, newTip)
1122+ } else {
1123+ r0 = ret.Get(0).(ports.PushSummary)
1124+ }
1125+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
1126+ r1 = returnFunc(ctx, p, repo, oldHead, newTip)
1127+ } else {
1128+ r1 = ret.Error(1)
1129+ }
1130+ return r0, r1
1131+}
1132+
1133+// MergeRequestService_PushSummary_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSummary'
1134+type MergeRequestService_PushSummary_Call struct {
1135+ *mock.Call
1136+}
1137+
1138+// PushSummary is a helper method to define mock.On call
1139+// - ctx context.Context
1140+// - p domain.Principal
1141+// - repo domain.RepoName
1142+// - oldHead domain.ObjectID
1143+// - newTip domain.ObjectID
1144+func (_e *MergeRequestService_Expecter) PushSummary(ctx any, p any, repo any, oldHead any, newTip any) *MergeRequestService_PushSummary_Call {
1145+ return &MergeRequestService_PushSummary_Call{Call: _e.mock.On("PushSummary", ctx, p, repo, oldHead, newTip)}
1146+}
1147+
1148+func (_c *MergeRequestService_PushSummary_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID)) *MergeRequestService_PushSummary_Call {
1149+ _c.Call.Run(func(args mock.Arguments) {
1150+ var arg0 context.Context
1151+ if args[0] != nil {
1152+ arg0 = args[0].(context.Context)
1153+ }
1154+ var arg1 domain.Principal
1155+ if args[1] != nil {
1156+ arg1 = args[1].(domain.Principal)
1157+ }
1158+ var arg2 domain.RepoName
1159+ if args[2] != nil {
1160+ arg2 = args[2].(domain.RepoName)
1161+ }
1162+ var arg3 domain.ObjectID
1163+ if args[3] != nil {
1164+ arg3 = args[3].(domain.ObjectID)
1165+ }
1166+ var arg4 domain.ObjectID
1167+ if args[4] != nil {
1168+ arg4 = args[4].(domain.ObjectID)
1169+ }
1170+ run(
1171+ arg0,
1172+ arg1,
1173+ arg2,
1174+ arg3,
1175+ arg4,
1176+ )
1177+ })
1178+ return _c
1179+}
1180+
1181+func (_c *MergeRequestService_PushSummary_Call) Return(pushSummary ports.PushSummary, err error) *MergeRequestService_PushSummary_Call {
1182+ _c.Call.Return(pushSummary, err)
1183+ return _c
1184+}
1185+
1186+func (_c *MergeRequestService_PushSummary_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error)) *MergeRequestService_PushSummary_Call {
1187+ _c.Call.Return(run)
1188+ return _c
1189+}
1190+
10231191 // ResolveComment provides a mock function for the type MergeRequestService
10241192 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {
10251193 ret := _mock.Called(ctx, p, name, number, commentID, resolved)
M internal/ports/mocks/OIDCKeyService.go +44 -0
@@ -48,6 +48,50 @@ func (_m *OIDCKeyService) EXPECT() *OIDCKeyService_Expecter {
4848 return &OIDCKeyService_Expecter{mock: &_m.Mock}
4949 }
5050
51+// Enabled provides a mock function for the type OIDCKeyService
52+func (_mock *OIDCKeyService) Enabled() bool {
53+ ret := _mock.Called()
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Enabled")
57+ }
58+
59+ var r0 bool
60+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
61+ r0 = returnFunc()
62+ } else {
63+ r0 = ret.Get(0).(bool)
64+ }
65+ return r0
66+}
67+
68+// OIDCKeyService_Enabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Enabled'
69+type OIDCKeyService_Enabled_Call struct {
70+ *mock.Call
71+}
72+
73+// Enabled is a helper method to define mock.On call
74+func (_e *OIDCKeyService_Expecter) Enabled() *OIDCKeyService_Enabled_Call {
75+ return &OIDCKeyService_Enabled_Call{Call: _e.mock.On("Enabled")}
76+}
77+
78+func (_c *OIDCKeyService_Enabled_Call) Run(run func()) *OIDCKeyService_Enabled_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ run()
81+ })
82+ return _c
83+}
84+
85+func (_c *OIDCKeyService_Enabled_Call) Return(b bool) *OIDCKeyService_Enabled_Call {
86+ _c.Call.Return(b)
87+ return _c
88+}
89+
90+func (_c *OIDCKeyService_Enabled_Call) RunAndReturn(run func() bool) *OIDCKeyService_Enabled_Call {
91+ _c.Call.Return(run)
92+ return _c
93+}
94+
5195 // RotateKey provides a mock function for the type OIDCKeyService
5296 func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
5397 ret := _mock.Called(ctx, p)
A internal/ports/mocks/PushTreeReader.go +223 -0
@@ -0,0 +1,223 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewPushTreeReader creates a new instance of PushTreeReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewPushTreeReader(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *PushTreeReader {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &PushTreeReader{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// PushTreeReader is an autogenerated mock type for the PushTreeReader type
39+type PushTreeReader struct {
40+ mock.Mock
41+}
42+
43+type PushTreeReader_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *PushTreeReader) EXPECT() *PushTreeReader_Expecter {
48+ return &PushTreeReader_Expecter{mock: &_m.Mock}
49+}
50+
51+// FirstDifferentEntry provides a mock function for the type PushTreeReader
52+func (_mock *PushTreeReader) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error) {
53+ ret := _mock.Called(ctx, repo, objects, old, new)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for FirstDifferentEntry")
57+ }
58+
59+ var r0 string
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) (string, error)); ok {
62+ return returnFunc(ctx, repo, objects, old, new)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) string); ok {
65+ r0 = returnFunc(ctx, repo, objects, old, new)
66+ } else {
67+ r0 = ret.Get(0).(string)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) error); ok {
70+ r1 = returnFunc(ctx, repo, objects, old, new)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// PushTreeReader_FirstDifferentEntry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FirstDifferentEntry'
78+type PushTreeReader_FirstDifferentEntry_Call struct {
79+ *mock.Call
80+}
81+
82+// FirstDifferentEntry is a helper method to define mock.On call
83+// - ctx context.Context
84+// - repo domain.RepoName
85+// - objects ports.PushObjects
86+// - old domain.ObjectID
87+// - new domain.ObjectID
88+func (_e *PushTreeReader_Expecter) FirstDifferentEntry(ctx any, repo any, objects any, old any, new any) *PushTreeReader_FirstDifferentEntry_Call {
89+ return &PushTreeReader_FirstDifferentEntry_Call{Call: _e.mock.On("FirstDifferentEntry", ctx, repo, objects, old, new)}
90+}
91+
92+func (_c *PushTreeReader_FirstDifferentEntry_Call) Run(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID)) *PushTreeReader_FirstDifferentEntry_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.RepoName
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.RepoName)
101+ }
102+ var arg2 ports.PushObjects
103+ if args[2] != nil {
104+ arg2 = args[2].(ports.PushObjects)
105+ }
106+ var arg3 domain.ObjectID
107+ if args[3] != nil {
108+ arg3 = args[3].(domain.ObjectID)
109+ }
110+ var arg4 domain.ObjectID
111+ if args[4] != nil {
112+ arg4 = args[4].(domain.ObjectID)
113+ }
114+ run(
115+ arg0,
116+ arg1,
117+ arg2,
118+ arg3,
119+ arg4,
120+ )
121+ })
122+ return _c
123+}
124+
125+func (_c *PushTreeReader_FirstDifferentEntry_Call) Return(name string, err error) *PushTreeReader_FirstDifferentEntry_Call {
126+ _c.Call.Return(name, err)
127+ return _c
128+}
129+
130+func (_c *PushTreeReader_FirstDifferentEntry_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error)) *PushTreeReader_FirstDifferentEntry_Call {
131+ _c.Call.Return(run)
132+ return _c
133+}
134+
135+// PushSubtrees provides a mock function for the type PushTreeReader
136+func (_mock *PushTreeReader) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) {
137+ ret := _mock.Called(ctx, repo, updates, objects, path, bounds)
138+
139+ if len(ret) == 0 {
140+ panic("no return value specified for PushSubtrees")
141+ }
142+
143+ var r0 domain.PushSubtrees
144+ var r1 error
145+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) (domain.PushSubtrees, error)); ok {
146+ return returnFunc(ctx, repo, updates, objects, path, bounds)
147+ }
148+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) domain.PushSubtrees); ok {
149+ r0 = returnFunc(ctx, repo, updates, objects, path, bounds)
150+ } else {
151+ r0 = ret.Get(0).(domain.PushSubtrees)
152+ }
153+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) error); ok {
154+ r1 = returnFunc(ctx, repo, updates, objects, path, bounds)
155+ } else {
156+ r1 = ret.Error(1)
157+ }
158+ return r0, r1
159+}
160+
161+// PushTreeReader_PushSubtrees_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSubtrees'
162+type PushTreeReader_PushSubtrees_Call struct {
163+ *mock.Call
164+}
165+
166+// PushSubtrees is a helper method to define mock.On call
167+// - ctx context.Context
168+// - repo domain.RepoName
169+// - updates []domain.RefUpdate
170+// - objects ports.PushObjects
171+// - path string
172+// - bounds domain.PushWalkBounds
173+func (_e *PushTreeReader_Expecter) PushSubtrees(ctx any, repo any, updates any, objects any, path any, bounds any) *PushTreeReader_PushSubtrees_Call {
174+ return &PushTreeReader_PushSubtrees_Call{Call: _e.mock.On("PushSubtrees", ctx, repo, updates, objects, path, bounds)}
175+}
176+
177+func (_c *PushTreeReader_PushSubtrees_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds)) *PushTreeReader_PushSubtrees_Call {
178+ _c.Call.Run(func(args mock.Arguments) {
179+ var arg0 context.Context
180+ if args[0] != nil {
181+ arg0 = args[0].(context.Context)
182+ }
183+ var arg1 domain.RepoName
184+ if args[1] != nil {
185+ arg1 = args[1].(domain.RepoName)
186+ }
187+ var arg2 []domain.RefUpdate
188+ if args[2] != nil {
189+ arg2 = args[2].([]domain.RefUpdate)
190+ }
191+ var arg3 ports.PushObjects
192+ if args[3] != nil {
193+ arg3 = args[3].(ports.PushObjects)
194+ }
195+ var arg4 string
196+ if args[4] != nil {
197+ arg4 = args[4].(string)
198+ }
199+ var arg5 domain.PushWalkBounds
200+ if args[5] != nil {
201+ arg5 = args[5].(domain.PushWalkBounds)
202+ }
203+ run(
204+ arg0,
205+ arg1,
206+ arg2,
207+ arg3,
208+ arg4,
209+ arg5,
210+ )
211+ })
212+ return _c
213+}
214+
215+func (_c *PushTreeReader_PushSubtrees_Call) Return(pushSubtrees domain.PushSubtrees, err error) *PushTreeReader_PushSubtrees_Call {
216+ _c.Call.Return(pushSubtrees, err)
217+ return _c
218+}
219+
220+func (_c *PushTreeReader_PushSubtrees_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error)) *PushTreeReader_PushSubtrees_Call {
221+ _c.Call.Return(run)
222+ return _c
223+}
D internal/ports/mocks/WorkflowGuard.go +0 -127
@@ -1,127 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package mocks
6-
7-import (
8- "context"
9-
10- "bnema.dev/ermite/internal/domain"
11- "bnema.dev/ermite/internal/ports"
12- mock "github.com/stretchr/testify/mock"
13-)
14-
15-// NewWorkflowGuard creates a new instance of WorkflowGuard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16-// The first argument is typically a *testing.T value.
17-func NewWorkflowGuard(t interface {
18- mock.TestingT
19- Cleanup(func())
20-}) *WorkflowGuard {
21- if helper, ok := t.(interface{ Helper() }); ok {
22- helper.Helper()
23- }
24-
25- mock := &WorkflowGuard{}
26- mock.Mock.Test(t)
27-
28- t.Cleanup(func() {
29- if helper, ok := t.(interface{ Helper() }); ok {
30- helper.Helper()
31- }
32- mock.AssertExpectations(t)
33- })
34-
35- return mock
36-}
37-
38-// WorkflowGuard is an autogenerated mock type for the WorkflowGuard type
39-type WorkflowGuard struct {
40- mock.Mock
41-}
42-
43-type WorkflowGuard_Expecter struct {
44- mock *mock.Mock
45-}
46-
47-func (_m *WorkflowGuard) EXPECT() *WorkflowGuard_Expecter {
48- return &WorkflowGuard_Expecter{mock: &_m.Mock}
49-}
50-
51-// ChangedWorkflow provides a mock function for the type WorkflowGuard
52-func (_mock *WorkflowGuard) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) {
53- ret := _mock.Called(ctx, repo, updates, objects)
54-
55- if len(ret) == 0 {
56- panic("no return value specified for ChangedWorkflow")
57- }
58-
59- var r0 string
60- var r1 error
61- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) (string, error)); ok {
62- return returnFunc(ctx, repo, updates, objects)
63- }
64- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) string); ok {
65- r0 = returnFunc(ctx, repo, updates, objects)
66- } else {
67- r0 = ret.Get(0).(string)
68- }
69- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) error); ok {
70- r1 = returnFunc(ctx, repo, updates, objects)
71- } else {
72- r1 = ret.Error(1)
73- }
74- return r0, r1
75-}
76-
77-// WorkflowGuard_ChangedWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChangedWorkflow'
78-type WorkflowGuard_ChangedWorkflow_Call struct {
79- *mock.Call
80-}
81-
82-// ChangedWorkflow is a helper method to define mock.On call
83-// - ctx context.Context
84-// - repo domain.RepoName
85-// - updates []domain.RefUpdate
86-// - objects ports.PushObjects
87-func (_e *WorkflowGuard_Expecter) ChangedWorkflow(ctx any, repo any, updates any, objects any) *WorkflowGuard_ChangedWorkflow_Call {
88- return &WorkflowGuard_ChangedWorkflow_Call{Call: _e.mock.On("ChangedWorkflow", ctx, repo, updates, objects)}
89-}
90-
91-func (_c *WorkflowGuard_ChangedWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects)) *WorkflowGuard_ChangedWorkflow_Call {
92- _c.Call.Run(func(args mock.Arguments) {
93- var arg0 context.Context
94- if args[0] != nil {
95- arg0 = args[0].(context.Context)
96- }
97- var arg1 domain.RepoName
98- if args[1] != nil {
99- arg1 = args[1].(domain.RepoName)
100- }
101- var arg2 []domain.RefUpdate
102- if args[2] != nil {
103- arg2 = args[2].([]domain.RefUpdate)
104- }
105- var arg3 ports.PushObjects
106- if args[3] != nil {
107- arg3 = args[3].(ports.PushObjects)
108- }
109- run(
110- arg0,
111- arg1,
112- arg2,
113- arg3,
114- )
115- })
116- return _c
117-}
118-
119-func (_c *WorkflowGuard_ChangedWorkflow_Call) Return(path string, err error) *WorkflowGuard_ChangedWorkflow_Call {
120- _c.Call.Return(path, err)
121- return _c
122-}
123-
124-func (_c *WorkflowGuard_ChangedWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error)) *WorkflowGuard_ChangedWorkflow_Call {
125- _c.Call.Return(run)
126- return _c
127-}
M internal/ports/oidc.go +2 -0
@@ -53,6 +53,8 @@ type OIDCRotation struct {
5353 // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC
5454 // implements it.
5555 type OIDCKeyService interface {
56+ // Enabled reports whether the server issues ID tokens.
57+ Enabled() bool
5658 // RotateKey replaces the signing key by a new one. It needs owner rights.
5759 RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error)
5860 }
M internal/ports/services.go +4 -0
@@ -109,6 +109,10 @@ type EventService interface {
109109 // MergeRequestService is the merge request use cases. *usecase.MergeRequests
110110 // implements it.
111111 type MergeRequestService interface {
112+ // Compare queries repository history; it stays here because this service owns the merge-git port.
113+ Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (CompareResult, error)
114+ // PushSummary queries repository history; it stays here because this service owns the merge-git port.
115+ PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (PushSummary, error)
112116 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)
113117 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)
114118 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)
M internal/usecase/ci.go +1 -1
@@ -106,7 +106,7 @@ type CIConfig struct {
106106 // read when an action of another repository is resolved for the run. Nil
107107 // reads nothing for a token (fail closed).
108108 Tokens ports.TokenStore
109- // OIDC signs the ID tokens of the jobs (CI-4); nil issues none: jobs get no
109+ // OIDC signs the ID tokens of the jobs (CI-4); nil or disabled issues none: jobs get no
110110 // token endpoint, and the provider is not served.
111111 OIDC *OIDC
112112 // Settings resolves the variables and secrets of a job (CI-3). Nil gives
A internal/usecase/ci_creation_policy_test.go +48 -0
@@ -0,0 +1,48 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "testing"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ "bnema.dev/ermite/internal/ports/mocks"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestCreateRunWorkflowPolicy(t *testing.T) {
18+ repo, err := domain.ParseRepoName("project")
19+ require.NoError(t, err)
20+ failure := errors.New("state unavailable")
21+ for _, tc := range []struct {
22+ name string
23+ off []string
24+ stateErr, want error
25+ }{
26+ {name: "enabled"},
27+ {name: "other disabled", off: []string{"other.yml"}},
28+ {name: "disabled", off: []string{"ci.yml"}, want: domain.ErrWorkflowDisabled},
29+ {name: "state failure", stateErr: failure, want: failure},
30+ } {
31+ t.Run(tc.name, func(t *testing.T) {
32+ state := mocks.NewCIWorkflowStore(t)
33+ store := mocks.NewCIStore(t)
34+ state.EXPECT().DisabledWorkflows(context.Background(), repo).Return(tc.off, tc.stateErr).Once()
35+ c := &CI{cfg: CIConfig{Store: store, WorkflowState: state}}
36+ build := func(id, number int64) ports.CIRunDraft { return ports.CIRunDraft{} }
37+ if tc.want == nil {
38+ store.EXPECT().CreateRun(context.Background(), repo, "ci.yml", mock.Anything).Return(ports.CIRunDraft{}, nil).Once()
39+ }
40+ _, err := c.createRun(context.Background(), pushFacts{}, repo, "ci.yml", build)
41+ if tc.want == nil {
42+ require.NoError(t, err)
43+ } else {
44+ require.ErrorIs(t, err, tc.want)
45+ }
46+ })
47+ }
48+}
M internal/usecase/ci_oidc.go +3 -9
@@ -31,7 +31,7 @@ var _ ports.OIDCService = (*CI)(nil)
3131 // server issues none. j must be assigned (its AssignedAt is set).
3232 func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
3333 c := rs.c
34- if c.cfg.OIDC == nil || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
34+ if !c.cfg.OIDC.Enabled() || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) {
3535 return ""
3636 }
3737 now := c.now()
@@ -48,21 +48,15 @@ func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
4848 }
4949
5050 // OIDCEnabled reports whether the server issues ID tokens.
51-func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC != nil }
51+func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC.Enabled() }
5252
5353 // OIDCDiscovery returns the OpenID Provider configuration.
5454 func (c *CI) OIDCDiscovery() ([]byte, error) {
55- if c.cfg.OIDC == nil {
56- return nil, domain.ErrNotFound
57- }
5855 return c.cfg.OIDC.Discovery()
5956 }
6057
6158 // OIDCKeys returns the JWKS.
6259 func (c *CI) OIDCKeys() ([]byte, error) {
63- if c.cfg.OIDC == nil {
64- return nil, domain.ErrNotFound
65- }
6660 return c.cfg.OIDC.JWKS()
6761 }
6862
@@ -73,7 +67,7 @@ func (c *CI) OIDCKeys() ([]byte, error) {
7367 // "https://github.com/<owner>".
7468 func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) {
7569 o := c.cfg.OIDC
76- if o == nil {
70+ if !o.Enabled() {
7771 return "", domain.ErrNotFound
7872 }
7973 if len(audience) > domain.MaxOIDCAudienceLen {
M internal/usecase/ci_pull.go +9 -1
@@ -681,11 +681,19 @@ func (c *CI) whileOpen(ctx context.Context, repo domain.RepoName, number int64,
681681 // createRun stores a run. The run of an open merge request is stored under the
682682 // gate, so that one that is closed meanwhile gets none (errPullClosed).
683683 func (c *CI) createRun(ctx context.Context, f pushFacts, repo domain.RepoName, wfPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
684+ off, err := c.workflowDisabled(ctx, repo, wfPath)
685+ if err != nil {
686+ return ports.CIRunDraft{}, err
687+ }
688+ if off {
689+ return ports.CIRunDraft{}, domain.ErrWorkflowDisabled
690+ }
691+
684692 if f.pull == nil || f.pull.action == domain.PullClosed {
685693 return c.cfg.Store.CreateRun(ctx, repo, wfPath, build)
686694 }
687695 var d ports.CIRunDraft
688- err := c.whileOpen(ctx, repo, f.pull.mr.Number, func() error {
696+ err = c.whileOpen(ctx, repo, f.pull.mr.Number, func() error {
689697 var err error
690698 d, err = c.cfg.Store.CreateRun(ctx, repo, wfPath, build)
691699 return err
M internal/usecase/ci_runner.go +1 -1
@@ -281,7 +281,7 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, sec jobSecrets, settings po
281281 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),
282282 Cache: c.cacheScope(rs.run, rs.src.GithubJSON),
283283 }
284- if sec.idToken != "" && c.cfg.OIDC != nil {
284+ if sec.idToken != "" && c.cfg.OIDC.Enabled() {
285285 out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken
286286 }
287287 return out
M internal/usecase/events.go +2 -2
@@ -573,7 +573,7 @@ func (e *Events) deliverOwnerOnly(ctx context.Context, w *watch, name domain.Rep
573573 case err != nil:
574574 return fmt.Errorf("check access to %q: %w", name, err)
575575 }
576- if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
576+ if !repo.Settings.IssuesEnabled() {
577577 return nil
578578 }
579579 return send(out)
@@ -613,7 +613,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
613613 if !canRead(w.p, repo) {
614614 return nil
615615 }
616- if isIssueEvent(be.ev) && repo.Settings.Issues.OrDefault() == domain.IssuesOff {
616+ if isIssueEvent(be.ev) && !repo.Settings.IssuesEnabled() {
617617 return nil // issues were turned off since: a replay must not show them
618618 }
619619 if withRepo {
M internal/usecase/inbox.go +1 -1
@@ -74,7 +74,7 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain
7474 return fmt.Errorf("read repository %q: %w", name, err)
7575 default:
7676 exists[name] = true
77- issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff
77+ issuesOn[name] = repo.Settings.IssuesEnabled()
7878 }
7979 return nil
8080 }
M internal/usecase/issue_moderation.go +1 -1
@@ -71,7 +71,7 @@ func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo
7171 if err != nil {
7272 return domain.Issue{}, err
7373 }
74- if r.Settings.Issues.OrDefault() == domain.IssuesOff {
74+ if !r.Settings.IssuesEnabled() {
7575 return domain.Issue{}, domain.ErrNotFound
7676 }
7777 now := m.clock.Now().UTC()
M internal/usecase/issue_submissions.go +1 -1
@@ -162,7 +162,7 @@ func (s *IssueSubmissions) openRepo(ctx context.Context, repo domain.RepoName) e
162162 }
163163 return err
164164 }
165- if !canRead(domain.Anonymous(), r) || r.Settings.Issues.OrDefault() != domain.IssuesPublic {
165+ if !canRead(domain.Anonymous(), r) || !r.Settings.AcceptsAnonymousIssues() {
166166 return domain.ErrNotFound
167167 }
168168 return nil
M internal/usecase/issues.go +1 -1
@@ -59,7 +59,7 @@ func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo
5959 if err != nil {
6060 return domain.Repo{}, err
6161 }
62- if repo.Settings.Issues.OrDefault() == domain.IssuesOff {
62+ if !repo.Settings.IssuesEnabled() {
6363 return domain.Repo{}, domain.ErrNotFound
6464 }
6565 return repo, nil
M internal/usecase/mergerequests.go +55 -5
@@ -21,7 +21,7 @@ import (
2121 )
2222
2323 // MaxMergeRequestCommits is the most commits GetMergeRequest returns.
24-const MaxMergeRequestCommits = 100
24+const MaxMergeRequestCommits = ports.MaxMergeRequestCommits
2525
2626 // maxWriteRetries bounds the retries of a write that loses a race with another
2727 // write of the same merge request. Each lost race means another writer won, so
@@ -546,6 +546,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
546546 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
547547 return ports.MergeRequestDetail{}, err
548548 }
549+ d.HeadUnread = !mr.HeadID.IsZero() && mr.HeadID != d.LastReadID
549550
550551 exclude, base, err := m.rangeOf(ctx, mr)
551552 if err != nil {
@@ -574,6 +575,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
574575 return ports.MergeRequestDetail{}, err
575576 }
576577 verifyCommits(ctx, m.verifier, name, d.Commits)
578+ if !d.LastReadID.IsZero() {
579+ for i, c := range d.Commits {
580+ if c.ID == d.LastReadID {
581+ d.NewCommits = i
582+ break
583+ }
584+ }
585+ }
577586 }
578587 if opts.SinceLastRead && !d.LastReadID.IsZero() {
579588 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {
@@ -1046,10 +1055,7 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
10461055 }
10471056 return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
10481057 }
1049- settings := repo.Settings
1050- if settings.IsZero() {
1051- settings = domain.DefaultRepoSettings()
1052- }
1058+ settings := repo.EffectiveSettings()
10531059 if !settings.Allows(mode) {
10541060 return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
10551061 }
@@ -1438,3 +1444,47 @@ func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, num
14381444 }
14391445 return root, nil
14401446 }
1447+
1448+// Compare lists at most ports.MaxCompareCommits source commits absent from target. Unlike bounded
1449+// history intersection, ancestry remains accurate even for deep histories.
1450+func (m *MergeRequests) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (ports.CompareResult, error) {
1451+ if _, err := m.readable(ctx, p, repo); err != nil {
1452+ return ports.CompareResult{}, err
1453+ }
1454+ if target == source {
1455+ return ports.CompareResult{Exact: true}, nil
1456+ }
1457+ ancestor, err := m.git.IsAncestor(ctx, repo, target, source)
1458+ if err != nil {
1459+ return ports.CompareResult{}, err
1460+ }
1461+ commits, more, err := m.git.ListRange(ctx, repo, target, source, ports.MaxCompareCommits)
1462+ if err != nil {
1463+ return ports.CompareResult{}, err
1464+ }
1465+ verifyCommits(ctx, m.verifier, repo, commits)
1466+ return ports.CompareResult{Ahead: commits, Behind: !ancestor, Exact: !more}, nil
1467+}
1468+
1469+// PushSummary detects rewrites by ancestry, not log ordering, and counts at
1470+// most ports.MaxPushSummaryCommits new commits. More distinguishes a longer range from a rewrite.
1471+func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (ports.PushSummary, error) {
1472+ if _, err := m.readable(ctx, p, repo); err != nil {
1473+ return ports.PushSummary{}, err
1474+ }
1475+ if oldHead == newTip {
1476+ return ports.PushSummary{}, nil
1477+ }
1478+ ancestor, err := m.git.IsAncestor(ctx, repo, oldHead, newTip)
1479+ if err != nil {
1480+ return ports.PushSummary{}, err
1481+ }
1482+ if !ancestor {
1483+ return ports.PushSummary{Rewritten: true}, nil
1484+ }
1485+ commits, more, err := m.git.ListRange(ctx, repo, oldHead, newTip, ports.MaxPushSummaryCommits)
1486+ if err != nil {
1487+ return ports.PushSummary{}, err
1488+ }
1489+ return ports.PushSummary{Count: len(commits), More: more}, nil
1490+}
M internal/usecase/mergerequests_test.go +144 -0
@@ -233,3 +233,147 @@ func TestAddNoteValidation(t *testing.T) {
233233 })
234234 }
235235 }
236+
237+func TestCompare(t *testing.T) {
238+ for _, tc := range []struct {
239+ name string
240+ ancestor, more, zero bool
241+ ancestorErr, rangeErr error
242+ }{
243+ {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true},
244+ {name: "unrelated histories"}, {name: "zero target forwards ancestry error", zero: true, ancestorErr: domain.ErrInvalid},
245+ {name: "ancestor error", ancestorErr: domain.ErrInvalid},
246+ {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid},
247+ } {
248+ t.Run(tc.name, func(t *testing.T) {
249+ u := newMRUnit(t)
250+ old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222")
251+ if tc.zero {
252+ old = domain.ObjectID{}
253+ }
254+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
255+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr)
256+ commits := []domain.Commit{{ID: tip}}
257+ if tc.ancestorErr == nil {
258+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxCompareCommits).Return(commits, tc.more, tc.rangeErr)
259+ }
260+ got, err := u.mrs.Compare(context.Background(), domain.Root(), u.repo.Name, old, tip)
261+ if tc.ancestorErr != nil {
262+ require.ErrorIs(t, err, tc.ancestorErr)
263+ return
264+ }
265+ if tc.rangeErr != nil {
266+ require.ErrorIs(t, err, tc.rangeErr)
267+ return
268+ }
269+ require.NoError(t, err)
270+ assert.Equal(t, ports.CompareResult{Ahead: commits, Behind: !tc.ancestor, Exact: !tc.more}, got)
271+ })
272+ }
273+}
274+
275+func TestPushSummary(t *testing.T) {
276+ for _, tc := range []struct {
277+ name string
278+ ancestor, more bool
279+ ancestorErr, rangeErr error
280+ }{
281+ {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true},
282+ {name: "unrelated histories"}, {name: "ancestor error", ancestorErr: domain.ErrInvalid},
283+ {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid},
284+ } {
285+ t.Run(tc.name, func(t *testing.T) {
286+ u := newMRUnit(t)
287+ old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222")
288+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
289+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr)
290+ if tc.ancestor && tc.ancestorErr == nil {
291+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxPushSummaryCommits).Return([]domain.Commit{{ID: tip}}, tc.more, tc.rangeErr)
292+ }
293+ got, err := u.mrs.PushSummary(context.Background(), domain.Root(), u.repo.Name, old, tip)
294+ if tc.ancestorErr != nil {
295+ require.ErrorIs(t, err, tc.ancestorErr)
296+ return
297+ }
298+ if tc.rangeErr != nil {
299+ require.ErrorIs(t, err, tc.rangeErr)
300+ return
301+ }
302+ require.NoError(t, err)
303+ want := ports.PushSummary{Rewritten: !tc.ancestor}
304+ if tc.ancestor {
305+ want.Count, want.More = 1, tc.more
306+ }
307+ assert.Equal(t, want, got)
308+ })
309+ }
310+}
311+
312+func TestReadTrackingSharedPosition(t *testing.T) {
313+ for _, tc := range []struct {
314+ name string
315+ principal domain.Principal
316+ readIndex int
317+ }{
318+ {"owner unread", domain.Owner(), 1}, {"owner already read", domain.Owner(), 0},
319+ {"owner rewritten", domain.Owner(), -1}, {"anonymous", domain.Anonymous(), 1}, {"root token", domain.Root(), 1},
320+ } {
321+ t.Run(tc.name, func(t *testing.T) {
322+ u := newMRUnit(t)
323+ u.repo.Visibility = domain.VisibilityPublic
324+ head := someID(t)
325+ old := oidFrom(t, "2222222222222222222222222222222222222222")
326+ read := old
327+ if tc.readIndex == 0 {
328+ read = head
329+ }
330+ mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateClosed, HeadID: head}
331+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
332+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil)
333+ u.store.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(1)).Return(nil, nil)
334+ u.store.EXPECT().GetRead(mock.Anything, u.repo.Name, int64(1)).Return(read, nil)
335+ u.git.EXPECT().HasCommit(mock.Anything, u.repo.Name, head).Return(true, nil)
336+ commits := []domain.Commit{{ID: head}}
337+ if tc.readIndex >= 0 {
338+ commits = append(commits, domain.Commit{ID: old})
339+ }
340+ u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, domain.ObjectID{}, head, usecase.MaxMergeRequestCommits).Return(commits, false, nil)
341+ detail, err := u.mrs.Get(context.Background(), tc.principal, u.repo.Name, 1, ports.GetMergeRequestOptions{})
342+ require.NoError(t, err)
343+ expected := 0
344+ assert.Equal(t, read, detail.LastReadID)
345+ assert.Equal(t, read != head, detail.HeadUnread)
346+ if tc.readIndex > 0 {
347+ expected = tc.readIndex
348+ }
349+ assert.Equal(t, expected, detail.NewCommits)
350+ })
351+ }
352+}
353+
354+func TestHistoryAuthorizationAndEqualTips(t *testing.T) {
355+ for _, allowed := range []bool{false, true} {
356+ t.Run(map[bool]string{false: "private", true: "equal"}[allowed], func(t *testing.T) {
357+ u := newMRUnit(t)
358+ if allowed {
359+ u.repo.Visibility = domain.VisibilityPublic
360+ }
361+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Twice()
362+ id := someID(t)
363+ comparison, err := u.mrs.Compare(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
364+ if allowed {
365+ require.NoError(t, err)
366+ assert.True(t, comparison.Exact)
367+ } else {
368+ require.ErrorIs(t, err, domain.ErrNotFound)
369+ }
370+ summary, err := u.mrs.PushSummary(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
371+ if allowed {
372+ require.NoError(t, err)
373+ assert.Zero(t, summary)
374+ } else {
375+ require.ErrorIs(t, err, domain.ErrNotFound)
376+ }
377+ })
378+ }
379+}
M internal/usecase/oidc.go +27 -3
@@ -74,8 +74,8 @@ type oidcKey struct {
7474 var _ ports.OIDCKeyService = (*OIDC)(nil)
7575
7676 // NewOIDC loads the signing keys, generates the first one when there is none,
77-// and forgets the retired keys whose publication is over. It returns nil
78-// without error when cfg.Sealer is nil: the server issues no ID tokens. A
77+// and forgets the retired keys whose publication is over. With cfg.Sealer nil
78+// it returns a disabled service: no ID tokens are issued. A
7979 // stored key that does not open with the secrets key is an error: the server
8080 // must not start (the secrets key check already refuses another key, so this
8181 // is a damaged record).
@@ -83,7 +83,7 @@ func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) {
8383 log := zerowrap.FromCtx(ctx)
8484 if cfg.Sealer == nil {
8585 log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens")
86- return nil, nil
86+ return &OIDC{}, nil
8787 }
8888 if cfg.NewKey == nil {
8989 cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) }
@@ -180,6 +180,13 @@ func (o *OIDC) signing() (oidcKey, bool) {
180180 // and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose
181181 // publication is over are deleted with it. Only the owner may rotate.
182182 func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
183+ if !o.Enabled() {
184+ if !p.HasOwnerRights() {
185+ return ports.OIDCRotation{}, domain.ErrForbidden
186+ }
187+ return ports.OIDCRotation{}, oidcDisabledError{}
188+ }
189+
183190 switch {
184191 case p.IsAnonymous():
185192 return ports.OIDCRotation{}, domain.ErrUnauthenticated
@@ -234,6 +241,9 @@ func publicJWK(kid string, pub *rsa.PublicKey) jwk {
234241
235242 // JWKS returns the JSON of the published keys.
236243 func (o *OIDC) JWKS() ([]byte, error) {
244+ if !o.Enabled() {
245+ return nil, domain.ErrNotFound
246+ }
237247 now := o.cfg.Clock.Now().UTC()
238248 o.mu.RLock()
239249 defer o.mu.RUnlock()
@@ -258,6 +268,9 @@ var oidcClaims = []string{
258268 // Discovery returns the JSON of the OpenID Provider configuration, in the
259269 // shape of GitHub's.
260270 func (o *OIDC) Discovery() ([]byte, error) {
271+ if !o.Enabled() {
272+ return nil, domain.ErrNotFound
273+ }
261274 return json.Marshal(ordered{
262275 {"issuer", o.issuer},
263276 {"jwks_uri", o.issuer + "/.well-known/jwks"},
@@ -303,3 +316,14 @@ func thumbprint(pub *rsa.PublicKey) string {
303316 }
304317
305318 func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
319+
320+// Enabled reports whether the provider issues ID tokens. A nil provider is disabled.
321+func (o *OIDC) Enabled() bool { return o != nil && o.cfg.Sealer != nil }
322+
323+// oidcDisabledError preserves the public disabled-provider message and precondition code.
324+type oidcDisabledError struct{}
325+
326+func (oidcDisabledError) Error() string {
327+ return "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY"
328+}
329+func (oidcDisabledError) Is(target error) bool { return target == domain.ErrPreconditionFailed }
M internal/usecase/oidc_test.go +44 -1
@@ -123,7 +123,8 @@ func TestNoSecretsKeyNoOIDC(t *testing.T) {
123123 f := newOIDCFixture(t)
124124 o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"})
125125 require.NoError(t, err)
126- assert.Nil(t, o)
126+ require.NotNil(t, o)
127+ assert.False(t, o.Enabled())
127128 keys, err := f.store.ListOIDCKeys(context.Background())
128129 require.NoError(t, err)
129130 assert.Empty(t, keys, "no key is generated")
@@ -338,3 +339,45 @@ func TestIDTokenClaimsPerEvent(t *testing.T) {
338339 })
339340 }
340341 }
342+
343+func TestDisabledOIDCRotationAuthorization(t *testing.T) {
344+ o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{})
345+ require.NoError(t, err)
346+ for _, tc := range []struct {
347+ name string
348+ p domain.Principal
349+ want error
350+ }{
351+ {"anonymous", domain.Anonymous(), domain.ErrForbidden},
352+ {"limited", domain.TokenPrincipal(domain.Token{ID: "limited"}), domain.ErrForbidden},
353+ {"root", domain.Root(), domain.ErrPreconditionFailed},
354+ {"owner", domain.Owner(), domain.ErrPreconditionFailed},
355+ } {
356+ t.Run(tc.name, func(t *testing.T) {
357+ _, err := o.RotateKey(context.Background(), tc.p)
358+ require.ErrorIs(t, err, tc.want)
359+ if tc.want == domain.ErrPreconditionFailed {
360+ assert.EqualError(t, err, "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY")
361+ }
362+ })
363+ }
364+ _, err = o.Discovery()
365+ assert.ErrorIs(t, err, domain.ErrNotFound)
366+ _, err = o.JWKS()
367+ assert.ErrorIs(t, err, domain.ErrNotFound)
368+}
369+
370+func TestDisabledOIDCPublicEndpoints(t *testing.T) {
371+ for _, provider := range []*usecase.OIDC{nil, {}} {
372+ _, err := provider.Discovery()
373+ require.ErrorIs(t, err, domain.ErrNotFound)
374+ _, err = provider.JWKS()
375+ require.ErrorIs(t, err, domain.ErrNotFound)
376+ ci := usecase.NewCI(context.Background(), usecase.CIConfig{Owner: "owner", Clock: fixedClock{}, OIDC: provider})
377+ _, err = ci.OIDCDiscovery()
378+ require.ErrorIs(t, err, domain.ErrNotFound)
379+ _, err = ci.OIDCKeys()
380+ require.ErrorIs(t, err, domain.ErrNotFound)
381+ ci.Close(context.Background())
382+ }
383+}
M internal/usecase/pushes.go +30 -14
@@ -26,7 +26,7 @@ type Pushes struct {
2626 auth ports.Revalidator
2727 fresh ports.RepoFreshener // nil: the reader needs no telling
2828 ci ports.WorkflowTrigger // nil: no CI
29- wf ports.WorkflowGuard // nil: pushes by job tokens are refused
29+ trees ports.PushTreeReader // nil: pushes by job tokens are refused
3030 tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
3131 mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
3232 }
@@ -73,11 +73,11 @@ func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes {
7373 return u
7474 }
7575
76-// WithWorkflowGuard lets Check tell whether a push changes workflow files, which
76+// WithPushTreeReader lets Check tell whether a push changes workflow files, which
7777 // a push by a job token may not do. Without it a push by a job token is
7878 // refused, as it cannot be judged. Call it before the first push; it returns u.
79-func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes {
80- u.wf = g
79+func (u *Pushes) WithPushTreeReader(g ports.PushTreeReader) *Pushes {
80+ u.trees = g
8181 return u
8282 }
8383
@@ -199,20 +199,36 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
199199 const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"
200200
201201 func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
202- if u.wf == nil {
202+ if u.trees == nil {
203203 return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)
204204 }
205- path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects)
206- switch {
207- case errors.Is(err, domain.ErrInvalid):
208- // A push the guard cannot judge within its bounds: refused, with the reason.
205+ t, err := u.trees.PushSubtrees(ctx, name, updates, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds())
206+ if err != nil {
207+ return workflowReadError(name, err)
208+ }
209+ c, found, err := domain.WorkflowChangeOf(updates, t)
210+ if err != nil {
209211 return fmt.Errorf("%w: %v", domain.ErrRejected, err)
210- case err != nil:
211- return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
212- case path != "":
213- return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
214212 }
215- return nil
213+ if !found {
214+ return nil
215+ }
216+ entry, err := u.trees.FirstDifferentEntry(ctx, name, objects, c.Old, c.New)
217+ if err != nil {
218+ return workflowReadError(name, err)
219+ }
220+ path := domain.WorkflowDir
221+ if entry != "" {
222+ path += "/" + entry
223+ }
224+ return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
225+}
226+
227+func workflowReadError(name domain.RepoName, err error) error {
228+ if errors.Is(err, domain.ErrInvalid) {
229+ return fmt.Errorf("%w: %v", domain.ErrRejected, err)
230+ }
231+ return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
216232 }
217233
218234 // Record is the post-receive step of an accepted push: it sets pushed_at and active_at,
M internal/usecase/pushes_test.go +79 -17
@@ -6,6 +6,7 @@ import (
66 "context"
77 "errors"
88 "fmt"
9+ "strings"
910 "testing"
1011 "time"
1112
@@ -440,16 +441,41 @@ func (f *pushFixture) check(ctx context.Context, p domain.Principal, n domain.Re
440441 return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{})
441442 }
442443
443-type fakeGuard struct {
444- path string
445- err error
446- got []domain.RefUpdate
447- objs ports.PushObjects
444+type fakePushTrees struct {
445+ path string
446+ err error
447+ diffErr error
448+ got []domain.RefUpdate
449+ objs ports.PushObjects
450+ bounds domain.PushWalkBounds
451+ subtreePath string
452+ calls int
453+ old, new domain.ObjectID
454+ data *domain.PushSubtrees
455+ change bool
448456 }
449457
450-func (g *fakeGuard) ChangedWorkflow(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects) (string, error) {
451- g.got, g.objs = ups, o
452- return g.path, g.err
458+func (g *fakePushTrees) PushSubtrees(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) {
459+ g.got, g.objs, g.subtreePath, g.bounds = ups, o, path, bounds
460+ if g.data != nil {
461+ return *g.data, g.err
462+ }
463+ t := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Subtrees: map[string]domain.Subtree{}}
464+ for _, up := range ups {
465+ t.Ends[up.Old] = domain.PushObjectCommit
466+ t.Ends[up.New] = domain.PushObjectCommit
467+ if g.path != "" || g.change {
468+ id, _ := domain.ParseObjectID(up.New)
469+ t.Subtrees[up.New] = domain.Subtree{Kind: domain.EntryDirectory, ID: id}
470+ }
471+ }
472+ return t, g.err
473+}
474+
475+func (g *fakePushTrees) FirstDifferentEntry(_ context.Context, _ domain.RepoName, _ ports.PushObjects, old, new domain.ObjectID) (string, error) {
476+ g.calls++
477+ g.old, g.new = old, new
478+ return strings.TrimPrefix(g.path, domain.WorkflowDir+"/"), g.diffErr
453479 }
454480
455481 // A push by the token of a job is judged on the workflows it changes, and by it
@@ -464,10 +490,10 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) {
464490 job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}})
465491 readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp})
466492
467- build := func(t *testing.T, g *fakeGuard) *pushFixture {
493+ build := func(t *testing.T, g *fakePushTrees) *pushFixture {
468494 f := newPushFixture(t)
469495 if g != nil {
470- f.pushes.WithWorkflowGuard(g)
496+ f.pushes.WithPushTreeReader(g)
471497 }
472498 f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe()
473499 return f
@@ -475,29 +501,65 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) {
475501
476502 t.Run("no workflow touched", func(t *testing.T) {
477503 t.Parallel()
478- g := &fakeGuard{}
504+ g := &fakePushTrees{}
479505 f := build(t, g)
480506 require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs))
481507 assert.Equal(t, ups, g.got, "every ref is judged")
482508 assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine")
509+ assert.Equal(t, domain.WorkflowDir, g.subtreePath)
510+ assert.Equal(t, domain.DefaultPushWalkBounds(), g.bounds)
511+ assert.Zero(t, g.calls)
483512 })
484513 t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) {
485514 t.Parallel()
486- f := build(t, &fakeGuard{path: ".github/workflows/ci.yml"})
515+ f := build(t, &fakePushTrees{path: ".github/workflows/ci.yml"})
487516 err := f.pushes.Check(ctx, job, n, ups, objs)
488517 require.ErrorIs(t, err, domain.ErrRejected)
489518 assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission")
490519 })
520+ t.Run("empty differing entry reports the directory and uses the domain pair once", func(t *testing.T) {
521+ g := &fakePushTrees{change: true}
522+ f := build(t, g)
523+ err := f.pushes.Check(ctx, job, n, ups, objs)
524+ require.ErrorIs(t, err, domain.ErrRejected)
525+ assert.Contains(t, err.Error(), "workflow `.github/workflows`")
526+ assert.Equal(t, 1, g.calls)
527+ assert.True(t, g.old.IsZero())
528+ assert.Equal(t, oidB, g.new.String())
529+ })
530+ t.Run("diff invalid is rejected with reason", func(t *testing.T) {
531+ g := &fakePushTrees{change: true, diffErr: fmt.Errorf("%w: path too long", domain.ErrInvalid)}
532+ f := build(t, g)
533+ err := f.pushes.Check(ctx, job, n, ups, objs)
534+ require.ErrorIs(t, err, domain.ErrRejected)
535+ assert.Contains(t, err.Error(), "path too long")
536+ assert.Equal(t, 1, g.calls)
537+ })
538+ t.Run("diff I/O failure retains its class", func(t *testing.T) {
539+ boom := errors.New("diff broke")
540+ g := &fakePushTrees{change: true, diffErr: boom}
541+ f := build(t, g)
542+ err := f.pushes.Check(ctx, job, n, ups, objs)
543+ require.ErrorIs(t, err, boom)
544+ assert.NotErrorIs(t, err, domain.ErrRejected)
545+ })
546+ t.Run("missing endpoint is rejected before diff", func(t *testing.T) {
547+ g := &fakePushTrees{data: &domain.PushSubtrees{}}
548+ f := build(t, g)
549+ err := f.pushes.Check(ctx, job, n, ups, objs)
550+ require.ErrorIs(t, err, domain.ErrRejected)
551+ assert.Zero(t, g.calls)
552+ })
491553 t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) {
492554 t.Parallel()
493- f := build(t, &fakeGuard{err: errors.New("git broke")})
555+ f := build(t, &fakePushTrees{err: errors.New("git broke")})
494556 err := f.pushes.Check(ctx, job, n, ups, objs)
495557 require.Error(t, err)
496558 assert.NotErrorIs(t, err, domain.ErrRejected)
497559 })
498560 t.Run("a push beyond the guard's bounds is refused as a policy, with the reason", func(t *testing.T) {
499561 t.Parallel()
500- f := build(t, &fakeGuard{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)})
562+ f := build(t, &fakePushTrees{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)})
501563 err := f.pushes.Check(ctx, job, n, ups, objs)
502564 require.ErrorIs(t, err, domain.ErrRejected)
503565 assert.Contains(t, err.Error(), "more than 10000 commits")
@@ -509,19 +571,19 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) {
509571 })
510572 t.Run("a job token without contents: write cannot push at all", func(t *testing.T) {
511573 t.Parallel()
512- f := build(t, &fakeGuard{})
574+ f := build(t, &fakePushTrees{})
513575 assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected)
514576 })
515577 t.Run("the owner is not asked", func(t *testing.T) {
516578 t.Parallel()
517- g := &fakeGuard{path: ".github/workflows/ci.yml"}
579+ g := &fakePushTrees{path: ".github/workflows/ci.yml"}
518580 f := build(t, g)
519581 require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs))
520582 assert.Nil(t, g.got)
521583 })
522584 t.Run("a job token never creates a repository on push", func(t *testing.T) {
523585 t.Parallel()
524- f := build(t, &fakeGuard{})
586+ f := build(t, &fakePushTrees{})
525587 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
526588 assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound)
527589 })
M internal/usecase/repos_write.go +1 -3
@@ -165,9 +165,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
165165 if params.Visibility != nil {
166166 repo.Visibility = *params.Visibility
167167 }
168- if repo.Settings.IsZero() {
169- repo.Settings = domain.DefaultRepoSettings()
170- }
168+ repo.Settings = repo.EffectiveSettings()
171169 if params.AllowedMergeModes != nil {
172170 repo.Settings.AllowedMergeModes = *params.AllowedMergeModes
173171 }

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 45

↑↓ moveenter openesc close