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
91 - `mr show` lists the latest verdict of each author (`verdicts` in `--json`).91 - `mr show` lists the latest verdict of each author (`verdicts` in `--json`).
92 - 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.92 - 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.
93 - Verdicts are immutable: editing or deleting the note keeps its verdict. Only a new verdict of the same author replaces it.93 - 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 (
64 github.com/bep/godartsass/v2 v2.5.0 // indirect64 github.com/bep/godartsass/v2 v2.5.0 // indirect
65 github.com/bep/golibsass v1.2.0 // indirect65 github.com/bep/golibsass v1.2.0 // indirect
66 github.com/bits-and-blooms/bitset v1.24.5 // indirect66 github.com/bits-and-blooms/bitset v1.24.5 // indirect
67- github.com/bnema/hexcheck v0.3.1 // indirect67+ github.com/bnema/hexcheck v0.4.0 // indirect
68 github.com/brunoga/deep v1.3.1 // indirect68 github.com/brunoga/deep v1.3.1 // indirect
69 github.com/bufbuild/buf v1.73.0 // indirect69 github.com/bufbuild/buf v1.73.0 // indirect
70 github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201 // indirect70 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) {
244 }244 }
245 245
246 // Read returns lines from line number from.246 // 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
247 func (s *Store) Read(_ context.Context, ref ports.CILogRef, from int64, maxLines, maxBytes int) ([]domain.CILogLine, error) {249 func (s *Store) Read(_ context.Context, ref ports.CILogRef, from int64, maxLines, maxBytes int) ([]domain.CILogLine, error) {
248 if !validRef(ref) || from < 0 {250 if !validRef(ref) || from < 0 {
249 return nil, domain.ErrNotFound251 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 {
56 }56 }
57 57
58 // Put stores the summary of a step, or removes it when markdown is empty.58 // 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
59 func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error {61 func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error {
60 if !validRef(ref) || step < 1 || step > domain.CIMaxSteps {62 if !validRef(ref) || step < 1 || step > domain.CIMaxSteps {
61 return domain.ErrNotFound63 return domain.ErrNotFound
M internal/adapters/ermitegit/commitobjects.go +12 -11
@@ -22,10 +22,10 @@ var _ ports.CommitObjectReader = (*Adapter)(nil)
22 // always serves it, and the wrapped reader only the repositories the library22 // always serves it, and the wrapped reader only the repositories the library
23 // cannot read.23 // cannot read.
24 //24 //
25-//hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain25+//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
26 func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {26 func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
27- if len(ids) > ports.MaxCommitLimit {27+ if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil {
28- return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit)28+ return nil, err
29 }29 }
30 cli := func() (map[domain.ObjectID][]byte, error) {30 cli := func() (map[domain.ObjectID][]byte, error) {
31 r, ok := a.cli.(ports.CommitObjectReader)31 r, ok := a.cli.(ports.CommitObjectReader)
@@ -36,12 +36,12 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
36 }36 }
37 return serve(ctx, a, repo, func(r *git.Repo) (map[domain.ObjectID][]byte, error) {37 return serve(ctx, a, repo, func(r *git.Repo) (map[domain.ObjectID][]byte, error) {
38 out := make(map[domain.ObjectID][]byte, len(ids))38 out := make(map[domain.ObjectID][]byte, len(ids))
39- var total int6439+ var budget domain.CommitObjectBudget
40 for _, id := range ids {40 for _, id := range ids {
41 if err := ctx.Err(); err != nil {41 if err := ctx.Err(); err != nil {
42 return nil, err42 return nil, err
43 }43 }
44- if _, done := out[id]; done {44+ if budget.Seen(id) {
45 continue45 continue
46 }46 }
47 gid, err := git.ParseID(id.String())47 gid, err := git.ParseID(id.String())
@@ -55,12 +55,13 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
55 if err != nil {55 if err != nil {
56 return nil, err56 return nil, err
57 }57 }
58- if typ != git.TypeCommit || size > domain.MaxCommitObjectBytes {58+ take, stop := budget.Admit(id, typ == git.TypeCommit, size)
59- continue59+ if stop {
60- }
61- if total+size > domain.MaxCommitObjectsBytes {
62 break60 break
63 }61 }
62+ if !take {
63+ continue
64+ }
64 typ, data, err := r.ReadObject(gid, nil)65 typ, data, err := r.ReadObject(gid, nil)
65 if errors.Is(err, git.ErrNotFound) {66 if errors.Is(err, git.ErrNotFound) {
66 continue // removed meanwhile67 continue // removed meanwhile
@@ -68,9 +69,9 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i
68 if err != nil {69 if err != nil {
69 return nil, err70 return nil, err
70 }71 }
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 {
72 out[id] = data73 out[id] = data
73- total += int64(len(data))74+ budget.Charge(id, int64(len(data)))
74 }75 }
75 }76 }
76 return out, nil77 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
27 // listRefs lists HEAD, then the branches, then the tags, like gitcli: a tag27 // listRefs lists HEAD, then the branches, then the tags, like gitcli: a tag
28 // follows to its commit when it leads to one; HEAD takes the ids of its28 // follows to its commit when it leads to one; HEAD takes the ids of its
29 // branch or tag, and only the name of an unborn branch.29 // 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
32 func listRefs(r *git.Repo) ([]domain.Ref, error) {30 func listRefs(r *git.Repo) ([]domain.Ref, error) {
33 var refs []domain.Ref31 var refs []domain.Ref
34 byName := make(map[string]domain.Ref)32 byName := make(map[string]domain.Ref)
@@ -121,55 +119,48 @@ func peelID(r *git.Repo, id git.ObjectID) (_ git.ObjectID, ok bool, _ error) {
121 return commit, true, nil119 return commit, true, nil
122 }120 }
123 121
124-// resolveRevision tries the candidates of gitcli in the same order, which122+// resolveRevision performs the lookups ordered by the domain, rather than
125-// the port documents: HEAD is HEAD; a full object id is an object before it123+// Repo.Resolve, whose Git-specific resolution order differs from the port.
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
131 func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) {124 func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) {
132- s := rev.String()125+ for _, candidate := range rev.Candidates() {
133- if rev.IsHead() || s == "HEAD" {126+ c, ok, err := lookupRevisionCandidate(r, candidate)
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)
141 if err != nil {127 if err != nil {
142 return resolved{}, err128 return resolved{}, err
143 }129 }
144- if c, ok, err := peelID(r, id); err != nil || ok {130+ if ok {
145- return resolved{commit: c}, err131+ return resolved{commit: c, ref: candidate.ResolvedRef()}, nil
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
151 }132 }
152 }133 }
153- switch {134+ return resolved{}, notFound(rev)
154- case len(s) > 5 && s[:5] == "refs/":135+}
155- if c, ok, err := peelRef(r, s); err != nil || ok {136+
156- return resolved{commit: c, ref: s}, err137+// 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
157 }146 }
158- case rev.IsAbbrevID():147+ return peelID(r, id)
159- id, err := r.ResolveCommitPrefix(s)148+ case domain.RevisionCandidateAbbrevID:
149+ id, err := r.ResolveCommitPrefix(candidate.Name)
160 if err == nil {150 if err == nil {
161 // The prefix may name an annotated tag: git peels it.151 // The prefix may name an annotated tag: git peels it.
162 id, err = r.PeelToCommit(id)152 id, err = r.PeelToCommit(id)
163 }153 }
164 switch {154 switch {
165 case err == nil:155 case err == nil:
166- return resolved{commit: id}, nil156+ return id, true, nil
167 case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType):157 case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType):
158+ return git.ObjectID{}, false, nil
168 default:159 default:
169- return resolved{}, err160+ return git.ObjectID{}, false, err
170 }161 }
171 }162 }
172- return resolved{}, notFound(rev)163+ return git.ObjectID{}, false, fmt.Errorf("unknown revision candidate kind %d", candidate.Kind)
173 }164 }
174 165
175 func notFound(rev domain.Revision) error {166 func notFound(rev domain.Revision) error {
@@ -477,8 +468,6 @@ func fillLastCommits(ctx context.Context, r *git.Repo, commit domain.ObjectID, d
477 // The ids, the names, the emails and the message share one backing string, cut468 // The ids, the names, the emails and the message share one backing string, cut
478 // into substrings: one allocation for the text of a commit instead of one per469 // into substrings: one allocation for the text of a commit instead of one per
479 // field.470 // 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
482 func convertCommit(c *git.Commit) (domain.Commit, error) {471 func convertCommit(c *git.Commit) (domain.Commit, error) {
483 if len(c.Encoding) > 0 && !isUTF8Label(c.Encoding) {472 if len(c.Encoding) > 0 && !isUTF8Label(c.Encoding) {
484 return domain.Commit{}, errFallback473 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.
291 291
292 // ResolveRevision turns a revision into a commit.292 // ResolveRevision turns a revision into a commit.
293 //293 //
294-//hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports294+//hexcheck:ignore suspicious-business-logic-in-adapter selects the configured CLI or library backend, with a separate route for abbreviated IDs; resolveRevision calls domain.Revision.Candidates for resolution order
295 func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {295 func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
296 cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) }296 cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) }
297 route := a.routes.ResolveRevision297 route := a.routes.ResolveRevision
M internal/adapters/gitcli/archive.go +0 -2
@@ -28,8 +28,6 @@ var _ ports.GitArchive = (*Git)(nil)
28 // not by a gzip binary. A client that goes away ends ctx, which kills git.28 // not by a gzip binary. A client that goes away ends ctx, which kills git.
29 // Nothing is written to w when the commit does not exist29 // Nothing is written to w when the commit does not exist
30 // (domain.ErrNotFound).30 // (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
33 func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error {31 func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error {
34 dir, err := g.repoDir(repo)32 dir, err := g.repoDir(repo)
35 if err != nil {33 if err != nil {
M internal/adapters/gitcli/changed.go +0 -4
@@ -21,8 +21,6 @@ const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
21 // renames not detected (a rename lists both paths, as GitHub's push filters21 // renames not detected (a rename lists both paths, as GitHub's push filters
22 // see it), and stops reading after max paths: truncated tells there were more.22 // see it), and stops reading after max paths: truncated tells there were more.
23 // A zero base diffs against the empty tree.23 // 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
26 func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limit int) ([]string, bool, error) {24 func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limit int) ([]string, bool, error) {
27 if limit < 1 || head.IsZero() {25 if limit < 1 || head.IsZero() {
28 return nil, false, fmt.Errorf("changed paths: %w: limit %d", domain.ErrInvalid, limit)26 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
63 // (refs/heads/*), newest first, at most limit. It runs61 // (refs/heads/*), newest first, at most limit. It runs
64 // `git log --not --branches --not <head>`: tags do not count, so the commit of62 // `git log --not --branches --not <head>`: tags do not count, so the commit of
65 // another tag that no branch has is listed.63 // 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
68 func (g *Git) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {64 func (g *Git) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
69 if head.IsZero() {65 if head.IsZero() {
70 return nil, false, fmt.Errorf("commits off branches: %w: no head", domain.ErrInvalid)66 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)
19 // that would take the total over domain.MaxCommitObjectsBytes (known from19 // that would take the total over domain.MaxCommitObjectsBytes (known from
20 // `info`, before any content is read, so the stream stays consistent).20 // `info`, before any content is read, so the stream stays consistent).
21 //21 //
22-//hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain22+//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
23 func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {23 func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
24- if len(ids) > ports.MaxCommitLimit {24+ if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil {
25- return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit)25+ return nil, err
26- }
27- for _, id := range ids {
28- if id.IsZero() {
29- return nil, fmt.Errorf("%w: empty commit id", domain.ErrInvalidObjectID)
30- }
31 }26 }
32 dir, err := g.repoDir(repo)27 dir, err := g.repoDir(repo)
33 if err != nil {28 if err != nil {
@@ -36,12 +31,12 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [
36 out := make(map[domain.ObjectID][]byte, len(ids))31 out := make(map[domain.ObjectID][]byte, len(ids))
37 err = g.cat.do(ctx, dir, func(c *catConn) error {32 err = g.cat.do(ctx, dir, func(c *catConn) error {
38 clear(out) // a retry on a fresh process starts over33 clear(out) // a retry on a fresh process starts over
39- var total int6434+ var budget domain.CommitObjectBudget
40 for _, id := range ids {35 for _, id := range ids {
41 if err := ctx.Err(); err != nil {36 if err := ctx.Err(); err != nil {
42 return err37 return err
43 }38 }
44- if _, done := out[id]; done {39+ if budget.Seen(id) {
45 continue40 continue
46 }41 }
47 h, err := c.info(id.String())42 h, err := c.info(id.String())
@@ -51,18 +46,19 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [
51 if err != nil {46 if err != nil {
52 return err47 return err
53 }48 }
54- if h.Type != "commit" || h.Size > domain.MaxCommitObjectBytes {49+ take, stop := budget.Admit(id, h.Type == "commit", h.Size)
55- continue50+ if stop {
56- }
57- if total+h.Size > domain.MaxCommitObjectsBytes {
58 break51 break
59 }52 }
53+ if !take {
54+ continue
55+ }
60 _, data, err := c.contents(id.String(), domain.MaxCommitObjectBytes)56 _, data, err := c.contents(id.String(), domain.MaxCommitObjectBytes)
61 if err != nil {57 if err != nil {
62 return err58 return err
63 }59 }
64 out[id] = data60 out[id] = data
65- total += int64(len(data))61+ budget.Charge(id, int64(len(data)))
66 }62 }
67 return nil63 return nil
68 })64 })
M internal/adapters/gitcli/diff.go +15 -47
@@ -32,8 +32,6 @@ const maxDiffLine = 64 << 10
32 // Statistics come from the line counts, binary files from their "-" counts.32 // Statistics come from the line counts, binary files from their "-" counts.
33 // The patch is parsed as it streams: nothing beyond the limits is kept, and33 // The patch is parsed as it streams: nothing beyond the limits is kept, and
34 // git is stopped once nothing more is needed.34 // 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
37 func (g *Git) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {35 func (g *Git) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {
38 dir, err := g.repoDir(repo)36 dir, err := g.repoDir(repo)
39 if err != nil {37 if err != nil {
@@ -91,7 +89,7 @@ type rawEntry struct {
91 89
92 // readDiff parses the output of diff-tree --raw --numstat -p.90 // readDiff parses the output of diff-tree --raw --numstat -p.
93 //91 //
94-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values, applying the limits the port passes in92+//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
95 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {93 func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) {
96 entries, err := readRaw(r)94 entries, err := readRaw(r)
97 if err != nil {95 if err != nil {
@@ -107,23 +105,17 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
107 stats.Deletions += entries[i].del105 stats.Deletions += entries[i].del
108 }106 }
109 var diff domain.Diff107 var diff domain.Diff
110- if limits.MaxFiles > 0 && len(entries) > limits.MaxFiles {108+ budget := domain.NewDiffBudget(limits)
111- entries = entries[:limits.MaxFiles]109+ kept, from, to, truncated := budget.Window(len(entries))
112- diff.Truncated = true110+ entries = entries[:kept]
113- }111+ diff.Truncated = truncated
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- }
120 112
121 // The patch holds one section per file, two for a type change, in the113 // The patch holds one section per file, two for a type change, in the
122 // order of the entries. Entries without lines to show (binary, pure114 // order of the entries. Entries without lines to show (binary, pure
123 // rename, mode change) have a section without hunks.115 // rename, mode change) have a section without hunks.
124 lastRead := -1116 lastRead := -1
125 for i := from; i < to; i++ {117 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) {
127 lastRead = i119 lastRead = i
128 }120 }
129 }121 }
@@ -133,8 +125,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
133 _, _ = r.Discard(1)125 _, _ = r.Discard(1)
134 }126 }
135 }127 }
136- pr := &patchReader{lr: newLineReader(r, maxDiffLine), bytesLeft: limits.MaxTotalBytes}128+ pr := &patchReader{lr: newLineReader(r, maxDiffLine), budget: budget}
137- budget := limits.MaxTotalLines
138 diff.Files = make([]domain.FileDiff, 0, len(entries))129 diff.Files = make([]domain.FileDiff, 0, len(entries))
139 for i := range entries {130 for i := range entries {
140 e := &entries[i]131 e := &entries[i]
@@ -144,21 +135,19 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
144 }135 }
145 inWindow := i >= from && i < to136 inWindow := i >= from && i < to
146 switch {137 switch {
147- case !inWindow, !changedLines(e):138+ case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del):
148 // Nothing to show.139 // Nothing to show.
149- case !wantsHunks(e, limits, budget, pr.bytesLeft):140+ case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del):
150 e.file.Truncated = true141 e.file.Truncated = true
151 default:142 default:
152- bytesBefore := pr.bytesLeft143+ before := pr.budget
153- hunks, lines, truncated, err := pr.file(sections, min(limits.MaxFileLines, budget))144+ hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines())
154 if err != nil {145 if err != nil {
155 return domain.Diff{}, domain.DiffStats{}, err146 return domain.Diff{}, domain.DiffStats{}, err
156 }147 }
157 e.file.Hunks, e.file.Truncated = hunks, truncated148 e.file.Hunks, e.file.Truncated = hunks, truncated
158 if truncated {149 if truncated {
159- pr.bytesLeft = bytesBefore // nothing of this file is kept150+ pr.budget = before // nothing of this file is kept
160- } else {
161- budget -= lines
162 }151 }
163 sections = 0152 sections = 0
164 }153 }
@@ -177,20 +166,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di
177 return diff, stats, nil166 return diff, stats, nil
178 }167 }
179 168
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-
191 // readRaw reads the `--raw -z` records, which start with ':'.169 // readRaw reads the `--raw -z` records, which start with ':'.
192-//
193-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
194 func readRaw(r *bufio.Reader) ([]rawEntry, error) {170 func readRaw(r *bufio.Reader) ([]rawEntry, error) {
195 var entries []rawEntry171 var entries []rawEntry
196 for {172 for {
@@ -241,8 +217,6 @@ func unexpectedEOF(err error) error {
241 217
242 // parseRawMeta parses ":<old mode> <new mode> <old id> <new id> <status>" and218 // parseRawMeta parses ":<old mode> <new mode> <old id> <new id> <status>" and
243 // returns the number of paths that follow.219 // returns the number of paths that follow.
244-//
245-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
246 func parseRawMeta(meta string) (rawEntry, int, error) {220 func parseRawMeta(meta string) (rawEntry, int, error) {
247 f := strings.Fields(strings.TrimPrefix(meta, ":"))221 f := strings.Fields(strings.TrimPrefix(meta, ":"))
248 if len(f) != 5 || f[4] == "" {222 if len(f) != 5 || f[4] == "" {
@@ -296,8 +270,6 @@ func parseRawMeta(meta string) (rawEntry, int, error) {
296 270
297 // readNumstat reads one `--numstat -z` record per entry. A binary file has271 // readNumstat reads one `--numstat -z` record per entry. A binary file has
298 // "-" counts.272 // "-" counts.
299-//
300-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
301 func readNumstat(r *bufio.Reader, entries []rawEntry) error {273 func readNumstat(r *bufio.Reader, entries []rawEntry) error {
302 for i := range entries {274 for i := range entries {
303 tok, err := readToken(r)275 tok, err := readToken(r)
@@ -341,7 +313,7 @@ type patchReader struct {
341 eof bool313 eof bool
342 cut bool // the last line read was cut314 cut bool // the last line read was cut
343 315
344- bytesLeft int // text bytes still allowed in the diff316+ budget domain.DiffBudget
345 }317 }
346 318
347 const sectionMarker = "diff --git "319 const sectionMarker = "diff --git "
@@ -434,8 +406,6 @@ func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, t
434 406
435 // section reads the hunks of the current section up to the next marker, which407 // section reads the hunks of the current section up to the next marker, which
436 // stays pending. It gives up (truncated) after more than max lines.408 // 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
439 func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {409 func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) {
440 var cur *domain.Hunk410 var cur *domain.Hunk
441 var oldN, newN int411 var oldN, newN int
@@ -490,8 +460,8 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
490 continue460 continue
491 }461 }
492 lines++462 lines++
493- p.bytesLeft -= len(text)463+ fits := p.budget.ChargeLine(len(text))
494- if lines > max || p.bytesLeft < 0 {464+ if lines > max || !fits {
495 return nil, lines, true, nil465 return nil, lines, true, nil
496 }466 }
497 cur.Lines = append(cur.Lines, l)467 cur.Lines = append(cur.Lines, l)
@@ -499,8 +469,6 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate
499 }469 }
500 470
501 // parseHunkHeader parses "@@ -a[,b] +c[,d] @@[ header]".471 // parseHunkHeader parses "@@ -a[,b] +c[,d] @@[ header]".
502-//
503-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values
504 func parseHunkHeader(s string) (domain.Hunk, error) {472 func parseHunkHeader(s string) (domain.Hunk, error) {
505 bad := func() (domain.Hunk, error) {473 bad := func() (domain.Hunk, error) {
506 return domain.Hunk{}, fmt.Errorf("git diff-tree: unexpected hunk header %q", truncateText(s))474 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 {
110 return n110 return n
111 }111 }
112 112
113-// MaxPushedCommits is the bound of the commits of a push, for the tests.
114-const MaxPushedCommits = maxPushedCommits
115-
116 // ParseRevList reads rev-list output with the bounds of the guard replaced, and113 // ParseRevList reads rev-list output with the bounds of the guard replaced, and
117 // returns the number of commits kept.114 // returns the number of commits kept.
118 func ParseRevList(r io.Reader, maxCommits, maxIDs int) (int, error) {115 func ParseRevList(r io.Reader, maxCommits, maxIDs int) (int, error) {
119 c, err := parseRevList(bufio.NewReaderSize(r, 64*1024), maxCommits, maxIDs)116 c, err := parseRevList(bufio.NewReaderSize(r, 64*1024), maxCommits, maxIDs)
120 return len(c), err117 return len(c), err
121 }118 }
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) {
39 }39 }
40 40
41 // BranchHead resolves refs/heads/<branch>^{commit} with `cat-file`.41 // 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
44 func (g *Git) BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) {42 func (g *Git) BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) {
45 dir, err := g.repoDir(repo)43 dir, err := g.repoDir(repo)
46 if err != nil {44 if err != nil {
@@ -95,8 +93,6 @@ func (g *Git) HasCommit(ctx context.Context, repo domain.RepoName, id domain.Obj
95 }93 }
96 94
97 // MergeBase runs `git merge-base`, which exits 1 when there is none.95 // 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
100 func (g *Git) MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error) {96 func (g *Git) MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error) {
101 dir, err := g.repoDir(repo)97 dir, err := g.repoDir(repo)
102 if err != nil {98 if err != nil {
@@ -129,8 +125,6 @@ func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, de
129 }125 }
130 126
131 // ListRange runs `git log <head> ^<exclude>`.127 // 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
134 func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {128 func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
135 var excluded []string129 var excluded []string
136 if !exclude.IsZero() {130 if !exclude.IsZero() {
@@ -142,8 +136,6 @@ func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head
142 // logCommits runs `git log` for the commits reachable from head: revArgs go136 // logCommits runs `git log` for the commits reachable from head: revArgs go
143 // before the head (options such as --not), excluded after it. Newest first,137 // before the head (options such as --not), excluded after it. Newest first,
144 // at most limit; more says there were others.138 // 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
147 func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) {139 func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) {
148 if limit < 1 || limit > ports.MaxCommitLimit {140 if limit < 1 || limit > ports.MaxCommitLimit {
149 return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)141 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.
183 }175 }
184 176
185 // DiffCommits reads the diff between two commits like GetCommit does.177 // 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
188 func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {178 func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {
189 dir, err := g.repoDir(repo)179 dir, err := g.repoDir(repo)
190 if err != nil {180 if err != nil {
@@ -198,8 +188,6 @@ func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head
198 }188 }
199 189
200 // CommitTree runs `git rev-parse <commit>^{tree}`.190 // CommitTree runs `git rev-parse <commit>^{tree}`.
201-//
202-//hexcheck:ignore suspicious-business-logic-in-adapter parses the id git printed
203 func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) {191 func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) {
204 dir, err := g.repoDir(repo)192 dir, err := g.repoDir(repo)
205 if err != nil {193 if err != nil {
@@ -229,8 +217,6 @@ func (g *Git) WriteCommit(ctx context.Context, repo domain.RepoName, object []by
229 // UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which217 // UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which
230 // takes the ref lock and refuses when the ref is not at old. A failure is218 // takes the ref lock and refuses when the ref is not at old. A failure is
231 // told from a moved branch by reading the branch again.219 // 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
234 func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error {220 func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error {
235 dir, err := g.repoDir(repo)221 dir, err := g.repoDir(repo)
236 if err != nil {222 if err != nil {
@@ -252,8 +238,6 @@ func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch str
252 // DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes238 // DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes
253 // the ref lock and refuses when the ref is not at old. A failure is told from239 // the ref lock and refuses when the ref is not at old. A failure is told from
254 // a moved or deleted branch by reading the branch again.240 // 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
257 func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {241 func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
258 dir, err := g.repoDir(repo)242 dir, err := g.repoDir(repo)
259 if err != nil {243 if err != nil {
M internal/adapters/gitcli/pull.go +0 -2
@@ -18,8 +18,6 @@ var _ ports.PullGit = (*Git)(nil)
18 // database and touches no ref or index: it prints the tree of the result and18 // database and touches no ref or index: it prints the tree of the result and
19 // exits 0, or exits 1 when the merge conflicts. Commits with no common history19 // exits 0, or exits 1 when the merge conflicts. Commits with no common history
20 // do not merge (GitHub makes no test merge for them either).20 // 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
23 func (g *Git) TestMerge(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.ObjectID, bool, error) {21 func (g *Git) TestMerge(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.ObjectID, bool, error) {
24 dir, err := g.repoDir(repo)22 dir, err := g.repoDir(repo)
25 if err != nil {23 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) {
51 return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"}51 return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"}
52 }52 }
53 changed := func(ups ...domain.RefUpdate) string {53 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{})
55 require.NoError(t, err)55 require.NoError(t, err)
56 return p56 return p
57 }57 }
@@ -68,11 +68,11 @@ func TestChangedWorkflow(t *testing.T) {
68 assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one")68 assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one")
69 assert.Equal(t, "", changed(), "no update")69 assert.Equal(t, "", changed(), "no update")
70 70
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{})
72 assert.Error(t, err, "an object git does not have is a failure, not a pass")72 assert.Error(t, err, "an object git does not have is a failure, not a pass")
73 }73 }
74 74
75-func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) {75+func TestPushSubtreesReadsTheQuarantineOfThePushOnly(t *testing.T) {
76 g, data := newGit(t)76 g, data := newGit(t)
77 ctx := context.Background()77 ctx := context.Background()
78 f := readertest.NewFixture(t, data, "proj")78 f := readertest.NewFixture(t, data, "proj")
@@ -90,23 +90,23 @@ func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) {
90 "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"},90 "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"},
91 "alternates without a dir": {Alternates: objects},91 "alternates without a dir": {Alternates: objects},
92 } {92 } {
93- _, err := g.ChangedWorkflow(ctx, f.Name, up, o)93+ _, err := g.PushSubtrees(ctx, f.Name, up, o, domain.WorkflowDir, domain.DefaultPushWalkBounds())
94 assert.ErrorIs(t, err, domain.ErrInvalid, name)94 assert.ErrorIs(t, err, domain.ErrInvalid, name)
95 }95 }
96 // An existing directory inside the repository is accepted as a location96 // An existing directory inside the repository is accepted as a location
97 // (the object is missing: a refusal of the object, not of the path).97 // (the object is missing: a refusal of the object, not of the path).
98 require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700))98 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())
100 require.Error(t, err)100 require.Error(t, err)
101 assert.NotContains(t, err.Error(), "outside the repository")101 assert.NotContains(t, err.Error(), "outside the repository")
102 // One that does not exist is refused.102 // 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())
104 assert.ErrorIs(t, err, domain.ErrInvalid)104 assert.ErrorIs(t, err, domain.ErrInvalid)
105 }105 }
106 106
107 // A symbolic link in the way cannot take the objects of a push out of the107 // A symbolic link in the way cannot take the objects of a push out of the
108 // repository, and a data directory reached through links still works.108 // repository, and a data directory reached through links still works.
109-func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) {109+func TestPushSubtreesResolvesSymbolicLinks(t *testing.T) {
110 g, data := newGit(t)110 g, data := newGit(t)
111 ctx := context.Background()111 ctx := context.Background()
112 f := readertest.NewFixture(t, data, "proj")112 f := readertest.NewFixture(t, data, "proj")
@@ -119,20 +119,20 @@ func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) {
119 // Inside objects/, a link to elsewhere is outside once resolved.119 // Inside objects/, a link to elsewhere is outside once resolved.
120 elsewhere := t.TempDir()120 elsewhere := t.TempDir()
121 require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link")))121 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())
123 assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/")123 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())
125 assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/")125 assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/")
126 126
127 // A link to somewhere inside objects/ is fine.127 // A link to somewhere inside objects/ is fine.
128 require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700))128 require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700))
129 require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link")))129 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())
131 require.Error(t, err)131 require.Error(t, err)
132 assert.NotContains(t, err.Error(), "outside the repository")132 assert.NotContains(t, err.Error(), "outside the repository")
133 }133 }
134 134
135-func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) {135+func TestPushSubtreesThroughALinkedDataDirectory(t *testing.T) {
136 _, data := newGit(t)136 _, data := newGit(t)
137 ctx := context.Background()137 ctx := context.Background()
138 f := readertest.NewFixture(t, data, "proj")138 f := readertest.NewFixture(t, data, "proj")
@@ -152,10 +152,10 @@ func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) {
152 q := filepath.Join(f.Bare, "objects", "incoming-9")152 q := filepath.Join(f.Bare, "objects", "incoming-9")
153 require.NoError(t, os.Mkdir(q, 0o700))153 require.NoError(t, os.Mkdir(q, 0o700))
154 for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} {154 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"}},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")})156+ ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
157 require.NoError(t, err, dir)157 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)
159 }159 }
160 }160 }
161 161
@@ -182,17 +182,7 @@ func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...strin
182 return strings.TrimSpace(string(out))182 return strings.TrimSpace(string(out))
183 }183 }
184 184
185-// withWorkflows is a commit whose .github/workflows is the tree given.185+func TestFirstDifferentEntryDoesNotBufferTheDiff(t *testing.T) {
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) {
196 g, data := newGit(t)186 g, data := newGit(t)
197 ctx := context.Background()187 ctx := context.Background()
198 f := readertest.NewFixture(t, data, "proj")188 f := readertest.NewFixture(t, data, "proj")
@@ -206,17 +196,17 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
206 }196 }
207 level = treeOf(t, f, entries...)197 level = treeOf(t, f, entries...)
208 }198 }
209- amp := withWorkflows(t, f, level)199+ amp := level
210 200
211 var before, after runtime.MemStats201 var before, after runtime.MemStats
212 runtime.GC()202 runtime.GC()
213 runtime.ReadMemStats(&before)203 runtime.ReadMemStats(&before)
214 start := time.Now()204 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))
216 took := time.Since(start)206 took := time.Since(start)
217 runtime.ReadMemStats(&after)207 runtime.ReadMemStats(&after)
218 require.NoError(t, err)208 require.NoError(t, err)
219- assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p)209+ assert.True(t, strings.HasPrefix(p, "d"), p)
220 assert.Less(t, took, 2*time.Second, "answered from the first path")210 assert.Less(t, took, 2*time.Second, "answered from the first path")
221 assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory")211 assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory")
222 // And no git is left behind.212 // And no git is left behind.
@@ -225,69 +215,50 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
225 }215 }
226 216
227 // A path longer than the bound fails closed: the push is refused, not read on.217 // 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) {
229 g, data := newGit(t)219 g, data := newGit(t)
230 ctx := context.Background()220 ctx := context.Background()
231 f := readertest.NewFixture(t, data, "proj")221 f := readertest.NewFixture(t, data, "proj")
232 t.Cleanup(func() { _ = g.Close(ctx) })222 t.Cleanup(func() { _ = g.Close(ctx) })
233 level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf")223 level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf")
234 level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes224 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))
236 assert.ErrorIs(t, err, domain.ErrInvalid)226 assert.ErrorIs(t, err, domain.ErrInvalid)
237 }227 }
238 228
239 // A tag may point to a blob, a tree or a commit; only the commits and trees can229 // A tag may point to a blob, a tree or a commit; only the commits and trees can
240 // hold a workflow, and an object that is not there refuses the push.230 // 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) {
242 g, data := newGit(t)232 g, data := newGit(t)
243 ctx := context.Background()233 ctx := context.Background()
244 f := readertest.NewFixture(t, data, "proj")234 f := readertest.NewFixture(t, data, "proj")
245 t.Cleanup(func() { _ = g.Close(ctx) })235 t.Cleanup(func() { _ = g.Close(ctx) })
246 f.Write("README", "r\n")236 f.Write("README", "r\n")
247- plain := f.Commit("plain")237+ plain := oidOf(t, f.Commit("plain")).String()
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
252 blob := f.WriteObject("blob", "hello\n")238 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")239+ tag := func(id, kind string) string {
254- annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n")240+ return f.WriteObject("tag", "object "+id+"\ntype "+kind+"\ntag t\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)
271 }241 }
272- for name, ups := range map[string][]domain.RefUpdate{242+ tree := f.Git("rev-parse", plain+"^{tree}")
273- "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()),243+ for _, r := range []struct {
274- "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()),244+ id string
275- "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF),245+ kind domain.PushObjectType
276- "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree),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}} {
277- "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF),247+ got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: r.id}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
278- } {248+ require.NoError(t, err)
279- p, err := changed(ups)249+ assert.Equal(t, r.kind, got.Ends[r.id])
280- require.NoError(t, err, name)250+ if r.kind == domain.PushObjectBlob {
281- assert.Equal(t, ".github/workflows/ci.yml", p, name)251+ assert.NotContains(t, got.Subtrees, r.id)
252+ }
282 }253 }
283- for name, ups := range map[string][]domain.RefUpdate{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"}} {
284- "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"),255+ _, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{r}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
285- "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()),256+ assert.ErrorIs(t, err, domain.ErrInvalid)
286- "a malformed object": ref(domain.ZeroOID, "ZZZZ"),
287- } {
288- _, err := changed(ups)
289- assert.Error(t, err, name)
290 }257 }
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)
291 }262 }
292 263
293 // rawTree writes a tree object by hand: entries are "mode name hexid", in the264 // 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 {
308 // mode 040000 below a directory .github of mode 040000. The same tree id as a279 // mode 040000 below a directory .github of mode 040000. The same tree id as a
309 // gitlink, a symbolic link or a file is nothing, and turning it into a280 // gitlink, a symbolic link or a file is nothing, and turning it into a
310 // directory activates it; the object id alone does not tell.281 // directory activates it; the object id alone does not tell.
311-func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) {282+func TestPushSubtreesEntryKinds(t *testing.T) {
312 g, data := newGit(t)283 g, data := newGit(t)
313 ctx := context.Background()284 ctx := context.Background()
314 f := readertest.NewFixture(t, data, "proj")285 f := readertest.NewFixture(t, data, "proj")
315 t.Cleanup(func() { _ = g.Close(ctx) })286 t.Cleanup(func() { _ = g.Close(ctx) })
316- f.Write("README", "r\n")287+ blob := f.WriteObject("blob", "x\n")
317- f.Commit("base")288+ tree := rawTree(t, f, [3]string{"100644", "ci.yml", blob})
318- yml := f.WriteObject("blob", "on: push\n")289+ for _, r := range []struct {
319- wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows290+ mode, id string
320- link := f.WriteObject("blob", ".github/other")291+ kind domain.EntryKind
321- file := f.WriteObject("blob", "not a directory\n")292+ }{{"040000", tree, domain.EntryDirectory}, {"160000", tree, domain.EntrySubmodule}, {"120000", blob, domain.EntrySymlink}, {"100644", blob, domain.EntryFile}, {"100755", blob, domain.EntryExecutable}} {
322- readme := f.WriteObject("blob", "r\n")293+ t.Run(r.mode, func(t *testing.T) {
323-294+ gh := rawTree(t, f, [3]string{r.mode, "workflows", r.id})
324- // A commit whose .github/workflows has the mode and id given.295+ root := rawTree(t, f, [3]string{"040000", ".github", gh})
325- withWorkflows := func(mode, id string) string {296+ oid := commitOf(t, f, root)
326- gh := rawTree(t, f, [3]string{mode, "workflows", id})297+ got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
327- return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh}))298+ require.NoError(t, err)
328- }299+ assert.Equal(t, r.kind, got.Subtrees[oid].Kind)
329- // A commit whose .github has the mode and id given.300+ assert.Equal(t, r.id, got.Subtrees[oid].ID.String())
330- withGithub := func(mode, id string) string {301+ assert.NotContains(t, got.Subtrees, domain.HeadKey)
331- return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id}))302+ if r.mode != "040000" {
332- }303+ root = rawTree(t, f, [3]string{r.mode, ".github", r.id})
333- asDir := withWorkflows("040000", wfTree)304+ oid = commitOf(t, f, root)
334- asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule305+ got, err = g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
335- asSymlink := withWorkflows("120000", link)306+ require.NoError(t, err)
336- asFile := withWorkflows("100644", file)307+ assert.NotContains(t, got.Subtrees, oid)
337- none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme}))308+ }
338- ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree})309+ })
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)
371 }310 }
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)
378 }311 }
379 312
380 // A history for the rules on the commits a push brings: main holds workflows A,313 // 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 {
418 return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...)351 return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...)
419 }352 }
420 353
421-func TestChangedWorkflowJudgesThePushedCommits(t *testing.T) {354+func TestPushSubtreesCommitsAndParents(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) {
488 g, data := newGit(t)355 g, data := newGit(t)
489 ctx := context.Background()356 ctx := context.Background()
490 h := newGuardHistory(t, g, data)357 h := newGuardHistory(t, g, data)
491 t.Cleanup(func() { _ = g.Close(ctx) })358 t.Cleanup(func() { _ = g.Close(ctx) })
492- h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") // the default branch does not exist359+ side := h.commit("B", "", h.o)
493- run := func(to string) string {360+ merge := h.commit("A", "", h.m, side)
494- p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: to, Ref: "refs/tags/t"}}, ports.PushObjects{})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())
495- require.NoError(t, err)362+ require.NoError(t, err)
496- return p363+ 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)
497 }371 }
498- assert.Equal(t, ".github/workflows/ci.yml", run(h.m), "an existing commit with workflows")372+ assert.Equal(t, strings.TrimSpace(want), strings.Join(lines, "\n"))
499- plain := h.commit("", "")373+ assert.Len(t, got.Commits, 2)
500- h.f.Git("update-ref", "refs/heads/plain", plain)374+ assert.Equal(t, h.trees["A"], got.Subtrees[domain.HeadKey].ID.String())
501- assert.Empty(t, run(plain), "an existing commit without")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")
502 }381 }
503 382
504 // The commits of a push are bounded: more than the bound fails closed.383 // 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) {
506 g, data := newGit(t)385 g, data := newGit(t)
507 ctx := context.Background()386 ctx := context.Background()
508 h := newGuardHistory(t, g, data)387 h := newGuardHistory(t, g, data)
@@ -525,10 +404,10 @@ func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) {
525 }404 }
526 // Plain commits carry the workflows of main (the fast-import tree has none,405 // Plain commits carry the workflows of main (the fast-import tree has none,
527 // so the chain is "removing" them): allowed, whatever their number, up to the bound.406 // 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})
529 require.NoError(t, err)408 require.NoError(t, err)
530- assert.Empty(t, p)409+ assert.Len(t, p.Commits, 20)
531- _, err = g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits + 1), Ref: "refs/heads/main"}}, ports.PushObjects{})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})
532 assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push")411 assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push")
533 }412 }
534 413
@@ -565,18 +444,18 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) {
565 b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small444 b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small
566 }445 }
567 b.WriteString("\n")446 b.WriteString("\n")
568- r := &countingReader{lines: gitcli.MaxPushedCommits, line: []byte(b.String())}447+ r := &countingReader{lines: domain.MaxPushedCommits, line: []byte(b.String())}
569- kept, err := gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects)448+ kept, err := gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs)
570 require.ErrorIs(t, err, domain.ErrInvalid)449 require.ErrorIs(t, err, domain.ErrInvalid)
571 assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length")450 assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length")
572 assert.Zero(t, kept, "nothing is returned with the error")451 assert.Zero(t, kept, "nothing is returned with the error")
573- total := gitcli.MaxPushedCommits * len(b.String())452+ total := domain.MaxPushedCommits * len(b.String())
574 assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total)453 assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total)
575 assert.Less(t, r.read*20, total)454 assert.Less(t, r.read*20, total)
576 455
577 // Within the bounds the same lines are kept: 100 commits of 198 parents.456 // Within the bounds the same lines are kept: 100 commits of 198 parents.
578 r = &countingReader{lines: 100, line: []byte(b.String())}457 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)
580 require.NoError(t, err)459 require.NoError(t, err)
581 assert.Equal(t, 100, kept)460 assert.Equal(t, 100, kept)
582 461
@@ -590,3 +469,65 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) {
590 _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000)469 _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000)
591 assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound")470 assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound")
592 }471 }
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) }
35 // ListRefs lists HEAD, the branches and the tags with `git for-each-ref`.35 // ListRefs lists HEAD, the branches and the tags with `git for-each-ref`.
36 // An annotated tag is followed to its commit; a tag of a tag is followed with36 // An annotated tag is followed to its commit; a tag of a tag is followed with
37 // one `cat-file` request each.37 // one `cat-file` request each.
38-//
39-//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain refs
40 func (g *Git) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {38 func (g *Git) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) {
41 dir, err := g.repoDir(repo)39 dir, err := g.repoDir(repo)
42 if err != nil {40 if err != nil {
@@ -155,66 +153,45 @@ func readHead(dir string) (string, error) {
155 return ref, nil153 return ref, nil
156 }154 }
157 155
158-// revCandidate is one way to read a revision as a commit.156+// resolve resolves the domain candidates on an open cat-file connection.
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
194 func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) {157 func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) {
195- for _, cand := range candidates(rev) {158+ for _, cand := range rev.Candidates() {
196- h, err := c.info(cand.spec)159+ id, ok, err := lookupRevisionCandidate(c, cand)
197- if notFound(err) {
198- continue
199- }
200 if err != nil {160 if err != nil {
201 return domain.ResolvedRevision{}, err161 return domain.ResolvedRevision{}, err
202 }162 }
203- if h.Type != "commit" {163+ if !ok {
204 continue164 continue
205 }165 }
206- id, err := domain.ParseObjectID(h.ID)166+ return domain.ResolvedRevision{CommitID: id, Ref: cand.ResolvedRef()}, nil
207- if err != nil {
208- return domain.ResolvedRevision{}, c.fail(err)
209- }
210- return domain.ResolvedRevision{CommitID: id, Ref: cand.ref}, nil
211 }167 }
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
213 }192 }
214 193
215 // ResolveRevision resolves a revision with `cat-file`.194 // 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
218 func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {195 func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
219 dir, err := g.repoDir(repo)196 dir, err := g.repoDir(repo)
220 if err != nil {197 if err != nil {
@@ -235,8 +212,6 @@ func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev dom
235 // tag, full ref name, HEAD; a single segment may also be a commit id), longest212 // tag, full ref name, HEAD; a single segment may also be a commit id), longest
236 // first, with up to one `cat-file` request per candidate on the repository's213 // first, with up to one `cat-file` request per candidate on the repository's
237 // process. At most domain.MaxRefPathTries segments are tried.214 // 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
240 func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {215 func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) {
241 dir, err := g.repoDir(repo)216 dir, err := g.repoDir(repo)
242 if err != nil {217 if err != nil {
@@ -264,6 +239,8 @@ type object struct {
264 // lookup resolves rev and reads the header of the object at path, in one239 // lookup resolves rev and reads the header of the object at path, in one
265 // request series on the repository's process. A missing path is240 // request series on the repository's process. A missing path is
266 // domain.ErrNotFound.241 // 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
267 func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) {244 func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) {
268 var obj object245 var obj object
269 err := g.cat.do(ctx, dir, func(c *catConn) error {246 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
392 }369 }
393 370
394 // parseTreeEntry parses "<mode> <type> <id> <size>\t<name>" of ls-tree -l.371 // 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
397 func parseTreeEntry(rec string, parent domain.TreePath) (domain.TreeEntry, error) {372 func parseTreeEntry(rec string, parent domain.TreePath) (domain.TreeEntry, error) {
398 meta, name, ok := strings.Cut(rec, "\t")373 meta, name, ok := strings.Cut(rec, "\t")
399 f := strings.Fields(meta)374 f := strings.Fields(meta)
@@ -487,8 +462,6 @@ feed:
487 462
488 // lastCommit returns the newest commit reachable from head that changed path;463 // lastCommit returns the newest commit reachable from head that changed path;
489 // an empty path means head itself.464 // 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
492 func (g *Git) lastCommit(ctx context.Context, dir string, head domain.ObjectID, path string) (domain.Commit, error) {465 func (g *Git) lastCommit(ctx context.Context, dir string, head domain.ObjectID, path string) (domain.Commit, error) {
493 var c domain.Commit466 var c domain.Commit
494 found := false467 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"
520 493
521 // readCommit reads one commit in commitFormat. It returns io.EOF at the end494 // readCommit reads one commit in commitFormat. It returns io.EOF at the end
522 // of the output.495 // 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
525 func readCommit(r *bufio.Reader) (domain.Commit, error) {496 func readCommit(r *bufio.Reader) (domain.Commit, error) {
526 var f [9]string497 var f [9]string
527 for i := range f {498 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 {
102 102
103 // PurgeStaging removes everything under repos/.staging/.103 // PurgeStaging removes everything under repos/.staging/.
104 //104 //
105-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only105+//hexcheck:ignore suspicious-business-logic-in-adapter removes staging leftovers from disk, tolerates a missing directory, and logs each removal
106 func (g *Git) PurgeStaging(ctx context.Context) error {106 func (g *Git) PurgeStaging(ctx context.Context) error {
107 entries, err := os.ReadDir(g.stagingDir())107 entries, err := os.ReadDir(g.stagingDir())
108 if err != nil {108 if err != nil {
@@ -280,8 +280,6 @@ func (g *Git) DefaultBranch(ctx context.Context, name domain.RepoName) (string,
280 // HasBranch checks refs/heads/<branch> with `git for-each-ref` after280 // HasBranch checks refs/heads/<branch> with `git for-each-ref` after
281 // `git check-ref-format`, which rejects the glob characters for-each-ref281 // `git check-ref-format`, which rejects the glob characters for-each-ref
282 // would otherwise interpret.282 // would otherwise interpret.
283-//
284-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
285 func (g *Git) HasBranch(ctx context.Context, name domain.RepoName, branch string) (bool, error) {283 func (g *Git) HasBranch(ctx context.Context, name domain.RepoName, branch string) (bool, error) {
286 dir, err := g.repoDir(name)284 dir, err := g.repoDir(name)
287 if err != nil {285 if err != nil {
M internal/adapters/gitcli/stats.go +0 -6
@@ -18,8 +18,6 @@ import (
18 18
19 // CountCommits runs `git rev-list --count`, from the commit graph when the19 // CountCommits runs `git rev-list --count`, from the commit graph when the
20 // repository has one.20 // repository has one.
21-//
22-//hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints
23 func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {21 func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
24 if head.IsZero() {22 if head.IsZero() {
25 return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid)23 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
45 // CommitTimes runs `git rev-list --since-as-filter --format=%ct`. The filter43 // CommitTimes runs `git rev-list --since-as-filter --format=%ct`. The filter
46 // looks at every reachable commit: --since stops the walk at the first older44 // looks at every reachable commit: --since stops the walk at the first older
47 // one, and misses newer commits behind it when dates are out of order.45 // 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
50 func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {46 func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
51 if head.IsZero() {47 if head.IsZero() {
52 return nil, false, fmt.Errorf("commit times: %w: no head", domain.ErrInvalid)48 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
91 }87 }
92 88
93 // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags.89 // 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
96 func (g *Git) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) {90 func (g *Git) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) {
97 dir, err := g.repoDir(repo)91 dir, err := g.repoDir(repo)
98 if err != nil {92 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
174 // and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of174 // and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of
175 // the forge. info/refs without a service is the dumb protocol, which ermite175 // the forge. info/refs without a service is the dumb protocol, which ermite
176 // does not serve.176 // 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
177 func (h *Handler) parseRoute(r *http.Request) (route, int) {179 func (h *Handler) parseRoute(r *http.Request) (route, int) {
178 p := strings.TrimPrefix(r.URL.Path, "/")180 p := strings.TrimPrefix(r.URL.Path, "/")
179 var head, rest string181 var head, rest string
@@ -224,8 +226,6 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) {
224 }226 }
225 227
226 // ServeHTTP implements http.Handler.228 // ServeHTTP implements http.Handler.
227-//
228-//hexcheck:ignore suspicious-business-logic-in-adapter transport orchestration: the authorization decisions are the use cases'
229 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {229 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
230 var rt route230 var rt route
231 var name domain.RepoName231 var name domain.RepoName
@@ -328,6 +328,8 @@ func credentials(r *http.Request) (secret string, present bool) {
328 // denied answers an authorization failure. A caller without valid328 // denied answers an authorization failure. A caller without valid
329 // credentials is asked for them wherever the repository does not show itself,329 // credentials is asked for them wherever the repository does not show itself,
330 // which keeps a private repository and a missing one indistinguishable.330 // 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
331 func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) {333 func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) {
332 switch {334 switch {
333 case errors.Is(err, domain.ErrUnauthenticated):335 case errors.Is(err, domain.ErrUnauthenticated):
@@ -418,8 +420,6 @@ func (h *Handler) acquire(ctx context.Context, w http.ResponseWriter, rt route,
418 }420 }
419 421
420 // serveBackend runs git http-backend for an authorized request.422 // 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
423 func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) {423 func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) {
424 ctx := r.Context()424 ctx := r.Context()
425 logger := zerowrap.FromCtx(ctx)425 logger := zerowrap.FromCtx(ctx)
M internal/adapters/githttp/hooks.go +0 -2
@@ -175,8 +175,6 @@ func (s *HookServer) Close(ctx context.Context) {
175 }175 }
176 176
177 // handle serves one connection: one request line, one reply line.177 // 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'
180 func (s *HookServer) handle(ctx context.Context, conn net.Conn) {178 func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
181 log := zerowrap.FromCtx(ctx)179 log := zerowrap.FromCtx(ctx)
182 _ = conn.SetReadDeadline(time.Now().Add(readTimeout))180 _ = 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) {
310 }, nil310 }, nil
311 }311 }
312 312
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
313 func encodeJob(j domain.CIJob) ([]byte, error) {316 func encodeJob(j domain.CIJob) ([]byte, error) {
314 if j.Repo.IsZero() {317 if j.Repo.IsZero() {
315 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)318 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -346,7 +349,6 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
346 return mustMarshal(pb), nil349 return mustMarshal(pb), nil
347 }350 }
348 351
349-//hexcheck:ignore suspicious-business-logic-in-adapter maps the stored record to the domain job field by field: no rule is decided here
350 func decodeJob(item kv.Item) (domain.CIJob, error) {352 func decodeJob(item kv.Item) (domain.CIJob, error) {
351 var pb storagepb.CIJob353 var pb storagepb.CIJob
352 if err := proto.Unmarshal(item.Value, &pb); err != nil {354 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
530 }532 }
531 533
532 // Apply writes the records in one batch.534 // 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
535 func (c *CI) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) {535 func (c *CI) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) {
536 var b kv.Batch536 var b kv.Batch
537 guarded, err := c.stageRecords(ctx, &b, w)537 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) {
576 576
577 const createRunTries = 20577 const createRunTries = 20
578 578
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.
580 //582 //
581-//hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy583+//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
582 func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {584 func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
583 offKey := workflowOffKey(repo, workflowPath)585 offKey := workflowOffKey(repo, workflowPath)
584 for range createRunTries {586 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- }
591 lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey))587 lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey))
592 if err != nil {588 if err != nil {
593 return ports.CIRunDraft{}, err589 return ports.CIRunDraft{}, err
@@ -620,6 +616,10 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s
620 }616 }
621 v, err := c.db.Apply(ctx, &b)617 v, err := c.db.Apply(ctx, &b)
622 if errors.Is(err, kv.ErrConflict) {618 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+ }
623 continue623 continue
624 }624 }
625 if err != nil {625 if err != nil {
@@ -669,8 +669,6 @@ func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource,
669 }669 }
670 670
671 // ListRuns returns the runs of the repository, newest first.671 // 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
674 func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) {672 func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) {
675 start := []byte(runsPrefix(repo))673 start := []byte(runsPrefix(repo))
676 end := []byte(runPrefix + repo.String() + "0") // '/' + 1674 end := []byte(runPrefix + repo.String() + "0") // '/' + 1
@@ -906,8 +904,6 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by
906 const sweepChunk = 500904 const sweepChunk = 500
907 905
908 // Sweep removes the runs completed before the time.906 // 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
911 func (c *CI) Sweep(ctx context.Context, before time.Time) ([]int64, error) {907 func (c *CI) Sweep(ctx context.Context, before time.Time) ([]int64, error) {
912 snap := c.db.Snapshot(ctx)908 snap := c.db.Snapshot(ctx)
913 var ids []int64909 var ids []int64
@@ -955,8 +951,6 @@ func runnerNameKey(n string) []byte { return []byte(runnerName + stri
955 func runnerTokenKey(h domain.TokenHash) []byte { return []byte(runnerTokPref + h.String()) }951 func runnerTokenKey(h domain.TokenHash) []byte { return []byte(runnerTokPref + h.String()) }
956 952
957 // CreateRunner stores the runner, its name index and its token index.953 // 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
960 func (c *CI) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) {954 func (c *CI) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) {
961 for range createRunTries {955 for range createRunTries {
962 var b kv.Batch956 var b kv.Batch
@@ -1115,7 +1109,6 @@ func encodeCall(c *domain.CICall) *storagepb.CICall {
1115 }1109 }
1116 }1110 }
1117 1111
1118-//hexcheck:ignore suspicious-business-logic-in-adapter maps the stored call to the domain call field by field: no rule is decided here
1119 func decodeCall(pb *storagepb.CICall) *domain.CICall {1112 func decodeCall(pb *storagepb.CICall) *domain.CICall {
1120 if pb == nil {1113 if pb == nil {
1121 return nil1114 return nil
M internal/adapters/kvstore/ci_artifact.go +3 -5
@@ -79,6 +79,9 @@ var artifactStates = []struct {
79 {domain.CIArtifactFinal, storagepb.CIArtifactState_CI_ARTIFACT_STATE_FINAL},79 {domain.CIArtifactFinal, storagepb.CIArtifactState_CI_ARTIFACT_STATE_FINAL},
80 }80 }
81 81
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
82 func encodeArtifact(a domain.CIArtifact) ([]byte, error) {85 func encodeArtifact(a domain.CIArtifact) ([]byte, error) {
83 if a.Repo.IsZero() || a.ID < 1 || a.RunID < 1 {86 if a.Repo.IsZero() || a.ID < 1 || a.RunID < 1 {
84 return nil, fmt.Errorf("%w: artifact without id, run or repository", domain.ErrInvalid)87 return nil, fmt.Errorf("%w: artifact without id, run or repository", domain.ErrInvalid)
@@ -106,7 +109,6 @@ func encodeArtifact(a domain.CIArtifact) ([]byte, error) {
106 return mustMarshal(pb), nil109 return mustMarshal(pb), nil
107 }110 }
108 111
109-//hexcheck:ignore suspicious-business-logic-in-adapter decodes a record into the domain type: a storage concern, no policy
110 func decodeArtifact(item kv.Item) (domain.CIArtifact, error) {112 func decodeArtifact(item kv.Item) (domain.CIArtifact, error) {
111 var pb storagepb.CIArtifact113 var pb storagepb.CIArtifact
112 if err := proto.Unmarshal(item.Value, &pb); err != nil {114 if err := proto.Unmarshal(item.Value, &pb); err != nil {
@@ -141,8 +143,6 @@ func decodeArtifact(item kv.Item) (domain.CIArtifact, error) {
141 const artifactIDTries = 1000143 const artifactIDTries = 1000
142 144
143 // NextArtifactID takes the next id in its own batch.145 // 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
146 func (c *CIArtifacts) NextArtifactID(ctx context.Context) (int64, error) {146 func (c *CIArtifacts) NextArtifactID(ctx context.Context) (int64, error) {
147 ci := &CI{db: c.db}147 ci := &CI{db: c.db}
148 for range artifactIDTries {148 for range artifactIDTries {
@@ -274,8 +274,6 @@ func (c *CIArtifacts) BlockArtifactIDs(ctx context.Context) ([]int64, error) {
274 // --- writes ---274 // --- writes ---
275 275
276 // ApplyArtifacts writes w in one batch.276 // 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
279 func (c *CIArtifacts) ApplyArtifacts(ctx context.Context, w ports.CIArtifactWrite) error {277 func (c *CIArtifacts) ApplyArtifacts(ctx context.Context, w ports.CIArtifactWrite) error {
280 var b kv.Batch278 var b kv.Batch
281 guard := false279 guard := false
M internal/adapters/kvstore/ci_concurrency.go +0 -4
@@ -103,7 +103,6 @@ func encodeConcurrencyRecord(r domain.CIConcurrencyRecord) []byte {
103 return mustMarshal(pb)103 return mustMarshal(pb)
104 }104 }
105 105
106-//hexcheck:ignore suspicious-business-logic-in-adapter maps the stored record to the domain record field by field: no rule is decided here
107 func decodeConcurrencyRecord(val []byte) (domain.CIConcurrencyRecord, error) {106 func decodeConcurrencyRecord(val []byte) (domain.CIConcurrencyRecord, error) {
108 var pb storagepb.CIConcurrencyRecord107 var pb storagepb.CIConcurrencyRecord
109 if err := proto.Unmarshal(val, &pb); err != nil {108 if err := proto.Unmarshal(val, &pb); err != nil {
@@ -146,7 +145,6 @@ func encodeConcurrencyMark(m domain.CIConcurrencyMark) []byte {
146 return mustMarshal(pb)145 return mustMarshal(pb)
147 }146 }
148 147
149-//hexcheck:ignore suspicious-business-logic-in-adapter maps the stored mark to the domain mark field by field: no rule is decided here
150 func decodeConcurrencyMark(val []byte) (domain.CIConcurrencyMark, error) {148 func decodeConcurrencyMark(val []byte) (domain.CIConcurrencyMark, error) {
151 var pb storagepb.CIConcurrencyMark149 var pb storagepb.CIConcurrencyMark
152 if err := proto.Unmarshal(val, &pb); err != nil {150 if err := proto.Unmarshal(val, &pb); err != nil {
@@ -257,8 +255,6 @@ func (c *CI) UpdateConcurrency(ctx context.Context, repo domain.RepoName, group
257 }255 }
258 256
259 // ListConcurrency returns every stored group with its marks.257 // 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
262 func (c *CI) ListConcurrency(ctx context.Context) ([]ports.CIConcurrencyGroup, error) {258 func (c *CI) ListConcurrency(ctx context.Context) ([]ports.CIConcurrencyGroup, error) {
263 type id struct{ repo, group string }259 type id struct{ repo, group string }
264 snap := c.db.Snapshot(ctx)260 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)
85 85
86 // BindSecretsKey stores the key check when there is none and compares it86 // BindSecretsKey stores the key check when there is none and compares it
87 // otherwise.87 // otherwise.
88+//
89+//hexcheck:ignore suspicious-business-logic-in-adapter initializes or compares the persisted encryption-key check with compare-and-swap retries
88 func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error {90 func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error {
89 key := []byte(secretsKeyCheck)91 key := []byte(secretsKeyCheck)
90 for range updateAttempts {92 for range updateAttempts {
@@ -111,6 +113,8 @@ func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error {
111 113
112 // stage makes the batch depend on the scope: the repository key is guarded114 // stage makes the batch depend on the scope: the repository key is guarded
113 // and, for an environment, its record is required at the version read.115 // 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
114 func (s *Settings) stage(ctx context.Context, b *kv.Batch, scope domain.SettingsScope) error {118 func (s *Settings) stage(ctx context.Context, b *kv.Batch, scope domain.SettingsScope) error {
115 if !scope.Valid() {119 if !scope.Valid() {
116 return fmt.Errorf("%w: scope", domain.ErrInvalid)120 return fmt.Errorf("%w: scope", domain.ErrInvalid)
@@ -203,6 +207,8 @@ func (s *Settings) GetSealedSecret(ctx context.Context, scope domain.SettingsSco
203 }207 }
204 208
205 // existsIn is exists on a snapshot, so a listing and its check agree.209 // 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
206 func (s *Settings) existsIn(ctx context.Context, snap *kv.Snapshot, scope domain.SettingsScope) error {212 func (s *Settings) existsIn(ctx context.Context, snap *kv.Snapshot, scope domain.SettingsScope) error {
207 if !scope.Valid() {213 if !scope.Valid() {
208 return fmt.Errorf("%w: scope", domain.ErrInvalid)214 return fmt.Errorf("%w: scope", domain.ErrInvalid)
@@ -309,8 +315,6 @@ func decodeEnvironment(val []byte) (domain.Environment, error) {
309 }315 }
310 316
311 // CreateEnvironment implements ports.CISettingsStore.317 // 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
314 func (s *Settings) CreateEnvironment(ctx context.Context, repo domain.RepoName, name string, at time.Time) (domain.Environment, error) {318 func (s *Settings) CreateEnvironment(ctx context.Context, repo domain.RepoName, name string, at time.Time) (domain.Environment, error) {
315 key := envKey(repo, name)319 key := envKey(repo, name)
316 for range updateAttempts {320 for range updateAttempts {
@@ -353,8 +357,6 @@ func (s *Settings) ListEnvironments(ctx context.Context, repo domain.RepoName) (
353 }357 }
354 358
355 // DeleteEnvironment implements ports.CISettingsStore.359 // 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
358 func (s *Settings) DeleteEnvironment(ctx context.Context, repo domain.RepoName, name string) error {360 func (s *Settings) DeleteEnvironment(ctx context.Context, repo domain.RepoName, name string) error {
359 key := envKey(repo, name)361 key := envKey(repo, name)
360 scope := domain.EnvironmentScope(repo, name)362 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) [][
84 // Create takes the next number of the repository from mrseq/<repo>, the84 // Create takes the next number of the repository from mrseq/<repo>, the
85 // sequence of the merge requests, and stores the issue under it; see85 // sequence of the merge requests, and stores the issue under it; see
86 // createNumbered. A missing repository gives domain.ErrNotFound.86 // 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
89 func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {87 func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
90 err := createNumbered(ctx, i.db, i.locks, issue.Repo, func(b *kv.Batch, n int64) error {88 err := createNumbered(ctx, i.db, i.locks, issue.Repo, func(b *kv.Batch, n int64) error {
91 issue.Number = n89 issue.Number = n
@@ -118,8 +116,6 @@ func (i *Issues) Get(ctx context.Context, repo domain.RepoName, number int64) (d
118 116
119 // List returns a page of the issues of the repository, highest number first. The117 // List returns a page of the issues of the repository, highest number first. The
120 // page token is the last number of the previous page.118 // 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
123 func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {119 func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
124 before := int64(0) // 0: from the highest120 before := int64(0) // 0: from the highest
125 if params.PageToken != "" {121 if params.PageToken != "" {
@@ -225,8 +221,6 @@ func (i *Issues) ListOpenAll(ctx context.Context) ([]domain.Issue, error) {
225 // Update replaces the issue with a version precondition and moves its index key221 // Update replaces the issue with a version precondition and moves its index key
226 // in the same batch. The repository must exist (see guardRepoKey), so that a222 // in the same batch. The repository must exist (see guardRepoKey), so that a
227 // concurrent delete of the repository leaves no index key behind.223 // concurrent delete of the repository leaves no index key behind.
228-//
229-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
230 func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {224 func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
231 val, err := encodeIssue(issue)225 val, err := encodeIssue(issue)
232 if err != nil {226 if err != nil {
@@ -330,7 +324,6 @@ func encodeIssue(issue domain.Issue) ([]byte, error) {
330 }), nil324 }), nil
331 }325 }
332 326
333-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
334 func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {327 func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
335 var pb storagepb.Issue328 var pb storagepb.Issue
336 if err := proto.Unmarshal(item.Value, &pb); err != nil {329 if err := proto.Unmarshal(item.Value, &pb); err != nil {
@@ -373,7 +366,6 @@ func encodeIssueComment(c domain.IssueComment) ([]byte, error) {
373 }), nil366 }), nil
374 }367 }
375 368
376-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
377 func decodeIssueComment(item kv.Item) (domain.IssueComment, error) {369 func decodeIssueComment(item kv.Item) (domain.IssueComment, error) {
378 var pb storagepb.IssueComment370 var pb storagepb.IssueComment
379 if err := proto.Unmarshal(item.Value, &pb); err != nil {371 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
94 // creation time then repository then id. There are at most94 // creation time then repository then id. There are at most
95 // domain.MaxPendingIssues of them (a little more under a race), so they are95 // domain.MaxPendingIssues of them (a little more under a race), so they are
96 // read and sorted. The page token is the position of the last item returned.96 // 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
99 func (s *PendingIssues) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {97 func (s *PendingIssues) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
100 prefix := pendingPrefix98 prefix := pendingPrefix
101 if !repo.IsZero() {99 if !repo.IsZero() {
@@ -216,8 +214,6 @@ func (s *PendingIssues) Count(ctx context.Context) (int, error) {
216 // Approve numbers the pending issue from mrseq/<repo> (see createNumbered),214 // Approve numbers the pending issue from mrseq/<repo> (see createNumbered),
217 // writes the open issue and its index key and deletes the pending record, in215 // writes the open issue and its index key and deletes the pending record, in
218 // one batch that requires the pending record to be unchanged.216 // 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
221 func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) {217 func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) {
222 var number int64218 var number int64
223 err := createNumbered(ctx, s.db, s.locks, repo, func(b *kv.Batch, n int64) error {219 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) {
291 }), nil287 }), nil
292 }288 }
293 289
294-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
295 func decodePending(repo domain.RepoName, id domain.PendingIssueID, item kv.Item) (domain.PendingIssue, error) {290 func decodePending(repo domain.RepoName, id domain.PendingIssueID, item kv.Item) (domain.PendingIssue, error) {
296 var pb storagepb.PendingIssue291 var pb storagepb.PendingIssue
297 if err := proto.Unmarshal(item.Value, &pb); err != nil {292 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
96 // when old is not known, as for a new request or a comment: the keys of mr are96 // when old is not known, as for a new request or a comment: the keys of mr are
97 // then put or deleted, whatever they were). Deleting a key that is not there is97 // then put or deleted, whatever they were). Deleting a key that is not there is
98 // not an error.98 // 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
99 func indexOpen(b *kv.Batch, old *domain.MergeRequest, mr domain.MergeRequest) {101 func indexOpen(b *kv.Batch, old *domain.MergeRequest, mr domain.MergeRequest) {
100 if old != nil && old.IsOpen() {102 if old != nil && old.IsOpen() {
101 if !mr.IsOpen() || old.Source != mr.Source {103 if !mr.IsOpen() || old.Source != mr.Source {
@@ -204,8 +206,6 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
204 // rewrites it (see guardRepo), so that a repository deleted meanwhile is not206 // rewrites it (see guardRepo), so that a repository deleted meanwhile is not
205 // left with a stray merge request (a push recording pushed_at makes it retry). A missing repository gives207 // left with a stray merge request (a push recording pushed_at makes it retry). A missing repository gives
206 // domain.ErrNotFound.208 // domain.ErrNotFound.
207-//
208-//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
209 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {209 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
210 err := createNumbered(ctx, m.db, m.locks, mr.Repo, func(b *kv.Batch, n int64) error {210 err := createNumbered(ctx, m.db, m.locks, mr.Repo, func(b *kv.Batch, n int64) error {
211 mr.Number = n211 mr.Number = n
@@ -451,7 +451,7 @@ func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (in
451 451
452 // Update replaces the merge request with a version precondition.452 // Update replaces the merge request with a version precondition.
453 //453 //
454-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only454+//hexcheck:ignore suspicious-business-logic-in-adapter replaces a versioned record and maintains open-request indexes and the derived mergeability record
455 func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) error {455 func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) error {
456 val, err := encodeMergeRequest(mr)456 val, err := encodeMergeRequest(mr)
457 if err != nil {457 if err != nil {
@@ -564,8 +564,6 @@ func (m *MergeRequests) GetRead(ctx context.Context, repo domain.RepoName, numbe
564 564
565 // SetRead writes mrread/<repo>/<number>. The repository must exist (see565 // SetRead writes mrread/<repo>/<number>. The repository must exist (see
566 // guardRepo): domain.ErrNotFound otherwise.566 // 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
569 func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error {567 func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error {
570 if head.IsZero() {568 if head.IsZero() {
571 return fmt.Errorf("%w: empty read position", domain.ErrInvalidObjectID)569 return fmt.Errorf("%w: empty read position", domain.ErrInvalidObjectID)
@@ -748,7 +746,6 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
748 }), nil746 }), nil
749 }747 }
750 748
751-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
752 func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest, error) {749 func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest, error) {
753 var pb storagepb.MergeRequest750 var pb storagepb.MergeRequest
754 if err := proto.Unmarshal(item.Value, &pb); err != nil {751 if err := proto.Unmarshal(item.Value, &pb); err != nil {
@@ -815,7 +812,6 @@ func encodeComment(c domain.Comment) ([]byte, error) {
815 }), nil812 }), nil
816 }813 }
817 814
818-//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
819 func decodeComment(item kv.Item) (domain.Comment, error) {815 func decodeComment(item kv.Item) (domain.Comment, error) {
820 var pb storagepb.Comment816 var pb storagepb.Comment
821 if err := proto.Unmarshal(item.Value, &pb); err != nil {817 if err := proto.Unmarshal(item.Value, &pb); err != nil {
@@ -931,8 +927,6 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
931 927
932 // decodeSettings returns the defaults for a repository stored before merge928 // decodeSettings returns the defaults for a repository stored before merge
933 // requests existed, and for settings left with no mode that this build knows.929 // 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
936 func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {930 func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
937 if pb == nil {931 if pb == nil {
938 return domain.DefaultRepoSettings()932 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 {
134 }134 }
135 135
136 // Update replaces a stored passkey, or returns domain.ErrNotFound.136 // Update replaces a stored passkey, or returns domain.ErrNotFound.
137-//
138-//hexcheck:ignore suspicious-business-logic-in-adapter version-checked replace, not a policy
139 func (s *Passkeys) Update(ctx context.Context, p domain.Passkey) error {137 func (s *Passkeys) Update(ctx context.Context, p domain.Passkey) error {
140 val, err := encodePasskey(p)138 val, err := encodePasskey(p)
141 if err != nil {139 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) {
67 const repoMutationTries = 867 const repoMutationTries = 8
68 68
69 // Mutate applies a retry-safe mutation under the version it read.69 // 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
72 func (s *Store) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {70 func (s *Store) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
73 key := repoKey(name)71 key := repoKey(name)
74 for range repoMutationTries {72 for range repoMutationTries {
@@ -158,8 +156,6 @@ const deleteTries = 32
158 // got in after the listing makes the batch fail and the keys are listed again,156 // got in after the listing makes the batch fail and the keys are listed again,
159 // and a write after the batch finds no repository: no key outlives the157 // and a write after the batch finds no repository: no key outlives the
160 // repository to attach itself to a repository created again.158 // 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
163 func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {159 func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
164 for range deleteTries {160 for range deleteTries {
165 snap := s.db.Snapshot(ctx)161 snap := s.db.Snapshot(ctx)
@@ -211,12 +207,8 @@ func encodeRepo(r domain.Repo) ([]byte, error) {
211 if err != nil {207 if err != nil {
212 return nil, err208 return nil, err
213 }209 }
214- settings := r.Settings210+ // Store defaults explicitly so future default changes do not alter stored repositories.
215- if settings.IsZero() {211+ settings := r.EffectiveSettings()
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- }
220 pbSettings, err := encodeSettings(settings)212 pbSettings, err := encodeSettings(settings)
221 if err != nil {213 if err != nil {
222 return nil, err214 return nil, err
M internal/adapters/kvstore/store.go +0 -2
@@ -186,8 +186,6 @@ var preIssueScopes = []domain.Scope{
186 // for, so it gets each of them if missing (this is no escalation). Every other186 // for, so it gets each of them if missing (this is no escalation). Every other
187 // token is left as it is. The batch requires each token to be at the version it187 // token is left as it is. The batch requires each token to be at the version it
188 // was read at.188 // 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
191 func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {189 func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
192 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {190 for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
193 pb := &storagepb.Token{}191 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 {
153 153
154 // Touch sets last_used_at when the given time is later than the stored one,154 // Touch sets last_used_at when the given time is later than the stored one,
155 // so that racing writers never move it back.155 // so that racing writers never move it back.
156-//
157-//hexcheck:ignore suspicious-business-logic-in-adapter monotonic write guard, not a policy
158 func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {156 func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
159 if !validTokenID(id) {157 if !validTokenID(id) {
160 return domain.ErrNotFound158 return domain.ErrNotFound
@@ -194,8 +192,6 @@ func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
194 // encodeToken refuses what the use case already refused (scopes, branch192 // encodeToken refuses what the use case already refused (scopes, branch
195 // prefixes): a malformed prefix must never reach the store, where it would193 // prefixes): a malformed prefix must never reach the store, where it would
196 // limit a token wrongly or not at all.194 // 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
199 func encodeToken(t domain.Token) ([]byte, error) {195 func encodeToken(t domain.Token) ([]byte, error) {
200 if !validTokenID(t.ID) {196 if !validTokenID(t.ID) {
201 return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)197 return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
@@ -237,8 +233,6 @@ func encodeToken(t domain.Token) ([]byte, error) {
237 233
238 // decodeToken drops scopes it does not know (written by a newer ermite): a234 // decodeToken drops scopes it does not know (written by a newer ermite): a
239 // token never gains rights by being read by an older build.235 // 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
242 func decodeToken(val []byte) (domain.Token, error) {236 func decodeToken(val []byte) (domain.Token, error) {
243 var pb storagepb.Token237 var pb storagepb.Token
244 if err := proto.Unmarshal(val, &pb); err != nil {238 if err := proto.Unmarshal(val, &pb); err != nil {
M internal/adapters/rpc/api.go +2 -4
@@ -87,9 +87,7 @@ type Config struct {
87 Secrets ports.SecretService87 Secrets ports.SecretService
88 Variables ports.VariableService88 Variables ports.VariableService
89 Environments ports.EnvironmentService89 Environments ports.EnvironmentService
90- // OIDCKeys rotates the key of the ID tokens of CI jobs; nil when the server90+ // OIDCKeys manages ID-token signing keys, including when OIDC is disabled.
91- // issues none (GetVersion then does not list "ci.oidc"). Same rule as
92- // Sessions about typed nil pointers.
93 OIDCKeys ports.OIDCKeyService91 OIDCKeys ports.OIDCKeyService
94 // JobArtifacts and Artifacts serve JobArtifactService and ArtifactService,92 // JobArtifacts and Artifacts serve JobArtifactService and ArtifactService,
95 // and GetVersion lists "ci.artifacts", when both are set and Runner is.93 // and GetVersion lists "ci.artifacts", when both are set and Runner is.
@@ -138,7 +136,7 @@ func New(cfg Config) *API {
138 if artifacts {136 if artifacts {
139 features = append(features, FeatureCIArtifacts)137 features = append(features, FeatureCIArtifacts)
140 }138 }
141- if cfg.OIDCKeys != nil {139+ if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() {
142 features = append(features, FeatureCIOIDC)140 features = append(features, FeatureCIOIDC)
143 }141 }
144 if cfg.TrustedKeys != nil {142 if cfg.TrustedKeys != nil {
M internal/adapters/rpc/convert.go +1 -4
@@ -58,10 +58,7 @@ func visibilityFromPB(v pb.Visibility) (domain.Visibility, error) {
58 }58 }
59 59
60 func repoToPB(v ports.RepoView) *pb.Repo {60 func repoToPB(v ports.RepoView) *pb.Repo {
61- settings := v.Settings61+ settings := v.EffectiveSettings()
62- if settings.IsZero() {
63- settings = domain.DefaultRepoSettings()
64- }
65 return &pb.Repo{62 return &pb.Repo{
66 Name: v.Name.String(),63 Name: v.Name.String(),
67 Description: v.Description,64 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
187 return connect.NewResponse(commitDetailToPB(detail)), nil187 return connect.NewResponse(commitDetailToPB(detail)), nil
188 }188 }
189 189
190-//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
191 func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {190 func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
192 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())191 name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision())
193 if err != nil {192 if err != nil {
M internal/adapters/rpc/mergerequest.go +0 -2
@@ -212,8 +212,6 @@ func commentID(s string) int64 {
212 }212 }
213 213
214 // headID parses a commit id of a request; the empty string is the zero id.214 // 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
217 func headID(s string) (domain.ObjectID, error) {215 func headID(s string) (domain.ObjectID, error) {
218 if s == "" {216 if s == "" {
219 return domain.ObjectID{}, nil217 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
83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84 // nothing else; the optional issues field stands outside the mask, and alone84 // nothing else; the optional issues field stands outside the mask, and alone
85 // it is a valid update.85 // 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
88 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {86 func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
89 var params ports.UpdateRepoParams87 var params ports.UpdateRepoParams
90 paths := msg.GetUpdateMask().GetPaths()88 paths := msg.GetUpdateMask().GetPaths()
M internal/adapters/rpc/server.go +5 -8
@@ -10,7 +10,6 @@ import (
10 "connectrpc.com/connect"10 "connectrpc.com/connect"
11 11
12 pb "bnema.dev/ermite-api/gen/ermite"12 pb "bnema.dev/ermite-api/gen/ermite"
13- "bnema.dev/ermite/internal/domain"
14 "bnema.dev/ermite/internal/ports"13 "bnema.dev/ermite/internal/ports"
15 )14 )
16 15
@@ -19,7 +18,7 @@ type serverService struct {
19 version string18 version string
20 features []string19 features []string
21 key ports.SigningKeyService20 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.
23 oidc ports.OIDCKeyService22 oidc ports.OIDCKeyService
24 }23 }
25 24
@@ -41,15 +40,13 @@ func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSi
41 }40 }
42 41
43 // RotateOIDCKey replaces the key that signs the ID tokens of CI jobs. Only the42 // 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.
45 func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) {45 func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) {
46- p := caller(ctx)
47 if s.oidc == nil {46 if s.oidc == nil {
48- if !p.HasOwnerRights() {47+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("this API serves no OIDC key service"))
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"))
52 }48 }
49+ p := caller(ctx)
53 r, err := s.oidc.RotateKey(ctx, p)50 r, err := s.oidc.RotateKey(ctx, p)
54 if err != nil {51 if err != nil {
55 return nil, connectError(ctx, err)52 return nil, connectError(ctx, err)
M internal/adapters/rpc/server_test.go +11 -0
@@ -49,6 +49,17 @@ func TestServerServiceGetSigningKey(t *testing.T) {
49 assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners())49 assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners())
50 }50 }
51 51
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+
52 // GetVersion lists "passkeys" only when the API is built with passkeys.63 // GetVersion lists "passkeys" only when the API is built with passkeys.
53 func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) {64 func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) {
54 t.Parallel()65 t.Parallel()
M internal/adapters/sigverify/gpg.go +0 -2
@@ -41,8 +41,6 @@ var _ ports.SignatureVerifier = GPG{}
41 41
42 // Verify implements ports.SignatureVerifier. signedAt is ignored: see the time42 // Verify implements ports.SignatureVerifier. signedAt is ignored: see the time
43 // policy of GPG.43 // 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
46 func (GPG) Verify(payload, signature []byte, committerEmail string, _ int64, keys []domain.TrustedKey) (v domain.CommitVerification) {44 func (GPG) Verify(payload, signature []byte, committerEmail string, _ int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
47 v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature}45 v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature}
48 defer func() { // a parser bug must not take the server down46 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 {
49 }49 }
50 50
51 // Verify implements ports.SignatureVerifier.51 // 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
54 func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) {52 func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
55 v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature}53 v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature}
56 defer func() { // a parser bug must not take the server down54 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
42 // trusted completes the verification of a signature whose cryptography is42 // trusted completes the verification of a signature whose cryptography is
43 // valid and whose key is trusted: verified when the key may sign for the43 // valid and whose key is trusted: verified when the key may sign for the
44 // committer email, email_mismatch otherwise.44 // 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
47 func trusted(v domain.CommitVerification, key domain.TrustedKey, committerEmail string) domain.CommitVerification {45 func trusted(v domain.CommitVerification, key domain.TrustedKey, committerEmail string) domain.CommitVerification {
48 v.KeyID, v.KeyTitle = key.ID, key.Title46 v.KeyID, v.KeyTitle = key.ID, key.Title
49 if key.TrustsEmail(committerEmail) {47 if key.TrustsEmail(committerEmail) {
M internal/adapters/web/actions_artifacts.go +0 -4
@@ -53,8 +53,6 @@ type ArtifactsView struct {
53 // runArtifacts is the artifacts section of a run, nil when there is nothing to53 // runArtifacts is the artifacts section of a run, nil when there is nothing to
54 // show: no artifacts on this server, a visitor who cannot list them, no54 // show: no artifacts on this server, a visitor who cannot list them, no
55 // artifact, or a failed read, which is logged; the run page never fails for it.55 // 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
58 func (s *Server) runArtifacts(ctx context.Context, repo domain.RepoName, p RunPage) *ArtifactsView {56 func (s *Server) runArtifacts(ctx context.Context, repo domain.RepoName, p RunPage) *ArtifactsView {
59 if s.cfg.Artifacts == nil || !SignedIn(ctx) {57 if s.cfg.Artifacts == nil || !SignedIn(ctx) {
60 return nil58 return nil
@@ -112,8 +110,6 @@ func parseArtifactPath(r *http.Request) (int64, error) {
112 // is never served: a zip is application/zip, anything else110 // is never served: a zip is application/zip, anything else
113 // application/octet-stream, and the answer is sandboxed and not sniffed, so111 // application/octet-stream, and the answer is sandboxed and not sniffed, so
114 // that nothing uploaded by a job can run in the origin of the forge.112 // 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
117 func (s *Server) artifactDownload(w http.ResponseWriter, r *http.Request) {113 func (s *Server) artifactDownload(w http.ResponseWriter, r *http.Request) {
118 ctx, cancel := context.WithTimeout(r.Context(), artifactTimeout)114 ctx, cancel := context.WithTimeout(r.Context(), artifactTimeout)
119 defer cancel()115 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
71 }71 }
72 72
73 // resolveRevision resolves rev to the commit it designates.73 // 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
76 func (s *Server) resolveRevision(ctx context.Context, repo domain.RepoName, rev string) (domain.ResolvedRevision, error) {74 func (s *Server) resolveRevision(ctx context.Context, repo domain.RepoName, rev string) (domain.ResolvedRevision, error) {
77 r, err := domain.ParseRevision(rev)75 r, err := domain.ParseRevision(rev)
78 if err != nil {76 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
54 }54 }
55 55
56 // checksSummary reduces checks to the mark a list shows next to a commit.56 // 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
57 func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary {59 func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary {
58 if len(checks) == 0 {60 if len(checks) == 0 {
59 return ChecksSummary{}61 return ChecksSummary{}
M internal/adapters/web/commit.go +0 -2
@@ -96,8 +96,6 @@ func diffLines(files []DiffFile) (lines int, invisible bool) {
96 96
97 // commit shows a commit: its message, its totals, the changed files and the97 // commit shows a commit: its message, its totals, the changed files and the
98 // diff against the first parent. ?view=split selects the split layout.98 // 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
101 func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {99 func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {
102 sha := r.PathValue("sha")100 sha := r.PathValue("sha")
103 if !isCommitID(sha) {101 if !isCommitID(sha) {
M internal/adapters/web/compare.go +3 -55
@@ -5,7 +5,6 @@ package web
5 import (5 import (
6 "context"6 "context"
7 "net/http"7 "net/http"
8- "slices"
9 8
10 "github.com/a-h/templ"9 "github.com/a-h/templ"
11 10
@@ -13,10 +12,6 @@ import (
13 "bnema.dev/ermite/internal/ports"12 "bnema.dev/ermite/internal/ports"
14 )13 )
15 14
16-// compareDepth bounds the commits read on each side to find where a branch
17-// left its target.
18-const compareDepth = maxComparePages * pageSize
19-
20 // compare shows a branch against the default branch of the repository: the15 // compare shows a branch against the default branch of the repository: the
21 // commits it is ahead by and, for the owner, the form that opens a merge16 // commits it is ahead by and, for the owner, the form that opens a merge
22 // request.17 // request.
@@ -27,8 +22,6 @@ const compareDepth = maxComparePages * pageSize
27 // own diff is then the whole change). Otherwise it lists the commits, which22 // own diff is then the whole change). Otherwise it lists the commits, which
28 // link to their own diffs, and says the whole diff comes with the merge23 // link to their own diffs, and says the whole diff comes with the merge
29 // request.24 // 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
32 func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) {25 func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) {
33 d, err := s.loadRepo(ctx, r.PathValue("repo"))26 d, err := s.loadRepo(ctx, r.PathValue("repo"))
34 if err != nil {27 if err != nil {
@@ -56,7 +49,9 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
56 p.Note = "this is the default branch: there is nothing to compare it with."49 p.Note = "this is the default branch: there is nothing to compare it with."
57 p.AheadText = "0 commits"50 p.AheadText = "0 commits"
58 } else {51 } 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
60 if err != nil {55 if err != nil {
61 return nil, 0, err56 return nil, 0, err
62 }57 }
@@ -139,53 +134,6 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
139 return Compare(l, p), http.StatusOK, nil134 return Compare(l, p), http.StatusOK, nil
140 }135 }
141 136
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-
189 // openRequestFor finds the open merge request with this source and target.137 // openRequestFor finds the open merge request with this source and target.
190 func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) {138 func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) {
191 token := ""139 token := ""
M internal/adapters/web/context.go +1 -1
@@ -30,7 +30,7 @@ type ContextRows struct {
30 // the numbers of the first hidden line on each side, which move together (the30 // the numbers of the first hidden line on each side, which move together (the
31 // lines are unchanged); n the number of lines, 0 for the rest of the file.31 // lines are unchanged); n the number of lines, 0 for the rest of the file.
32 //32 //
33-//hexcheck:ignore suspicious-business-logic-in-adapter cuts a file the use case opened into rows of the diff; no rule is decided here33+//hexcheck:ignore suspicious-business-logic-in-adapter validates diff-context HTTP parameters, rejects binary or oversized files, and caps rendered context rows
34 func (s *Server) diffContext(ctx context.Context, r *http.Request) (templ.Component, int, error) {34 func (s *Server) diffContext(ctx context.Context, r *http.Request) (templ.Component, int, error) {
35 sha := r.PathValue("sha")35 sha := r.PathValue("sha")
36 if !isFullID(sha) {36 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 *
370 370
371 // applyFile puts the colours on the lines of a file; false when the data is371 // applyFile puts the colours on the lines of a file; false when the data is
372 // not the one of this file (another number of lines), nothing is changed.372 // 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
375 func applyFile(d *hlData, f *DiffFile, hunks []domain.Hunk) bool {373 func applyFile(d *hlData, f *DiffFile, hunks []domain.Hunk) bool {
376 n := 0374 n := 0
377 for _, h := range hunks {375 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 {
342 342
343 // issuesOn is domain.ErrNotFound unless the repository has issues on and343 // issuesOn is domain.ErrNotFound unless the repository has issues on and
344 // s.cfg.Issues is set: the same answer as for a repository the visitor cannot see.344 // 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
345 func (s *Server) issuesOn(ctx context.Context, rawName string) error {347 func (s *Server) issuesOn(ctx context.Context, rawName string) error {
346 if s.cfg.Issues == nil {348 if s.cfg.Issues == nil {
347 return domain.ErrNotFound349 return domain.ErrNotFound
@@ -354,7 +356,7 @@ func (s *Server) issuesOn(ctx context.Context, rawName string) error {
354 if err != nil {356 if err != nil {
355 return err357 return err
356 }358 }
357- if v.Settings.Issues.OrDefault() == domain.IssuesOff {359+ if !v.EffectiveSettings().IssuesEnabled() {
358 return domain.ErrNotFound360 return domain.ErrNotFound
359 }361 }
360 return nil362 return nil
M internal/adapters/web/events_test.go +13 -0
@@ -465,3 +465,16 @@ func TestEventsIssuesViewsAreDeniedLikeAMissingRepo(t *testing.T) {
465 resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil)465 resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil)
466 assert.Equal(t, http.StatusOK, resp.StatusCode)466 assert.Equal(t, http.StatusOK, resp.StatusCode)
467 }467 }
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))
356 // banner tells the commits page how many commits its branch got since the356 // banner tells the commits page how many commits its branch got since the
357 // head it lists.357 // head it lists.
358 //358 //
359-//hexcheck:ignore suspicious-business-logic-in-adapter counts the commits of a page for the banner text; display only359+//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
360 func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) {360 func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
361 if l.ref == "" || !isFullID(l.head) {361 if l.ref == "" || !isFullID(l.head) {
362 return nil, nil362 return nil, nil
@@ -376,37 +376,9 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
376 case tip == "":376 case tip == "":
377 return gap, nil // the branch was deleted377 return gap, nil // the branch was deleted
378 }378 }
379- text := ""379+ text, err := l.bannerText(ctx, tip)
380- if tip != l.head {380+ if err != nil {
381- name, err := domain.ParseRepoName(l.repo)381+ return nil, err
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- }
410 }382 }
411 c := LiveSlot("commits-head")383 c := LiveSlot("commits-head")
412 if text != "" {384 if text != "" {
@@ -416,6 +388,45 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment
416 return []fragment{f}, err388 return []fragment{f}, err
417 }389 }
418 390
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+
419 // newCommits is the text of the banner: "1 new commit — reload".430 // newCommits is the text of the banner: "1 new commit — reload".
420 func newCommits(count string, n int) string {431 func newCommits(count string, n int) string {
421 if n == 1 {432 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
97 if err != nil {97 if err != nil {
98 return nil, err98 return nil, err
99 }99 }
100- if d.repo.Settings.Issues.OrDefault() == domain.IssuesOff {100+ if !d.repo.Settings.IssuesEnabled() {
101 return nil, errNotFound101 return nil, errNotFound
102 }102 }
103 return d, nil103 return d, nil
@@ -129,7 +129,7 @@ func (s *Server) issueRow(repo string, i domain.Issue) IssueRow {
129 129
130 // issueList lists the issues of one state, a page at a time.130 // issueList lists the issues of one state, a page at a time.
131 //131 //
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 mode132+//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
133 func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {133 func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
134 d, err := s.issueRepo(ctx, r.PathValue("repo"))134 d, err := s.issueRepo(ctx, r.PathValue("repo"))
135 if err != nil {135 if err != nil {
@@ -179,7 +179,7 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
179 if token != "" {179 if token != "" {
180 p.Newest = link("")180 p.Newest = link("")
181 }181 }
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()) {
183 p.NewHref = issuesBase(d.name()) + "/new"183 p.NewHref = issuesBase(d.name()) + "/new"
184 }184 }
185 185
@@ -216,8 +216,6 @@ func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component
216 }216 }
217 217
218 // issueShow shows an issue with its comments.218 // 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
221 func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {219 func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
222 n, err := mrNumber(r)220 n, err := mrNumber(r)
223 if err != nil {221 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
245 }245 }
246 246
247 // issueSubmittedPage is GET /<repo>/issues/submitted.247 // 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
248 func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {250 func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
249 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)251 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
250 defer cancel()252 defer cancel()
@@ -253,7 +255,7 @@ func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
253 s.fail(ctx, w, r, err)255 s.fail(ctx, w, r, err)
254 return256 return
255 }257 }
256- if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic {258+ if !d.repo.Settings.ShowsIssueSubmissionReceipt() {
257 s.fail(ctx, w, r, errNotFound)259 s.fail(ctx, w, r, errNotFound)
258 return260 return
259 }261 }
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
67 return s.issueTransition(ctx, r, s.cfg.Issues.Reopen)67 return s.issueTransition(ctx, r, s.cfg.Issues.Reopen)
68 }68 }
69 69
70-//hexcheck:ignore suspicious-business-logic-in-adapter closes or reopens through the use case that owns the transition; the form only picks which
71 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) {70 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) {
72 repo := r.PathValue("repo")71 repo := r.PathValue("repo")
73 n, err := number(r)72 n, err := number(r)
M internal/adapters/web/mr.go +96 -88
@@ -62,9 +62,6 @@ const (
62 // maxOpenPages bounds the pages read for the open merge requests, which62 // maxOpenPages bounds the pages read for the open merge requests, which
63 // the first list page shows in full.63 // the first list page shows in full.
64 maxOpenPages = 564 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
68 // railBranches is how many branches the list offers to compare.65 // railBranches is how many branches the list offers to compare.
69 railBranches = 1566 railBranches = 15
70 )67 )
@@ -546,16 +543,14 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component,
546 return s.mrPage(ctx, r, true)543 return s.mrPage(ctx, r, true)
547 }544 }
548 545
549-//hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here
550 func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {546 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+ }
551 n, err := mrNumber(r)550 n, err := mrNumber(r)
552 if err != nil {551 if err != nil {
553 return nil, 0, err552 return nil, 0, err
554 }553 }
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- }
559 d, err := s.loadRepo(ctx, r.PathValue("repo"))554 d, err := s.loadRepo(ctx, r.PathValue("repo"))
560 if err != nil {555 if err != nil {
561 return nil, 0, err556 return nil, 0, err
@@ -574,7 +569,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
574 return nil, 0, errNotFound569 return nil, 0, errNotFound
575 }570 }
576 m := res.MergeRequest571 m := res.MergeRequest
577- now := s.cfg.Now()
578 signed := SignedIn(ctx)572 signed := SignedIn(ctx)
579 open := m.State == domain.MRStateOpen573 open := m.State == domain.MRStateOpen
580 head := m.HeadID.String()574 head := m.HeadID.String()
@@ -595,13 +589,93 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
595 Files: s.hl.files(ctx, res.Diff.Files),589 Files: s.hl.files(ctx, res.Diff.Files),
596 }590 }
597 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))591 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()
598 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {672 if desc := m.Description.String(); strings.TrimSpace(desc) != "" {
599 // Relative links point into the source branch as it is now (its head673 // Relative links point into the source branch as it is now (its head
600 // commit); the heading ids carry their own prefix, apart from the674 // commit); the heading ids carry their own prefix, apart from the
601 // ids of the diff.675 // ids of the diff.
602 h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))676 h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments))
603 if err != nil {677 if err != nil {
604- return nil, 0, err678+ return err
605 }679 }
606 p.Desc = h680 p.Desc = h
607 }681 }
@@ -644,45 +718,30 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
644 p.Checks.Note = ""718 p.Checks.Note = ""
645 }719 }
646 }720 }
647- p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)721+ return nil
648- countNotes(p.Files, res.Comments)722+}
649- if !res.DiffBaseID.IsZero() {
650- lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
651- }
652- p.From = from
653 723
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()
654 // The commits of the request, newest first.728 // The commits of the request, newest first.
655- for _, c := range res.Commits {729+ for i, c := range res.Commits {
656- row := commitRow(d.name(), c, now)730+ row := commitRow(name, c, now)
657 p.Commits = append(p.Commits, MRCommit{731 p.Commits = append(p.Commits, MRCommit{
658 Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,732 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,
660 })734 })
661 }735 }
662 lastRead := res.LastReadID.String()736 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- }
675 if res.CommitsTruncated {737 if res.CommitsTruncated {
676 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."738 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
677 }739 }
678 740
679 // Since the last read: the navigation, and what the page says about it.741 // 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- }
683 if signed && lastRead != "" {742 if signed && lastRead != "" {
684 p.Since = &SinceNav{743 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"),
686 ReadShort: short(lastRead), HeadShort: short(head), Current: since,745 ReadShort: short(lastRead), HeadShort: short(head), Current: since,
687 }746 }
688 }747 }
@@ -702,57 +761,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
702 p.Since.Files = len(p.Files)761 p.Since.Files = len(p.Files)
703 }762 }
704 763
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
756 }764 }
757 765
758 // hasBranch tells whether the repository has the branch.766 // hasBranch tells whether the repository has the branch.
@@ -766,11 +774,11 @@ func (d *repoData) hasBranch(name string) bool {
766 }774 }
767 775
768 // mergeBox is what the owner can do with an open request.776 // 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 {
770 b := &MergeBox{778 b := &MergeBox{
771 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),779 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
772 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",780 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
773- CanMarkRead: head != "" && head != lastRead,781+ CanMarkRead: canMarkRead,
774 CanDeleteBranch: source != d.repo.DefaultBranch,782 CanDeleteBranch: source != d.repo.DefaultBranch,
775 Mergeability: mg,783 Mergeability: mg,
776 }784 }
M internal/adapters/web/mr_files.go +0 -2
@@ -157,8 +157,6 @@ func splitPath(p string) (name, dir string) {
157 // the files of a window with their hunks and their comments, for a157 // the files of a window with their hunks and their comments, for a
158 // placeholder of the page. A base or a head that is not the one of the diff158 // placeholder of the page. A base or a head that is not the one of the diff
159 // any more answers 409: the page says to reload.159 // 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
162 func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {160 func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
163 n, err := mrNumber(r)161 n, err := mrNumber(r)
164 if err != nil {162 if err != nil {
M internal/adapters/web/mr_post.go +0 -2
@@ -295,8 +295,6 @@ func commentID(s string) int64 {
295 }295 }
296 296
297 // headID parses a commit id of a form; the empty string is the zero id.297 // 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
300 func headID(s string) (domain.ObjectID, error) {298 func headID(s string) (domain.ObjectID, error) {
301 if s == "" {299 if s == "" {
302 return domain.ObjectID{}, nil300 return domain.ObjectID{}, nil
M internal/adapters/web/mr_test.go +4 -4
@@ -63,7 +63,7 @@ func TestMergeBoxModes(t *testing.T) {
63 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}63 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
64 s := &Server{}64 s := &Server{}
65 65
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"))
67 require.Len(t, behind.Modes, 3)67 require.Len(t, behind.Modes, 3)
68 assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")68 assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
69 assert.NotEmpty(t, behind.Modes[0].Reason)69 assert.NotEmpty(t, behind.Modes[0].Reason)
@@ -73,7 +73,7 @@ func TestMergeBoxModes(t *testing.T) {
73 assert.True(t, behind.CanMerge())73 assert.True(t, behind.CanMerge())
74 assert.True(t, behind.Signed)74 assert.True(t, behind.Signed)
75 75
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"))
77 assert.False(t, conflict.CanMerge())77 assert.False(t, conflict.CanMerge())
78 assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")78 assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
79 assert.Equal(t, 0, conflict.Checked())79 assert.Equal(t, 0, conflict.Checked())
@@ -84,7 +84,7 @@ func TestMergeBoxModes(t *testing.T) {
84 func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {84 func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
85 t.Parallel()85 t.Parallel()
86 d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}86 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"))
88 assert.False(t, b.Signed, "no commit is created, nothing to explain")88 assert.False(t, b.Signed, "no commit is created, nothing to explain")
89 assert.Len(t, b.Modes, 1)89 assert.Len(t, b.Modes, 1)
90 }90 }
@@ -94,7 +94,7 @@ func TestMergeBoxNamesTheSigningKey(t *testing.T) {
94 key := mocks.NewSigningKeyService(t)94 key := mocks.NewSigningKeyService(t)
95 key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})95 key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
96 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}96 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"))
98 assert.Equal(t, "SHA256:abc", b.Fingerprint)98 assert.Equal(t, "SHA256:abc", b.Fingerprint)
99 }99 }
100 100
M internal/adapters/web/paths.go +0 -2
@@ -33,8 +33,6 @@ type pathList struct {
33 // an ETag that makes it a 304 (answered before the list is loaded, so it never33 // an ETag that makes it a 304 (answered before the list is loaded, so it never
34 // waits for a build); the owner's copy is private and never stored. The ETag34 // waits for a build); the owner's copy is private and never stored. The ETag
35 // differs for the gzip and the plain variant.35 // 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
38 func (s *Server) paths(w http.ResponseWriter, r *http.Request) {36 func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
39 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)37 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
40 defer cancel()38 defer cancel()
M internal/adapters/web/raw.go +0 -2
@@ -31,8 +31,6 @@ const rawCSP = "default-src 'none'; sandbox"
31 // image types (by their bytes, never SVG) as images, and anything else as an31 // image types (by their bytes, never SVG) as images, and anything else as an
32 // attachment of unknown type. A file is never served as HTML or SVG, so what32 // attachment of unknown type. A file is never served as HTML or SVG, so what
33 // a repository holds cannot run in the origin of the forge.33 // 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
36 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {34 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
37 ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)35 ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)
38 defer cancel()36 defer cancel()
M internal/adapters/web/refs.go +0 -4
@@ -15,8 +15,6 @@ import (
15 // that is the short name of a branch or tag wins. A branch wins over a tag of15 // that is the short name of a branch or tag wins. A branch wins over a tag of
16 // the same name. rest has no leading slash and is "" when the path is the ref16 // the same name. rest has no leading slash and is "" when the path is the ref
17 // alone. ok is false when no ref matches; the caller may then try a commit id.17 // 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
20 func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) {18 func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) {
21 names := refsByName(refs)19 names := refsByName(refs)
22 p = strings.Trim(p, "/")20 p = strings.Trim(p, "/")
@@ -97,8 +95,6 @@ func (t target) commitID() string {
97 // resolveCommit sets the full id of a target named by an abbreviated commit id,95 // resolveCommit sets the full id of a target named by an abbreviated commit id,
98 // with one ResolveRevision. The page renders without it (it only lacks the file96 // with one ResolveRevision. The page renders without it (it only lacks the file
99 // finder), so a failure is not one.97 // 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
102 func (s *Server) resolveCommit(ctx context.Context, repo domain.RepoName, t *target) {98 func (s *Server) resolveCommit(ctx context.Context, repo domain.RepoName, t *target) {
103 if !t.detached || isFullID(t.rev) {99 if !t.detached || isFullID(t.rev) {
104 return100 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
44 44
45 // repoRecord reads the repository record only, without Git: its name,45 // repoRecord reads the repository record only, without Git: its name,
46 // visibility and settings (the defaults when it has none).46 // 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
49 func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) {47 func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
50 repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)48 repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
51 if err != nil {49 if err != nil {
52 return domain.Repo{}, err50 return domain.Repo{}, err
53 }51 }
54- if repo.Settings.IsZero() {52+ repo.Settings = repo.EffectiveSettings()
55- repo.Settings = domain.DefaultRepoSettings()
56- }
57 return repo, nil53 return repo, nil
58 }54 }
59 55
@@ -132,7 +128,7 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
132 // switcher on the head ref and the ":" targets. The caller sets the title,128 // switcher on the head ref and the ":" targets. The caller sets the title,
133 // the crumbs, the status and the rail.129 // the crumbs, the status and the rail.
134 //130 //
135-//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here131+//hexcheck:ignore suspicious-business-logic-in-adapter renders repository navigation; the issues tab uses the domain IssuesEnabled predicate
136 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {132 func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
137 l := s.base()133 l := s.base()
138 l.Repo = d.name()134 l.Repo = d.name()
@@ -157,7 +153,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
157 mrs.Count = s.openMRCount(ctx, d.repo.Name)153 mrs.Count = s.openMRCount(ctx, d.repo.Name)
158 l.Sections = append(l.Sections, mrs)154 l.Sections = append(l.Sections, mrs)
159 }155 }
160- if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {156+ if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {
161 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162 }158 }
163 l.Sections = s.withActions(l.Sections, d.name(), section)159 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 {
72 72
73 // tree lists a directory of a ref, with the last commit of each entry. With73 // tree lists a directory of a ref, with the last commit of each entry. With
74 // ?rows it answers the entries alone, for a directory opened in place.74 // ?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
77 func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {75 func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {
78 d, err := s.loadRepo(ctx, r.PathValue("repo"))76 d, err := s.loadRepo(ctx, r.PathValue("repo"))
79 if err != nil {77 if err != nil {
M internal/adapters/webauthn/webauthn.go +0 -2
@@ -137,8 +137,6 @@ func (v *Verifier) BeginLogin() ([]byte, []byte, error) {
137 137
138 // FinishLogin implements ports.PasskeyVerifier. The library compares the user138 // FinishLogin implements ports.PasskeyVerifier. The library compares the user
139 // handle of the response with userHandle.139 // 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
142 func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {140 func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {
143 var session gowebauthn.SessionData141 var session gowebauthn.SessionData
144 if err := json.Unmarshal(ceremony, &session); err != nil {142 if err := json.Unmarshal(ceremony, &session); err != nil {
M internal/app/forge.go +3 -8
@@ -151,8 +151,7 @@ type forge struct {
151 CI *usecase.CI151 CI *usecase.CI
152 // Settings are the secrets, variables and environments of CI.152 // Settings are the secrets, variables and environments of CI.
153 Settings *usecase.CISettings153 Settings *usecase.CISettings
154- // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets154+ // OIDC is the provider of ID tokens, disabled without a secrets key.
155- // key.
156 OIDC *usecase.OIDC155 OIDC *usecase.OIDC
157 API *rpc.API156 API *rpc.API
158 Bus ports.EventBus157 Bus ports.EventBus
@@ -313,7 +312,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
313 opts.ci(&ciCfg)312 opts.ci(&ciCfg)
314 }313 }
315 f.CI = usecase.NewCI(ctx, ciCfg)314 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())
317 f.Repos.WithCIRunCleaner(f.CI)316 f.Repos.WithCIRunCleaner(f.CI)
318 f.CI.Start()317 f.CI.Start()
319 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{318 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
394 if f.Passkeys != nil {393 if f.Passkeys != nil {
395 apiPasskeys = f.Passkeys394 apiPasskeys = f.Passkeys
396 }395 }
397- var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys
398- if f.OIDC != nil {
399- apiOIDC = f.OIDC
400- }
401 var jobArtifacts ports.JobArtifactService396 var jobArtifacts ports.JobArtifactService
402 var artifacts ports.ArtifactService397 var artifacts ports.ArtifactService
403 if cfg.CIArtifacts {398 if cfg.CIArtifacts {
@@ -421,7 +416,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
421 Secrets: f.Settings,416 Secrets: f.Settings,
422 Variables: f.Settings,417 Variables: f.Settings,
423 Environments: f.Settings,418 Environments: f.Settings,
424- OIDCKeys: apiOIDC,419+ OIDCKeys: f.OIDC,
425 JobArtifacts: jobArtifacts,420 JobArtifacts: jobArtifacts,
426 Artifacts: artifacts,421 Artifacts: artifacts,
427 Version: serverVersion(),422 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) {
569 }569 }
570 return n, false570 return n, false
571 }571 }
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 {
92 }92 }
93 return true93 return true
94 }94 }
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) {
60 })60 })
61 }61 }
62 }62 }
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 {
407 Alternates string407 Alternates string
408 }408 }
409 409
410-// WorkflowGuard finds the changes of a push to the workflow files of a410+// PushTreeReader reads, for a push still in quarantine, the objects the
411-// repository.411+// workflow policy (domain.WorkflowChangeOf) judges. It knows nothing of the
412-type WorkflowGuard interface {412+// policy: it peels, classifies, walks and compares trees.
413- // ChangedWorkflow returns the first path under .github/workflows/ that the413+type PushTreeReader interface {
414- // updates change, "" for none. Two checks, as GitHub judges the token of a414+ // PushSubtrees reads what a push brings, and the entry at path (a
415- // job: (1) every commit the push brings (those no ref has yet) is compared415+ // directory path without leading or trailing slash, e.g.
416- // with each of its parents (a root commit with nothing) and is a change when416+ // domain.WorkflowDir) in each object concerned.
417- // it has workflows that differ from every parent's; (2) the workflows a ref417+ //
418- // ends with must be its old ones or none for an update, and the default418+ // Updates that delete (New == ZeroOID) have no objects: they are skipped.
419- // branch head's or none for a new ref (none when there is no head). A419+ // For every other update, Old (unless ZeroOID) and New are peeled
420- // workflows directory that disappears is no change, and a deleted ref changes420+ // (`<oid>^{}`): the result is in Ends keyed by the oid as given. An end
421- // nothing. objects are those of the push in progress; a directory that is not421+ // that is missing, malformed, or peels to neither commit, tree nor blob is
422- // inside the repository is refused. A push beyond the bounds of the check422+ // an error matching domain.ErrInvalid.
423- // (commits, parents) is an error that matches domain.ErrInvalid.423+ //
424- ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects) (path string, err error)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)
425 }451 }
426 452
427 // --- inbound ---453 // --- inbound ---
M internal/ports/dto.go +28 -0
@@ -249,9 +249,27 @@ type IssueDetail struct {
249 Comments []domain.IssueComment249 Comments []domain.IssueComment
250 }250 }
251 251
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+
252 // MergeRequestDetail is a merge request with its review data.266 // MergeRequestDetail is a merge request with its review data.
253 type MergeRequestDetail struct {267 type MergeRequestDetail struct {
254 MergeRequest domain.MergeRequest268 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
255 // Commits are the commits of the head that the target lacks, newest273 // Commits are the commits of the head that the target lacks, newest
256 // first, at most MaxMergeRequestCommits.274 // first, at most MaxMergeRequestCommits.
257 Commits []domain.Commit275 Commits []domain.Commit
@@ -357,3 +375,13 @@ type InboxOverview struct {
357 // whatever the filter: they feed the filter itself.375 // whatever the filter: they feed the filter itself.
358 Repos []domain.RepoName376 Repos []domain.RepoName
359 }377 }
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
210 return _c210 return _c
211 }211 }
212 212
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+
213 // CountOpen provides a mock function for the type MergeRequestService297 // CountOpen provides a mock function for the type MergeRequestService
214 func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {298 func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
215 ret := _mock.Called(ctx, p, name)299 ret := _mock.Called(ctx, p, name)
@@ -1020,6 +1104,90 @@ func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx conte
1020 return _c1104 return _c
1021 }1105 }
1022 1106
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+
1023 // ResolveComment provides a mock function for the type MergeRequestService1191 // ResolveComment provides a mock function for the type MergeRequestService
1024 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {1192 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {
1025 ret := _mock.Called(ctx, p, name, number, commentID, resolved)1193 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 {
48 return &OIDCKeyService_Expecter{mock: &_m.Mock}48 return &OIDCKeyService_Expecter{mock: &_m.Mock}
49 }49 }
50 50
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+
51 // RotateKey provides a mock function for the type OIDCKeyService95 // RotateKey provides a mock function for the type OIDCKeyService
52 func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {96 func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
53 ret := _mock.Called(ctx, p)97 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 {
53 // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC53 // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC
54 // implements it.54 // implements it.
55 type OIDCKeyService interface {55 type OIDCKeyService interface {
56+ // Enabled reports whether the server issues ID tokens.
57+ Enabled() bool
56 // RotateKey replaces the signing key by a new one. It needs owner rights.58 // RotateKey replaces the signing key by a new one. It needs owner rights.
57 RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error)59 RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error)
58 }60 }
M internal/ports/services.go +4 -0
@@ -109,6 +109,10 @@ type EventService interface {
109 // MergeRequestService is the merge request use cases. *usecase.MergeRequests109 // MergeRequestService is the merge request use cases. *usecase.MergeRequests
110 // implements it.110 // implements it.
111 type MergeRequestService interface {111 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)
112 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)116 Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error)
113 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)117 List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error)
114 Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error)118 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 {
106 // read when an action of another repository is resolved for the run. Nil106 // read when an action of another repository is resolved for the run. Nil
107 // reads nothing for a token (fail closed).107 // reads nothing for a token (fail closed).
108 Tokens ports.TokenStore108 Tokens ports.TokenStore
109- // OIDC signs the ID tokens of the jobs (CI-4); nil issues none: jobs get no109+ // OIDC signs the ID tokens of the jobs (CI-4); nil or disabled issues none: jobs get no
110 // token endpoint, and the provider is not served.110 // token endpoint, and the provider is not served.
111 OIDC *OIDC111 OIDC *OIDC
112 // Settings resolves the variables and secrets of a job (CI-3). Nil gives112 // 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)
31 // server issues none. j must be assigned (its AssignedAt is set).31 // server issues none. j must be assigned (its AssignedAt is set).
32 func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {32 func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
33 c := rs.c33 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) {
35 return ""35 return ""
36 }36 }
37 now := c.now()37 now := c.now()
@@ -48,21 +48,15 @@ func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
48 }48 }
49 49
50 // OIDCEnabled reports whether the server issues ID tokens.50 // 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() }
52 52
53 // OIDCDiscovery returns the OpenID Provider configuration.53 // OIDCDiscovery returns the OpenID Provider configuration.
54 func (c *CI) OIDCDiscovery() ([]byte, error) {54 func (c *CI) OIDCDiscovery() ([]byte, error) {
55- if c.cfg.OIDC == nil {
56- return nil, domain.ErrNotFound
57- }
58 return c.cfg.OIDC.Discovery()55 return c.cfg.OIDC.Discovery()
59 }56 }
60 57
61 // OIDCKeys returns the JWKS.58 // OIDCKeys returns the JWKS.
62 func (c *CI) OIDCKeys() ([]byte, error) {59 func (c *CI) OIDCKeys() ([]byte, error) {
63- if c.cfg.OIDC == nil {
64- return nil, domain.ErrNotFound
65- }
66 return c.cfg.OIDC.JWKS()60 return c.cfg.OIDC.JWKS()
67 }61 }
68 62
@@ -73,7 +67,7 @@ func (c *CI) OIDCKeys() ([]byte, error) {
73 // "https://github.com/<owner>".67 // "https://github.com/<owner>".
74 func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) {68 func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) {
75 o := c.cfg.OIDC69 o := c.cfg.OIDC
76- if o == nil {70+ if !o.Enabled() {
77 return "", domain.ErrNotFound71 return "", domain.ErrNotFound
78 }72 }
79 if len(audience) > domain.MaxOIDCAudienceLen {73 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,
681 // createRun stores a run. The run of an open merge request is stored under the681 // createRun stores a run. The run of an open merge request is stored under the
682 // gate, so that one that is closed meanwhile gets none (errPullClosed).682 // gate, so that one that is closed meanwhile gets none (errPullClosed).
683 func (c *CI) createRun(ctx context.Context, f pushFacts, repo domain.RepoName, wfPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {683 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+
684 if f.pull == nil || f.pull.action == domain.PullClosed {692 if f.pull == nil || f.pull.action == domain.PullClosed {
685 return c.cfg.Store.CreateRun(ctx, repo, wfPath, build)693 return c.cfg.Store.CreateRun(ctx, repo, wfPath, build)
686 }694 }
687 var d ports.CIRunDraft695 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 {
689 var err error697 var err error
690 d, err = c.cfg.Store.CreateRun(ctx, repo, wfPath, build)698 d, err = c.cfg.Store.CreateRun(ctx, repo, wfPath, build)
691 return err699 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
281 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),281 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),
282 Cache: c.cacheScope(rs.run, rs.src.GithubJSON),282 Cache: c.cacheScope(rs.run, rs.src.GithubJSON),
283 }283 }
284- if sec.idToken != "" && c.cfg.OIDC != nil {284+ if sec.idToken != "" && c.cfg.OIDC.Enabled() {
285 out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken285 out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken
286 }286 }
287 return out287 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
573 case err != nil:573 case err != nil:
574 return fmt.Errorf("check access to %q: %w", name, err)574 return fmt.Errorf("check access to %q: %w", name, err)
575 }575 }
576- if repo.Settings.Issues.OrDefault() == domain.IssuesOff {576+ if !repo.Settings.IssuesEnabled() {
577 return nil577 return nil
578 }578 }
579 return send(out)579 return send(out)
@@ -613,7 +613,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName
613 if !canRead(w.p, repo) {613 if !canRead(w.p, repo) {
614 return nil614 return nil
615 }615 }
616- if isIssueEvent(be.ev) && repo.Settings.Issues.OrDefault() == domain.IssuesOff {616+ if isIssueEvent(be.ev) && !repo.Settings.IssuesEnabled() {
617 return nil // issues were turned off since: a replay must not show them617 return nil // issues were turned off since: a replay must not show them
618 }618 }
619 if withRepo {619 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
74 return fmt.Errorf("read repository %q: %w", name, err)74 return fmt.Errorf("read repository %q: %w", name, err)
75 default:75 default:
76 exists[name] = true76 exists[name] = true
77- issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff77+ issuesOn[name] = repo.Settings.IssuesEnabled()
78 }78 }
79 return nil79 return nil
80 }80 }
M internal/usecase/issue_moderation.go +1 -1
@@ -71,7 +71,7 @@ func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo
71 if err != nil {71 if err != nil {
72 return domain.Issue{}, err72 return domain.Issue{}, err
73 }73 }
74- if r.Settings.Issues.OrDefault() == domain.IssuesOff {74+ if !r.Settings.IssuesEnabled() {
75 return domain.Issue{}, domain.ErrNotFound75 return domain.Issue{}, domain.ErrNotFound
76 }76 }
77 now := m.clock.Now().UTC()77 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
162 }162 }
163 return err163 return err
164 }164 }
165- if !canRead(domain.Anonymous(), r) || r.Settings.Issues.OrDefault() != domain.IssuesPublic {165+ if !canRead(domain.Anonymous(), r) || !r.Settings.AcceptsAnonymousIssues() {
166 return domain.ErrNotFound166 return domain.ErrNotFound
167 }167 }
168 return nil168 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
59 if err != nil {59 if err != nil {
60 return domain.Repo{}, err60 return domain.Repo{}, err
61 }61 }
62- if repo.Settings.Issues.OrDefault() == domain.IssuesOff {62+ if !repo.Settings.IssuesEnabled() {
63 return domain.Repo{}, domain.ErrNotFound63 return domain.Repo{}, domain.ErrNotFound
64 }64 }
65 return repo, nil65 return repo, nil
M internal/usecase/mergerequests.go +55 -5
@@ -21,7 +21,7 @@ import (
21 )21 )
22 22
23 // MaxMergeRequestCommits is the most commits GetMergeRequest returns.23 // MaxMergeRequestCommits is the most commits GetMergeRequest returns.
24-const MaxMergeRequestCommits = 10024+const MaxMergeRequestCommits = ports.MaxMergeRequestCommits
25 25
26 // maxWriteRetries bounds the retries of a write that loses a race with another26 // maxWriteRetries bounds the retries of a write that loses a race with another
27 // write of the same merge request. Each lost race means another writer won, so27 // 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
546 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {546 if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil {
547 return ports.MergeRequestDetail{}, err547 return ports.MergeRequestDetail{}, err
548 }548 }
549+ d.HeadUnread = !mr.HeadID.IsZero() && mr.HeadID != d.LastReadID
549 550
550 exclude, base, err := m.rangeOf(ctx, mr)551 exclude, base, err := m.rangeOf(ctx, mr)
551 if err != nil {552 if err != nil {
@@ -574,6 +575,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
574 return ports.MergeRequestDetail{}, err575 return ports.MergeRequestDetail{}, err
575 }576 }
576 verifyCommits(ctx, m.verifier, name, d.Commits)577 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+ }
577 }586 }
578 if opts.SinceLastRead && !d.LastReadID.IsZero() {587 if opts.SinceLastRead && !d.LastReadID.IsZero() {
579 switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); {588 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
1046 }1055 }
1047 return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)1056 return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
1048 }1057 }
1049- settings := repo.Settings1058+ settings := repo.EffectiveSettings()
1050- if settings.IsZero() {
1051- settings = domain.DefaultRepoSettings()
1052- }
1053 if !settings.Allows(mode) {1059 if !settings.Allows(mode) {
1054 return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)1060 return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
1055 }1061 }
@@ -1438,3 +1444,47 @@ func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, num
1438 }1444 }
1439 return root, nil1445 return root, nil
1440 }1446 }
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) {
233 })233 })
234 }234 }
235 }235 }
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 {
74 var _ ports.OIDCKeyService = (*OIDC)(nil)74 var _ ports.OIDCKeyService = (*OIDC)(nil)
75 75
76 // NewOIDC loads the signing keys, generates the first one when there is none,76 // 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 nil77+// and forgets the retired keys whose publication is over. With cfg.Sealer nil
78-// without error when cfg.Sealer is nil: the server issues no ID tokens. A78+// it returns a disabled service: no ID tokens are issued. A
79 // stored key that does not open with the secrets key is an error: the server79 // stored key that does not open with the secrets key is an error: the server
80 // must not start (the secrets key check already refuses another key, so this80 // must not start (the secrets key check already refuses another key, so this
81 // is a damaged record).81 // is a damaged record).
@@ -83,7 +83,7 @@ func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) {
83 log := zerowrap.FromCtx(ctx)83 log := zerowrap.FromCtx(ctx)
84 if cfg.Sealer == nil {84 if cfg.Sealer == nil {
85 log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens")85 log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens")
86- return nil, nil86+ return &OIDC{}, nil
87 }87 }
88 if cfg.NewKey == nil {88 if cfg.NewKey == nil {
89 cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) }89 cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) }
@@ -180,6 +180,13 @@ func (o *OIDC) signing() (oidcKey, bool) {
180 // and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose180 // and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose
181 // publication is over are deleted with it. Only the owner may rotate.181 // publication is over are deleted with it. Only the owner may rotate.
182 func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {182 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+
183 switch {190 switch {
184 case p.IsAnonymous():191 case p.IsAnonymous():
185 return ports.OIDCRotation{}, domain.ErrUnauthenticated192 return ports.OIDCRotation{}, domain.ErrUnauthenticated
@@ -234,6 +241,9 @@ func publicJWK(kid string, pub *rsa.PublicKey) jwk {
234 241
235 // JWKS returns the JSON of the published keys.242 // JWKS returns the JSON of the published keys.
236 func (o *OIDC) JWKS() ([]byte, error) {243 func (o *OIDC) JWKS() ([]byte, error) {
244+ if !o.Enabled() {
245+ return nil, domain.ErrNotFound
246+ }
237 now := o.cfg.Clock.Now().UTC()247 now := o.cfg.Clock.Now().UTC()
238 o.mu.RLock()248 o.mu.RLock()
239 defer o.mu.RUnlock()249 defer o.mu.RUnlock()
@@ -258,6 +268,9 @@ var oidcClaims = []string{
258 // Discovery returns the JSON of the OpenID Provider configuration, in the268 // Discovery returns the JSON of the OpenID Provider configuration, in the
259 // shape of GitHub's.269 // shape of GitHub's.
260 func (o *OIDC) Discovery() ([]byte, error) {270 func (o *OIDC) Discovery() ([]byte, error) {
271+ if !o.Enabled() {
272+ return nil, domain.ErrNotFound
273+ }
261 return json.Marshal(ordered{274 return json.Marshal(ordered{
262 {"issuer", o.issuer},275 {"issuer", o.issuer},
263 {"jwks_uri", o.issuer + "/.well-known/jwks"},276 {"jwks_uri", o.issuer + "/.well-known/jwks"},
@@ -303,3 +316,14 @@ func thumbprint(pub *rsa.PublicKey) string {
303 }316 }
304 317
305 func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }318 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) {
123 f := newOIDCFixture(t)123 f := newOIDCFixture(t)
124 o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"})124 o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"})
125 require.NoError(t, err)125 require.NoError(t, err)
126- assert.Nil(t, o)126+ require.NotNil(t, o)
127+ assert.False(t, o.Enabled())
127 keys, err := f.store.ListOIDCKeys(context.Background())128 keys, err := f.store.ListOIDCKeys(context.Background())
128 require.NoError(t, err)129 require.NoError(t, err)
129 assert.Empty(t, keys, "no key is generated")130 assert.Empty(t, keys, "no key is generated")
@@ -338,3 +339,45 @@ func TestIDTokenClaimsPerEvent(t *testing.T) {
338 })339 })
339 }340 }
340 }341 }
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 {
26 auth ports.Revalidator26 auth ports.Revalidator
27 fresh ports.RepoFreshener // nil: the reader needs no telling27 fresh ports.RepoFreshener // nil: the reader needs no telling
28 ci ports.WorkflowTrigger // nil: no CI28 ci ports.WorkflowTrigger // nil: no CI
29- wf ports.WorkflowGuard // nil: pushes by job tokens are refused29+ trees ports.PushTreeReader // nil: pushes by job tokens are refused
30 tips ports.CIRefTipStore // nil: who pushes a ref is not recorded30 tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
31 mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked31 mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
32 }32 }
@@ -73,11 +73,11 @@ func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes {
73 return u73 return u
74 }74 }
75 75
76-// WithWorkflowGuard lets Check tell whether a push changes workflow files, which76+// WithPushTreeReader lets Check tell whether a push changes workflow files, which
77 // a push by a job token may not do. Without it a push by a job token is77 // a push by a job token may not do. Without it a push by a job token is
78 // refused, as it cannot be judged. Call it before the first push; it returns u.78 // refused, as it cannot be judged. Call it before the first push; it returns u.
79-func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes {79+func (u *Pushes) WithPushTreeReader(g ports.PushTreeReader) *Pushes {
80- u.wf = g80+ u.trees = g
81 return u81 return u
82 }82 }
83 83
@@ -199,20 +199,36 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
199 const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"199 const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"
200 200
201 func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {201 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 {
203 return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)203 return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)
204 }204 }
205- path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects)205+ t, err := u.trees.PushSubtrees(ctx, name, updates, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds())
206- switch {206+ if err != nil {
207- case errors.Is(err, domain.ErrInvalid):207+ return workflowReadError(name, err)
208- // A push the guard cannot judge within its bounds: refused, with the reason.208+ }
209+ c, found, err := domain.WorkflowChangeOf(updates, t)
210+ if err != nil {
209 return fmt.Errorf("%w: %v", domain.ErrRejected, err)211 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)
214 }212 }
215- return nil213+ 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)
216 }232 }
217 233
218 // Record is the post-receive step of an accepted push: it sets pushed_at and active_at,234 // 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 (
6 "context"6 "context"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9+ "strings"
9 "testing"10 "testing"
10 "time"11 "time"
11 12
@@ -440,16 +441,41 @@ func (f *pushFixture) check(ctx context.Context, p domain.Principal, n domain.Re
440 return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{})441 return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{})
441 }442 }
442 443
443-type fakeGuard struct {444+type fakePushTrees struct {
444- path string445+ path string
445- err error446+ err error
446- got []domain.RefUpdate447+ diffErr error
447- objs ports.PushObjects448+ 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
448 }456 }
449 457
450-func (g *fakeGuard) ChangedWorkflow(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects) (string, error) {458+func (g *fakePushTrees) PushSubtrees(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) {
451- g.got, g.objs = ups, o459+ g.got, g.objs, g.subtreePath, g.bounds = ups, o, path, bounds
452- return g.path, g.err460+ 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
453 }479 }
454 480
455 // A push by the token of a job is judged on the workflows it changes, and by it481 // 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) {
464 job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}})490 job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}})
465 readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp})491 readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp})
466 492
467- build := func(t *testing.T, g *fakeGuard) *pushFixture {493+ build := func(t *testing.T, g *fakePushTrees) *pushFixture {
468 f := newPushFixture(t)494 f := newPushFixture(t)
469 if g != nil {495 if g != nil {
470- f.pushes.WithWorkflowGuard(g)496+ f.pushes.WithPushTreeReader(g)
471 }497 }
472 f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe()498 f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe()
473 return f499 return f
@@ -475,29 +501,65 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) {
475 501
476 t.Run("no workflow touched", func(t *testing.T) {502 t.Run("no workflow touched", func(t *testing.T) {
477 t.Parallel()503 t.Parallel()
478- g := &fakeGuard{}504+ g := &fakePushTrees{}
479 f := build(t, g)505 f := build(t, g)
480 require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs))506 require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs))
481 assert.Equal(t, ups, g.got, "every ref is judged")507 assert.Equal(t, ups, g.got, "every ref is judged")
482 assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine")508 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)
483 })512 })
484 t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) {513 t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) {
485 t.Parallel()514 t.Parallel()
486- f := build(t, &fakeGuard{path: ".github/workflows/ci.yml"})515+ f := build(t, &fakePushTrees{path: ".github/workflows/ci.yml"})
487 err := f.pushes.Check(ctx, job, n, ups, objs)516 err := f.pushes.Check(ctx, job, n, ups, objs)
488 require.ErrorIs(t, err, domain.ErrRejected)517 require.ErrorIs(t, err, domain.ErrRejected)
489 assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission")518 assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission")
490 })519 })
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+ })
491 t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) {553 t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) {
492 t.Parallel()554 t.Parallel()
493- f := build(t, &fakeGuard{err: errors.New("git broke")})555+ f := build(t, &fakePushTrees{err: errors.New("git broke")})
494 err := f.pushes.Check(ctx, job, n, ups, objs)556 err := f.pushes.Check(ctx, job, n, ups, objs)
495 require.Error(t, err)557 require.Error(t, err)
496 assert.NotErrorIs(t, err, domain.ErrRejected)558 assert.NotErrorIs(t, err, domain.ErrRejected)
497 })559 })
498 t.Run("a push beyond the guard's bounds is refused as a policy, with the reason", func(t *testing.T) {560 t.Run("a push beyond the guard's bounds is refused as a policy, with the reason", func(t *testing.T) {
499 t.Parallel()561 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)})
501 err := f.pushes.Check(ctx, job, n, ups, objs)563 err := f.pushes.Check(ctx, job, n, ups, objs)
502 require.ErrorIs(t, err, domain.ErrRejected)564 require.ErrorIs(t, err, domain.ErrRejected)
503 assert.Contains(t, err.Error(), "more than 10000 commits")565 assert.Contains(t, err.Error(), "more than 10000 commits")
@@ -509,19 +571,19 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) {
509 })571 })
510 t.Run("a job token without contents: write cannot push at all", func(t *testing.T) {572 t.Run("a job token without contents: write cannot push at all", func(t *testing.T) {
511 t.Parallel()573 t.Parallel()
512- f := build(t, &fakeGuard{})574+ f := build(t, &fakePushTrees{})
513 assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected)575 assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected)
514 })576 })
515 t.Run("the owner is not asked", func(t *testing.T) {577 t.Run("the owner is not asked", func(t *testing.T) {
516 t.Parallel()578 t.Parallel()
517- g := &fakeGuard{path: ".github/workflows/ci.yml"}579+ g := &fakePushTrees{path: ".github/workflows/ci.yml"}
518 f := build(t, g)580 f := build(t, g)
519 require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs))581 require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs))
520 assert.Nil(t, g.got)582 assert.Nil(t, g.got)
521 })583 })
522 t.Run("a job token never creates a repository on push", func(t *testing.T) {584 t.Run("a job token never creates a repository on push", func(t *testing.T) {
523 t.Parallel()585 t.Parallel()
524- f := build(t, &fakeGuard{})586+ f := build(t, &fakePushTrees{})
525 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)587 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
526 assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound)588 assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound)
527 })589 })
M internal/usecase/repos_write.go +1 -3
@@ -165,9 +165,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
165 if params.Visibility != nil {165 if params.Visibility != nil {
166 repo.Visibility = *params.Visibility166 repo.Visibility = *params.Visibility
167 }167 }
168- if repo.Settings.IsZero() {168+ repo.Settings = repo.EffectiveSettings()
169- repo.Settings = domain.DefaultRepoSettings()
170- }
171 if params.AllowedMergeModes != nil {169 if params.AllowedMergeModes != nil {
172 repo.Settings.AllowedMergeModes = *params.AllowedMergeModes170 repo.Settings.AllowedMergeModes = *params.AllowedMergeModes
173 }171 }

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