Commit dfb48fe52b

dfb48fe52baf1b0a626017169cbb6abe9bc93fc1

parent: 7e7a919b3b

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-08 07:07 UTC

hookd, wiki: every push shape, what CI makes of it, and a test per row

A CI page on the wiki with one table: fifteen push shapes against five
jobs that each isolate one rule (no filter, paths, paths-ignore,
schedule, tags), and what dedupe, the path filters, the schedules and
the reaper produce for each, with the commit status that follows.

TestPushShapes in internal/hookd runs every row against real git and
the store through the same entry points a push, a merge request head,
a tag and the scheduler's tick use. TestPushShapesTableOnWiki checks
that the page carries each row as the code has it, so the table cannot
drift from the code without a test saying so.

Ref #184, Ref #176, Ref #177
.gitbay/wiki/CI.org added +105
@@ -0,0 +1,105 @@
1#+title: CI: what a push queues
2
3Three mechanisms decide what a push does to CI, and they interact:
4
5- *Dedupe.* A job's result is a property of the commit's tree. A commit
6 that already has a passed, queued or running build for a job is not
7 queued again; a commit whose tree already passed a job gets that
8 result as its status, naming the build it came from (#177). A failed,
9 cancelled or abandoned build does not count: that commit runs again.
10- *Path filters.* =paths= and =paths-ignore= on a job are evaluated
11 against the files the push changed. The diff base is the old tip when
12 it is an ancestor of the new one, and the merge base with the default
13 branch otherwise: a new branch, or a force push after a rebase (#176).
14 A filter that cannot be evaluated, because there is no diff base or
15 the diff fails, runs the job rather than skipping it. A filter that
16 excludes the push records a =skipped= status, which =require-checks=
17 accepts (#172).
18- *The reaper.* A build a runner claimed and never reported is failed by
19 the scheduler's tick: two minutes after its log stream ended, or at
20 the build deadline if no stream was ever seen (#179). Its status reads
21 =build abandoned=.
22
23Scheduled jobs run on their cron against the default branch, never on
24push; a default-branch push registers or updates them. Tag jobs run on
25a matching tag push and nothing else.
26
27* The table
28
29One =ci.yml= with five jobs, each isolating one rule:
30
31#+begin_src yaml
32jobs:
33 plain:
34 steps: [echo plain]
35 onapp:
36 paths: [app/**]
37 steps: [echo onapp]
38 notapp:
39 paths-ignore: [app/**]
40 steps: [echo notapp]
41 nightly:
42 schedule: "0 3 * * *"
43 steps: [echo nightly]
44 release:
45 tags: "v*"
46 steps: [echo release]
47#+end_src
48
49Every push below changes =app/x= and nothing else, so a push whose
50filters are evaluated queues =onapp= and skips =notapp=, and a push
51whose filters cannot be evaluated queues both. Each cell is what the
52push produced for that job; the commit's =ci/<job>= status follows:
53
54- =queued=: a build, and a =pending= status until the runner reports.
55 =queued, untrusted= is a build without the repository's secrets.
56- =skipped=: no build, and a =skipped= status naming the filter.
57- =reused=: no build, and a =success= status naming the earlier build
58 with the same tree.
59- =registered=: no build; the job's schedule is (re)registered.
60- =abandoned=: the build failed and the status reads =build abandoned=.
61- =—=: nothing new. A status the commit already had stands.
62
63| push | plain | onapp | notapp | nightly | release | ci/config |
64|---+---+---+---+---+---+---|
65| first push of the default branch | queued | queued | queued | registered | — | — |
66| push to the default branch | queued | queued | skipped | registered | — | — |
67| push to another branch | queued | queued | skipped | — | — | — |
68| a new branch, no old sha | queued | queued | skipped | — | — | — |
69| rebase onto a moved default branch, old not an ancestor | queued | queued | skipped | — | — | — |
70| rewritten commit, same tree as a passed build | reused | reused | skipped | — | — | — |
71| fast-forward of a commit built on another branch | — | — | — | registered | — | — |
72| a commit whose earlier build failed, on a new branch | queued | queued | — | — | — | — |
73| merge request head from a fork | queued, untrusted | queued, untrusted | queued, untrusted | — | — | — |
74| tag push | — | — | — | — | queued | — |
75| schedule tick on the default branch | — | — | — | queued | — | — |
76| claimed builds whose runner vanished | abandoned | abandoned | — | — | — | — |
77| push to the default branch with an old sha that cannot be diffed | queued | queued | queued | registered | — | — |
78| push with a broken ci.yml | — | — | — | — | — | failure |
79| push with no ci.yml | — | — | — | — | — | — |
80
81Rows worth a second look:
82
83- The first push of the default branch has no diff base at all: the
84 merge base of the tip with itself is the tip. Every filtered job
85 runs. The same holds when the old sha cannot be diffed on the default
86 branch; on any other branch the merge base takes over and the filters
87 apply.
88- A rewritten commit with the same tree reuses =plain= and =onapp=
89 because the tree check comes before the filter. =notapp= never had a
90 passed build to reuse, and the push changed nothing, so the filter
91 skips it again.
92- A fast-forward of a commit built elsewhere queues nothing: the
93 builds belong to the commit, not the branch. The default-branch push
94 still registers the schedule.
95- A failed build does not stand for its commit. The same commit pushed
96 to another branch runs again; =notapp= keeps its skipped status.
97- A merge request head from a fork has no diff base and, unlike a
98 branch push, no merge-base fallback: every job runs, without secrets.
99 Filtering a head down to no jobs would make it unmergeable under
100 =require-checks= (#172).
101
102=TestPushShapes= in =internal/hookd= runs every row against real git
103and the store, and =TestPushShapesTableOnWiki= checks that this page
104carries each row as the code has it. A row that changes fails there
105first.
.gitbay/wiki/Home.org +1
@@ -6,6 +6,7 @@ CLI-first git forge: SSH is the API, the web is a rendering.
66- [[FAQ][FAQ]] — common questions from GitHub migrants
77- [[Users][User guide]] — accounts, keys, verified commits, repos, issues, MRs
88- [[Stacked-MRs][Stacked merge requests]] — dependent merge requests, merged one layer at a time
9- [[CI][CI]] — what a push queues: dedupe, path filters, schedules, the reaper
910- [[Admin][Admin guide]] — install, configuration reference, backup, security
1011- [[API][API and webhooks]] — the JSON API contract, tokens, payloads
1112- [[Threat-Model][Threat model]] — what the forge trusts and never does
.gitbay/wiki/Users.org +3
@@ -486,6 +486,9 @@ immediately, and =build cancel <owner/name> <n>= withdraws one, queued
486486or running: a running build stops at the runner within seconds and the
487487log says who cancelled it. Both need write access.
488488
489What each push shape queues, with dedupe, path filters, schedules and
490the reaper together, is one table on [[CI][CI]].
491
489492* Large files (LFS)
490493
491494Standard Git LFS works over both transports with no setup beyond the
internal/hookd/pushshapes_test.go added +464
@@ -0,0 +1,464 @@
1package hookd
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/ci"
13 "gitbay.org/gitbay/internal/config"
14 "gitbay.org/gitbay/internal/control"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// Every push shape, and what dedupe, the path filters, the schedules and
19// the reaper make of it. The table on the wiki's CI page is these rows;
20// the test asserts each against the code and then against the page, so
21// the two cannot drift apart (#184). #176 was a shape nobody had listed.
22//
23// One ci.yml for every shape, five jobs that each isolate one rule:
24//
25// plain no filter
26// onapp paths: app/**
27// notapp paths-ignore: app/**
28// nightly schedule
29// release tags: v*
30//
31// Every push in the table changes app/x and nothing else, so a filtered
32// push queues onapp and skips notapp, and a push whose filters cannot be
33// evaluated queues both.
34
35const shapesCI = `jobs:
36 plain:
37 steps:
38 - echo plain
39 onapp:
40 paths:
41 - app/**
42 steps:
43 - echo onapp
44 notapp:
45 paths-ignore:
46 - app/**
47 steps:
48 - echo notapp
49 nightly:
50 schedule: "0 3 * * *"
51 steps:
52 - echo nightly
53 release:
54 tags: "v*"
55 steps:
56 - echo release
57`
58
59var shapeJobs = []string{"plain", "onapp", "notapp", "nightly", "release"}
60
61const zeroSHA40 = "0000000000000000000000000000000000000000"
62
63type shapeFixture struct {
64 t *testing.T
65 st *store.Store
66 repo store.Repo
67 uid int64
68 root string
69 src string
70 dir string
71 srv *Server
72 sched *ci.Scheduler
73 base string
74 // Snapshot taken by mark, so the observation reports only what the
75 // shape itself produced.
76 markedSHA string
77 buildsBefore int
78 statusBefore map[string]string
79 schedBefore map[string]bool
80}
81
82func newShapeFixture(t *testing.T) *shapeFixture {
83 t.Helper()
84 st, err := store.Open(":memory:")
85 if err != nil {
86 t.Fatal(err)
87 }
88 t.Cleanup(func() { st.Close() })
89 if err := st.MigrateUp(); err != nil {
90 t.Fatal(err)
91 }
92 uid, err := st.CreateUser("alice", false)
93 if err != nil {
94 t.Fatal(err)
95 }
96 repoID, err := st.CreateRepo("user", uid, "app", "public")
97 if err != nil {
98 t.Fatal(err)
99 }
100 repo, err := st.RepoByID(repoID)
101 if err != nil {
102 t.Fatal(err)
103 }
104 root := t.TempDir()
105 f := &shapeFixture{t: t, st: st, repo: repo, uid: uid, root: root, src: filepath.Join(root, "src")}
106 f.dir = control.RepoDir(root, repo.OwnerName, repo.Name)
107 cfg := config.Config{}
108 cfg.Server.Root, cfg.Server.SiteURL = root, "https://x.test"
109 f.srv = &Server{cfg: cfg, st: st}
110 f.sched = &ci.Scheduler{St: st, SiteURL: "https://x.test",
111 RepoDir: func(owner, name string) string { return control.RepoDir(root, owner, name) }}
112
113 os.MkdirAll(filepath.Join(f.src, ".gitbay"), 0o755)
114 os.MkdirAll(filepath.Join(f.src, "app"), 0o755)
115 os.MkdirAll(filepath.Join(f.src, "docs"), 0o755)
116 f.write(".gitbay/ci.yml", shapesCI)
117 f.write("app/x", "1\n")
118 f.write("docs/d", "d\n")
119 f.git(root, "init", "-q", "-b", "main", "src")
120 f.git(f.src, "add", ".")
121 f.git(f.src, "commit", "-q", "-m", "base")
122 f.base = f.sha("HEAD")
123 os.MkdirAll(filepath.Dir(f.dir), 0o755)
124 f.git(root, "init", "-q", "--bare", f.dir)
125 f.sync()
126 return f
127}
128
129func (f *shapeFixture) git(dir string, args ...string) string {
130 f.t.Helper()
131 cmd := exec.Command("git", args...)
132 cmd.Dir = dir
133 cmd.Env = append(os.Environ(),
134 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
135 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
136 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
137 out, err := cmd.CombinedOutput()
138 if err != nil {
139 f.t.Fatalf("git %v: %v\n%s", args, err, out)
140 }
141 return string(out)
142}
143
144func (f *shapeFixture) write(rel, content string) {
145 if err := os.WriteFile(filepath.Join(f.src, rel), []byte(content), 0o644); err != nil {
146 f.t.Fatal(err)
147 }
148}
149
150func (f *shapeFixture) sha(ref string) string {
151 return strings.TrimSpace(f.git(f.src, "rev-parse", ref))
152}
153
154// sync moves every branch and tag of the working repository into the
155// served bare one, as the pushes the shapes stand for would have.
156func (f *shapeFixture) sync() {
157 f.git(f.src, "push", "-q", "--force", f.dir, "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
158}
159
160// appCommit commits a change to app/x on the current branch.
161func (f *shapeFixture) appCommit(msg string) string {
162 f.write("app/x", msg+"\n")
163 f.git(f.src, "add", ".")
164 f.git(f.src, "commit", "-q", "-m", msg)
165 return f.sha("HEAD")
166}
167
168func (f *shapeFixture) push(branch, old, sha string) {
169 f.sync()
170 control.QueueBranchBuilds(f.st, f.root, "https://x.test", f.repo, f.uid, branch, old, sha, time.Now())
171}
172
173// finish reports every pending build as a runner would, status on the
174// commit included.
175func (f *shapeFixture) finish(status string) {
176 for {
177 b, ok, err := f.st.ClaimBuild([]int64{f.repo.ID})
178 if err != nil {
179 f.t.Fatal(err)
180 }
181 if !ok {
182 return
183 }
184 if err := f.st.FinishBuild(b.ID, status); err != nil {
185 f.t.Fatal(err)
186 }
187 f.st.SetCommitStatus(f.repo.ID, b.SHA, "ci/"+b.Job, status, "reported", "", f.uid)
188 }
189}
190
191func (f *shapeFixture) statuses(sha string) map[string]string {
192 out := map[string]string{}
193 list, err := f.st.ListCommitStatuses(f.repo.ID, sha)
194 if err != nil {
195 f.t.Fatal(err)
196 }
197 for _, s := range list {
198 out[s.Context] = s.State + ": " + s.Description
199 }
200 return out
201}
202
203func (f *shapeFixture) schedules() map[string]bool {
204 out := map[string]bool{}
205 due, err := f.st.DueSchedules("9999-01-01T00:00:00Z")
206 if err != nil {
207 f.t.Fatal(err)
208 }
209 for _, s := range due {
210 out[s.Job] = true
211 }
212 return out
213}
214
215// mark snapshots the state the observed action starts from.
216func (f *shapeFixture) mark(sha string) {
217 builds, err := f.st.ListBuilds(f.repo.ID, 1000)
218 if err != nil {
219 f.t.Fatal(err)
220 }
221 f.markedSHA, f.buildsBefore = sha, len(builds)
222 f.statusBefore, f.schedBefore = f.statuses(sha), f.schedules()
223}
224
225// observe reduces what the action produced to one word per job, plus
226// the ci/config status. The words are the table's vocabulary.
227func (f *shapeFixture) observe() map[string]string {
228 out := map[string]string{}
229 builds, err := f.st.ListBuilds(f.repo.ID, 1000)
230 if err != nil {
231 f.t.Fatal(err)
232 }
233 fresh := map[string]store.Build{}
234 for _, b := range builds[:len(builds)-f.buildsBefore] { // newest first
235 fresh[b.Job] = b
236 }
237 after, sched := f.statuses(f.markedSHA), f.schedules()
238 for _, j := range shapeJobs {
239 switch st, changed := after["ci/"+j], after["ci/"+j] != f.statusBefore["ci/"+j]; {
240 case fresh[j].Status == "pending" && !fresh[j].Trusted:
241 out[j] = "queued, untrusted"
242 case fresh[j].Status == "pending":
243 out[j] = "queued"
244 case changed && strings.HasPrefix(st, "failure: build abandoned"):
245 out[j] = "abandoned"
246 case changed && strings.HasPrefix(st, "skipped:"):
247 out[j] = "skipped"
248 case changed && strings.HasPrefix(st, "success:") && strings.Contains(st, "same tree"):
249 out[j] = "reused"
250 case changed:
251 out[j] = st
252 case sched[j] && !f.schedBefore[j]:
253 out[j] = "registered"
254 default:
255 out[j] = "—"
256 }
257 }
258 cfg, changed := after["ci/config"], after["ci/config"] != f.statusBefore["ci/config"]
259 if changed && strings.HasPrefix(cfg, "failure:") {
260 out["ci/config"] = "failure"
261 } else {
262 out["ci/config"] = "—"
263 }
264 return out
265}
266
267type pushShape struct {
268 name string
269 run func(f *shapeFixture)
270 want []string // plain, onapp, notapp, nightly, release, ci/config
271}
272
273var pushShapes = []pushShape{
274 {"first push of the default branch", func(f *shapeFixture) {
275 f.mark(f.base)
276 f.push("main", zeroSHA40, f.base)
277 }, []string{"queued", "queued", "queued", "registered", "—", "—"}},
278
279 {"push to the default branch", func(f *shapeFixture) {
280 c := f.appCommit("more")
281 f.mark(c)
282 f.push("main", f.base, c)
283 }, []string{"queued", "queued", "skipped", "registered", "—", "—"}},
284
285 {"push to another branch", func(f *shapeFixture) {
286 f.git(f.src, "checkout", "-q", "-b", "feat")
287 c := f.appCommit("more")
288 f.mark(c)
289 f.push("feat", f.base, c)
290 }, []string{"queued", "queued", "skipped", "—", "—", "—"}},
291
292 {"a new branch, no old sha", func(f *shapeFixture) {
293 f.git(f.src, "checkout", "-q", "-b", "feat")
294 c := f.appCommit("more")
295 f.mark(c)
296 f.push("feat", zeroSHA40, c)
297 }, []string{"queued", "queued", "skipped", "—", "—", "—"}},
298
299 {"rebase onto a moved default branch, old not an ancestor", func(f *shapeFixture) {
300 f.git(f.src, "checkout", "-q", "-b", "feat")
301 c1 := f.appCommit("more")
302 f.git(f.src, "checkout", "-q", "main")
303 f.write("docs/d", "moved\n")
304 f.git(f.src, "add", ".")
305 f.git(f.src, "commit", "-q", "-m", "docs on main")
306 f.git(f.src, "checkout", "-q", "feat")
307 f.git(f.src, "rebase", "-q", "main")
308 c2 := f.sha("HEAD")
309 f.mark(c2)
310 f.push("feat", c1, c2)
311 }, []string{"queued", "queued", "skipped", "—", "—", "—"}},
312
313 {"rewritten commit, same tree as a passed build", func(f *shapeFixture) {
314 f.git(f.src, "checkout", "-q", "-b", "feat")
315 c1 := f.appCommit("more")
316 f.push("feat", zeroSHA40, c1)
317 f.finish("success")
318 f.git(f.src, "commit", "-q", "--allow-empty", "-m", "rewritten")
319 c2 := f.sha("HEAD")
320 f.mark(c2)
321 f.push("feat", c1, c2)
322 }, []string{"reused", "reused", "skipped", "—", "—", "—"}},
323
324 {"fast-forward of a commit built on another branch", func(f *shapeFixture) {
325 f.git(f.src, "checkout", "-q", "-b", "feat")
326 c1 := f.appCommit("more")
327 f.push("feat", zeroSHA40, c1)
328 f.finish("success")
329 f.git(f.src, "checkout", "-q", "main")
330 f.git(f.src, "merge", "-q", "--ff-only", "feat")
331 f.mark(c1)
332 f.push("main", f.base, c1)
333 }, []string{"—", "—", "—", "registered", "—", "—"}},
334
335 {"a commit whose earlier build failed, on a new branch", func(f *shapeFixture) {
336 f.git(f.src, "checkout", "-q", "-b", "feat")
337 c1 := f.appCommit("more")
338 f.push("feat", zeroSHA40, c1)
339 f.finish("failure")
340 f.git(f.src, "branch", "-q", "again", "feat")
341 f.mark(c1)
342 f.push("again", zeroSHA40, c1)
343 }, []string{"queued", "queued", "—", "—", "—", "—"}},
344
345 {"merge request head from a fork", func(f *shapeFixture) {
346 f.git(f.src, "checkout", "-q", "-b", "feat")
347 c1 := f.appCommit("more")
348 f.git(f.src, "push", "-q", f.dir, "+refs/heads/feat:refs/merge-requests/1/head")
349 f.mark(c1)
350 control.QueueMRBuilds(f.st, f.root, "https://x.test", f.repo, f.uid, 1, c1)
351 }, []string{"queued, untrusted", "queued, untrusted", "queued, untrusted", "—", "—", "—"}},
352
353 {"tag push", func(f *shapeFixture) {
354 f.git(f.src, "tag", "v1")
355 f.sync()
356 f.mark(f.base)
357 f.srv.queueTagBuilds(f.repo, f.uid, "v1", f.base)
358 }, []string{"—", "—", "—", "—", "queued", "—"}},
359
360 {"schedule tick on the default branch", func(f *shapeFixture) {
361 f.push("main", zeroSHA40, f.base)
362 f.mark(f.base)
363 f.sched.RunDue(time.Now().AddDate(1, 0, 0))
364 }, []string{"—", "—", "—", "queued", "—", "—"}},
365
366 {"claimed builds whose runner vanished", func(f *shapeFixture) {
367 f.git(f.src, "checkout", "-q", "-b", "feat")
368 c1 := f.appCommit("more")
369 f.push("feat", zeroSHA40, c1)
370 for i := 0; i < 2; i++ {
371 if _, ok, err := f.st.ClaimBuild(nil); err != nil || !ok {
372 f.t.Fatalf("claim: %v ok=%v", err, ok)
373 }
374 }
375 if _, err := f.st.DB.Exec(`UPDATE builds SET started_at = '2020-01-01T00:00:00Z'`); err != nil {
376 f.t.Fatal(err)
377 }
378 f.mark(c1)
379 f.sched.RunDue(time.Now())
380 }, []string{"abandoned", "abandoned", "—", "—", "—", "—"}},
381
382 {"push to the default branch with an old sha that cannot be diffed", func(f *shapeFixture) {
383 c := f.appCommit("more")
384 f.mark(c)
385 f.push("main", strings.Repeat("1", 40), c)
386 }, []string{"queued", "queued", "queued", "registered", "—", "—"}},
387
388 {"push with a broken ci.yml", func(f *shapeFixture) {
389 f.write(".gitbay/ci.yml", "jobs: [\n")
390 c := f.appCommit("broken")
391 f.mark(c)
392 f.push("main", f.base, c)
393 }, []string{"—", "—", "—", "—", "—", "failure"}},
394
395 {"push with no ci.yml", func(f *shapeFixture) {
396 f.git(f.src, "rm", "-q", ".gitbay/ci.yml")
397 c := f.appCommit("no ci")
398 f.mark(c)
399 f.push("main", f.base, c)
400 }, []string{"—", "—", "—", "—", "—", "—"}},
401}
402
403var shapeColumns = append([]string{"push"}, append(append([]string{}, shapeJobs...), "ci/config")...)
404
405// tableRow renders one shape the way the wiki's table carries it.
406func tableRow(cells []string) string {
407 return "| " + strings.Join(cells, " | ") + " |"
408}
409
410// normalizeRow strips the alignment padding org-mode adds to a table.
411func normalizeRow(line string) string {
412 cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|")
413 for i := range cells {
414 cells[i] = strings.TrimSpace(cells[i])
415 }
416 return tableRow(cells)
417}
418
419func TestPushShapes(t *testing.T) {
420 for _, sh := range pushShapes {
421 t.Run(sh.name, func(t *testing.T) {
422 f := newShapeFixture(t)
423 sh.run(f)
424 got := f.observe()
425 for i, j := range append(append([]string{}, shapeJobs...), "ci/config") {
426 if got[j] != sh.want[i] {
427 t.Errorf("%s: %s = %q, want %q", sh.name, j, got[j], sh.want[i])
428 }
429 }
430 })
431 }
432}
433
434// The wiki's table is the test's expectations rendered as rows, and the
435// header names the columns; a row the page lacks or states differently
436// fails here.
437func TestPushShapesTableOnWiki(t *testing.T) {
438 raw, err := os.ReadFile(filepath.Join("..", "..", ".gitbay", "wiki", "CI.org"))
439 if err != nil {
440 t.Fatal(err)
441 }
442 have := map[string]bool{}
443 for _, line := range strings.Split(string(raw), "\n") {
444 if strings.HasPrefix(strings.TrimSpace(line), "|") {
445 have[normalizeRow(line)] = true
446 }
447 }
448 if !have[tableRow(shapeColumns)] {
449 t.Errorf("CI.org lacks the header row %s", tableRow(shapeColumns))
450 }
451 for _, sh := range pushShapes {
452 row := tableRow(append([]string{sh.name}, sh.want...))
453 if !have[row] {
454 t.Errorf("CI.org lacks the row %s", row)
455 }
456 }
457 if t.Failed() {
458 fmt.Fprintln(os.Stderr, "the table as the code has it:")
459 fmt.Fprintln(os.Stderr, tableRow(shapeColumns))
460 for _, sh := range pushShapes {
461 fmt.Fprintln(os.Stderr, tableRow(append([]string{sh.name}, sh.want...)))
462 }
463 }
464}