Skip to content

Commit 38da94c

Browse files
julianknutsenclaude
andcommitted
Add 5 robustness & UX improvements: timeouts, color, push errors, doctor, completion
- Federation subprocess timeouts: replace bare exec.Command with exec.CommandContext across all execDoltCLI methods (10m clone, 60s push, 30s SQL/checkout, 15s remote ops) - NO_COLOR / --color flag: add SetColorMode to style package, --color persistent flag on root (always/auto/never) - Surface push errors: PushWithSync and PushBranch now return errors, Push() returns error, all 10 callers warn on failure instead of silently swallowing - wl doctor: diagnostic command checking dolt install, credentials, env vars, joined wastelands, and functional GPG signing verification - Shell completion: enable Cobra completion subcommand, add ValidArgsFunction for wanted-ID and branch commands, flag completion for --type/--effort/--status/--severity, config key/value completion, ListWantedIDs helper in commons Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b8d02cb commit 38da94c

26 files changed

+640
-34
lines changed

README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,26 @@ Requires [Go 1.24+](https://go.dev/dl/).
5050

5151
[Dolt](https://docs.dolthub.com/introduction/installation) must be installed and in your PATH.
5252

53+
### Shell Completion (optional)
54+
55+
Enable tab completion for commands, wanted IDs, branch names, and flag values:
56+
57+
```bash
58+
# Bash (add to ~/.bashrc)
59+
source <(wl completion bash)
60+
61+
# Zsh (add to ~/.zshrc)
62+
source <(wl completion zsh)
63+
64+
# Fish
65+
wl completion fish | source
66+
67+
# PowerShell
68+
wl completion powershell | Out-String | Invoke-Expression
69+
```
70+
71+
After sourcing, `wl claim <Tab>` completes open wanted IDs, `wl merge <Tab>` completes branch names, and flags like `--type` and `--effort` complete their valid values.
72+
5373
## Join a Wasteland
5474

5575
1. [Install dolt](https://docs.dolthub.com/introduction/installation) and run `dolt login`
@@ -345,11 +365,13 @@ Config and data follow XDG conventions:
345365
| `wl merge <branch>` | Merge a reviewed branch | `--keep-branch`, `--no-push` |
346366
| `wl config get\|set` | Read or write configuration | |
347367
| `wl verify` | Check GPG signatures | `--last` |
368+
| `wl doctor` | Check setup for common issues | |
369+
| `wl completion <shell>` | Generate shell completion script | `bash`, `zsh`, `fish`, `powershell` |
348370
| `wl list` | List joined wastelands | |
349371
| `wl leave [upstream]` | Leave a wasteland | |
350-
| `wl version` | Print version info | |
372+
| `wl version` | Print version info | `--color` |
351373

352-
All commands accept `--wasteland <org/db>` when multiple wastelands are joined.
374+
All commands accept `--wasteland <org/db>` when multiple wastelands are joined and `--color <always|auto|never>` to control colored output.
353375

354376
## Environment Variables
355377

cmd/wl/branch_helpers.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,12 @@ func (m *mutationContext) Setup() (cleanup func(), err error) {
5858
// Push pushes changes to the appropriate remote(s).
5959
// In wild-west mode: PushWithSync (upstream + origin).
6060
// In PR mode: PushBranch (origin only).
61-
func (m *mutationContext) Push() {
61+
func (m *mutationContext) Push() error {
6262
if m.noPush {
63-
return
63+
return nil
6464
}
6565
if m.branch != "" {
66-
_ = commons.PushBranch(m.cfg.LocalDir, m.branch, m.stdout)
67-
} else {
68-
_ = commons.PushWithSync(m.cfg.LocalDir, m.stdout)
66+
return commons.PushBranch(m.cfg.LocalDir, m.branch, m.stdout)
6967
}
68+
return commons.PushWithSync(m.cfg.LocalDir, m.stdout)
7069
}

cmd/wl/cmd_accept.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ Examples:
5050
cmd.Flags().StringVar(&message, "message", "", "Freeform message")
5151
cmd.Flags().BoolVar(&noPush, "no-push", false, "Skip pushing to remotes (offline work)")
5252
_ = cmd.MarkFlagRequired("quality")
53+
cmd.ValidArgsFunction = completeWantedIDs("in_review")
54+
_ = cmd.RegisterFlagCompletionFunc("severity", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
55+
return []string{"leaf", "branch", "root"}, cobra.ShellCompDirectiveNoFileComp
56+
})
5357

5458
return cmd
5559
}
@@ -108,7 +112,10 @@ func runAccept(cmd *cobra.Command, stdout, _ io.Writer, wantedID string, quality
108112
fmt.Fprintf(stdout, " Branch: %s\n", mc.BranchName())
109113
}
110114

111-
mc.Push()
115+
if err := mc.Push(); err != nil {
116+
fmt.Fprintf(stdout, "\n %s %s\n", style.Warning.Render(style.IconWarn),
117+
"Push failed — changes saved locally. Run 'wl sync' to retry.")
118+
}
112119

113120
return nil
114121
}

cmd/wl/cmd_approve.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Examples:
2929
}
3030

3131
cmd.Flags().StringVar(&comment, "comment", "", "Review comment")
32+
cmd.ValidArgsFunction = completeBranchNames
3233

3334
return cmd
3435
}

cmd/wl/cmd_browse.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ EXAMPLES:
5454
cmd.Flags().IntVar(&priority, "priority", -1, "Filter by priority (0=critical, 2=medium, 4=backlog)")
5555
cmd.Flags().IntVar(&limit, "limit", 50, "Maximum items to display")
5656
cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
57+
_ = cmd.RegisterFlagCompletionFunc("status", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
58+
return []string{"open", "claimed", "in_review", "completed", "withdrawn"}, cobra.ShellCompDirectiveNoFileComp
59+
})
60+
_ = cmd.RegisterFlagCompletionFunc("type", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
61+
return []string{"feature", "bug", "design", "rfc", "docs"}, cobra.ShellCompDirectiveNoFileComp
62+
})
5763

5864
return cmd
5965
}

cmd/wl/cmd_claim.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Examples:
3333
}
3434

3535
cmd.Flags().BoolVar(&noPush, "no-push", false, "Skip pushing to remotes (offline work)")
36+
cmd.ValidArgsFunction = completeWantedIDs("open")
3637

3738
return cmd
3839
}
@@ -64,7 +65,10 @@ func runClaim(cmd *cobra.Command, stdout, _ io.Writer, wantedID string, noPush b
6465
fmt.Fprintf(stdout, " Branch: %s\n", mc.BranchName())
6566
}
6667

67-
mc.Push()
68+
if err := mc.Push(); err != nil {
69+
fmt.Fprintf(stdout, "\n %s %s\n", style.Warning.Render(style.IconWarn),
70+
"Push failed — changes saved locally. Run 'wl sync' to retry.")
71+
}
6872

6973
return nil
7074
}

cmd/wl/cmd_close.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Examples:
3333
}
3434

3535
cmd.Flags().BoolVar(&noPush, "no-push", false, "Skip pushing to remotes (offline work)")
36+
cmd.ValidArgsFunction = completeWantedIDs("in_review")
3637

3738
return cmd
3839
}
@@ -63,7 +64,10 @@ func runClose(cmd *cobra.Command, stdout, _ io.Writer, wantedID string, noPush b
6364
fmt.Fprintf(stdout, " Branch: %s\n", mc.BranchName())
6465
}
6566

66-
mc.Push()
67+
if err := mc.Push(); err != nil {
68+
fmt.Fprintf(stdout, "\n %s %s\n", style.Warning.Render(style.IconWarn),
69+
"Push failed — changes saved locally. Run 'wl sync' to retry.")
70+
}
6771

6872
return nil
6973
}

cmd/wl/cmd_config.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ func newConfigGetCmd(stdout, stderr io.Writer) *cobra.Command {
4242
Use: "get <key>",
4343
Short: "Get a configuration value",
4444
Args: cobra.ExactArgs(1),
45+
ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
46+
if len(args) > 0 {
47+
return nil, cobra.ShellCompDirectiveNoFileComp
48+
}
49+
return []string{"mode", "signing", "provider-type", "github-repo"}, cobra.ShellCompDirectiveNoFileComp
50+
},
4551
RunE: func(cmd *cobra.Command, args []string) error {
4652
return runConfigGet(cmd, stdout, stderr, args[0])
4753
},
@@ -53,6 +59,20 @@ func newConfigSetCmd(stdout, stderr io.Writer) *cobra.Command {
5359
Use: "set <key> <value>",
5460
Short: "Set a configuration value",
5561
Args: cobra.ExactArgs(2),
62+
ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
63+
switch len(args) {
64+
case 0:
65+
return []string{"mode", "signing", "github-repo"}, cobra.ShellCompDirectiveNoFileComp
66+
case 1:
67+
switch args[0] {
68+
case "mode":
69+
return []string{"wild-west", "pr"}, cobra.ShellCompDirectiveNoFileComp
70+
case "signing":
71+
return []string{"true", "false"}, cobra.ShellCompDirectiveNoFileComp
72+
}
73+
}
74+
return nil, cobra.ShellCompDirectiveNoFileComp
75+
},
5676
RunE: func(cmd *cobra.Command, args []string) error {
5777
return runConfigSet(cmd, stdout, stderr, args[0], args[1])
5878
},

cmd/wl/cmd_delete.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Examples:
3535
}
3636

3737
cmd.Flags().BoolVar(&noPush, "no-push", false, "Skip pushing to remotes (offline work)")
38+
cmd.ValidArgsFunction = completeWantedIDs("open")
3839

3940
return cmd
4041
}
@@ -64,7 +65,10 @@ func runDelete(cmd *cobra.Command, stdout, _ io.Writer, wantedID string, noPush
6465
fmt.Fprintf(stdout, " Branch: %s\n", mc.BranchName())
6566
}
6667

67-
mc.Push()
68+
if err := mc.Push(); err != nil {
69+
fmt.Fprintf(stdout, "\n %s %s\n", style.Warning.Render(style.IconWarn),
70+
"Push failed — changes saved locally. Run 'wl sync' to retry.")
71+
}
6872

6973
return nil
7074
}

cmd/wl/cmd_doctor.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/julianknutsen/wasteland/internal/federation"
12+
"github.com/julianknutsen/wasteland/internal/style"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
func newDoctorCmd(stdout, stderr io.Writer) *cobra.Command {
17+
return &cobra.Command{
18+
Use: "doctor",
19+
Short: "Check your wasteland setup for common issues",
20+
Long: `Run diagnostic checks on your wasteland setup.
21+
22+
Verifies dolt installation, credentials, environment variables,
23+
and per-wasteland configuration.
24+
25+
Examples:
26+
wl doctor`,
27+
Args: cobra.NoArgs,
28+
RunE: func(_ *cobra.Command, _ []string) error {
29+
return runDoctor(stdout, stderr, exec.LookPath, os.Getenv, federation.NewConfigStore())
30+
},
31+
}
32+
}
33+
34+
// doctorDeps holds injectable dependencies for testing.
35+
type doctorDeps struct {
36+
lookPath func(string) (string, error)
37+
getenv func(string) string
38+
store federation.ConfigStore
39+
}
40+
41+
func runDoctor(stdout, _ io.Writer, lookPath func(string) (string, error), getenv func(string) string, store federation.ConfigStore) error {
42+
deps := &doctorDeps{lookPath: lookPath, getenv: getenv, store: store}
43+
runDoctorChecks(stdout, deps)
44+
return nil
45+
}
46+
47+
func runDoctorChecks(stdout io.Writer, deps *doctorDeps) {
48+
// 1. dolt installed
49+
checkDolt(stdout, deps)
50+
51+
// 2. dolt credentials
52+
checkDoltCreds(stdout)
53+
54+
// 3. DOLTHUB_TOKEN
55+
checkEnvVar(stdout, deps, "DOLTHUB_TOKEN")
56+
57+
// 4. DOLTHUB_ORG
58+
checkEnvVar(stdout, deps, "DOLTHUB_ORG")
59+
60+
// 5. Wastelands joined
61+
checkWastelands(stdout, deps)
62+
}
63+
64+
func checkDolt(stdout io.Writer, deps *doctorDeps) {
65+
doltPath, err := deps.lookPath("dolt")
66+
if err != nil {
67+
fmt.Fprintf(stdout, " %s dolt: not found in PATH\n", style.Error.Render(style.IconFail))
68+
return
69+
}
70+
71+
cmd := exec.Command(doltPath, "version")
72+
output, err := cmd.Output()
73+
if err != nil {
74+
fmt.Fprintf(stdout, " %s dolt: found but 'dolt version' failed: %v\n", style.Warning.Render(style.IconWarn), err)
75+
return
76+
}
77+
ver := strings.TrimSpace(string(output))
78+
fmt.Fprintf(stdout, " %s dolt: %s\n", style.Success.Render(style.IconPass), ver)
79+
}
80+
81+
func checkDoltCreds(stdout io.Writer) {
82+
home, err := os.UserHomeDir()
83+
if err != nil {
84+
fmt.Fprintf(stdout, " %s dolt credentials: cannot determine home directory\n", style.Warning.Render(style.IconWarn))
85+
return
86+
}
87+
credsDir := filepath.Join(home, ".dolt", "creds")
88+
entries, err := os.ReadDir(credsDir)
89+
if err != nil {
90+
fmt.Fprintf(stdout, " %s dolt credentials: no credentials directory found (%s)\n", style.Warning.Render(style.IconWarn), credsDir)
91+
return
92+
}
93+
var keyCount int
94+
for _, e := range entries {
95+
if !e.IsDir() && strings.HasSuffix(e.Name(), ".jwk") {
96+
keyCount++
97+
}
98+
}
99+
if keyCount == 0 {
100+
fmt.Fprintf(stdout, " %s dolt credentials: no key files found in %s\n", style.Warning.Render(style.IconWarn), credsDir)
101+
} else {
102+
fmt.Fprintf(stdout, " %s dolt credentials: %d key(s) found\n", style.Success.Render(style.IconPass), keyCount)
103+
}
104+
}
105+
106+
func checkEnvVar(stdout io.Writer, deps *doctorDeps, name string) {
107+
val := deps.getenv(name)
108+
if val == "" {
109+
fmt.Fprintf(stdout, " %s %s: not set\n", style.Warning.Render(style.IconWarn), name)
110+
} else {
111+
// Show partial value for ORG, hide TOKEN
112+
if name == "DOLTHUB_ORG" {
113+
fmt.Fprintf(stdout, " %s %s: set (%s)\n", style.Success.Render(style.IconPass), name, val)
114+
} else {
115+
fmt.Fprintf(stdout, " %s %s: set\n", style.Success.Render(style.IconPass), name)
116+
}
117+
}
118+
}
119+
120+
func checkWastelands(stdout io.Writer, deps *doctorDeps) {
121+
upstreams, err := deps.store.List()
122+
if err != nil {
123+
fmt.Fprintf(stdout, " %s wastelands: error listing: %v\n", style.Error.Render(style.IconFail), err)
124+
return
125+
}
126+
if len(upstreams) == 0 {
127+
fmt.Fprintf(stdout, " %s wastelands: none joined (run 'wl join <upstream>')\n", style.Warning.Render(style.IconWarn))
128+
return
129+
}
130+
fmt.Fprintf(stdout, " %s %d wasteland(s) joined\n", style.Success.Render(style.IconPass), len(upstreams))
131+
132+
for _, upstream := range upstreams {
133+
cfg, err := deps.store.Load(upstream)
134+
if err != nil {
135+
fmt.Fprintf(stdout, "\n %s:\n", upstream)
136+
fmt.Fprintf(stdout, " %s config: failed to load: %v\n", style.Error.Render(style.IconFail), err)
137+
continue
138+
}
139+
fmt.Fprintf(stdout, "\n %s:\n", upstream)
140+
141+
// Local clone exists
142+
if _, err := os.Stat(cfg.LocalDir); err != nil {
143+
fmt.Fprintf(stdout, " %s Local clone: missing (%s)\n", style.Error.Render(style.IconFail), cfg.LocalDir)
144+
} else {
145+
fmt.Fprintf(stdout, " %s Local clone: %s\n", style.Success.Render(style.IconPass), cfg.LocalDir)
146+
}
147+
148+
// Mode
149+
fmt.Fprintf(stdout, " %s Mode: %s\n", style.Success.Render(style.IconPass), cfg.ResolveMode())
150+
151+
// GPG signing
152+
checkGPGSigning(stdout, cfg, deps)
153+
}
154+
}
155+
156+
func checkGPGSigning(stdout io.Writer, cfg *federation.Config, deps *doctorDeps) {
157+
if !cfg.Signing {
158+
fmt.Fprintf(stdout, " %s GPG signing: disabled\n", style.Warning.Render(style.IconWarn))
159+
return
160+
}
161+
162+
// Check that dolt has a signing key configured.
163+
doltPath, err := deps.lookPath("dolt")
164+
if err != nil {
165+
fmt.Fprintf(stdout, " %s GPG signing: enabled but dolt not found\n", style.Warning.Render(style.IconWarn))
166+
return
167+
}
168+
169+
cmd := exec.Command(doltPath, "config", "--global", "--get", "sqlserver.global.signingkey")
170+
keyOut, err := cmd.Output()
171+
keyID := strings.TrimSpace(string(keyOut))
172+
if err != nil || keyID == "" {
173+
fmt.Fprintf(stdout, " %s GPG signing: enabled but no signing key configured in dolt\n", style.Error.Render(style.IconFail))
174+
fmt.Fprintf(stdout, " Run: dolt config --global --add sqlserver.global.signingkey <your-gpg-key-id>\n")
175+
return
176+
}
177+
178+
// Check that the GPG key actually exists locally.
179+
gpgCmd := exec.Command("gpg", "--list-secret-keys", keyID)
180+
if err := gpgCmd.Run(); err != nil {
181+
fmt.Fprintf(stdout, " %s GPG signing: key %s not found in GPG keyring\n", style.Error.Render(style.IconFail), keyID)
182+
fmt.Fprintf(stdout, " Run: gpg --list-secret-keys --keyid-format long\n")
183+
return
184+
}
185+
186+
fmt.Fprintf(stdout, " %s GPG signing: enabled (key %s)\n", style.Success.Render(style.IconPass), keyID)
187+
}

0 commit comments

Comments
 (0)