Commit a3fa0f0777

a3fa0f077742b3128e569f42abea2dff985d9fe5

parent: d2db7d8e1a

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 06:54 UTC

usage, help: print the program and the CLI's own path for the command

A leading --path= carries the path the CLI resolved a call to. Usage
refusals and help print it in place of the registered path where the
two differ, prefix every usage line with gitbay or ssh git@host, and
mark a leading <owner/name> optional at a terminal.

Ref #267

Layout: unified · split

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 +32 −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,14 @@ 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)
276306 }
277307 }
278308}
internal/control/help.go +74 −8
@@ -111,16 +111,80 @@ func runHelp(c *Ctx, args []string) int {
111111 })
112112}
113113
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.
114// viaCLI reports whether the caller is the gitbay CLI, as far as the
115// session says: a terminal (only the CLI or a caller passing --term sets
116// one), or a CLI path, which only the CLI sends.
117func (c *Ctx) viaCLI() bool {
118 return c.Term.Cols > 0 || c.CLIPath != ""
119}
120
121// program is how help spells the command it documents: gitbay for the
122// CLI, ssh otherwise.
117123func (c *Ctx) program() string {
118 if c.Term.Cols > 0 {
124 if c.viaCLI() {
119125 return "gitbay"
120126 }
121127 return "ssh git@" + hostOf(c.Cfg.Server.SiteURL)
122128}
123129
130// cliUsage marks a leading <owner/name> optional in a usage line for the
131// CLI, which infers it inside a clone (cmd/gitbay/ssh.go's withRepo).
132// Stock ssh never does.
133func cliUsage(usage string) string {
134 return strings.Replace(usage, "<owner/name>", "[<owner/name>]", 1)
135}
136
137// shownAs rewrites full, which starts with the registered path, to start
138// with the CLI's path instead when the CLI sent one that differs (#267).
139// The CLI path must name this command: either it regroups it (the same
140// last word, auth keys remove for keys remove) or extends it (repo
141// topics list for repo topics). Arguments after a CLI command can
142// dispatch to a longer registered path (gitbay repo topics list add
143// reaches repo topics add), and that command keeps its own name.
144func (c *Ctx) shownAs(registered, full string) string {
145 rest, ok := strings.CutPrefix(full, registered)
146 if !ok || c.CLIPath == "" || c.CLIPath == registered {
147 return full
148 }
149 reg, cli := strings.Fields(registered), strings.Fields(c.CLIPath)
150 if len(reg) == 0 || len(cli) == 0 || (cli[len(cli)-1] != reg[len(reg)-1] && !strings.HasPrefix(c.CLIPath, registered+" ")) {
151 return full
152 }
153 return c.CLIPath + rest
154}
155
156// shownBelow is how another command listed beside registered prints to
157// this caller. When the CLI only regrouped the command (auth keys remove
158// for keys remove, the same last word) the other command takes the CLI's
159// parent in place of the registered one. When the CLI renamed the last
160// word (repo topics list for repo topics) nothing follows about the
161// other command's name, so it keeps its registered path.
162func (c *Ctx) shownBelow(registered, other string) string {
163 reg, cli, o := strings.Fields(registered), strings.Fields(c.CLIPath), strings.Fields(other)
164 if len(cli) == 0 || len(reg) == 0 || len(o) < len(reg) || cli[len(cli)-1] != reg[len(reg)-1] ||
165 !slices.Equal(o[:len(reg)-1], reg[:len(reg)-1]) {
166 return other
167 }
168 return joinPath(append(slices.Clip(cli[:len(cli)-1]), o[len(reg)-1:]...))
169}
170
171// usageShape is cmd's registered usage as this caller should see it: the
172// CLI's path in place of the registered one where they differ, and a
173// leading <owner/name> optional for the CLI.
174func (c *Ctx) usageShape(cmd Command) string {
175 shape := c.shownAs(joinPath(cmd.Path), cmd.Usage)
176 if c.viaCLI() {
177 shape = cliUsage(shape)
178 }
179 return shape
180}
181
182// cmdUsage is the running command's usage with the program in front, as
183// a usage refusal prints it.
184func (c *Ctx) cmdUsage() string {
185 return c.program() + " " + c.usageShape(c.Cmd)
186}
187
124188func (c *Ctx) heading(w io.Writer, s string) {
125189 fmt.Fprintln(w, c.Term.paint(sgrBold, s))
126190}
@@ -152,7 +216,8 @@ func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
152216 // A required flag (repo delete --yes) or an alternative
153217 // (notifications read <id>... | --all) has no " [--" to cut at, so
154218 // the usage prints whole.
155 shape := cmd.Usage
219 registered := joinPath(cmd.Path)
220 shape := c.usageShape(cmd)
156221 if i := strings.Index(shape, " [--"); i >= 0 {
157222 shape = shape[:i] + " [flags]"
158223 }
@@ -190,7 +255,7 @@ func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
190255 fmt.Fprintln(w)
191256 c.heading(w, "SEE ALSO")
192257 for _, b := range below {
193 fmt.Fprintf(w, " %s %s\n", c.program(), joinPath(b.Path))
258 fmt.Fprintf(w, " %s %s\n", c.program(), c.shownBelow(registered, joinPath(b.Path)))
194259 }
195260 }
196261}
@@ -200,7 +265,8 @@ func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) {
200265 fmt.Fprintln(w, head)
201266 fmt.Fprintln(w)
202267 c.heading(w, "USAGE")
203 fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), prefix)
268 display := c.shownAs(prefix, prefix)
269 fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), display)
204270 wide := 0
205271 for _, cmd := range cmds {
206272 wide = max(wide, cells(strings.TrimPrefix(joinPath(cmd.Path), prefix+" ")))
@@ -224,5 +290,5 @@ func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) {
224290 }
225291 }
226292 fmt.Fprintln(w)
227 fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), prefix)
293 fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), display)
228294}
internal/control/help_test.go +101 −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,102 @@ 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}