Commit ff6271a9d4

ff6271a9d4570cd46f169091637a9d2e40ad5c2a

parent: 59a6e6bf08

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-28 04:42 UTC

cli: front-ends for every registry command, and a test that keeps it that way

The command tree in cmd/gitbay is hand-written, so a command could land
in the registry — reachable over raw SSH, the web and the API — while
`gitbay <cmd>` still answered "unknown command". explore, wiki, blame,
commit, commit-file, refs, download, settings visibility and audit were
all in that state.

TestEveryCommandIsReachable walks the tree and fails on any registered
command with no way to type it. Commands deliberately absent (the
runner's wire protocol, account import-bundle) are listed with reasons.

Layout: unified · split

cmd/gitbay/coverage_test.go added +57
@@ -0,0 +1,57 @@
1package main
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/spf13/cobra"
8
9 "gitbay.org/gitbay/internal/control"
10)
11
12// Commands with no CLI front-end on purpose. Everything else in the
13// registry must be reachable by typing it, or the CLI is not the
14// complete interface the design claims.
15var notInCLI = map[string]string{
16 "help": "cobra provides its own",
17 "runner next": "the CI runner's wire protocol, not for humans",
18 "runner done": "the CI runner's wire protocol, not for humans",
19 "runner log": "the CI runner's wire protocol, not for humans",
20 "account import-bundle": "the wire format behind `gitbay migrate`",
21}
22
23// TestEveryCommandIsReachable guards the invariant that adding a command
24// to the registry is enough. The tree is hand-written, so a command can
25// land server-side — reachable over raw SSH, the web and the API — while
26// `gitbay <cmd>` still says "unknown command". That happened to `explore`
27// and `wiki`.
28func TestEveryCommandIsReachable(t *testing.T) {
29 wired := map[string]bool{}
30 var walk func(*cobra.Command)
31 walk = func(c *cobra.Command) {
32 if p := c.Annotations[serverPath]; p != "" {
33 wired[p] = true
34 }
35 for _, sub := range c.Commands() {
36 walk(sub)
37 }
38 }
39 walk(newRoot())
40
41 for _, cmd := range control.Commands() {
42 path := strings.Join(cmd.Path, " ")
43 if wired[path] || notInCLI[path] != "" {
44 continue
45 }
46 t.Errorf("%q is in the registry but not in the CLI tree; "+
47 "add a pass() for it, or list it in notInCLI with a reason", path)
48 }
49
50 // A hand-built command (auth pgp add, repo settings visibility) is
51 // fine, but it must still name a real command.
52 for path := range wired {
53 if _, _, ok := control.Lookup(strings.Fields(path)); !ok {
54 t.Errorf("CLI dispatches %q, which the registry does not define", path)
55 }
56 }
57}
cmd/gitbay/main.go +34 −5
@@ -17,6 +17,15 @@ import (
17) 17)
18 18
19func main() { 19func main() {
20 if err := newRoot().Execute(); err != nil {
21 fmt.Fprintln(os.Stderr, "gitbay:", err)
22 os.Exit(protocol.ExitUsage)
23 }
24}
25
26// newRoot builds the command tree. Separate from main so the coverage
27// test can walk it.
28func newRoot() *cobra.Command {
20 root := &cobra.Command{ 29 root := &cobra.Command{
21 Use: "gitbay", 30 Use: "gitbay",
22 Short: "CLI-first git forge client", 31 Short: "CLI-first git forge client",
@@ -40,6 +49,12 @@ func main() {
40 passOpts{server: []string{"dashboard"}}), 49 passOpts{server: []string{"dashboard"}}),
41 pass("feed", "activity on repositories you can reach [--limit n] [--cursor c]", 50 pass("feed", "activity on repositories you can reach [--limit n] [--cursor c]",
42 passOpts{server: []string{"feed"}}), 51 passOpts{server: []string{"feed"}}),
52 pass("explore", "public repositories on this instance [--limit n] [--cursor c]",
53 passOpts{server: []string{"explore"}}),
54 group("wiki", "a repository's wiki pages",
55 pass("list", "list pages: [<owner/name>]", passOpts{server: []string{"wiki", "list"}, needsRepo: true}),
56 pass("show", "print a page: [<owner/name>] [<page>]", passOpts{server: []string{"wiki", "show"}, needsRepo: true}),
57 ),
43 repoCmd(), 58 repoCmd(),
44 issueCmd(), 59 issueCmd(),
45 milestoneCmd(), 60 milestoneCmd(),
@@ -57,15 +72,16 @@ func main() {
57 initCmd(), 72 initCmd(),
58 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", 73 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>",
59 passOpts{server: []string{"register"}}), 74 passOpts{server: []string{"register"}}),
75 pass("audit", "instance audit log (admins): [--limit <n>]", passOpts{server: []string{"audit"}}),
60 manCmd(root), 76 manCmd(root),
61 ) 77 )
62 78 return root
63 if err := root.Execute(); err != nil {
64 fmt.Fprintln(os.Stderr, "gitbay:", err)
65 os.Exit(protocol.ExitUsage)
66 }
67} 79}
68 80
81// serverPath is the annotation key holding a passthrough command's
82// server-side path, so the tree can be checked against the registry.
83const serverPath = "gitbay.server_path"
84
69// passOpts describes how one CLI command maps onto the server command. 85// passOpts describes how one CLI command maps onto the server command.
70type passOpts struct { 86type passOpts struct {
71 server []string // server-side command path 87 server []string // server-side command path
@@ -81,6 +97,7 @@ func pass(use, short string, o passOpts) *cobra.Command {
81 return &cobra.Command{ 97 return &cobra.Command{
82 Use: use, 98 Use: use,
83 Short: short, 99 Short: short,
100 Annotations: map[string]string{serverPath: strings.Join(o.server, " ")},
84 DisableFlagParsing: true, 101 DisableFlagParsing: true,
85 RunE: func(cmd *cobra.Command, args []string) error { 102 RunE: func(cmd *cobra.Command, args []string) error {
86 // cobra still owns `forge <cmd> --help`. 103 // cobra still owns `forge <cmd> --help`.
@@ -194,6 +211,7 @@ func authCmd() *cobra.Command {
194 } 211 }
195 pgpAdd := &cobra.Command{ 212 pgpAdd := &cobra.Command{
196 Use: "add", Short: "register an OpenPGP public key (armored, on stdin)", 213 Use: "add", Short: "register an OpenPGP public key (armored, on stdin)",
214 Annotations: map[string]string{serverPath: "pgp add"},
197 DisableFlagParsing: true, 215 DisableFlagParsing: true,
198 RunE: func(cmd *cobra.Command, args []string) error { 216 RunE: func(cmd *cobra.Command, args []string) error {
199 t, err := resolveTarget() 217 t, err := resolveTarget()
@@ -245,6 +263,15 @@ func repoCmd() *cobra.Command {
245 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), 263 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
246 pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}), 264 pass("tree", "list a directory: [<path>] [--ref <ref>]", passOpts{server: []string{"repo", "tree"}, needsRepo: true}),
247 pass("cat", "read a file: <path> [--ref <ref>]", passOpts{server: []string{"repo", "cat"}, needsRepo: true}), 265 pass("cat", "read a file: <path> [--ref <ref>]", passOpts{server: []string{"repo", "cat"}, needsRepo: true}),
266 pass("blame", "attribute lines to commits: <path> [--ref <ref>] [--from <n>] [--to <n>]",
267 passOpts{server: []string{"repo", "blame"}, needsRepo: true}),
268 pass("commit", "show one commit with its patch: <sha>",
269 passOpts{server: []string{"repo", "commit"}, needsRepo: true}),
270 pass("commit-file", "write a file and commit it: <path> [--ref <ref>] [--message <m>] --file -",
271 passOpts{server: []string{"repo", "commit-file"}, needsRepo: true, stdinOK: true}),
272 pass("refs", "list branches and tags", passOpts{server: []string{"repo", "refs"}, needsRepo: true}),
273 pass("download", "write a tar.gz of a ref to stdout: [--ref <r>] > repo.tar.gz",
274 passOpts{server: []string{"repo", "download"}, needsRepo: true}),
248 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}), 275 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}),
249 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}), 276 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}),
250 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), 277 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
@@ -293,6 +320,7 @@ func repoCmd() *cobra.Command {
293 pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}), 320 pass("require-approvals", "require N fresh approvals to merge: <n>", passOpts{server: []string{"repo", "settings", "require-approvals"}, needsRepo: true}),
294 pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}), 321 pass("require-resolved", "require threads resolved to merge: on|off", passOpts{server: []string{"repo", "settings", "require-resolved"}, needsRepo: true}),
295 pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}), 322 pass("require-checks", "gate merges on green statuses: ... on|off", passOpts{server: []string{"repo", "settings", "require-checks"}, needsRepo: true}),
323 pass("visibility", "set repository visibility: public|private", passOpts{server: []string{"repo", "settings", "visibility"}, needsRepo: true}),
296 pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}), 324 pass("require-signed", "require verified commit signatures: ... on|off", passOpts{server: []string{"repo", "settings", "require-signed"}, needsRepo: true}),
297 pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}), 325 pass("description", "set the repository description: <text>", passOpts{server: []string{"repo", "settings", "description"}, needsRepo: true}),
298 pass("website", "set the repository website: <url> ('' clears)", passOpts{server: []string{"repo", "settings", "website"}, needsRepo: true}), 326 pass("website", "set the repository website: <url> ('' clears)", passOpts{server: []string{"repo", "settings", "website"}, needsRepo: true}),
@@ -373,6 +401,7 @@ func importCmd() *cobra.Command {
373 return &cobra.Command{ 401 return &cobra.Command{
374 Use: "import", 402 Use: "import",
375 Short: "server-side mirror of a foreign repo: gitbay repo import <owner/name> --from <url> [--private] [--token-stdin]", 403 Short: "server-side mirror of a foreign repo: gitbay repo import <owner/name> --from <url> [--private] [--token-stdin]",
404 Annotations: map[string]string{serverPath: "repo import"},
376 DisableFlagParsing: true, 405 DisableFlagParsing: true,
377 RunE: func(cmd *cobra.Command, args []string) error { 406 RunE: func(cmd *cobra.Command, args []string) error {
378 for _, a := range args { 407 for _, a := range args {