cli: help and usage print the command as typed !485

merged merged by cmc on 2026-09-28 22:05 UTC · krz/gitbay:cli-ux-help into main

43 files changed, +797 −115

Layout: unified · split

CHANGELOG.org +11
@@ -69,6 +69,10 @@ must add =--scope full=. Existing tokens keep their scope.
6969 separately (#275).
7070- Audit retention deletes by id, up to the newest row older than the
7171 retention, so a clock step back cannot leave a gap in the chain (#275).
72*Upgrade note.* Upgrade the instance before the CLI: an older server
73refuses the CLI's leading =--path== argument as an unknown command,
74for the eighteen commands whose CLI path differs from the registry's
75(the =gitbay auth ...= commands and =repo topics list=).
7276- =dashboard= and =feed= print activity as sentences
7377 (=cmc opened issue krz/gitbay#12=) instead of raw event payloads,
7478 and a labelled event names its labels there and on the web feed. An
@@ -76,6 +80,13 @@ must add =--scope full=. Existing tokens keep their scope.
7680 =open_issues= field of =dashboard --json= no longer includes issues
7781 assigned to the caller. =notifications list= names =--all= when only
7882 read items remain (#265).
83- Help and usage print the command the way the caller typed it —
84 =usage: gitbay auth keys remove <fingerprint>=, not the registry's
85 own path. =auth --help= lists the email and API token commands
86 alongside keys and PGP. A bad-flag error prints the same prefixed
87 usage line a wrong-argument refusal does. Outside the CLI a usage
88 refusal reads =usage: ssh git@<host> ...= on every surface,
89 including the error text of the web UI and the JSON API (#267).
7990
8091* v1.36.0 — 2026-09-23
8192
cmd/gitbay/main.go +56 −17
@@ -253,12 +253,13 @@ func pass(use string, o passOpts) *cobra.Command {
253253 // The registry is the only place flags are written down, so
254254 // --help asks the server rather than reprinting the one-line
255255 // summary cobra holds.
256 cliPath := cliPathOf(cmd)
256257 for _, a := range args {
257258 if a == "--help" || a == "-h" {
258 os.Exit(runServerHelp(o))
259 os.Exit(runServerHelp(o, cliPath))
259260 }
260261 }
261 os.Exit(runPass(o, args))
262 os.Exit(runPass(o, cliPath, args))
262263 return nil
263264 },
264265 }
@@ -276,16 +277,17 @@ func withShort(cmd *cobra.Command, short string) *cobra.Command {
276277}
277278
278279// runServerHelp prints the registry's usage for one command.
279func runServerHelp(o passOpts) int {
280func runServerHelp(o passOpts, cliPath string) int {
280281 t, err := resolveTarget()
281282 if err != nil {
282283 fmt.Fprintln(os.Stderr, "gitbay:", err)
283284 return protocol.ExitFailure
284285 }
285 return runSSH(t, append([]string{"help"}, o.server...), strings.NewReader(""))
286 argv := withCLIPath(cliPath, strings.Join(o.server, " "), append([]string{"help"}, o.server...))
287 return runSSH(t, argv, strings.NewReader(""))
286288}
287289
288func runPass(o passOpts, args []string) int {
290func runPass(o passOpts, cliPath string, args []string) int {
289291 t, err := resolveTarget()
290292 if err != nil {
291293 fmt.Fprintln(os.Stderr, "gitbay:", err)
@@ -343,7 +345,8 @@ func runPass(o passOpts, args []string) int {
343345 stdin = r
344346 }
345347 }
346 return runSSHPaged(t, append(o.server, args...), stdin, pages(o.server, args))
348 argv := withCLIPath(cliPath, strings.Join(o.server, " "), append(o.server, args...))
349 return runSSHPaged(t, argv, stdin, pages(o.server, args))
347350}
348351
349352func isEmptyReader(r io.Reader) bool {
@@ -380,27 +383,51 @@ func group(use, short string, subs ...*cobra.Command) *cobra.Command {
380383 // know by that name, cobra's own tree still prints.
381384 local := c.HelpFunc()
382385 c.SetHelpFunc(func(cmd *cobra.Command, args []string) {
383 if !serverHelp(use) {
386 if !serverHelp(use, cliPathOf(c)) {
384387 local(cmd, args)
385388 }
386389 })
387390 return c
388391}
389392
393// aliasGroupNames are CLI-only noun names with no matching registry
394// prefix (internal/control's nounAliases keys — kept in sync by hand,
395// since the CLI has no registry to consult): auth's own cliPath is
396// "auth", equal to the prefix it asks help for, so withCLIPath's
397// equality shortcut reads that as "no override needed" even though no
398// registered command is named "auth" at all, and help would silently
399// fall back to the registered forms (#267 review finding). Force
400// --path= for these regardless of the equality check.
401var aliasGroupNames = map[string]bool{"auth": true}
402
403// helpArgv builds the server argv for a group's --help: --path=cliPath
404// when the CLI's path differs from the registered prefix it names, or
405// unconditionally when prefix is a CLI-only alias grouping the registry
406// never answers to under that name.
407func helpArgv(prefix, cliPath string) []string {
408 argv := []string{"help", prefix}
409 if aliasGroupNames[prefix] {
410 return append([]string{"--path=" + cliPath}, argv...)
411 }
412 return withCLIPath(cliPath, prefix, argv)
413}
414
390415// serverHelp prints the registry's usage for a prefix and reports whether
391// it did. At a terminal it goes through the terminal-aware path, so it
392// gets the same --term=<cols>[,color] treatment (and layout) as any other
393// command; piped, it stays a quiet capture, so a network or lookup
394// failure falls back to cobra's local help without noise.
395func serverHelp(prefix string) bool {
416// it did. cliPath is the group's own path in the CLI. At a terminal it
417// goes through the terminal-aware path, so it gets the same
418// --term=<cols>[,color] treatment (and layout) as any other command;
419// piped, it stays a quiet capture, so a network or lookup failure falls
420// back to cobra's local help without noise.
421func serverHelp(prefix, cliPath string) bool {
396422 t, err := resolveTarget()
397423 if err != nil {
398424 return false
399425 }
426 argv := helpArgv(prefix, cliPath)
400427 if term.IsTerminal(int(os.Stdout.Fd())) {
401 return runSSH(t, []string{"help", prefix}, strings.NewReader("")) == 0
428 return runSSH(t, argv, strings.NewReader("")) == 0
402429 }
403 out, code := sshCapture(t, []string{"help", prefix})
430 out, code := sshCapture(t, argv)
404431 if code != 0 || out == "" {
405432 return false
406433 }
@@ -430,6 +457,12 @@ func authCmd() *cobra.Command {
430457 keysAdd := pass("add", passOpts{server: []string{"keys", "add"}, alwaysStdin: true, stdinWhat: "an SSH public key"})
431458 // keys add always reads stdin on the server; wire it through directly.
432459 keysAdd.RunE = func(cmd *cobra.Command, args []string) error {
460 cliPath := cliPathOf(cmd)
461 for _, a := range args {
462 if a == "--help" || a == "-h" {
463 os.Exit(runServerHelp(passOpts{server: []string{"keys", "add"}}, cliPath))
464 }
465 }
433466 t, err := resolveTarget()
434467 if err != nil {
435468 return err
@@ -438,7 +471,7 @@ func authCmd() *cobra.Command {
438471 if err != nil {
439472 return err
440473 }
441 os.Exit(runSSH(t, append([]string{"keys", "add"}, args...), in))
474 os.Exit(runSSH(t, withCLIPath(cliPath, "keys add", append([]string{"keys", "add"}, args...)), in))
442475 return nil
443476 }
444477 pgpAdd := &cobra.Command{
@@ -450,6 +483,12 @@ func authCmd() *cobra.Command {
450483 },
451484 DisableFlagParsing: true,
452485 RunE: func(cmd *cobra.Command, args []string) error {
486 cliPath := cliPathOf(cmd)
487 for _, a := range args {
488 if a == "--help" || a == "-h" {
489 os.Exit(runServerHelp(passOpts{server: []string{"pgp", "add"}}, cliPath))
490 }
491 }
453492 t, err := resolveTarget()
454493 if err != nil {
455494 return err
@@ -458,7 +497,7 @@ func authCmd() *cobra.Command {
458497 if err != nil {
459498 return err
460499 }
461 os.Exit(runSSH(t, append([]string{"pgp", "add"}, args...), in))
500 os.Exit(runSSH(t, withCLIPath(cliPath, "pgp add", append([]string{"pgp", "add"}, args...)), in))
462501 return nil
463502 },
464503 }
@@ -467,7 +506,7 @@ func authCmd() *cobra.Command {
467506 pass("list", passOpts{server: []string{"token", "list"}}),
468507 pass("revoke", passOpts{server: []string{"token", "revoke"}}),
469508 )
470 return group("auth", "identity: whoami, SSH and PGP keys",
509 return group("auth", "whoami, SSH and PGP keys, email, API tokens",
471510 pass("export", passOpts{server: []string{"account", "export"}}),
472511 tokens,
473512 pass("whoami", passOpts{server: []string{"whoami"}}),
cmd/gitbay/serverpath_test.go added +81
@@ -0,0 +1,81 @@
1package main
2
3import (
4 "slices"
5 "testing"
6
7 "github.com/spf13/cobra"
8)
9
10// TestServerPathMismatches pins the commands whose CLI path differs from
11// the server path they dispatch, the ones that send --path= so usage and
12// help print the CLI's path (#267). A change to this list is deliberate.
13func TestServerPathMismatches(t *testing.T) {
14 want := []string{
15 "auth email add", "auth email list", "auth email primary",
16 "auth email remove", "auth email verify",
17 "auth export",
18 "auth keys add", "auth keys label", "auth keys list", "auth keys remove",
19 "auth pgp add", "auth pgp list", "auth pgp remove",
20 "auth token create", "auth token list", "auth token revoke",
21 "auth whoami",
22 "repo topics list",
23 }
24
25 var got []string
26 var walk func(*cobra.Command)
27 walk = func(c *cobra.Command) {
28 if p := c.Annotations[serverPath]; p != "" {
29 if cli := cliPathOf(c); cli != p {
30 got = append(got, cli)
31 }
32 }
33 for _, sub := range c.Commands() {
34 walk(sub)
35 }
36 }
37 root := newRoot()
38 root.InitDefaultHelpCmd()
39 walk(root)
40 slices.Sort(got)
41
42 if !slices.Equal(got, want) {
43 t.Errorf("mismatched CLI paths = %v\nwant %v", got, want)
44 }
45}
46
47// TestHelpArgvSendsThePathForAnAliasGroup pins the review-round fix: a
48// bare `gitbay auth --help` (cliPath == prefix == "auth") must still send
49// --path=auth, since the registry has no "auth" command for withCLIPath's
50// equality shortcut to correctly skip (#267 follow-up).
51func TestHelpArgvSendsThePathForAnAliasGroup(t *testing.T) {
52 got := helpArgv("auth", "auth")
53 want := []string{"--path=auth", "help", "auth"}
54 if !slices.Equal(got, want) {
55 t.Errorf("helpArgv(auth, auth) = %v, want %v", got, want)
56 }
57}
58
59// TestHelpArgvSendsNoPathWhenCLIMatchesTheRegistry pins the unaffected
60// case: an ordinary noun's cliPath and registered prefix are the same
61// string, and it is not a CLI-only alias grouping, so no --path= is sent
62// and the server resolves it on its own.
63func TestHelpArgvSendsNoPathWhenCLIMatchesTheRegistry(t *testing.T) {
64 got := helpArgv("issue", "issue")
65 want := []string{"help", "issue"}
66 if !slices.Equal(got, want) {
67 t.Errorf("helpArgv(issue, issue) = %v, want %v", got, want)
68 }
69}
70
71// TestHelpArgvSendsThePathForAMismatchedGroup pins the ordinary
72// Task 2.1 case, unchanged by the alias-group fix: a nested group whose
73// cliPath differs from its registered prefix (auth keys, for keys)
74// already sends --path= through withCLIPath.
75func TestHelpArgvSendsThePathForAMismatchedGroup(t *testing.T) {
76 got := helpArgv("keys", "auth keys")
77 want := []string{"--path=auth keys", "help", "keys"}
78 if !slices.Equal(got, want) {
79 t.Errorf("helpArgv(keys, auth keys) = %v, want %v", got, want)
80 }
81}
cmd/gitbay/ssh.go +18
@@ -17,6 +17,7 @@ import (
1717 "gitbay.org/gitbay/internal/cliconfig"
1818 "gitbay.org/gitbay/internal/protocol"
1919 "gitbay.org/gitbay/internal/toolpath"
20 "github.com/spf13/cobra"
2021)
2122
2223// context is the resolved target for a command: which instance to talk to
@@ -166,6 +167,23 @@ func pages(server, args []string) bool {
166167 return false
167168}
168169
170// cliPathOf is the path a cobra command was reached by, without the
171// root's name: "auth keys remove".
172func cliPathOf(cmd *cobra.Command) string {
173 return strings.TrimPrefix(cmd.CommandPath(), "gitbay ")
174}
175
176// withCLIPath prepends --path=<cliPath> to a server command line when the
177// CLI path is set and differs from the server path it dispatches, so
178// usage and help print a command the caller can run (#267). Dispatch
179// strips it before Lookup, as it does --term=.
180func withCLIPath(cliPath, server string, argv []string) []string {
181 if cliPath == "" || cliPath == server {
182 return argv
183 }
184 return append([]string{"--path=" + cliPath}, argv...)
185}
186
169187// runSSH executes the server command over the system ssh binary, wiring
170188// stdio through, with no pager. It returns the remote exit code.
171189func runSSH(t target, serverArgv []string, stdin io.Reader) int {
cmd/gitbay/summaries_gen.go +6 −6
@@ -42,12 +42,12 @@ var summaries = map[string]string{
4242 "explore": "list public repositories",
4343 "feed": "activity on repositories you can reach",
4444 "help": "list available commands",
45 "issue assign": "assignees",
45 "issue assign": "add or remove assignees",
4646 "issue close": "close an issue",
47 "issue comment": "comment",
47 "issue comment": "add a comment",
4848 "issue create": "open an issue",
4949 "issue edit": "edit title or body",
50 "issue label": "labels",
50 "issue label": "add or remove labels",
5151 "issue list": "list issues",
5252 "issue milestone": "set or clear an issue's milestone",
5353 "issue reopen": "reopen an issue",
@@ -65,13 +65,13 @@ var summaries = map[string]string{
6565 "milestone list": "list milestones with progress",
6666 "milestone reopen": "reopen a milestone",
6767 "mr close": "close without merging",
68 "mr comment": "comment",
68 "mr comment": "add a comment",
6969 "mr create": "open a merge request",
7070 "mr diff": "show the diff",
7171 "mr diff-comment": "comment on a diff line",
7272 "mr draft": "mark a merge request as work in progress",
7373 "mr edit": "edit title or body",
74 "mr label": "labels",
74 "mr label": "add or remove labels",
7575 "mr list": "list merge requests",
7676 "mr merge": "merge",
7777 "mr milestone": "set or clear an MR's milestone",
@@ -80,7 +80,7 @@ var summaries = map[string]string{
8080 "mr resolve": "resolve a review thread",
8181 "mr retarget": "retarget onto another branch",
8282 "mr review request": "ask specific people for a review",
83 "mr review": "review",
83 "mr review": "record a review verdict",
8484 "mr revisions": "the heads a merge request has had",
8585 "mr show": "show a merge request",
8686 "mr threads": "review threads on an MR",
cmd/gitbay/summaries_test.go +18
@@ -65,3 +65,21 @@ func TestGroupsSayWhatTheServerSays(t *testing.T) {
6565 }
6666 }
6767}
68
69// aliasGroupNames is kept by hand because the CLI has no registry to
70// consult; this fails when it drifts from internal/control's nounAliases
71// keys instead of silently falling back to the registered forms (#267).
72func TestAliasGroupNamesMatchServer(t *testing.T) {
73 want := map[string]bool{}
74 for _, name := range control.NounAliasNames() {
75 want[name] = true
76 }
77 if len(aliasGroupNames) != len(want) {
78 t.Fatalf("aliasGroupNames has %d entries, server has %d", len(aliasGroupNames), len(want))
79 }
80 for name := range want {
81 if !aliasGroupNames[name] {
82 t.Errorf("aliasGroupNames is missing %q", name)
83 }
84 }
85}
cmd/gitbay/term_test.go +31
@@ -1,8 +1,11 @@
11package main
22
33import (
4 "slices"
45 "strings"
56 "testing"
7
8 "github.com/spf13/cobra"
69)
710
811func TestTermValue(t *testing.T) {
@@ -77,3 +80,31 @@ func TestPages(t *testing.T) {
7780 t.Error("list must not page")
7881 }
7982}
83
84func TestCLIPathOf(t *testing.T) {
85 root := &cobra.Command{Use: "gitbay"}
86 auth := &cobra.Command{Use: "auth"}
87 keys := &cobra.Command{Use: "keys"}
88 remove := &cobra.Command{Use: "remove"}
89 keys.AddCommand(remove)
90 auth.AddCommand(keys)
91 root.AddCommand(auth)
92 if got := cliPathOf(remove); got != "auth keys remove" {
93 t.Errorf("cliPathOf = %q", got)
94 }
95}
96
97func TestWithCLIPath(t *testing.T) {
98 argv := []string{"keys", "remove", "abc"}
99 if got := withCLIPath("keys remove", "keys remove", argv); !slices.Equal(got, argv) {
100 t.Errorf("matching path: %v", got)
101 }
102 if got := withCLIPath("", "keys remove", argv); !slices.Equal(got, argv) {
103 t.Errorf("empty cliPath: %v", got)
104 }
105 got := withCLIPath("auth keys remove", "keys remove", argv)
106 want := []string{"--path=auth keys remove", "keys", "remove", "abc"}
107 if !slices.Equal(got, want) {
108 t.Errorf("got %v, want %v", got, want)
109 }
110}
e2e/cliusage_test.go added +119
@@ -0,0 +1,119 @@
1package e2e
2
3import (
4 "fmt"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// The CLI sends its own path where it differs from the registered one, so
11// usage and help print a command that exists: gitbay auth keys remove,
12// never gitbay keys remove (#267). Stock ssh sends none and sees the
13// registered path, the only one it can type.
14func TestCLIUsagePrintsTheInvokingPath(t *testing.T) {
15 t.Parallel()
16 inst := startInstance(t)
17 key := inst.newKey(t, "alice")
18 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub",
19 "--email", "alice@example.test", "--verified")
20
21 c := &cli{bin: buildGitbayCLI(t), configDir: t.TempDir(), inst: inst, key: key}
22 c.must(t, "", "", "remote", "add", "test", "127.0.0.1",
23 "--port", fmt.Sprint(inst.port),
24 "--ssh-option", "-i", "--ssh-option", key,
25 "--ssh-option", "-oIdentitiesOnly=yes",
26 "--ssh-option", "-oStrictHostKeyChecking=no",
27 "--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"),
28 "--ssh-option", "-oBatchMode=yes",
29 "--default")
30
31 _, errOut, code := c.run(t, "", "", "auth", "keys", "remove")
32 if code == 0 || !strings.Contains(errOut, "usage: gitbay auth keys remove <fingerprint>") {
33 t.Errorf("CLI refusal: exit %d, stderr %q", code, errOut)
34 }
35
36 out, errOut, code := c.run(t, "", "", "auth", "keys", "remove", "--help")
37 if code != 0 || !strings.Contains(out, "gitbay auth keys remove <fingerprint>") {
38 t.Errorf("CLI help: exit %d, stdout %q, stderr %q", code, out, errOut)
39 }
40
41 // A command whose CLI path matches sends no --path=, and off a
42 // terminal prints the ssh form as before.
43 _, errOut, code = c.run(t, "", "", "issue", "show", "alice/app")
44 if code == 0 || !strings.Contains(errOut, "usage: ssh git@") || !strings.Contains(errOut, " issue show <owner/name> <n>") {
45 t.Errorf("matching command: exit %d, stderr %q", code, errOut)
46 }
47
48 _, errOut, code = inst.ssh(t, key, "", "keys", "remove")
49 if code == 0 || !strings.Contains(errOut, "usage: ssh git@") || !strings.Contains(errOut, " keys remove <fingerprint>") {
50 t.Errorf("stock ssh: exit %d, stderr %q", code, errOut)
51 }
52 if strings.Contains(errOut, "auth") {
53 t.Errorf("stock ssh saw the CLI's auth grouping: %q", errOut)
54 }
55
56 // A bare `gitbay auth --help` (cliPath == "auth", the registered
57 // prefix it asks for) must still send --path=auth: the registry has
58 // no "auth" command, so withCLIPath's equality shortcut would
59 // otherwise read that as "no override needed" and print the
60 // registered rows the CLI cannot actually type (#267 review finding).
61 out, errOut, code = c.run(t, "", "", "auth", "--help")
62 if code != 0 {
63 t.Errorf("gitbay auth --help: exit %d, stdout %q, stderr %q", code, out, errOut)
64 }
65 for _, want := range []string{"auth keys add", "auth export"} {
66 if !strings.Contains(out, want) {
67 t.Errorf("gitbay auth --help: missing %q in %q", want, out)
68 }
69 }
70
71 out, errOut, code = inst.ssh(t, key, "", "help", "auth")
72 if code != 0 {
73 t.Errorf("ssh help auth: exit %d, stdout %q, stderr %q", code, out, errOut)
74 }
75 for _, want := range []string{"keys add", "account export"} {
76 if !strings.Contains(out, want) {
77 t.Errorf("ssh help auth: missing %q in %q", want, out)
78 }
79 }
80 if strings.Contains(out, "auth keys add") {
81 t.Errorf("stock ssh saw the CLI's auth grouping: %q", out)
82 }
83}
84
85// keys add and pgp add wire stdin directly to the server rather than going
86// through pass(), so they lost the --help check every other passthrough
87// command has: --help was treated as key material instead of showing help
88// (#267).
89func TestKeysAddAndPGPAddCheckHelpBeforeStdin(t *testing.T) {
90 t.Parallel()
91 inst := startInstance(t)
92 key := inst.newKey(t, "alice")
93 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub",
94 "--email", "alice@example.test", "--verified")
95
96 c := &cli{bin: buildGitbayCLI(t), configDir: t.TempDir(), inst: inst, key: key}
97 c.must(t, "", "", "remote", "add", "test", "127.0.0.1",
98 "--port", fmt.Sprint(inst.port),
99 "--ssh-option", "-i", "--ssh-option", key,
100 "--ssh-option", "-oIdentitiesOnly=yes",
101 "--ssh-option", "-oStrictHostKeyChecking=no",
102 "--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"),
103 "--ssh-option", "-oBatchMode=yes",
104 "--default")
105
106 for _, args := range [][]string{
107 {"auth", "keys", "add", "--help"},
108 {"auth", "pgp", "add", "--help"},
109 } {
110 out, errOut, code := c.run(t, "", "", args...)
111 if code != 0 {
112 t.Errorf("%v: exit %d, stdout %q, stderr %q", args, code, out, errOut)
113 }
114 want := "gitbay " + strings.Join(args[:len(args)-1], " ")
115 if !strings.Contains(out, want) {
116 t.Errorf("%v: stdout %q does not contain %q", args, out, want)
117 }
118 }
119}
internal/control/admin.go +2 −2
@@ -137,7 +137,7 @@ func runAdminUserList(c *Ctx, args []string) int {
137137 if code >= 0 {
138138 return code
139139 }
140 f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 0,
140 f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 0,
141141 Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]"})
142142 if err != nil {
143143 return c.fail(protocol.ExitUsage, "%v", err)
@@ -410,7 +410,7 @@ func runAdminRepoList(c *Ctx, args []string) int {
410410 if code >= 0 {
411411 return code
412412 }
413 f, err := parseFlags(args, flagSpec{Values: []string{"--owner", "--visibility"}, MaxPos: 0,
413 f, err := c.parseArgs(args, flagSpec{Values: []string{"--owner", "--visibility"}, MaxPos: 0,
414414 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]"})
415415 if err != nil {
416416 return c.fail(protocol.ExitUsage, "%v", err)
internal/control/adminhost.go +1 −1
@@ -77,7 +77,7 @@ func runAdminUserCreate(c *Ctx, args []string) int {
7777 if code := requireInstanceAdmin(c); code >= 0 {
7878 return code
7979 }
80 f, err := parseFlags(args, flagSpec{Values: []string{"--email", "--key"}, Bools: []string{"--admin", "--verified"}, MaxPos: 1, Usage: c.Cmd.Usage})
80 f, err := c.parseArgs(args, flagSpec{Values: []string{"--email", "--key"}, Bools: []string{"--admin", "--verified"}, MaxPos: 1, Usage: c.Cmd.Usage})
8181 if err != nil {
8282 return c.fail(protocol.ExitUsage, "%v", err)
8383 }
internal/control/audit.go +1 −1
@@ -29,7 +29,7 @@ func runAudit(c *Ctx, args []string) int {
2929 return c.fail(protocol.ExitDenied, "the audit log is for instance admins; ask one")
3030 }
3131 f := store.AuditFilter{Limit: 100}
32 fl, err := parseFlags(args, flagSpec{Values: []string{"--limit", "--actor", "--action", "--since"}, MaxPos: 0, Usage: c.Cmd.Usage})
32 fl, err := c.parseArgs(args, flagSpec{Values: []string{"--limit", "--actor", "--action", "--since"}, MaxPos: 0, Usage: c.Cmd.Usage})
3333 if err != nil {
3434 return c.fail(protocol.ExitUsage, "%v", err)
3535 }
internal/control/build.go +3 −3
@@ -158,7 +158,7 @@ func runBuildList(c *Ctx, args []string) int {
158158 if code >= 0 {
159159 return code
160160 }
161 f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--status", "--job"}, MaxPos: 1, Usage: c.Cmd.Usage})
161 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--status", "--job"}, MaxPos: 1, Usage: c.Cmd.Usage})
162162 if err != nil {
163163 return c.fail(protocol.ExitUsage, "%v", err)
164164 }
@@ -238,7 +238,7 @@ func runBuildShow(c *Ctx, args []string) int {
238238}
239239
240240func runBuildLog(c *Ctx, args []string) int {
241 f, err := parseFlags(args, flagSpec{Bools: []string{"--follow"}, MaxPos: 2, Usage: c.Cmd.Usage})
241 f, err := c.parseArgs(args, flagSpec{Bools: []string{"--follow"}, MaxPos: 2, Usage: c.Cmd.Usage})
242242 if err != nil {
243243 return c.fail(protocol.ExitUsage, "%v", err)
244244 }
@@ -465,7 +465,7 @@ func runRunnerNext(c *Ctx, args []string) int {
465465 if code >= 0 {
466466 return code
467467 }
468 f, err := parseFlags(args, flagSpec{Bools: []string{"--untrusted"}, MaxPos: -1,
468 f, err := c.parseArgs(args, flagSpec{Bools: []string{"--untrusted"}, MaxPos: -1,
469469 Usage: "runner next [--untrusted] [<owner/name>...]"})
470470 if err != nil {
471471 return c.fail(protocol.ExitUsage, "%v", err)
internal/control/commitfile.go +1 −1
@@ -43,7 +43,7 @@ const maxCommitFileBytes = 1 << 20
4343// A repository that requires verified signatures therefore refuses the
4444// command rather than writing a commit its own policy would reject.
4545func runCommitFile(c *Ctx, args []string) int {
46 f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--message", "--file"}, MaxPos: -1, Usage: c.Cmd.Usage})
46 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--message", "--file"}, MaxPos: -1, Usage: c.Cmd.Usage})
4747 if err != nil {
4848 return c.fail(protocol.ExitUsage, "%v", err)
4949 }
internal/control/control.go +27 −12
@@ -30,6 +30,12 @@ type Ctx struct {
3030 // Term is the client's terminal, from GITBAY_TERM. The zero value
3131 // is plain output.
3232 Term Term
33 // CLIPath is the path the gitbay CLI resolved this call to, from a
34 // leading --path=, when it differs from the registered path being
35 // dispatched (auth keys remove for keys remove). Usage and help
36 // print it in place of the registered path (#267). Empty for stock
37 // ssh, the web and the API.
38 CLIPath string
3339 // ViaAPI marks requests arriving over HTTP, from the token API or
3440 // the web. Every command runs there; nothing is held back for SSH
3541 // any more (#234). The flag stays because the rate limiter and the
@@ -66,13 +72,13 @@ type Ctx struct {
6672// usage reports a bad invocation with the command's registered usage,
6773// the one source of it.
6874func (c *Ctx) usage() int {
69 return c.fail(protocol.ExitUsage, "usage: %s", c.Cmd.Usage)
75 return c.fail(protocol.ExitUsage, "usage: %s", c.cmdUsage())
7076}
7177
7278// usageWith reports a specific problem with the arguments, then the
7379// registered usage, so a person always sees the shape that was expected.
7480func (c *Ctx) usageWith(msg string) int {
75 return c.fail(protocol.ExitUsage, "%s\nusage: %s", msg, c.Cmd.Usage)
81 return c.fail(protocol.ExitUsage, "%s\nusage: %s", msg, c.cmdUsage())
7682}
7783
7884// Flag is one flag in a command's help.
@@ -132,18 +138,27 @@ func Dispatch(c *Ctx, argv []string) int {
132138 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
133139 }
134140 // A leading --term=<v> selects terminal output for this session, the
135 // same as GITBAY_TERM. It must come off before Lookup: Lookup matches
136 // argv against a command's Path, and a --term= in front would never
137 // match one. Over HTTP it is dropped unread: the web and the API
138 // render no terminal.
139 if v, ok := strings.CutPrefix(argv[0], "--term="); ok {
140 if !c.ViaAPI {
141 c.Term = ParseTerm(v)
141 // same as GITBAY_TERM; a leading --path=<v> is the CLI's own path for
142 // the command (Ctx.CLIPath). Both come off before Lookup, in either
143 // order: Lookup matches argv against a command's Path, and either in
144 // front would never match one. Over HTTP both are dropped unread: the
145 // web and the API render no terminal and have no CLI path.
146 for len(argv) > 0 {
147 if v, ok := strings.CutPrefix(argv[0], "--term="); ok {
148 if !c.ViaAPI {
149 c.Term = ParseTerm(v)
150 }
151 } else if v, ok := strings.CutPrefix(argv[0], "--path="); ok {
152 if !c.ViaAPI {
153 c.CLIPath = v
154 }
155 } else {
156 break
142157 }
143158 argv = argv[1:]
144 if len(argv) == 0 {
145 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
146 }
159 }
160 if len(argv) == 0 {
161 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
147162 }
148163 cmd, rest, ok := Lookup(argv)
149164 c.Cmd = cmd
internal/control/control_test.go +58 −2
@@ -260,6 +260,34 @@ func TestFailErrExitCodes(t *testing.T) {
260260 }
261261}
262262
263// TestPathArgument: --path= is read only as a leading argument, in
264// either order with --term=, and never over HTTP.
265func TestPathArgument(t *testing.T) {
266 cases := []struct {
267 name string
268 viaAPI bool
269 argv []string
270 want string
271 wantArgv []string
272 }{
273 {"leading", false, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
274 {"after term", false, []string{"--term=80", "--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
275 {"before term", false, []string{"--path=auth keys remove", "--term=80", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
276 {"later", false, []string{"keys", "remove", "abc", "--path=x"}, "", []string{"abc", "--path=x"}},
277 {"over HTTP", true, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "", []string{"abc"}},
278 }
279 for _, tc := range cases {
280 c := &Ctx{Scope: "git", ViaAPI: tc.viaAPI, Stdout: io.Discard, Stderr: io.Discard}
281 Dispatch(c, tc.argv)
282 if c.CLIPath != tc.want {
283 t.Errorf("%s: CLIPath %q, want %q", tc.name, c.CLIPath, tc.want)
284 }
285 if !slices.Equal(c.Argv, tc.wantArgv) {
286 t.Errorf("%s: Argv %q, want %q", tc.name, c.Argv, tc.wantArgv)
287 }
288 }
289}
290
263291// TestArgumentRefusalsNameTheUsage: a missing positional argument is a
264292// usage error that prints the registered usage, the shared reference
265293// helpers included (#215).
@@ -267,12 +295,40 @@ func TestArgumentRefusalsNameTheUsage(t *testing.T) {
267295 for _, argv := range [][]string{{"build", "show"}, {"release", "show"}, {"mr", "resolve"}, {"issue", "show"}} {
268296 var out, errOut bytes.Buffer
269297 c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut}
298 c.Cfg.Server.SiteURL = "https://forge.test"
270299 if code := Dispatch(c, argv); code != protocol.ExitUsage {
271300 t.Errorf("%v: exit %d, want %d (%s)", argv, code, protocol.ExitUsage, errOut.String())
272301 continue
273302 }
274 if !strings.Contains(errOut.String(), "usage: "+strings.Join(argv, " ")) {
275 t.Errorf("%v: no usage line: %q", argv, errOut.String())
303 want := "usage: ssh git@forge.test " + strings.Join(argv, " ")
304 if !strings.Contains(errOut.String(), want) {
305 t.Errorf("%v: got %q, want it to contain %q", argv, errOut.String(), want)
306 }
307 }
308}
309
310// TestFlagRefusalsNameTheUsage: a bad flag prints its usage the same way
311// a missing positional does, program and CLI path included (#267).
312func TestFlagRefusalsNameTheUsage(t *testing.T) {
313 cases := []struct {
314 name string
315 argv []string
316 want string
317 }{
318 {"cli", []string{"--path=auth token create", "token", "create", "--bogus"}, "unknown flag \"--bogus\"\nusage: gitbay auth token create --name <n>"},
319 {"stock ssh", []string{"token", "create", "--bogus"}, "unknown flag \"--bogus\"\nusage: ssh git@forge.test token create --name <n>"},
320 {"cli, repo", []string{"--term=80", "issue", "list", "a/b", "--bogus"}, "usage: gitbay issue list [<owner/name>]"},
321 }
322 for _, tc := range cases {
323 var out, errOut bytes.Buffer
324 c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut}
325 c.Cfg.Server.SiteURL = "https://forge.test"
326 if code := Dispatch(c, tc.argv); code != protocol.ExitUsage {
327 t.Errorf("%s: exit %d, want %d (%s)", tc.name, code, protocol.ExitUsage, errOut.String())
328 continue
329 }
330 if !strings.Contains(errOut.String(), tc.want) {
331 t.Errorf("%s: got %q, want it to contain %q", tc.name, errOut.String(), tc.want)
276332 }
277333 }
278334}
internal/control/deploykey.go +1 −1
@@ -37,7 +37,7 @@ func init() {
3737}
3838
3939func runDeployKeyAdd(c *Ctx, args []string) int {
40 f, err := parseFlags(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage})
40 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage})
4141 if err != nil {
4242 return c.fail(protocol.ExitUsage, "%v", err)
4343 }
internal/control/diffcomment.go +1 −1
@@ -50,7 +50,7 @@ func init() {
5050}
5151
5252func runDiffComment(c *Ctx, args []string) int {
53 f, err := parseFlags(args, flagSpec{Values: []string{"--path", "--line", "--reply", "--message", "--file"},
53 f, err := c.parseArgs(args, flagSpec{Values: []string{"--path", "--line", "--reply", "--message", "--file"},
5454 Bools: []string{"--old", "--pending"}, MaxPos: -1,
5555 Usage: "mr diff-comment <owner/name> <n> --path <file> --line <l> [--old] [--pending] [--reply <id>] [--message <m> | --file -]"})
5656 if err != nil {
internal/control/explore.go +1 −1
@@ -95,7 +95,7 @@ func runExplore(c *Ctx, args []string) int {
9595// release asset get writes an asset. The web's /archive route is the
9696// same bytes with a Content-Disposition on them.
9797func runRepoDownload(c *Ctx, args []string) int {
98 f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: -1, Usage: c.Cmd.Usage})
98 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: -1, Usage: c.Cmd.Usage})
9999 if err != nil {
100100 return c.fail(protocol.ExitUsage, "%v", err)
101101 }
internal/control/flags.go +15
@@ -92,6 +92,21 @@ func parseFlags(args []string, spec flagSpec) (flags, error) {
9292 return f, nil
9393}
9494
95// parseArgs is parseFlags for the running command, with the usage line
96// printed the way a usage refusal prints it (cmdUsage): the program in
97// front and the CLI's own path where it differs (#267). spec.Usage stays
98// the text, since some commands spell their flags out more fully there
99// than in the registered Usage.
100func (c *Ctx) parseArgs(args []string, spec flagSpec) (flags, error) {
101 usage := strings.TrimPrefix(spec.Usage, "usage: ")
102 spec.Usage = ""
103 f, err := parseFlags(args, spec)
104 if err != nil && usage != "" {
105 err = fmt.Errorf("%v\nusage: %s %s", err, c.program(), c.usageShape(c.Cmd.Path, usage))
106 }
107 return f, err
108}
109
95110// pos is the nth positional argument, or "" when absent.
96111func (f flags) pos(n int) string {
97112 if n < len(f.Pos) {
internal/control/ghimport.go +1 −1
@@ -148,7 +148,7 @@ func attribution(src string, n int64, kind, login, date string) string {
148148}
149149
150150func runImportIssues(c *Ctx, args []string) int {
151 f, err := parseFlags(args, flagSpec{Values: []string{"--from", "--api-base"}, Bools: []string{"--token-stdin"}, MaxPos: 1,
151 f, err := c.parseArgs(args, flagSpec{Values: []string{"--from", "--api-base"}, Bools: []string{"--token-stdin"}, MaxPos: 1,
152152 Usage: "repo import-issues <owner/name> --from <owner/repo> [--api-base <url>] [--token-stdin]"})
153153 if err != nil {
154154 return c.fail(protocol.ExitUsage, "%v", err)
internal/control/help.go +165 −18
@@ -28,6 +28,7 @@ var nounSummaries = map[string]string{
2828 "account": "export or import your account, for instance migration",
2929 "admin": "instance administration (admins)",
3030 "audit": "instance audit log (admins)",
31 "auth": "whoami, SSH and PGP keys, email, API tokens",
3132 "build": "CI builds",
3233 "dashboard": "pinned repos, open MRs, assigned issues, recent builds",
3334 "email": "manage email addresses",
@@ -61,6 +62,45 @@ var nounSummaries = map[string]string{
6162// test (task 4.5).
6263func NounSummaries() map[string]string { return nounSummaries }
6364
65// nounAlias is one bucket of registered commands, reachable under a
66// CLI-only noun that is not itself a registry path (auth, gathering
67// several unrelated registry prefixes): Registered is what runHelp
68// matches against the registry, CLI is the path a gitbay caller
69// actually types to reach it — not always Registered with the alias's
70// own name stitched on (account export -> auth export drops a word),
71// so the two are paired explicitly rather than derived.
72type nounAlias struct {
73 Registered string
74 CLI string
75}
76
77// nounAliases groups a CLI-only noun into the real prefixes it gathers,
78// so `help auth` renders with the same layout a real noun gets instead
79// of falling back to whatever a caller does when help fails. A stock
80// ssh caller — the only one who could ever ask for a bare "auth" and
81// get nothing back from the registry — sees the Registered forms
82// unchanged; the CLI, having sent its own path, sees CLI.
83var nounAliases = map[string][]nounAlias{
84 "auth": {
85 {"account export", "auth export"},
86 {"whoami", "auth whoami"},
87 {"keys", "auth keys"},
88 {"email", "auth email"},
89 {"pgp", "auth pgp"},
90 {"token", "auth token"},
91 },
92}
93
94// NounAliasNames returns the keys of nounAliases, for the CLI's own
95// aliasGroupNames agreement test.
96func NounAliasNames() []string {
97 names := make([]string, 0, len(nounAliases))
98 for name := range nounAliases {
99 names = append(names, name)
100 }
101 return names
102}
103
64104// helpEntry is one row of the registry as help reports it.
65105type helpEntry struct {
66106 Path string `json:"path"`
@@ -76,11 +116,33 @@ type helpEntry struct {
76116// noun with several commands under it renders a READ/WRITE summary.
77117func runHelp(c *Ctx, args []string) int {
78118 prefix := joinPath(args)
119 prefixes := []string{prefix}
120 override := map[string]string{}
121 if aliased, ok := nounAliases[prefix]; ok {
122 prefixes = nil
123 for _, a := range aliased {
124 prefixes = append(prefixes, a.Registered)
125 }
126 if c.CLIPath != "" {
127 for _, cmd := range registry {
128 p := joinPath(cmd.Path)
129 for _, a := range aliased {
130 if p == a.Registered || strings.HasPrefix(p, a.Registered+" ") {
131 override[p] = a.CLI + strings.TrimPrefix(p, a.Registered)
132 break
133 }
134 }
135 }
136 }
137 }
79138 var matched []Command
80139 for _, cmd := range registry {
81140 p := joinPath(cmd.Path)
82 if prefix == "" || p == prefix || strings.HasPrefix(p, prefix+" ") {
83 matched = append(matched, cmd)
141 for _, pfx := range prefixes {
142 if pfx == "" || p == pfx || strings.HasPrefix(p, pfx+" ") {
143 matched = append(matched, cmd)
144 break
145 }
84146 }
85147 }
86148 if len(matched) == 0 {
@@ -106,21 +168,85 @@ func runHelp(c *Ctx, args []string) int {
106168 case joinPath(matched[0].Path) == prefix:
107169 c.helpVerb(w, matched[0], matched[1:])
108170 default:
109 c.helpNoun(w, prefix, matched)
171 c.helpNoun(w, prefix, matched, override)
110172 }
111173 })
112174}
113175
114// program is how help spells the command it documents: the CLI at a
115// terminal (only the CLI or a caller passing --term sets one), ssh
116// otherwise.
176// viaCLI reports whether the caller is the gitbay CLI, as far as the
177// session says: a terminal (only the CLI or a caller passing --term sets
178// one), or a CLI path, which only the CLI sends.
179func (c *Ctx) viaCLI() bool {
180 return c.Term.Cols > 0 || c.CLIPath != ""
181}
182
183// program is how help spells the command it documents: gitbay for the
184// CLI, ssh otherwise.
117185func (c *Ctx) program() string {
118 if c.Term.Cols > 0 {
186 if c.viaCLI() {
119187 return "gitbay"
120188 }
121189 return "ssh git@" + hostOf(c.Cfg.Server.SiteURL)
122190}
123191
192// cliUsage marks a leading <owner/name> optional in a usage line for the
193// CLI, which infers it inside a clone (cmd/gitbay/ssh.go's withRepo).
194// Stock ssh never does.
195func cliUsage(usage string) string {
196 return strings.Replace(usage, "<owner/name>", "[<owner/name>]", 1)
197}
198
199// shownAs rewrites full, which starts with the registered path, to start
200// with the CLI's path instead when the CLI sent one that differs (#267).
201// The CLI path must name this command: either it regroups it (the same
202// last word, auth keys remove for keys remove) or extends it (repo
203// topics list for repo topics). Arguments after a CLI command can
204// dispatch to a longer registered path (gitbay repo topics list add
205// reaches repo topics add), and that command keeps its own name.
206func (c *Ctx) shownAs(registered, full string) string {
207 rest, ok := strings.CutPrefix(full, registered)
208 if !ok || c.CLIPath == "" || c.CLIPath == registered {
209 return full
210 }
211 reg, cli := strings.Fields(registered), strings.Fields(c.CLIPath)
212 if len(reg) == 0 || len(cli) == 0 || (cli[len(cli)-1] != reg[len(reg)-1] && !strings.HasPrefix(c.CLIPath, registered+" ")) {
213 return full
214 }
215 return c.CLIPath + rest
216}
217
218// shownBelow is how another command listed beside registered prints to
219// this caller. When the CLI only regrouped the command (auth keys remove
220// for keys remove, the same last word) the other command takes the CLI's
221// parent in place of the registered one. When the CLI renamed the last
222// word (repo topics list for repo topics) nothing follows about the
223// other command's name, so it keeps its registered path.
224func (c *Ctx) shownBelow(registered, other string) string {
225 reg, cli, o := strings.Fields(registered), strings.Fields(c.CLIPath), strings.Fields(other)
226 if len(cli) == 0 || len(reg) == 0 || len(o) < len(reg) || cli[len(cli)-1] != reg[len(reg)-1] ||
227 !slices.Equal(o[:len(reg)-1], reg[:len(reg)-1]) {
228 return other
229 }
230 return joinPath(append(slices.Clip(cli[:len(cli)-1]), o[len(reg)-1:]...))
231}
232
233// usageShape is a usage line for the command registered at path as this
234// caller should see it: the CLI's path in place of the registered one
235// where they differ, and a leading <owner/name> optional for the CLI.
236func (c *Ctx) usageShape(path []string, usage string) string {
237 shape := c.shownAs(joinPath(path), usage)
238 if c.viaCLI() {
239 shape = cliUsage(shape)
240 }
241 return shape
242}
243
244// cmdUsage is the running command's usage with the program in front, as
245// a usage refusal prints it.
246func (c *Ctx) cmdUsage() string {
247 return c.program() + " " + c.usageShape(c.Cmd.Path, c.Cmd.Usage)
248}
249
124250func (c *Ctx) heading(w io.Writer, s string) {
125251 fmt.Fprintln(w, c.Term.paint(sgrBold, s))
126252}
@@ -152,7 +278,8 @@ func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
152278 // A required flag (repo delete --yes) or an alternative
153279 // (notifications read <id>... | --all) has no " [--" to cut at, so
154280 // the usage prints whole.
155 shape := cmd.Usage
281 registered := joinPath(cmd.Path)
282 shape := c.usageShape(cmd.Path, cmd.Usage)
156283 if i := strings.Index(shape, " [--"); i >= 0 {
157284 shape = shape[:i] + " [flags]"
158285 }
@@ -190,20 +317,39 @@ func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
190317 fmt.Fprintln(w)
191318 c.heading(w, "SEE ALSO")
192319 for _, b := range below {
193 fmt.Fprintf(w, " %s %s\n", c.program(), joinPath(b.Path))
320 fmt.Fprintf(w, " %s %s\n", c.program(), c.shownBelow(registered, joinPath(b.Path)))
194321 }
195322 }
196323}
197324
198func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) {
325// helpNoun renders a noun with several commands under it. override, from
326// an aliased noun (auth), gives the full CLI path for a row that is not
327// itself under prefix (keys list, gathered under auth, becomes "auth
328// keys list"); it is empty for an ordinary noun, so every row there
329// still trims to just its own verb.
330func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command, override map[string]string) {
199331 head := nounSummaries[strings.Fields(prefix)[0]]
200332 fmt.Fprintln(w, head)
201 fmt.Fprintln(w)
202 c.heading(w, "USAGE")
203 fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), prefix)
333 // An aliased noun is a CLI grouping: over stock ssh there is no
334 // "auth <verb>" to type, and each row already names its full command.
335 _, aliased := nounAliases[prefix]
336 bare := aliased && c.CLIPath == ""
337 display := c.shownAs(prefix, prefix)
338 if !bare {
339 fmt.Fprintln(w)
340 c.heading(w, "USAGE")
341 fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), display)
342 }
343 rowText := func(cmd Command) string {
344 full := joinPath(cmd.Path)
345 if ov, ok := override[full]; ok {
346 return ov
347 }
348 return strings.TrimPrefix(full, prefix+" ")
349 }
204350 wide := 0
205351 for _, cmd := range cmds {
206 wide = max(wide, cells(strings.TrimPrefix(joinPath(cmd.Path), prefix+" ")))
352 wide = max(wide, cells(rowText(cmd)))
207353 }
208354 for _, section := range []struct {
209355 title string
@@ -219,10 +365,11 @@ func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) {
219365 c.heading(w, section.title)
220366 first = false
221367 }
222 verb := strings.TrimPrefix(joinPath(cmd.Path), prefix+" ")
223 fmt.Fprintf(w, " %s %s\n", pad(verb, wide), cmd.Summary)
368 fmt.Fprintf(w, " %s %s\n", pad(rowText(cmd), wide), cmd.Summary)
224369 }
225370 }
226 fmt.Fprintln(w)
227 fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), prefix)
371 if !bare {
372 fmt.Fprintln(w)
373 fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), display)
374 }
228375}
internal/control/help_test.go +134 −2
@@ -25,7 +25,7 @@ func TestHelpVerb(t *testing.T) {
2525 out := helpOut(t, Term{Cols: 100}, "issue", "list")
2626 for _, want := range []string{
2727 "list issues\n",
28 "USAGE\n gitbay issue list <owner/name> [flags]\n",
28 "USAGE\n gitbay issue list [<owner/name>] [flags]\n",
2929 "FLAGS\n",
3030 " --state open|closed|all",
3131 "which issues (default open)\n",
@@ -50,7 +50,7 @@ func TestHelpVerb(t *testing.T) {
5050// as though --yes were optional.
5151func TestHelpVerbUsageKeepsRequiredFlags(t *testing.T) {
5252 out := helpOut(t, Term{Cols: 100}, "repo", "delete")
53 if !strings.Contains(out, "USAGE\n gitbay repo delete <owner/name> --yes\n") {
53 if !strings.Contains(out, "USAGE\n gitbay repo delete [<owner/name>] --yes\n") {
5454 t.Errorf("missing required --yes in usage:\n%s", out)
5555 }
5656 if strings.Contains(out, "[flags]") {
@@ -142,3 +142,135 @@ func TestHelpIsComplete(t *testing.T) {
142142 }
143143 }
144144}
145
146func TestCmdUsagePrefixesTheProgram(t *testing.T) {
147 c := &Ctx{Cmd: Command{Path: []string{"keys", "remove"}, Usage: "keys remove <fingerprint>"}}
148 c.Cfg.Server.SiteURL = "https://forge.test"
149 if got := c.cmdUsage(); got != "ssh git@forge.test keys remove <fingerprint>" {
150 t.Errorf("ssh form: %q", got)
151 }
152 c.Term = Term{Cols: 100}
153 if got := c.cmdUsage(); got != "gitbay keys remove <fingerprint>" {
154 t.Errorf("cli form, no CLIPath sent: %q", got)
155 }
156 c.CLIPath = "auth keys remove"
157 if got := c.cmdUsage(); got != "gitbay auth keys remove <fingerprint>" {
158 t.Errorf("cli form, mismatched registered path: %q", got)
159 }
160 c.Term = Term{}
161 if got := c.cmdUsage(); got != "gitbay auth keys remove <fingerprint>" {
162 t.Errorf("cli form off a terminal, mismatched registered path: %q", got)
163 }
164
165 c2 := &Ctx{Cmd: Command{Path: []string{"repo", "tree"}, Usage: "repo tree <owner/name> [<path>] [--ref <ref>]"}, Term: Term{Cols: 100}}
166 if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" {
167 t.Errorf("optional owner/name: %q", got)
168 }
169 c2.CLIPath = "repo tree"
170 if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" {
171 t.Errorf("matching CLIPath changes nothing: %q", got)
172 }
173
174 c3 := &Ctx{Cmd: Command{Path: []string{"repo", "topics", "add"}, Usage: "repo topics add <owner/name> <topic>..."}, Term: Term{Cols: 100}, CLIPath: "repo topics list"}
175 if got := c3.cmdUsage(); got != "gitbay repo topics add [<owner/name>] <topic>..." {
176 t.Errorf("CLIPath naming another command: %q", got)
177 }
178 c3.Cmd = Command{Path: []string{"repo", "topics"}, Usage: "repo topics <owner/name>"}
179 if got := c3.cmdUsage(); got != "gitbay repo topics list [<owner/name>]" {
180 t.Errorf("CLIPath extending the registered path: %q", got)
181 }
182 c3.CLIPath = " "
183 if got := c3.cmdUsage(); got != "gitbay repo topics [<owner/name>]" {
184 t.Errorf("blank CLIPath: %q", got)
185 }
186}
187
188// TestShownBelow: another command listed beside the one the CLI named
189// takes the CLI's parent when the CLI only regrouped the command (auth
190// keys remove), and keeps its registered path when the CLI renamed the
191// leaf (repo topics list for repo topics), since that says nothing about
192// what the other command is called.
193func TestShownBelow(t *testing.T) {
194 cases := []struct {
195 cliPath, registered, other, want string
196 }{
197 {"", "keys remove", "keys list", "keys list"},
198 {"keys remove", "keys remove", "keys list", "keys list"},
199 {"auth keys remove", "keys remove", "keys list", "auth keys list"},
200 {"auth export", "account export", "account export extra", "auth export extra"},
201 {"repo topics list", "repo topics", "repo topics add", "repo topics add"},
202 }
203 for _, tc := range cases {
204 c := &Ctx{CLIPath: tc.cliPath}
205 if got := c.shownBelow(tc.registered, tc.other); got != tc.want {
206 t.Errorf("CLIPath %q, %q beside %q: got %q, want %q", tc.cliPath, tc.other, tc.registered, got, tc.want)
207 }
208 }
209}
210
211// TestHelpPrintsTheCLIPath: help reached through the CLI with a --path=
212// prints the CLI's path in USAGE, the noun header and SEE ALSO.
213func TestHelpPrintsTheCLIPath(t *testing.T) {
214 via := func(t *testing.T, term Term, argv ...string) string {
215 t.Helper()
216 var out, errOut bytes.Buffer
217 c := &Ctx{Stdout: &out, Stderr: &errOut, Term: term, Scope: "full"}
218 c.Cfg.Server.SiteURL = "https://forge.test"
219 if code := Dispatch(c, argv); code != protocol.ExitOK {
220 t.Fatalf("%v: exit %d: %s", argv, code, errOut.String())
221 }
222 return out.String()
223 }
224 verb := via(t, Term{Cols: 100}, "--path=auth keys remove", "help", "keys", "remove")
225 if !strings.Contains(verb, "USAGE\n gitbay auth keys remove <fingerprint>") {
226 t.Errorf("verb usage:\n%s", verb)
227 }
228 topics := via(t, Term{Cols: 100}, "--path=repo topics list", "help", "repo", "topics")
229 for _, want := range []string{"USAGE\n gitbay repo topics list [<owner/name>]", "SEE ALSO\n gitbay repo topics add\n"} {
230 if !strings.Contains(topics, want) {
231 t.Errorf("missing %q in:\n%s", want, topics)
232 }
233 }
234 if strings.Contains(topics, "topics list add") {
235 t.Errorf("SEE ALSO renamed a child after the leaf:\n%s", topics)
236 }
237 noun := via(t, Term{Cols: 100}, "--path=auth keys", "help", "keys")
238 for _, want := range []string{"USAGE\n gitbay auth keys <verb> ...\n", "gitbay auth keys <verb> --help for flags.\n"} {
239 if !strings.Contains(noun, want) {
240 t.Errorf("missing %q in:\n%s", want, noun)
241 }
242 }
243}
244
245func TestHelpRendersAnAliasedNounWithTheRegistryLayout(t *testing.T) {
246 var out, errOut bytes.Buffer
247 c := &Ctx{Stdout: &out, Stderr: &errOut, Term: Term{Cols: 100}, Scope: "full", CLIPath: "auth"}
248 c.Cfg.Server.SiteURL = "https://forge.test"
249 if code := Dispatch(c, []string{"help", "auth"}); code != protocol.ExitOK {
250 t.Fatalf("exit %d: %s", code, errOut.String())
251 }
252 got := out.String()
253 for _, want := range []string{"auth whoami", "auth keys list", "auth pgp add", "auth token create", "auth export"} {
254 if !strings.Contains(got, want) {
255 t.Errorf("missing %q in:\n%s", want, got)
256 }
257 }
258 if strings.Contains(got, "no command matches") {
259 t.Errorf("auth did not resolve: %s", got)
260 }
261}
262
263func TestHelpRendersAnAliasedNounInRegisteredFormOverSSH(t *testing.T) {
264 got := helpOut(t, Term{}, "auth")
265 for _, want := range []string{"whoami", "keys list", "pgp add", "token create", "account export"} {
266 if !strings.Contains(got, want) {
267 t.Errorf("missing %q in:\n%s", want, got)
268 }
269 }
270 if strings.Contains(got, "auth keys list") {
271 t.Errorf("stock ssh should not see the CLI-only auth prefix: %s", got)
272 }
273 if strings.Contains(got, " auth <verb>") {
274 t.Errorf("stock ssh should not be told to type auth <verb>, which the server has no command for: %s", got)
275 }
276}
internal/control/identity.go +1 −1
@@ -152,7 +152,7 @@ func expiresText(t *time.Time, now time.Time) string {
152152}
153153
154154func runKeysAdd(c *Ctx, args []string) int {
155 f, err := parseFlags(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})
155 f, err := c.parseArgs(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})
156156 if err != nil {
157157 return c.fail(protocol.ExitUsage, "%v", err)
158158 }
internal/control/import.go +1 −1
@@ -39,7 +39,7 @@ esac
3939`
4040
4141func runRepoImport(c *Ctx, args []string) int {
42 f, err := parseFlags(args, flagSpec{Values: []string{"--from"}, Bools: []string{"--private", "--token-stdin"}, MaxPos: 1,
42 f, err := c.parseArgs(args, flagSpec{Values: []string{"--from"}, Bools: []string{"--private", "--token-stdin"}, MaxPos: 1,
4343 Usage: "repo import <owner/name> --from <url> [--private] [--token-stdin]"})
4444 if err != nil {
4545 return c.fail(protocol.ExitUsage, "%v", err)
internal/control/issue.go +6 −6
@@ -67,7 +67,7 @@ func init() {
6767 },
6868 ReadsStdin: true, Run: runIssueEdit})
6969 register(Command{Path: []string{"issue", "comment"},
70 Summary: "comment",
70 Summary: "add a comment",
7171 Usage: "issue comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
7272 Flags: []Flag{
7373 {"--message", "<m>", "the comment's text", ""},
@@ -90,7 +90,7 @@ func init() {
9090 Examples: []string{"issue reopen krz/gitbay 42"},
9191 Run: runIssueReopen})
9292 register(Command{Path: []string{"issue", "label"},
93 Summary: "labels",
93 Summary: "add or remove labels",
9494 Usage: "issue label <owner/name> <n> [--add <l>]... [--remove <l>]...",
9595 Flags: []Flag{
9696 {"--add", "<l>", "label to add, may repeat", ""},
@@ -99,7 +99,7 @@ func init() {
9999 Examples: []string{"issue label krz/gitbay 42 --add bug --remove needs-triage"},
100100 Run: runIssueLabel})
101101 register(Command{Path: []string{"issue", "assign"},
102 Summary: "assignees",
102 Summary: "add or remove assignees",
103103 Usage: "issue assign <owner/name> <n> [--add <user>]... [--remove <user>]...",
104104 Flags: []Flag{
105105 {"--add", "<user>", "user to assign, may repeat", ""},
@@ -178,7 +178,7 @@ func issueToOut(i store.Issue, withBody bool) issueOut {
178178}
179179
180180func runIssueCreate(c *Ctx, args []string) int {
181 f, err := parseFlags(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1,
181 f, err := c.parseArgs(args, flagSpec{Values: []string{"--format", "--title", "--body", "--file"}, MaxPos: 1,
182182 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org]"})
183183 if err != nil {
184184 return c.fail(protocol.ExitUsage, "%v", err)
@@ -231,7 +231,7 @@ func runIssueList(c *Ctx, args []string) int {
231231 return code
232232 }
233233 f := store.IssueFilter{State: "open"}
234 fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
234 fl, err := c.parseArgs(args, flagSpec{Values: []string{"--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
235235 if err != nil {
236236 return c.fail(protocol.ExitUsage, "%v", err)
237237 }
@@ -371,7 +371,7 @@ func setIssueState(c *Ctx, args []string, state string) int {
371371// they are accepted and reported in the returned flags, and count toward
372372// "at least one edit was given" alongside title/body/format.
373373func editText(c *Ctx, args []string, kind string, extra ...string) (rest []string, title, body, format *string, f flags, code int) {
374 f, err := parseFlags(args, flagSpec{Values: append([]string{"--title", "--body", "--file", "--format"}, extra...), MaxPos: -1,
374 f, err := c.parseArgs(args, flagSpec{Values: append([]string{"--title", "--body", "--file", "--format"}, extra...), MaxPos: -1,
375375 Usage: kind + " edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]"})
376376 if err != nil {
377377 return nil, nil, nil, nil, flags{}, c.fail(protocol.ExitUsage, "%v", err)
internal/control/label.go +1 −1
@@ -68,7 +68,7 @@ func runLabelList(c *Ctx, args []string) int {
6868}
6969
7070func runLabelSet(c *Ctx, args []string) int {
71 f, err := parseFlags(args, flagSpec{Values: []string{"--color"}, MaxPos: -1, Usage: c.Cmd.Usage})
71 f, err := c.parseArgs(args, flagSpec{Values: []string{"--color"}, MaxPos: -1, Usage: c.Cmd.Usage})
7272 if err != nil {
7373 return c.fail(protocol.ExitUsage, "%v", err)
7474 }
internal/control/migrate.go +1 −1
@@ -155,7 +155,7 @@ func migAttribution(src, kind, author, date string, n int64) string {
155155// the git push that follows cannot be refused by them. Resumable: markers
156156// skip everything already imported.
157157func runAccountImportBundle(c *Ctx, args []string) int {
158 f, err := parseFlags(args, flagSpec{Values: []string{"--source"}, MaxPos: 0, Usage: "account import-bundle [--source <host>] < bundle.json"})
158 f, err := c.parseArgs(args, flagSpec{Values: []string{"--source"}, MaxPos: 0, Usage: "account import-bundle [--source <host>] < bundle.json"})
159159 if err != nil {
160160 return c.fail(protocol.ExitUsage, "%v", err)
161161 }
internal/control/milestone.go +2 −2
@@ -62,7 +62,7 @@ func init() {
6262var duePat = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
6363
6464func runMilestoneCreate(c *Ctx, args []string) int {
65 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2,
65 f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2,
6666 Usage: "milestone create <owner/name> <title> [--description <d>] [--due YYYY-MM-DD]"})
6767 if err != nil {
6868 return c.fail(protocol.ExitUsage, "%v", err)
@@ -93,7 +93,7 @@ func runMilestoneCreate(c *Ctx, args []string) int {
9393}
9494
9595func runMilestoneList(c *Ctx, args []string) int {
96 f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "milestone list <owner/name> [--state open|closed|all]"})
96 f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "milestone list <owner/name> [--state open|closed|all]"})
9797 if err != nil {
9898 return c.fail(protocol.ExitUsage, "%v", err)
9999 }
internal/control/mirrorcmd.go +1 −1
@@ -43,7 +43,7 @@ func init() {
4343}
4444
4545func runMirrorAdd(c *Ctx, args []string) int {
46 f, err := parseFlags(args, flagSpec{Values: []string{"--direction", "--username"}, Bools: []string{"--token-stdin"}, MaxPos: 2,
46 f, err := c.parseArgs(args, flagSpec{Values: []string{"--direction", "--username"}, Bools: []string{"--token-stdin"}, MaxPos: 2,
4747 Usage: "repo mirror add <owner/name> <url> --direction push|pull [--username <u>] [--token-stdin]"})
4848 if err != nil {
4949 return c.fail(protocol.ExitUsage, "%v", err)
internal/control/mr.go +9 −9
@@ -143,7 +143,7 @@ func init() {
143143 Examples: []string{"mr retarget krz/gitbay 431 main"},
144144 Run: runMRRetarget})
145145 register(Command{Path: []string{"mr", "comment"},
146 Summary: "comment",
146 Summary: "add a comment",
147147 Usage: "mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
148148 Flags: []Flag{
149149 {"--message", "<m>", "the comment's text", ""},
@@ -153,7 +153,7 @@ func init() {
153153 Examples: []string{`mr comment krz/gitbay 431 --message "looks good"`},
154154 ReadsStdin: true, Run: runMRComment})
155155 register(Command{Path: []string{"mr", "review"},
156 Summary: "review",
156 Summary: "record a review verdict",
157157 Usage: "mr review <owner/name> <n> --approve|--request-changes|--comment|--discard",
158158 Flags: []Flag{
159159 {"--approve", "", "approve the merge request", ""},
@@ -173,7 +173,7 @@ func init() {
173173 Examples: []string{"mr review request krz/gitbay 431 --add cmc"},
174174 Run: runMRReviewRequest})
175175 register(Command{Path: []string{"mr", "label"},
176 Summary: "labels",
176 Summary: "add or remove labels",
177177 Usage: "mr label <owner/name> <n> [--add <l>]... [--remove <l>]...",
178178 Flags: []Flag{
179179 {"--add", "<l>", "label to add, may repeat", ""},
@@ -207,7 +207,7 @@ type ForkOut struct {
207207}
208208
209209func runRepoFork(c *Ctx, args []string) int {
210 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--owner"}, MaxPos: 1, Usage: "repo fork <owner/name> [--owner <o>] [--name <n>]"})
210 f, err := c.parseArgs(args, flagSpec{Values: []string{"--name", "--owner"}, MaxPos: 1, Usage: "repo fork <owner/name> [--owner <o>] [--name <n>]"})
211211 if err != nil {
212212 return c.fail(protocol.ExitUsage, "%v", err)
213213 }
@@ -393,7 +393,7 @@ func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool
393393func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
394394
395395func runMRCreate(c *Ctx, args []string) int {
396 f, err := parseFlags(args, flagSpec{Values: []string{"--source", "--target", "--title", "--body", "--file", "--format"},
396 f, err := c.parseArgs(args, flagSpec{Values: []string{"--source", "--target", "--title", "--body", "--file", "--format"},
397397 Bools: []string{"--draft"}, MaxPos: 1,
398398 Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--draft]"})
399399 if err != nil {
@@ -566,7 +566,7 @@ func runMRList(c *Ctx, args []string) int {
566566 return code
567567 }
568568 f := store.MRFilter{State: "open"}
569 fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--label", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
569 fl, err := c.parseArgs(args, flagSpec{Values: []string{"--state", "--label", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
570570 if err != nil {
571571 return c.fail(protocol.ExitUsage, "%v", err)
572572 }
@@ -1205,7 +1205,7 @@ func runMRLabel(c *Ctx, args []string) int {
12051205}
12061206
12071207func runMRMerge(c *Ctx, args []string) int {
1208 f, err := parseFlags(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"})
1208 f, err := c.parseArgs(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"})
12091209 if err != nil {
12101210 return c.fail(protocol.ExitUsage, "%v", err)
12111211 }
@@ -1775,7 +1775,7 @@ func setMRDraft(c *Ctx, args []string, draft bool) int {
17751775}
17761776
17771777func runMRClose(c *Ctx, args []string) int {
1778 f, err := parseFlags(args, flagSpec{Values: []string{"--by"}, MaxPos: 2,
1778 f, err := c.parseArgs(args, flagSpec{Values: []string{"--by"}, MaxPos: 2,
17791779 Usage: "mr close <owner/name> <n> [--by <m>]"})
17801780 if err != nil {
17811781 return c.fail(protocol.ExitUsage, "%v", err)
@@ -1907,7 +1907,7 @@ func runMRRevisions(c *Ctx, args []string) int {
19071907}
19081908
19091909func runMRRangeDiff(c *Ctx, args []string) int {
1910 f, err := parseFlags(args, flagSpec{Values: []string{"--from", "--to"}, MaxPos: 2, Usage: c.Cmd.Usage})
1910 f, err := c.parseArgs(args, flagSpec{Values: []string{"--from", "--to"}, MaxPos: 2, Usage: c.Cmd.Usage})
19111911 if err != nil {
19121912 return c.fail(protocol.ExitUsage, "%v", err)
19131913 }
internal/control/notifications.go +3 −3
@@ -285,7 +285,7 @@ func runNotificationsSettingsPush(c *Ctx, args []string) int {
285285const maxDeviceTokenBytes = 512
286286
287287func runNotificationsDeviceAdd(c *Ctx, args []string) int {
288 f, err := parseFlags(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage})
288 f, err := c.parseArgs(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage})
289289 if err != nil {
290290 return c.fail(protocol.ExitUsage, "%v", err)
291291 }
@@ -389,7 +389,7 @@ func runNotificationsList(c *Ctx, args []string) int {
389389 if code >= 0 {
390390 return code
391391 }
392 fl, err := parseFlags(rest, flagSpec{Bools: []string{"--all"}, Usage: c.Cmd.Usage})
392 fl, err := c.parseArgs(rest, flagSpec{Bools: []string{"--all"}, Usage: c.Cmd.Usage})
393393 if err != nil {
394394 return c.fail(protocol.ExitUsage, "%v", err)
395395 }
@@ -443,7 +443,7 @@ func runNotificationsList(c *Ctx, args []string) int {
443443}
444444
445445func runNotificationsRead(c *Ctx, args []string) int {
446 fl, err := parseFlags(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: c.Cmd.Usage})
446 fl, err := c.parseArgs(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: c.Cmd.Usage})
447447 if err != nil {
448448 return c.fail(protocol.ExitUsage, "%v", err)
449449 }
internal/control/org.go +1 −1
@@ -214,7 +214,7 @@ func runOrgDelete(c *Ctx, args []string) int {
214214}
215215
216216func runOrgMembersAdd(c *Ctx, args []string) int {
217 f, err := parseFlags(args, flagSpec{Values: []string{"--role"}, MaxPos: -1, Usage: "org members add <org> <user> [--role member|admin]"})
217 f, err := c.parseArgs(args, flagSpec{Values: []string{"--role"}, MaxPos: -1, Usage: "org members add <org> <user> [--role member|admin]"})
218218 if err != nil {
219219 return c.fail(protocol.ExitUsage, "%v", err)
220220 }
internal/control/orglabel.go +3 −3
@@ -86,7 +86,7 @@ func orgReader(c *Ctx, name string) (store.Org, []int64, int) {
8686}
8787
8888func runOrgLabelSet(c *Ctx, args []string) int {
89 f, err := parseFlags(args, flagSpec{Values: []string{"--color"}, MaxPos: 2, Usage: c.Cmd.Usage})
89 f, err := c.parseArgs(args, flagSpec{Values: []string{"--color"}, MaxPos: 2, Usage: c.Cmd.Usage})
9090 if err != nil {
9191 return c.fail(protocol.ExitUsage, "%v", err)
9292 }
@@ -180,7 +180,7 @@ func runOrgLabelRemove(c *Ctx, args []string) int {
180180}
181181
182182func runOrgMilestoneCreate(c *Ctx, args []string) int {
183 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, Usage: c.Cmd.Usage})
183 f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--due"}, MaxPos: 2, Usage: c.Cmd.Usage})
184184 if err != nil {
185185 return c.fail(protocol.ExitUsage, "%v", err)
186186 }
@@ -212,7 +212,7 @@ func runOrgMilestoneCreate(c *Ctx, args []string) int {
212212}
213213
214214func runOrgMilestoneList(c *Ctx, args []string) int {
215 f, err := parseFlags(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "org milestone list <org> [--state open|closed|all]"})
215 f, err := c.parseArgs(args, flagSpec{Values: []string{"--state"}, MaxPos: 1, Usage: "org milestone list <org> [--state open|closed|all]"})
216216 if err != nil {
217217 return c.fail(protocol.ExitUsage, "%v", err)
218218 }
internal/control/read.go +2 −2
@@ -110,7 +110,7 @@ func runRepoRefs(c *Ctx, args []string) int {
110110const BlameSpan = 1000
111111
112112func runRepoBlame(c *Ctx, args []string) int {
113 f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--from", "--to"}, MaxPos: -1, Usage: c.Cmd.Usage})
113 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--from", "--to"}, MaxPos: -1, Usage: c.Cmd.Usage})
114114 if err != nil {
115115 return c.fail(protocol.ExitUsage, "%v", err)
116116 }
@@ -220,7 +220,7 @@ func runRepoBlame(c *Ctx, args []string) int {
220220// off argv. Positionals are returned in order so each command can name them
221221// in its own usage message.
222222func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) {
223 f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: maxPos, Usage: c.Cmd.Usage})
223 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: maxPos, Usage: c.Cmd.Usage})
224224 if err != nil {
225225 return nil, "", c.fail(protocol.ExitUsage, "%v", err)
226226 }
internal/control/release.go +2 −2
@@ -111,7 +111,7 @@ func releaseRef(c *Ctx, args []string, perm func(store.User, store.Repo, string)
111111}
112112
113113func runReleaseCreate(c *Ctx, args []string) int {
114 f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage})
114 f, err := c.parseArgs(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage})
115115 if err != nil {
116116 return c.fail(protocol.ExitUsage, "%v", err)
117117 }
@@ -183,7 +183,7 @@ func releaseToOut(r store.Release, withNotes bool) releaseOut {
183183}
184184
185185func runReleaseEdit(c *Ctx, args []string) int {
186 f, err := parseFlags(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage})
186 f, err := c.parseArgs(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage})
187187 if err != nil {
188188 return c.fail(protocol.ExitUsage, "%v", err)
189189 }
internal/control/repo.go +3 −3
@@ -249,7 +249,7 @@ func resolveRepo(c *Ctx, path string, check func(store.User, store.Repo, string)
249249}
250250
251251func runRepoCreate(c *Ctx, args []string) int {
252 f, err := parseFlags(args, flagSpec{Values: []string{"--description"}, Bools: []string{"--private"}, MaxPos: 1, Usage: "repo create <owner/name> [--private] [--description <text>]"})
252 f, err := c.parseArgs(args, flagSpec{Values: []string{"--description"}, Bools: []string{"--private"}, MaxPos: 1, Usage: "repo create <owner/name> [--private] [--description <text>]"})
253253 if err != nil {
254254 return c.fail(protocol.ExitUsage, "%v", err)
255255 }
@@ -1020,7 +1020,7 @@ func MatchesRepo(q, path, desc string, topics []string) bool {
10201020}
10211021
10221022func runRepoGrep(c *Ctx, args []string) int {
1023 f, err := parseFlags(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep <owner/name> <query> [--ref <ref>]"})
1023 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref"}, MaxPos: 2, Usage: "repo grep <owner/name> <query> [--ref <ref>]"})
10241024 if err != nil {
10251025 return c.fail(protocol.ExitUsage, "%v", err)
10261026 }
@@ -1246,7 +1246,7 @@ func setProtect(c *Ctx, args []string, protect bool) int {
12461246// base, measured from their merge base the way a merge request diff is,
12471247// so a base that moved on does not show up as removals (#118).
12481248func runRepoDiff(c *Ctx, args []string) int {
1249 f, err := parseFlags(args, flagSpec{MaxPos: 3, Usage: "repo diff <owner/name> <base> <head>"})
1249 f, err := c.parseArgs(args, flagSpec{MaxPos: 3, Usage: "repo diff <owner/name> <base> <head>"})
12501250 if err != nil || len(f.Pos) != 3 {
12511251 return c.usage()
12521252 }
internal/control/runnerrepo.go +1 −1
@@ -37,7 +37,7 @@ func init() {
3737}
3838
3939func runRepoRunnerAdd(c *Ctx, args []string) int {
40 f, err := parseFlags(args, flagSpec{MaxPos: 1, Usage: "repo runner add <owner/name> < key.pub"})
40 f, err := c.parseArgs(args, flagSpec{MaxPos: 1, Usage: "repo runner add <owner/name> < key.pub"})
4141 if err != nil || len(f.Pos) != 1 {
4242 return c.usage()
4343 }
internal/control/search.go +1 −1
@@ -84,7 +84,7 @@ func Search(st *store.Store, root string, userID int64, q string, kinds []string
8484}
8585
8686func runSearch(c *Ctx, args []string) int {
87 f, err := parseFlags(args, flagSpec{Multi: []string{"--kind"}, MaxPos: 1, Usage: c.Cmd.Usage})
87 f, err := c.parseArgs(args, flagSpec{Multi: []string{"--kind"}, MaxPos: 1, Usage: c.Cmd.Usage})
8888 if err != nil {
8989 return c.fail(protocol.ExitUsage, "%v", err)
9090 }
internal/control/sig.go +1 −1
@@ -141,7 +141,7 @@ func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sh
141141}
142142
143143func runRepoLog(c *Ctx, args []string) int {
144 f, perr := parseFlags(args, flagSpec{Values: []string{"--ref", "--limit", "--path"}, MaxPos: 1, Usage: "repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]"})
144 f, perr := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--limit", "--path"}, MaxPos: 1, Usage: "repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]"})
145145 if perr != nil {
146146 return c.fail(protocol.ExitUsage, "%v", perr)
147147 }
internal/control/snippet.go +2 −2
@@ -158,7 +158,7 @@ func newSnippetID() string {
158158}
159159
160160func runSnippetCreate(c *Ctx, args []string) int {
161 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: c.Cmd.Usage})
161 f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: c.Cmd.Usage})
162162 if err != nil {
163163 return c.fail(protocol.ExitUsage, "%v", err)
164164 }
@@ -287,7 +287,7 @@ func runSnippetList(c *Ctx, args []string) int {
287287}
288288
289289func runSnippetEdit(c *Ctx, args []string) int {
290 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: c.Cmd.Usage})
290 f, err := c.parseArgs(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: c.Cmd.Usage})
291291 if err != nil {
292292 return c.fail(protocol.ExitUsage, "%v", err)
293293 }
internal/control/thread.go +1 −1
@@ -77,7 +77,7 @@ func runComment(c *Ctx, args []string, t thread, noun string,
7777 add func(id, userID int64, body, format string) error,
7878 participants func(id int64) ([]int64, error),
7979) int {
80 f, err := parseFlags(args, flagSpec{Values: []string{"--message", "--file", "--format"}, MaxPos: -1,
80 f, err := c.parseArgs(args, flagSpec{Values: []string{"--message", "--file", "--format"}, MaxPos: -1,
8181 Usage: noun + " comment <owner/name> <n> [--message <m> | --file -] [--format md|org]"})
8282 if err != nil {
8383 return c.fail(protocol.ExitUsage, "%v", err)
internal/control/token.go +2 −2
@@ -65,7 +65,7 @@ func (c *Ctx) ttlFlag(f flags) (*time.Time, int) {
6565}
6666
6767func runTokenCreate(c *Ctx, args []string) int {
68 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})
68 f, err := c.parseArgs(args, flagSpec{Values: []string{"--name", "--scope", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})
6969 if err != nil {
7070 return c.fail(protocol.ExitUsage, "%v", err)
7171 }
@@ -128,7 +128,7 @@ func runTokenList(c *Ctx, args []string) int {
128128}
129129
130130func runTokenRevoke(c *Ctx, args []string) int {
131 f, err := parseFlags(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
131 f, err := c.parseArgs(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
132132 if err != nil {
133133 return c.fail(protocol.ExitUsage, "%v", err)
134134 }
internal/control/webhook.go +2 −2
@@ -45,7 +45,7 @@ func init() {
4545}
4646
4747func runWebhookAdd(c *Ctx, args []string) int {
48 f, err := parseFlags(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]"})
48 f, err := c.parseArgs(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]"})
4949 if err != nil {
5050 return c.fail(protocol.ExitUsage, "%v", err)
5151 }
@@ -136,7 +136,7 @@ func runWebhookRemove(c *Ctx, args []string) int {
136136}
137137
138138func runWebhookDeliveries(c *Ctx, args []string) int {
139 f, err := parseFlags(args, flagSpec{Values: []string{"--limit"}, MaxPos: 1, Usage: "webhook deliveries <owner/name> [--limit n]"})
139 f, err := c.parseArgs(args, flagSpec{Values: []string{"--limit"}, MaxPos: 1, Usage: "webhook deliveries <owner/name> [--limit n]"})
140140 if err != nil {
141141 return c.fail(protocol.ExitUsage, "%v", err)
142142 }