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 { | |