Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Make the CLI speak git's dialect

f956874883e9fdbb95f13b5429bb1104dcec2756 · Keenan Nemetz · 18d ago · parent c1e3e3ca

Make the CLI speak git's dialect

The flags were wrong in the way that matters: `-title` is a single-dash long
option, which anything that thinks like getopt reads as `-t -i -t -l -e`. Git's
conventions (gitcli(7)) are `--long`, `-o`, and no bundling, so this replaces the
parser and every call site.

Now:

- `--long value`, `--long=value`, `-o value`, `-ovalue`.
- Short options are never bundled: `-ab` is not `-a -b`. For an option that takes
  a value, `-mm` is the stuck form and means the value "m"; for a boolean, a
  bundle is an error.
- `-o=value` is refused with a message pointing at `-ov` or `--long=v`, because
  silently taking "=value" is the kind of thing that costs an hour later.
- Options come before arguments, and an option after an argument is an error
  naming the argument rather than something to guess about.
- `--` and `--end-of-options` end option parsing; `-h`/`--help` print the
  subcommand's usage and exit 0; usage mistakes exit 129, as git does.
- `-m`/`-F` mirror `git commit`: mutually exclusive, `-F -` reads stdin.
- `-l` is repeatable; comma-separated still works for convenience.
- `issue list --format` takes `%(id)`, `%(shortid)`, `%(state)`, `%(author)`,
  `%(title)`, `%(labels)`, `%(created)` for scripts.
- `vole.author` is consulted before `user.name` for the default author.

Two bugs came out of the change, both caught by tests rather than by reading:

- The new parser stored `--config` as an ordinary value and never copied it to
  the path the commands actually use, so every command silently fell back to
  ./vole.toml. A test that rebuilds an index in a temp directory failed loudly.
- The integration test itself passed `-config`, which the new parser read as
  `-c onfig` — exactly the ambiguity this commit removes, in the wild.

Verified by hand against a live instance: repeatable `-l`, stuck `--title=x`,
`--format`, `issue comment -m text <id>` accepted, `issue comment <id> -m text`
refused with exit 129 and "options must come before arguments", `-h` exiting 0,
and a missing option value exiting 129.

internal/server/integration_test.go +22−4

@@ -6,6 +6,7 @@ import (
6 6 "crypto/ed25519"
7 7 "crypto/rand"
8 8 "encoding/pem"
9 "errors"
9 10 "fmt"
10 11 "io"
11 12 "log/slog"
@@ -337,7 +338,7 @@ func TestRebuildIndexAfterDelete(t *testing.T) {
337 338 if err := os.WriteFile(cfgPath, []byte(fmt.Sprintf("data_dir = %q\n", f.cfg.DataDir)), 0o644); err != nil {
338 339 t.Fatal(err)
339 340 }
340 cmd := exec.Command(binPath, "index", "rebuild", "-config", cfgPath)
341 cmd := exec.Command(binPath, "index", "rebuild", "--config", cfgPath)
341 342 out, err := cmd.CombinedOutput()
342 343 if err != nil {
343 344 t.Fatalf("rebuild: %v\n%s", err, out)
@@ -614,7 +615,7 @@ func TestIssueCLIEndToEnd(t *testing.T) {
614 615 return out.String(), errb.String(), err
615 616 }
616 617
617 out, errs, err := run("issue", "new", "-title", "Filed from the CLI", "-body", "Body from the CLI.", "-label", "bug")
618 out, errs, err := run("issue", "new", "--title", "Filed from the CLI", "-m", "Body from the CLI.", "-l", "bug")
618 619 if err != nil {
619 620 t.Fatalf("issue new: %v\n%s", err, errs)
620 621 }
@@ -643,7 +644,7 @@ func TestIssueCLIEndToEnd(t *testing.T) {
643 644 if out, errs, err := run("issue", "list"); err != nil || !strings.Contains(out, short) {
644 645 t.Fatalf("issue list did not show a usable id: %v %q %s", err, out, errs)
645 646 }
646 if _, errs, err := run("issue", "comment", short, "-body", "A reply from the CLI."); err != nil {
647 if _, errs, err := run("issue", "comment", "-m", "A reply from the CLI.", short); err != nil {
647 648 t.Fatalf("comment: %v\n%s", err, errs)
648 649 }
649 650 if _, body := f.get("/r/" + f.repo + "/issues/" + id); !strings.Contains(body, "A reply from the CLI.") {
@@ -658,9 +659,26 @@ func TestIssueCLIEndToEnd(t *testing.T) {
658 659 }
659 660
660 661 // And a local validation failure never reaches the server.
661 if _, errs, err := run("issue", "new", "-title", "", "-body", "x"); err == nil {
662 if _, errs, err := run("issue", "new", "--title", "", "-m", "x"); err == nil {
662 663 t.Fatalf("empty title accepted by the CLI: %s", errs)
663 664 }
665
666 // CLI conventions: a usage mistake exits 129, and options must come first.
667 cmd := exec.Command(binPath, "issue", "comment", short, "-m", "late option")
668 cmd.Dir = clone
669 var errb bytes.Buffer
670 cmd.Stderr = &errb
671 err = cmd.Run()
672 if err == nil {
673 t.Fatal("option after argument was accepted")
674 }
675 var ee *exec.ExitError
676 if errors.As(err, &ee) && ee.ExitCode() != 129 {
677 t.Fatalf("usage error exit code = %d, want 129 (%s)", ee.ExitCode(), errb.String())
678 }
679 if !strings.Contains(errb.String(), "options must come before arguments") {
680 t.Fatalf("unhelpful usage error: %s", errb.String())
681 }
664 682 }
665 683
666 684 func (f *fixture) serverIssues(t *testing.T) string {