detached 5344be5

merge: hexmove/workflow into chore/hexcheck-v0.4.0

commit
5344be58ed42bce473cef478a77f4f97e2dd593c
author
brice · · 2026-10-19 19:37 UTC
committer
brice · · 2026-10-19 20:01 UTC
merge of
9f9eee5 + a1ec1af · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +1150 -835
M internal/adapters/gitcli/export_test.go +0 -6
@@ -110,15 +110,9 @@ func (g *Git) CatFileWaiting() int {
110110 return n
111111 }
112112
113-// MaxPushedCommits is the bound of the commits of a push, for the tests.
114-const MaxPushedCommits = maxPushedCommits
115-
116113 // ParseRevList reads rev-list output with the bounds of the guard replaced, and
117114 // returns the number of commits kept.
118115 func ParseRevList(r io.Reader, maxCommits, maxIDs int) (int, error) {
119116 c, err := parseRevList(bufio.NewReaderSize(r, 64*1024), maxCommits, maxIDs)
120117 return len(c), err
121118 }
122-
123-// MaxPushedObjects is the bound of the ids of the commits of a push.
124-const MaxPushedObjects = maxPushedObjects
@@ -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
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.PushedEnd{}, 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.PushedEnd) []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.PushedEnd{Type: domain.ObjectType(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.PushedEnd) []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].Type != domain.ObjectBlob {
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].Type != domain.ObjectBlob {
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+}
@@ -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.PushedEnd{}, 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.PushedEnd) []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.PushedEnd{Type: domain.ObjectType(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.PushedEnd) []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].Type != domain.ObjectBlob {
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].Type != domain.ObjectBlob {
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 +131 -217 renamed, 52% similar
@@ -51,7 +51,7 @@ func TestChangedWorkflow(t *testing.T) {
5151 return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"}
5252 }
5353 changed := func(ups ...domain.RefUpdate) string {
54- p, err := g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
54+ p, err := changedWorkflow(g, ctx, f.Name, ups, ports.PushObjects{})
5555 require.NoError(t, err)
5656 return p
5757 }
@@ -68,11 +68,11 @@ func TestChangedWorkflow(t *testing.T) {
6868 assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one")
6969 assert.Equal(t, "", changed(), "no update")
7070
71- _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{})
71+ _, err := changedWorkflow(g, ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{})
7272 assert.Error(t, err, "an object git does not have is a failure, not a pass")
7373 }
7474
75-func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) {
75+func TestPushSubtreesReadsTheQuarantineOfThePushOnly(t *testing.T) {
7676 g, data := newGit(t)
7777 ctx := context.Background()
7878 f := readertest.NewFixture(t, data, "proj")
@@ -90,23 +90,23 @@ func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) {
9090 "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"},
9191 "alternates without a dir": {Alternates: objects},
9292 } {
93- _, err := g.ChangedWorkflow(ctx, f.Name, up, o)
93+ _, err := g.PushSubtrees(ctx, f.Name, up, o, domain.WorkflowDir, domain.DefaultPushWalkBounds())
9494 assert.ErrorIs(t, err, domain.ErrInvalid, name)
9595 }
9696 // An existing directory inside the repository is accepted as a location
9797 // (the object is missing: a refusal of the object, not of the path).
9898 require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700))
99- _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects})
99+ _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
100100 require.Error(t, err)
101101 assert.NotContains(t, err.Error(), "outside the repository")
102102 // One that does not exist is refused.
103- _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"})
103+ _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
104104 assert.ErrorIs(t, err, domain.ErrInvalid)
105105 }
106106
107107 // A symbolic link in the way cannot take the objects of a push out of the
108108 // repository, and a data directory reached through links still works.
109-func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) {
109+func TestPushSubtreesResolvesSymbolicLinks(t *testing.T) {
110110 g, data := newGit(t)
111111 ctx := context.Background()
112112 f := readertest.NewFixture(t, data, "proj")
@@ -119,20 +119,20 @@ func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) {
119119 // Inside objects/, a link to elsewhere is outside once resolved.
120120 elsewhere := t.TempDir()
121121 require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link")))
122- _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")})
122+ _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
123123 assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/")
124- _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")})
124+ _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
125125 assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/")
126126
127127 // A link to somewhere inside objects/ is fine.
128128 require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700))
129129 require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link")))
130- _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")})
130+ _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
131131 require.Error(t, err)
132132 assert.NotContains(t, err.Error(), "outside the repository")
133133 }
134134
135-func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) {
135+func TestPushSubtreesThroughALinkedDataDirectory(t *testing.T) {
136136 _, data := newGit(t)
137137 ctx := context.Background()
138138 f := readertest.NewFixture(t, data, "proj")
@@ -152,10 +152,10 @@ func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) {
152152 q := filepath.Join(f.Bare, "objects", "incoming-9")
153153 require.NoError(t, os.Mkdir(q, 0o700))
154154 for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} {
155- p, err := g2.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}},
156- ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")})
155+ p, err := g2.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}},
156+ ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
157157 require.NoError(t, err, dir)
158- assert.Equal(t, ".github/workflows/ci.yml", p, dir)
158+ assert.Equal(t, domain.EntryDirectory, p.Subtrees[oidOf(t, wf).String()].Kind, dir)
159159 }
160160 }
161161
@@ -182,17 +182,7 @@ func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...strin
182182 return strings.TrimSpace(string(out))
183183 }
184184
185-// withWorkflows is a commit whose .github/workflows is the tree given.
186-func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string {
187- t.Helper()
188- gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows")
189- return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github"))
190-}
191-
192-// The trees of a push are the pusher's: a workflows directory that fans out
193-// into ten million paths is answered with its first path, at once, by a git
194-// that is stopped, without the diff ever being held in memory.
195-func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
185+func TestFirstDifferentEntryDoesNotBufferTheDiff(t *testing.T) {
196186 g, data := newGit(t)
197187 ctx := context.Background()
198188 f := readertest.NewFixture(t, data, "proj")
@@ -206,17 +196,17 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
206196 }
207197 level = treeOf(t, f, entries...)
208198 }
209- amp := withWorkflows(t, f, level)
199+ amp := level
210200
211201 var before, after runtime.MemStats
212202 runtime.GC()
213203 runtime.ReadMemStats(&before)
214204 start := time.Now()
215- p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{})
205+ p, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level))
216206 took := time.Since(start)
217207 runtime.ReadMemStats(&after)
218208 require.NoError(t, err)
219- assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p)
209+ assert.True(t, strings.HasPrefix(p, "d"), p)
220210 assert.Less(t, took, 2*time.Second, "answered from the first path")
221211 assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory")
222212 // And no git is left behind.
@@ -225,69 +215,50 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
225215 }
226216
227217 // A path longer than the bound fails closed: the push is refused, not read on.
228-func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) {
218+func TestFirstDifferentEntryRefusesAnEndlessPath(t *testing.T) {
229219 g, data := newGit(t)
230220 ctx := context.Background()
231221 f := readertest.NewFixture(t, data, "proj")
232222 t.Cleanup(func() { _ = g.Close(ctx) })
233223 level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf")
234224 level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes
235- _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{})
225+ _, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level))
236226 assert.ErrorIs(t, err, domain.ErrInvalid)
237227 }
238228
239229 // A tag may point to a blob, a tree or a commit; only the commits and trees can
240230 // hold a workflow, and an object that is not there refuses the push.
241-func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) {
231+func TestPushSubtreesPeelsAndClassifiesBothEnds(t *testing.T) {
242232 g, data := newGit(t)
243233 ctx := context.Background()
244234 f := readertest.NewFixture(t, data, "proj")
245235 t.Cleanup(func() { _ = g.Close(ctx) })
246236 f.Write("README", "r\n")
247- plain := f.Commit("plain")
248- f.Write(".github/workflows/ci.yml", "on: push\n")
249- wf := f.Commit("workflow")
250- f.Write(".github/workflows/ci.yml", "on: [push]\n")
251- f.Commit("the default branch moves on to other workflows") // HEAD is not wf
237+ plain := oidOf(t, f.Commit("plain")).String()
252238 blob := f.WriteObject("blob", "hello\n")
253- annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a <a@b> 1 +0000\n\nm\n")
254- annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n")
255- rootTree := f.Git("rev-parse", wf+"^{tree}")
256- ref := func(old, new string) []domain.RefUpdate {
257- return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}}
258- }
259- changed := func(ups []domain.RefUpdate) (string, error) {
260- return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
239+ tag := func(id, kind string) string {
240+ return f.WriteObject("tag", "object "+id+"\ntype "+kind+"\ntag t\ntagger a <a@b> 1 +0000\n\nm\n")
261241 }
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- }
272- for name, ups := range map[string][]domain.RefUpdate{
273- "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()),
274- "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()),
275- "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF),
276- "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree),
277- "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF),
278- } {
279- p, err := changed(ups)
280- require.NoError(t, err, name)
281- assert.Equal(t, ".github/workflows/ci.yml", p, name)
242+ tree := f.Git("rev-parse", plain+"^{tree}")
243+ for _, r := range []struct {
244+ id string
245+ kind domain.ObjectType
246+ }{{plain, domain.ObjectCommit}, {tag(plain, "commit"), domain.ObjectCommit}, {blob, domain.ObjectBlob}, {tag(blob, "blob"), domain.ObjectBlob}, {tree, domain.ObjectTree}, {tag(tag(plain, "commit"), "tag"), domain.ObjectCommit}} {
247+ got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: r.id}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
248+ require.NoError(t, err)
249+ assert.Equal(t, r.kind, got.Ends[r.id].Type)
250+ if r.kind == domain.ObjectBlob {
251+ assert.NotContains(t, got.Subtrees, r.id)
252+ }
282253 }
283- for name, ups := range map[string][]domain.RefUpdate{
284- "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"),
285- "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()),
286- "a malformed object": ref(domain.ZeroOID, "ZZZZ"),
287- } {
288- _, err := changed(ups)
289- assert.Error(t, err, name)
254+ for _, r := range []domain.RefUpdate{{Old: domain.ZeroOID, New: strings.Repeat("1", 40)}, {Old: strings.Repeat("2", 40), New: plain}, {Old: domain.ZeroOID, New: "ZZZZ"}} {
255+ _, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{r}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
256+ assert.ErrorIs(t, err, domain.ErrInvalid)
290257 }
258+ got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: "invalid but deleted", New: domain.ZeroOID}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
259+ require.NoError(t, err)
260+ assert.Empty(t, got.Ends)
261+ assert.Empty(t, got.Commits)
291262 }
292263
293264 // rawTree writes a tree object by hand: entries are "mode name hexid", in the
@@ -308,73 +279,35 @@ func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string {
308279 // mode 040000 below a directory .github of mode 040000. The same tree id as a
309280 // gitlink, a symbolic link or a file is nothing, and turning it into a
310281 // directory activates it; the object id alone does not tell.
311-func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) {
282+func TestPushSubtreesEntryKinds(t *testing.T) {
312283 g, data := newGit(t)
313284 ctx := context.Background()
314285 f := readertest.NewFixture(t, data, "proj")
315286 t.Cleanup(func() { _ = g.Close(ctx) })
316- f.Write("README", "r\n")
317- f.Commit("base")
318- yml := f.WriteObject("blob", "on: push\n")
319- wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows
320- link := f.WriteObject("blob", ".github/other")
321- file := f.WriteObject("blob", "not a directory\n")
322- readme := f.WriteObject("blob", "r\n")
323-
324- // A commit whose .github/workflows has the mode and id given.
325- withWorkflows := func(mode, id string) string {
326- gh := rawTree(t, f, [3]string{mode, "workflows", id})
327- return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh}))
328- }
329- // A commit whose .github has the mode and id given.
330- withGithub := func(mode, id string) string {
331- return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id}))
332- }
333- asDir := withWorkflows("040000", wfTree)
334- asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule
335- asSymlink := withWorkflows("120000", link)
336- asFile := withWorkflows("100644", file)
337- none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme}))
338- ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree})
339- githubDir := withGithub("040000", ghDir)
340- githubGitlink := withGithub("160000", ghDir) // the same id, a submodule
341- githubFile := withGithub("100644", file)
342- githubLink := withGithub("120000", link)
343-
344- for name, c := range map[string]struct {
345- old, new string
346- want string
347- }{
348- "gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"},
349- "a directory to a gitlink removes it": {asDir, asGitlink, ""},
350- "symlink to a directory": {asSymlink, asDir, ".github/workflows/ci.yml"},
351- "a directory to a symlink removes it": {asDir, asSymlink, ""},
352- "file to a directory": {asFile, asDir, ".github/workflows/ci.yml"},
353- "a directory to a file removes it": {asDir, asFile, ""},
354- "nothing to a directory": {none, asDir, ".github/workflows/ci.yml"},
355- "a directory to nothing": {asDir, none, ""},
356- "the same gitlink": {asGitlink, asGitlink, ""},
357- "gitlink to symlink": {asGitlink, asSymlink, ""},
358- ".github a gitlink to a directory, same id": {githubGitlink, githubDir, ".github/workflows/ci.yml"},
359- ".github a directory to a gitlink": {githubDir, githubGitlink, ""},
360- ".github a file to a directory": {githubFile, githubDir, ".github/workflows/ci.yml"},
361- ".github a symlink to a directory": {githubLink, githubDir, ".github/workflows/ci.yml"},
362- ".github a directory to a symlink": {githubDir, githubLink, ""},
363- "a new ref with a gitlink": {domain.ZeroOID, asGitlink, ""},
364- "a new ref with a symlink": {domain.ZeroOID, asSymlink, ""},
365- "a new ref with .github a gitlink": {domain.ZeroOID, githubGitlink, ""},
366- "a new ref with a directory": {domain.ZeroOID, asDir, ".github/workflows/ci.yml"},
367- } {
368- p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{})
369- require.NoError(t, err, name)
370- assert.Equal(t, c.want, p, name)
287+ blob := f.WriteObject("blob", "x\n")
288+ tree := rawTree(t, f, [3]string{"100644", "ci.yml", blob})
289+ for _, r := range []struct {
290+ mode, id string
291+ kind domain.EntryKind
292+ }{{"040000", tree, domain.EntryDirectory}, {"160000", tree, domain.EntrySubmodule}, {"120000", blob, domain.EntrySymlink}, {"100644", blob, domain.EntryFile}, {"100755", blob, domain.EntryExecutable}} {
293+ t.Run(r.mode, func(t *testing.T) {
294+ gh := rawTree(t, f, [3]string{r.mode, "workflows", r.id})
295+ root := rawTree(t, f, [3]string{"040000", ".github", gh})
296+ oid := commitOf(t, f, root)
297+ got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
298+ require.NoError(t, err)
299+ assert.Equal(t, r.kind, got.Subtrees[oid].Kind)
300+ assert.Equal(t, r.id, got.Subtrees[oid].ID.String())
301+ assert.NotContains(t, got.Subtrees, domain.HeadKey)
302+ if r.mode != "040000" {
303+ root = rawTree(t, f, [3]string{r.mode, ".github", r.id})
304+ oid = commitOf(t, f, root)
305+ got, err = g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
306+ require.NoError(t, err)
307+ assert.NotContains(t, got.Subtrees, oid)
308+ }
309+ })
371310 }
372-
373- // A tag that peels to the commit is judged the same way.
374- tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a <a@b> 1 +0000\n\nm\n")
375- p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{})
376- require.NoError(t, err)
377- assert.Equal(t, ".github/workflows/ci.yml", p)
378311 }
379312
380313 // A history for the rules on the commits a push brings: main holds workflows A,
@@ -418,91 +351,37 @@ func (h *guardHistory) commit(workflows, _ string, parents ...string) string {
418351 return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...)
419352 }
420353
421-func TestChangedWorkflowJudgesThePushedCommits(t *testing.T) {
422- g, data := newGit(t)
423- ctx := context.Background()
424- h := newGuardHistory(t, g, data)
425- t.Cleanup(func() { _ = g.Close(ctx) })
426- f := h.f
427- run := func(ups ...domain.RefUpdate) (string, error) {
428- return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
429- }
430- create := func(ref, to string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: to, Ref: ref} }
431- move := func(ref, from, to string) domain.RefUpdate { return domain.RefUpdate{Old: from, New: to, Ref: ref} }
432- const ci = ".github/workflows/ci.yml"
433-
434- allowed := func(name string, ups ...domain.RefUpdate) {
435- t.Helper()
436- p, err := run(ups...)
437- require.NoError(t, err, name)
438- assert.Empty(t, p, name)
439- }
440- refused := func(name string, ups ...domain.RefUpdate) {
441- t.Helper()
442- p, err := run(ups...)
443- require.NoError(t, err, name)
444- assert.Equal(t, ci, p, name)
445- }
446-
447- // The staging repro: a new branch off main with a commit that touches no workflow.
448- allowed("new branch off main with a plain commit", create("refs/heads/smoke", h.commit("A", "", h.m)))
449- allowed("a new branch at the head of main", create("refs/heads/same", h.m))
450- allowed("a new tag at the head of main", create("refs/tags/v1", h.m))
451- allowed("a new branch whose commits carry no workflows at all", create("refs/heads/bare", h.commit("", "")))
452- allowed("a fast-forward with plain commits", move("refs/heads/main", h.m, h.commit("A", "", h.commit("A", "", h.m))))
453- allowed("a commit that removes the workflows", move("refs/heads/main", h.m, h.commit("", "", h.m)))
454-
455- // A pushed commit that changes them is refused wherever it goes.
456- refused("new branch with a commit that changes workflows", create("refs/heads/w", h.commit("C", "", h.m)))
457- refused("a root commit that carries workflows", create("refs/heads/root", h.commit("C", "")))
458- refused("an update with a commit that changes workflows", move("refs/heads/main", h.m, h.commit("C", "", h.m)))
459- n4 := h.commit("C", "", h.m)
460- n5 := h.commit("A", "", n4) // reverts the change of the commit before
461- refused("a fast-forward through a commit that changes them and a later one that reverts it", move("refs/heads/main", h.m, n5))
462- refused("a new branch through the same", create("refs/heads/through", n5))
463- n6 := h.commit("A", "", h.commit("C", "", h.o))
464- refused("a commit that changes them below plain ones, on an old base", create("refs/heads/below", h.commit("C", "", n6)))
465-
466- // A ref cannot be moved onto existing commits with other workflows.
467- refused("a new branch at an old commit whose workflows differ from main", create("refs/heads/b", h.o))
468- refused("a new tag at that commit", create("refs/tags/old", h.o))
469- refused("an update moving back to an older commit with other workflows", move("refs/heads/main", h.m, h.o))
470- allowed("an update to a commit with the same workflows", move("refs/heads/old", h.o, h.commit("B", "", h.o)))
471- allowed("an update that moves a ref to a commit without workflows (removes them)", move("refs/heads/old", h.o, h.commit("", "", h.o)))
472-
473- // Merges: a change only if it differs from every parent.
474- allowed("a merge of existing commits that keeps main's workflows", move("refs/heads/main", h.m, h.commit("A", "", h.m, h.o)))
475- allowed("a new branch with a merge keeping one parent's workflows", create("refs/heads/merge", h.commit("A", "", h.m, h.o)))
476- refused("a merge that differs from both parents", create("refs/heads/merge2", h.commit("D", "", h.m, h.o)))
477- refused("a merge of a pushed side that changes them (the side commit is itself new)", create("refs/heads/merge3", h.commit("A", "", h.m, h.commit("C", "", h.o))))
478- // One that takes the old side's workflows is a change of the ref, not of a commit.
479- refused("a merge taking the other parent's workflows, onto main", move("refs/heads/main", h.m, h.commit("B", "", h.m, h.o)))
480-
481- // Several updates: the first refused refuses the push.
482- refused("a plain ref and a refused one", create("refs/heads/ok", h.commit("A", "", h.m)), create("refs/heads/w2", h.commit("C", "", h.m)))
483- allowed("deletes", move("refs/heads/old", h.o, domain.ZeroOID))
484-}
485-
486-// With no default branch head, a new ref is compared with no workflows at all.
487-func TestChangedWorkflowOfANewRefWithoutADefaultBranchHead(t *testing.T) {
354+func TestPushSubtreesCommitsAndParents(t *testing.T) {
488355 g, data := newGit(t)
489356 ctx := context.Background()
490357 h := newGuardHistory(t, g, data)
491358 t.Cleanup(func() { _ = g.Close(ctx) })
492- h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") // the default branch does not exist
493- run := func(to string) string {
494- p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: to, Ref: "refs/tags/t"}}, ports.PushObjects{})
495- require.NoError(t, err)
496- return p
359+ side := h.commit("B", "", h.o)
360+ merge := h.commit("A", "", h.m, side)
361+ got, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}, {Old: domain.ZeroOID, New: merge}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
362+ require.NoError(t, err)
363+ want := h.f.Git("rev-list", "--parents", merge, "--not", "--all")
364+ var lines []string
365+ for _, c := range got.Commits {
366+ line := c.ID.String()
367+ for _, p := range c.Parents {
368+ line += " " + p.String()
369+ }
370+ lines = append(lines, line)
497371 }
498- assert.Equal(t, ".github/workflows/ci.yml", run(h.m), "an existing commit with workflows")
499- plain := h.commit("", "")
500- h.f.Git("update-ref", "refs/heads/plain", plain)
501- assert.Empty(t, run(plain), "an existing commit without")
372+ assert.Equal(t, strings.TrimSpace(want), strings.Join(lines, "\n"))
373+ assert.Len(t, got.Commits, 2)
374+ assert.Equal(t, h.trees["A"], got.Subtrees[domain.HeadKey].ID.String())
375+ assert.Equal(t, h.trees["B"], got.Subtrees[h.o].ID.String())
376+ // Any directory path is accepted; the reader has no workflow path policy.
377+ other, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}}, ports.PushObjects{}, "README", domain.DefaultPushWalkBounds())
378+ require.NoError(t, err)
379+ assert.Equal(t, domain.EntryFile, other.Subtrees[merge].Kind)
380+ assert.NotContains(t, other.Subtrees, domain.HeadKey, "HEAD is lazy for non-creates")
502381 }
503382
504383 // The commits of a push are bounded: more than the bound fails closed.
505-func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) {
384+func TestPushSubtreesBoundsThePushedCommits(t *testing.T) {
506385 g, data := newGit(t)
507386 ctx := context.Background()
508387 h := newGuardHistory(t, g, data)
@@ -525,10 +404,10 @@ func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) {
525404 }
526405 // Plain commits carry the workflows of main (the fast-import tree has none,
527406 // so the chain is "removing" them): allowed, whatever their number, up to the bound.
528- p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits), Ref: "refs/heads/main"}}, ports.PushObjects{})
407+ p, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(20), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80})
529408 require.NoError(t, err)
530- assert.Empty(t, p)
531- _, err = g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits + 1), Ref: "refs/heads/main"}}, ports.PushObjects{})
409+ assert.Len(t, p.Commits, 20)
410+ _, err = g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(21), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80})
532411 assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push")
533412 }
534413
@@ -565,18 +444,18 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) {
565444 b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small
566445 }
567446 b.WriteString("\n")
568- r := &countingReader{lines: gitcli.MaxPushedCommits, line: []byte(b.String())}
569- kept, err := gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects)
447+ r := &countingReader{lines: domain.MaxPushedCommits, line: []byte(b.String())}
448+ kept, err := gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs)
570449 require.ErrorIs(t, err, domain.ErrInvalid)
571450 assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length")
572451 assert.Zero(t, kept, "nothing is returned with the error")
573- total := gitcli.MaxPushedCommits * len(b.String())
452+ total := domain.MaxPushedCommits * len(b.String())
574453 assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total)
575454 assert.Less(t, r.read*20, total)
576455
577456 // Within the bounds the same lines are kept: 100 commits of 198 parents.
578457 r = &countingReader{lines: 100, line: []byte(b.String())}
579- kept, err = gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects)
458+ kept, err = gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs)
580459 require.NoError(t, err)
581460 assert.Equal(t, 100, kept)
582461
@@ -590,3 +469,38 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) {
590469 _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000)
591470 assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound")
592471 }
472+
473+func changedWorkflow(g *gitcli.Git, ctx context.Context, repo domain.RepoName, ups []domain.RefUpdate, objects ports.PushObjects) (string, error) {
474+ t, err := g.PushSubtrees(ctx, repo, ups, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds())
475+ if err != nil {
476+ return "", err
477+ }
478+ c, found, err := domain.WorkflowChangeOf(ups, t)
479+ if err != nil || !found {
480+ return "", err
481+ }
482+ entry, err := g.FirstDifferentEntry(ctx, repo, objects, c.Old, c.New)
483+ if err != nil {
484+ return "", err
485+ }
486+ if entry == "" {
487+ return domain.WorkflowDir, nil
488+ }
489+ return domain.WorkflowDir + "/" + entry, nil
490+}
491+
492+func TestFirstDifferentEntry(t *testing.T) {
493+ g, data := newGit(t)
494+ ctx := context.Background()
495+ f := readertest.NewFixture(t, data, "proj")
496+ t.Cleanup(func() { _ = g.Close(ctx) })
497+ tree := oidOf(t, treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tci.yml"))
498+ entry, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, tree)
499+ require.NoError(t, err)
500+ assert.Equal(t, "ci.yml", entry)
501+ entry, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, tree, tree)
502+ require.NoError(t, err)
503+ assert.Empty(t, entry)
504+ _, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{Dir: t.TempDir()}, domain.ObjectID{}, tree)
505+ assert.ErrorIs(t, err, domain.ErrInvalid)
506+}
@@ -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 }241 }
262- for name, ups := range map[string][]domain.RefUpdate{242+ tree := f.Git("rev-parse", plain+"^{tree}")
263- "create a lightweight tag on a blob": ref(domain.ZeroOID, blob),243+ for _, r := range []struct {
264- "create an annotated tag on a blob": ref(domain.ZeroOID, annotatedBlob),244+ id string
265- "a tag from a blob to another blob": ref(blob, annotatedBlob),245+ kind domain.ObjectType
266- "a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob),246+ }{{plain, domain.ObjectCommit}, {tag(plain, "commit"), domain.ObjectCommit}, {blob, domain.ObjectBlob}, {tag(blob, "blob"), domain.ObjectBlob}, {tree, domain.ObjectTree}, {tag(tag(plain, "commit"), "tag"), domain.ObjectCommit}} {
267- } {247+ got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: r.id}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
268- p, err := changed(ups)248+ require.NoError(t, err)
269- require.NoError(t, err, name)249+ assert.Equal(t, r.kind, got.Ends[r.id].Type)
270- assert.Empty(t, p, name)250+ if r.kind == domain.ObjectBlob {
271- }251+ assert.NotContains(t, got.Subtrees, r.id)
272- for name, ups := range map[string][]domain.RefUpdate{252+ }
273- "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()),
274- "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()),
275- "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF),
276- "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree),
277- "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF),
278- } {
279- p, err := changed(ups)
280- require.NoError(t, err, name)
281- assert.Equal(t, ".github/workflows/ci.yml", p, name)
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,38 @@ 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+}
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 business rule for which pushed commits and ref changes alter workflows; pending move to domain/usecase
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-}
@@ -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 business rule for which pushed commits and ref changes alter workflows; pending move to domain/usecase
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/app/forge.go +1 -1
@@ -312,7 +312,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
312312 opts.ci(&ciCfg)
313313 }
314314 f.CI = usecase.NewCI(ctx, ciCfg)
315- f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI())
315+ f.Pushes.WithWorkflowTrigger(f.CI).WithPushTreeReader(git).WithRefTips(store.CI())
316316 f.Repos.WithCIRunCleaner(f.CI)
317317 f.CI.Start()
318318 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
@@ -312,7 +312,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
312 opts.ci(&ciCfg)312 opts.ci(&ciCfg)
313 }313 }
314 f.CI = usecase.NewCI(ctx, ciCfg)314 f.CI = usecase.NewCI(ctx, ciCfg)
315- f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI())315+ f.Pushes.WithWorkflowTrigger(f.CI).WithPushTreeReader(git).WithRefTips(store.CI())
316 f.Repos.WithCIRunCleaner(f.CI)316 f.Repos.WithCIRunCleaner(f.CI)
317 f.CI.Start()317 f.CI.Start()
318 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{
A internal/domain/ci_workflow_guard.go +126 -0
@@ -0,0 +1,126 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "fmt"
6+
7+// ObjectType is the type of a peeled Git object.
8+type ObjectType string
9+
10+// Peeled object types accepted by the push tree reader.
11+const (
12+ ObjectCommit ObjectType = "commit"
13+ ObjectTree ObjectType = "tree"
14+ ObjectBlob ObjectType = "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+// PushedEnd describes a peeled ref endpoint.
30+type PushedEnd struct{ Type ObjectType }
31+
32+// Subtree is the kind and identity of an entry at a tree path.
33+type Subtree struct {
34+ Kind EntryKind
35+ ID ObjectID
36+}
37+
38+// PushedCommit is a newly reachable commit with its ordered parents.
39+type PushedCommit struct {
40+ ID ObjectID
41+ Parents []ObjectID
42+}
43+
44+// HeadKey identifies the default branch head in PushSubtrees.Subtrees.
45+const HeadKey = "HEAD"
46+
47+// PushSubtrees contains endpoint types and entries keyed by original object ID
48+// (or HeadKey), and newly reachable commits in rev-list order. Absent entries
49+// mean there is no subtree at the requested path.
50+type PushSubtrees struct {
51+ Ends map[string]PushedEnd
52+ Commits []PushedCommit
53+ Subtrees map[string]Subtree
54+}
55+
56+// WorkflowTreeChange is the first forbidden workflow-tree replacement.
57+// A zero Old denotes no workflow directory.
58+type WorkflowTreeChange struct{ Old, New ObjectID }
59+
60+// WorkflowChangeOf applies the job-token workflow policy. Every new commit must
61+// keep the workflows of at least one parent; then each ref must keep its old
62+// workflows (HEAD's for a new ref). Disappearance and ref deletion are allowed.
63+// New commits are judged before ref ends, in their supplied order. Missing ends
64+// are a reader contract breach and fail closed with ErrInvalid.
65+func WorkflowChangeOf(updates []RefUpdate, t PushSubtrees) (WorkflowTreeChange, bool, error) {
66+ for _, up := range updates {
67+ if up.IsDelete() {
68+ continue
69+ }
70+ ids := []string{up.New}
71+ if !up.IsCreate() {
72+ ids = append(ids, up.Old)
73+ }
74+ for _, id := range ids {
75+ if _, ok := t.Ends[id]; !ok {
76+ return WorkflowTreeChange{}, false, fmt.Errorf("%w: missing push end %s", ErrInvalid, id)
77+ }
78+ }
79+ }
80+ dirOf := func(key string) ObjectID {
81+ s := t.Subtrees[key]
82+ if s.Kind != EntryDirectory {
83+ return ObjectID{}
84+ }
85+ return s.ID
86+ }
87+ for _, c := range t.Commits {
88+ cur := dirOf(c.ID.String())
89+ if cur.IsZero() {
90+ continue
91+ }
92+ var first ObjectID
93+ same := false
94+ for i, p := range c.Parents {
95+ w := dirOf(p.String())
96+ if i == 0 {
97+ first = w
98+ }
99+ if w == cur {
100+ same = true
101+ }
102+ }
103+ if !same {
104+ return WorkflowTreeChange{first, cur}, true, nil
105+ }
106+ }
107+ for _, up := range updates {
108+ if up.IsDelete() || t.Ends[up.New].Type == ObjectBlob {
109+ continue
110+ }
111+ cur := dirOf(up.New)
112+ if cur.IsZero() {
113+ continue
114+ }
115+ var base ObjectID
116+ if up.IsCreate() {
117+ base = dirOf(HeadKey)
118+ } else if t.Ends[up.Old].Type != ObjectBlob {
119+ base = dirOf(up.Old)
120+ }
121+ if base != cur {
122+ return WorkflowTreeChange{base, cur}, true, nil
123+ }
124+ }
125+ return WorkflowTreeChange{}, false, nil
126+}
@@ -0,0 +1,126 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import "fmt"
6+
7+// ObjectType is the type of a peeled Git object.
8+type ObjectType string
9+
10+// Peeled object types accepted by the push tree reader.
11+const (
12+ ObjectCommit ObjectType = "commit"
13+ ObjectTree ObjectType = "tree"
14+ ObjectBlob ObjectType = "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+// PushedEnd describes a peeled ref endpoint.
30+type PushedEnd struct{ Type ObjectType }
31+
32+// Subtree is the kind and identity of an entry at a tree path.
33+type Subtree struct {
34+ Kind EntryKind
35+ ID ObjectID
36+}
37+
38+// PushedCommit is a newly reachable commit with its ordered parents.
39+type PushedCommit struct {
40+ ID ObjectID
41+ Parents []ObjectID
42+}
43+
44+// HeadKey identifies the default branch head in PushSubtrees.Subtrees.
45+const HeadKey = "HEAD"
46+
47+// PushSubtrees contains endpoint types and entries keyed by original object ID
48+// (or HeadKey), and newly reachable commits in rev-list order. Absent entries
49+// mean there is no subtree at the requested path.
50+type PushSubtrees struct {
51+ Ends map[string]PushedEnd
52+ Commits []PushedCommit
53+ Subtrees map[string]Subtree
54+}
55+
56+// WorkflowTreeChange is the first forbidden workflow-tree replacement.
57+// A zero Old denotes no workflow directory.
58+type WorkflowTreeChange struct{ Old, New ObjectID }
59+
60+// WorkflowChangeOf applies the job-token workflow policy. Every new commit must
61+// keep the workflows of at least one parent; then each ref must keep its old
62+// workflows (HEAD's for a new ref). Disappearance and ref deletion are allowed.
63+// New commits are judged before ref ends, in their supplied order. Missing ends
64+// are a reader contract breach and fail closed with ErrInvalid.
65+func WorkflowChangeOf(updates []RefUpdate, t PushSubtrees) (WorkflowTreeChange, bool, error) {
66+ for _, up := range updates {
67+ if up.IsDelete() {
68+ continue
69+ }
70+ ids := []string{up.New}
71+ if !up.IsCreate() {
72+ ids = append(ids, up.Old)
73+ }
74+ for _, id := range ids {
75+ if _, ok := t.Ends[id]; !ok {
76+ return WorkflowTreeChange{}, false, fmt.Errorf("%w: missing push end %s", ErrInvalid, id)
77+ }
78+ }
79+ }
80+ dirOf := func(key string) ObjectID {
81+ s := t.Subtrees[key]
82+ if s.Kind != EntryDirectory {
83+ return ObjectID{}
84+ }
85+ return s.ID
86+ }
87+ for _, c := range t.Commits {
88+ cur := dirOf(c.ID.String())
89+ if cur.IsZero() {
90+ continue
91+ }
92+ var first ObjectID
93+ same := false
94+ for i, p := range c.Parents {
95+ w := dirOf(p.String())
96+ if i == 0 {
97+ first = w
98+ }
99+ if w == cur {
100+ same = true
101+ }
102+ }
103+ if !same {
104+ return WorkflowTreeChange{first, cur}, true, nil
105+ }
106+ }
107+ for _, up := range updates {
108+ if up.IsDelete() || t.Ends[up.New].Type == ObjectBlob {
109+ continue
110+ }
111+ cur := dirOf(up.New)
112+ if cur.IsZero() {
113+ continue
114+ }
115+ var base ObjectID
116+ if up.IsCreate() {
117+ base = dirOf(HeadKey)
118+ } else if t.Ends[up.Old].Type != ObjectBlob {
119+ base = dirOf(up.Old)
120+ }
121+ if base != cur {
122+ return WorkflowTreeChange{base, cur}, true, nil
123+ }
124+ }
125+ return WorkflowTreeChange{}, false, nil
126+}
A internal/domain/ci_workflow_guard_test.go +119 -0
@@ -0,0 +1,119 @@
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+ blobs []domain.ObjectID
43+ want domain.WorkflowTreeChange
44+ found bool
45+ missing bool
46+ }
47+ change := func(old, new domain.ObjectID) domain.WorkflowTreeChange {
48+ return domain.WorkflowTreeChange{Old: old, New: new}
49+ }
50+ rows := []row{
51+ {name: "no updates"},
52+ {name: "delete", ups: []domain.RefUpdate{up(a, zero)}},
53+ {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}},
54+ {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},
55+ {name: "new ref at HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), a.String(): dir(x)}},
56+ {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)}},
57+ {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},
58+ {name: "unborn HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
59+ {name: "HEAD has no workflows", ups: []domain.RefUpdate{up(zero, a)}},
60+ {name: "same workflow directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
61+ {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},
62+ {name: "directory disappears", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
63+ {name: "lookalike path absent", ups: []domain.RefUpdate{up(a, b)}},
64+ {name: "root with workflows", commits: []domain.PushedCommit{commit(a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
65+ {name: "root without workflows", commits: []domain.PushedCommit{commit(a)}},
66+ {name: "plain pushed commit", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
67+ {name: "pushed directory disappears", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
68+ {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)}},
69+ {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},
70+ {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},
71+ {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},
72+ {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},
73+ {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},
74+ {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},
75+ {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},
76+ {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},
77+ {name: "missing ends fail closed", ups: []domain.RefUpdate{up(a, b)}, missing: true},
78+ }
79+ rows = append(rows,
80+ row{name: "new tree end with workflows", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
81+ row{name: "new tree end without workflows", ups: []domain.RefUpdate{up(zero, a)}},
82+ row{name: "new blob ref", ups: []domain.RefUpdate{up(zero, a)}, blobs: []domain.ObjectID{a}},
83+ )
84+
85+ for _, kind := range []domain.EntryKind{domain.EntrySubmodule, domain.EntrySymlink, domain.EntryFile, domain.EntryExecutable} {
86+ rows = append(rows,
87+ 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},
88+ 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}}},
89+ 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}}},
90+ )
91+ }
92+ 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}}})
93+ for _, r := range rows {
94+ t.Run(r.name, func(t *testing.T) {
95+ data := domain.PushSubtrees{Ends: map[string]domain.PushedEnd{}, Commits: r.commits, Subtrees: r.trees}
96+ if !r.missing {
97+ for _, u := range r.ups {
98+ for _, oid := range []string{u.Old, u.New} {
99+ data.Ends[oid] = domain.PushedEnd{Type: domain.ObjectCommit}
100+ }
101+ }
102+ }
103+ if r.name == "new tree end with workflows" || r.name == "new tree end without workflows" {
104+ data.Ends[a.String()] = domain.PushedEnd{Type: domain.ObjectTree}
105+ }
106+ for _, b := range r.blobs {
107+ data.Ends[b.String()] = domain.PushedEnd{Type: domain.ObjectBlob}
108+ }
109+ got, found, err := domain.WorkflowChangeOf(r.ups, data)
110+ if r.missing {
111+ require.ErrorIs(t, err, domain.ErrInvalid)
112+ return
113+ }
114+ require.NoError(t, err)
115+ assert.Equal(t, r.found, found)
116+ assert.Equal(t, r.want, got)
117+ })
118+ }
119+}
@@ -0,0 +1,119 @@
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+ blobs []domain.ObjectID
43+ want domain.WorkflowTreeChange
44+ found bool
45+ missing bool
46+ }
47+ change := func(old, new domain.ObjectID) domain.WorkflowTreeChange {
48+ return domain.WorkflowTreeChange{Old: old, New: new}
49+ }
50+ rows := []row{
51+ {name: "no updates"},
52+ {name: "delete", ups: []domain.RefUpdate{up(a, zero)}},
53+ {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}},
54+ {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},
55+ {name: "new ref at HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), a.String(): dir(x)}},
56+ {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)}},
57+ {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},
58+ {name: "unborn HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
59+ {name: "HEAD has no workflows", ups: []domain.RefUpdate{up(zero, a)}},
60+ {name: "same workflow directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
61+ {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},
62+ {name: "directory disappears", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
63+ {name: "lookalike path absent", ups: []domain.RefUpdate{up(a, b)}},
64+ {name: "root with workflows", commits: []domain.PushedCommit{commit(a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
65+ {name: "root without workflows", commits: []domain.PushedCommit{commit(a)}},
66+ {name: "plain pushed commit", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}},
67+ {name: "pushed directory disappears", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}},
68+ {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)}},
69+ {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},
70+ {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},
71+ {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},
72+ {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},
73+ {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},
74+ {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},
75+ {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},
76+ {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},
77+ {name: "missing ends fail closed", ups: []domain.RefUpdate{up(a, b)}, missing: true},
78+ }
79+ rows = append(rows,
80+ row{name: "new tree end with workflows", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true},
81+ row{name: "new tree end without workflows", ups: []domain.RefUpdate{up(zero, a)}},
82+ row{name: "new blob ref", ups: []domain.RefUpdate{up(zero, a)}, blobs: []domain.ObjectID{a}},
83+ )
84+
85+ for _, kind := range []domain.EntryKind{domain.EntrySubmodule, domain.EntrySymlink, domain.EntryFile, domain.EntryExecutable} {
86+ rows = append(rows,
87+ 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},
88+ 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}}},
89+ 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}}},
90+ )
91+ }
92+ 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}}})
93+ for _, r := range rows {
94+ t.Run(r.name, func(t *testing.T) {
95+ data := domain.PushSubtrees{Ends: map[string]domain.PushedEnd{}, Commits: r.commits, Subtrees: r.trees}
96+ if !r.missing {
97+ for _, u := range r.ups {
98+ for _, oid := range []string{u.Old, u.New} {
99+ data.Ends[oid] = domain.PushedEnd{Type: domain.ObjectCommit}
100+ }
101+ }
102+ }
103+ if r.name == "new tree end with workflows" || r.name == "new tree end without workflows" {
104+ data.Ends[a.String()] = domain.PushedEnd{Type: domain.ObjectTree}
105+ }
106+ for _, b := range r.blobs {
107+ data.Ends[b.String()] = domain.PushedEnd{Type: domain.ObjectBlob}
108+ }
109+ got, found, err := domain.WorkflowChangeOf(r.ups, data)
110+ if r.missing {
111+ require.ErrorIs(t, err, domain.ErrInvalid)
112+ return
113+ }
114+ require.NoError(t, err)
115+ assert.Equal(t, r.found, found)
116+ assert.Equal(t, r.want, got)
117+ })
118+ }
119+}
M internal/ports/ci.go +41 -15
@@ -407,21 +407,47 @@ type PushObjects struct {
407407 Alternates string
408408 }
409409
410-// WorkflowGuard finds the changes of a push to the workflow files of a
411-// repository.
412-type WorkflowGuard interface {
413- // ChangedWorkflow returns the first path under .github/workflows/ that the
414- // updates change, "" for none. Two checks, as GitHub judges the token of a
415- // job: (1) every commit the push brings (those no ref has yet) is compared
416- // with each of its parents (a root commit with nothing) and is a change when
417- // it has workflows that differ from every parent's; (2) the workflows a ref
418- // ends with must be its old ones or none for an update, and the default
419- // branch head's or none for a new ref (none when there is no head). A
420- // workflows directory that disappears is no change, and a deleted ref changes
421- // nothing. objects are those of the push in progress; a directory that is not
422- // inside the repository is refused. A push beyond the bounds of the check
423- // (commits, parents) is an error that matches domain.ErrInvalid.
424- ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects) (path string, err error)
410+// PushTreeReader reads, for a push still in quarantine, the objects the
411+// workflow policy (domain.WorkflowChangeOf) judges. It knows nothing of the
412+// policy: it peels, classifies, walks and compares trees.
413+type PushTreeReader interface {
414+ // PushSubtrees reads what a push brings, and the entry at path (a
415+ // directory path without leading or trailing slash, e.g.
416+ // domain.WorkflowDir) in each object concerned.
417+ //
418+ // Updates that delete (New == ZeroOID) have no objects: they are skipped.
419+ // For every other update, Old (unless ZeroOID) and New are peeled
420+ // (`<oid>^{}`): the result is in Ends keyed by the oid as given. An end
421+ // that is missing, malformed, or peels to neither commit, tree nor blob is
422+ // an error matching domain.ErrInvalid.
423+ //
424+ // Commits is `rev-list --parents <tips> --not --all` where tips are the
425+ // distinct New ends that peel to a commit: the commits no ref has yet,
426+ // each with its parents, in rev-list order. More than bounds.Commits
427+ // commits, more than bounds.IDs ids (commits and parents, repeats
428+ // included) or a commit whose parent line exceeds the adapter's line
429+ // buffer is an error matching domain.ErrInvalid, raised as soon as the
430+ // line crossing the bound is read.
431+ //
432+ // Subtrees holds the entry at path for every end, every commit, every
433+ // parent, and, when a create has a non-blob New, the default branch head
434+ // (key domain.HeadKey, absent when HEAD is unborn or has no such entry). An object that peels to a blob, or an
435+ // object with no entry at path (including a path that crosses a
436+ // gitlink, symlink or file), has no key. Kind is EntryDirectory only for
437+ // mode 040000 and type tree.
438+ //
439+ // objects are the quarantine directories of the push; one outside the
440+ // repository's objects directory (symlinks resolved) is an error matching
441+ // domain.ErrInvalid, checked before anything is read.
442+ PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error)
443+
444+ // FirstDifferentEntry returns the name of the first top-level entry that
445+ // differs between the trees old and new (`diff-tree --name-only -z
446+ // --no-renames`, read as a stream up to the first name, then git is
447+ // stopped). A zero old is the empty tree. It returns "" when nothing
448+ // differs. A name longer than the adapter's path bound is an error
449+ // matching domain.ErrInvalid. objects as above.
450+ FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects PushObjects, old, new domain.ObjectID) (name string, err error)
425451 }
426452
427453 // --- inbound ---
@@ -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 ---
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+}
@@ -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-}
@@ -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/usecase/pushes.go +30 -14
@@ -26,7 +26,7 @@ type Pushes struct {
2626 auth ports.Revalidator
2727 fresh ports.RepoFreshener // nil: the reader needs no telling
2828 ci ports.WorkflowTrigger // nil: no CI
29- wf ports.WorkflowGuard // nil: pushes by job tokens are refused
29+ trees ports.PushTreeReader // nil: pushes by job tokens are refused
3030 tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
3131 mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
3232 }
@@ -73,11 +73,11 @@ func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes {
7373 return u
7474 }
7575
76-// WithWorkflowGuard lets Check tell whether a push changes workflow files, which
76+// WithPushTreeReader lets Check tell whether a push changes workflow files, which
7777 // a push by a job token may not do. Without it a push by a job token is
7878 // refused, as it cannot be judged. Call it before the first push; it returns u.
79-func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes {
80- u.wf = g
79+func (u *Pushes) WithPushTreeReader(g ports.PushTreeReader) *Pushes {
80+ u.trees = g
8181 return u
8282 }
8383
@@ -199,20 +199,36 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
199199 const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"
200200
201201 func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
202- if u.wf == nil {
202+ if u.trees == nil {
203203 return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)
204204 }
205- path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects)
206- switch {
207- case errors.Is(err, domain.ErrInvalid):
208- // A push the guard cannot judge within its bounds: refused, with the reason.
205+ t, err := u.trees.PushSubtrees(ctx, name, updates, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds())
206+ if err != nil {
207+ return workflowReadError(name, err)
208+ }
209+ c, found, err := domain.WorkflowChangeOf(updates, t)
210+ if err != nil {
209211 return fmt.Errorf("%w: %v", domain.ErrRejected, err)
210- case err != nil:
211- return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
212- case path != "":
213- return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
214212 }
215- return nil
213+ if !found {
214+ return nil
215+ }
216+ entry, err := u.trees.FirstDifferentEntry(ctx, name, objects, c.Old, c.New)
217+ if err != nil {
218+ return workflowReadError(name, err)
219+ }
220+ path := domain.WorkflowDir
221+ if entry != "" {
222+ path += "/" + entry
223+ }
224+ return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
225+}
226+
227+func workflowReadError(name domain.RepoName, err error) error {
228+ if errors.Is(err, domain.ErrInvalid) {
229+ return fmt.Errorf("%w: %v", domain.ErrRejected, err)
230+ }
231+ return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
216232 }
217233
218234 // Record is the post-receive step of an accepted push: it sets pushed_at and active_at,
@@ -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 (
66 "context"
77 "errors"
88 "fmt"
9+ "strings"
910 "testing"
1011 "time"
1112
@@ -440,16 +441,41 @@ func (f *pushFixture) check(ctx context.Context, p domain.Principal, n domain.Re
440441 return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{})
441442 }
442443
443-type fakeGuard struct {
444- path string
445- err error
446- got []domain.RefUpdate
447- objs ports.PushObjects
444+type fakePushTrees struct {
445+ path string
446+ err error
447+ diffErr error
448+ got []domain.RefUpdate
449+ objs ports.PushObjects
450+ bounds domain.PushWalkBounds
451+ subtreePath string
452+ calls int
453+ old, new domain.ObjectID
454+ data *domain.PushSubtrees
455+ change bool
448456 }
449457
450-func (g *fakeGuard) ChangedWorkflow(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects) (string, error) {
451- g.got, g.objs = ups, o
452- return g.path, g.err
458+func (g *fakePushTrees) PushSubtrees(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) {
459+ g.got, g.objs, g.subtreePath, g.bounds = ups, o, path, bounds
460+ if g.data != nil {
461+ return *g.data, g.err
462+ }
463+ t := domain.PushSubtrees{Ends: map[string]domain.PushedEnd{}, Subtrees: map[string]domain.Subtree{}}
464+ for _, up := range ups {
465+ t.Ends[up.Old] = domain.PushedEnd{Type: domain.ObjectCommit}
466+ t.Ends[up.New] = domain.PushedEnd{Type: domain.ObjectCommit}
467+ if g.path != "" || g.change {
468+ id, _ := domain.ParseObjectID(up.New)
469+ t.Subtrees[up.New] = domain.Subtree{Kind: domain.EntryDirectory, ID: id}
470+ }
471+ }
472+ return t, g.err
473+}
474+
475+func (g *fakePushTrees) FirstDifferentEntry(_ context.Context, _ domain.RepoName, _ ports.PushObjects, old, new domain.ObjectID) (string, error) {
476+ g.calls++
477+ g.old, g.new = old, new
478+ return strings.TrimPrefix(g.path, domain.WorkflowDir+"/"), g.diffErr
453479 }
454480
455481 // A push by the token of a job is judged on the workflows it changes, and by it
@@ -464,10 +490,10 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) {
464490 job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}})
465491 readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp})
466492
467- build := func(t *testing.T, g *fakeGuard) *pushFixture {
493+ build := func(t *testing.T, g *fakePushTrees) *pushFixture {
468494 f := newPushFixture(t)
469495 if g != nil {
470- f.pushes.WithWorkflowGuard(g)
496+ f.pushes.WithPushTreeReader(g)
471497 }
472498 f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe()
473499 return f
@@ -475,29 +501,65 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) {
475501
476502 t.Run("no workflow touched", func(t *testing.T) {
477503 t.Parallel()
478- g := &fakeGuard{}
504+ g := &fakePushTrees{}
479505 f := build(t, g)
480506 require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs))
481507 assert.Equal(t, ups, g.got, "every ref is judged")
482508 assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine")
509+ assert.Equal(t, domain.WorkflowDir, g.subtreePath)
510+ assert.Equal(t, domain.DefaultPushWalkBounds(), g.bounds)
511+ assert.Zero(t, g.calls)
483512 })
484513 t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) {
485514 t.Parallel()
486- f := build(t, &fakeGuard{path: ".github/workflows/ci.yml"})
515+ f := build(t, &fakePushTrees{path: ".github/workflows/ci.yml"})
487516 err := f.pushes.Check(ctx, job, n, ups, objs)
488517 require.ErrorIs(t, err, domain.ErrRejected)
489518 assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission")
490519 })
520+ t.Run("empty differing entry reports the directory and uses the domain pair once", func(t *testing.T) {
521+ g := &fakePushTrees{change: true}
522+ f := build(t, g)
523+ err := f.pushes.Check(ctx, job, n, ups, objs)
524+ require.ErrorIs(t, err, domain.ErrRejected)
525+ assert.Contains(t, err.Error(), "workflow `.github/workflows`")
526+ assert.Equal(t, 1, g.calls)
527+ assert.True(t, g.old.IsZero())
528+ assert.Equal(t, oidB, g.new.String())
529+ })
530+ t.Run("diff invalid is rejected with reason", func(t *testing.T) {
531+ g := &fakePushTrees{change: true, diffErr: fmt.Errorf("%w: path too long", domain.ErrInvalid)}
532+ f := build(t, g)
533+ err := f.pushes.Check(ctx, job, n, ups, objs)
534+ require.ErrorIs(t, err, domain.ErrRejected)
535+ assert.Contains(t, err.Error(), "path too long")
536+ assert.Equal(t, 1, g.calls)
537+ })
538+ t.Run("diff I/O failure retains its class", func(t *testing.T) {
539+ boom := errors.New("diff broke")
540+ g := &fakePushTrees{change: true, diffErr: boom}
541+ f := build(t, g)
542+ err := f.pushes.Check(ctx, job, n, ups, objs)
543+ require.ErrorIs(t, err, boom)
544+ assert.NotErrorIs(t, err, domain.ErrRejected)
545+ })
546+ t.Run("missing endpoint is rejected before diff", func(t *testing.T) {
547+ g := &fakePushTrees{data: &domain.PushSubtrees{}}
548+ f := build(t, g)
549+ err := f.pushes.Check(ctx, job, n, ups, objs)
550+ require.ErrorIs(t, err, domain.ErrRejected)
551+ assert.Zero(t, g.calls)
552+ })
491553 t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) {
492554 t.Parallel()
493- f := build(t, &fakeGuard{err: errors.New("git broke")})
555+ f := build(t, &fakePushTrees{err: errors.New("git broke")})
494556 err := f.pushes.Check(ctx, job, n, ups, objs)
495557 require.Error(t, err)
496558 assert.NotErrorIs(t, err, domain.ErrRejected)
497559 })
498560 t.Run("a push beyond the guard's bounds is refused as a policy, with the reason", func(t *testing.T) {
499561 t.Parallel()
500- f := build(t, &fakeGuard{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)})
562+ f := build(t, &fakePushTrees{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)})
501563 err := f.pushes.Check(ctx, job, n, ups, objs)
502564 require.ErrorIs(t, err, domain.ErrRejected)
503565 assert.Contains(t, err.Error(), "more than 10000 commits")
@@ -509,19 +571,19 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) {
509571 })
510572 t.Run("a job token without contents: write cannot push at all", func(t *testing.T) {
511573 t.Parallel()
512- f := build(t, &fakeGuard{})
574+ f := build(t, &fakePushTrees{})
513575 assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected)
514576 })
515577 t.Run("the owner is not asked", func(t *testing.T) {
516578 t.Parallel()
517- g := &fakeGuard{path: ".github/workflows/ci.yml"}
579+ g := &fakePushTrees{path: ".github/workflows/ci.yml"}
518580 f := build(t, g)
519581 require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs))
520582 assert.Nil(t, g.got)
521583 })
522584 t.Run("a job token never creates a repository on push", func(t *testing.T) {
523585 t.Parallel()
524- f := build(t, &fakeGuard{})
586+ f := build(t, &fakePushTrees{})
525587 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
526588 assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound)
527589 })
@@ -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.PushedEnd{}, Subtrees: map[string]domain.Subtree{}}
464+ for _, up := range ups {
465+ t.Ends[up.Old] = domain.PushedEnd{Type: domain.ObjectCommit}
466+ t.Ends[up.New] = domain.PushedEnd{Type: domain.ObjectCommit}
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 })

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close