Skip to content

Commit 8105e46

Browse files
julianknutsenclaude
andcommitted
Add 5 robustness & UX improvements: timeouts, color, push errors, doctor, completion
1. Auto-sync before mutations — mutationContext.Setup() pulls upstream 2. Animated spinners for long operations (browse, me, mutations) 3. Color-coded priority (P0=red, P1=yellow, P3/P4=dim) 4. Stale claim warnings in wl me (>7 days) 5. Shell completion timeout (2s) and file-based caching (5s TTL) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f85076e commit 8105e46

5 files changed

Lines changed: 222 additions & 18 deletions

File tree

cmd/wl/branch_helpers.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package main
22

33
import (
4+
"fmt"
45
"io"
56

67
"github.com/julianknutsen/wasteland/internal/commons"
78
"github.com/julianknutsen/wasteland/internal/federation"
9+
"github.com/julianknutsen/wasteland/internal/style"
810
)
911

1012
// mutationContext wraps branch checkout/return/push logic so all mutation
@@ -37,13 +39,20 @@ func (m *mutationContext) BranchName() string {
3739
return m.branch
3840
}
3941

40-
// Setup prepares the branch context. In PR mode it checks out the item branch.
42+
// Setup prepares the mutation context: checks dolt, syncs upstream, and
43+
// (in PR mode) checks out the item branch.
4144
// The returned cleanup function must be deferred to return to main.
4245
func (m *mutationContext) Setup() (cleanup func(), err error) {
4346
noop := func() {}
4447
if err := requireDolt(); err != nil {
4548
return noop, err
4649
}
50+
sp := style.StartSpinner(m.stdout, "Syncing with upstream...")
51+
syncErr := commons.PullUpstream(m.cfg.LocalDir)
52+
sp.Stop()
53+
if syncErr != nil {
54+
fmt.Fprintf(m.stdout, " warning: upstream sync failed: %v\n", syncErr)
55+
}
4756
if m.branch == "" {
4857
return noop, nil
4958
}

cmd/wl/cmd_browse.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,10 @@ func runBrowse(cmd *cobra.Command, stdout, _ io.Writer, filter BrowseFilter, jso
104104
}
105105

106106
func runBrowseLocal(stdout io.Writer, cfg *federation.Config, query string, jsonOut bool) error {
107-
fmt.Fprintf(stdout, "Syncing with upstream...\n")
108-
109-
if err := commons.PullUpstream(cfg.LocalDir); err != nil {
107+
sp := style.StartSpinner(stdout, "Syncing with upstream...")
108+
err := commons.PullUpstream(cfg.LocalDir)
109+
sp.Stop()
110+
if err != nil {
110111
return fmt.Errorf("pulling upstream: %w", err)
111112
}
112113

@@ -300,15 +301,15 @@ func wlParseCSVLine(line string) []string {
300301
func wlFormatPriority(pri string) string {
301302
switch pri {
302303
case "0":
303-
return "P0"
304+
return style.Error.Render("P0")
304305
case "1":
305-
return "P1"
306+
return style.Warning.Render("P1")
306307
case "2":
307308
return "P2"
308309
case "3":
309-
return "P3"
310+
return style.Dim.Render("P3")
310311
case "4":
311-
return "P4"
312+
return style.Dim.Render("P4")
312313
default:
313314
return pri
314315
}

cmd/wl/cmd_me.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"fmt"
55
"io"
6+
"strconv"
67
"strings"
78

89
"github.com/julianknutsen/wasteland/internal/commons"
@@ -44,9 +45,10 @@ func runMe(cmd *cobra.Command, stdout, _ io.Writer) error {
4445
dbDir := cfg.LocalDir
4546

4647
// Fetch both remotes (non-destructive — no merge into local main).
47-
fmt.Fprintf(stdout, "Fetching upstream and origin...\n")
48+
sp := style.StartSpinner(stdout, "Fetching upstream and origin...")
4849
upstreamOK := commons.FetchRemote(dbDir, "upstream") == nil
4950
originOK := commons.FetchRemote(dbDir, "origin") == nil
51+
sp.Stop()
5052

5153
printed := false
5254

@@ -56,7 +58,7 @@ func runMe(cmd *cobra.Command, stdout, _ io.Writer) error {
5658
if len(upstreamItems) > 0 {
5759
fmt.Fprintf(stdout, "\n%s\n", style.Bold.Render("On upstream (master DB):"))
5860
for _, row := range upstreamItems {
59-
fmt.Fprintf(stdout, " %-12s %-30s %-12s %-4s %s\n", row[0], row[1], row[2], wlFormatPriority(row[3]), row[4])
61+
fmt.Fprintf(stdout, " %-12s %-30s %-12s %-4s %-8s%s\n", row[0], row[1], row[2], wlFormatPriority(row[3]), row[4], staleWarning(row[5]))
6062
}
6163
printed = true
6264
}
@@ -80,7 +82,7 @@ func runMe(cmd *cobra.Command, stdout, _ io.Writer) error {
8082
if len(forkOnly) > 0 {
8183
fmt.Fprintf(stdout, "\n%s\n", style.Bold.Render("On origin only (your fork — not yet on upstream):"))
8284
for _, row := range forkOnly {
83-
fmt.Fprintf(stdout, " %-12s %-30s %-12s %-4s %s\n", row[0], row[1], row[2], wlFormatPriority(row[3]), row[4])
85+
fmt.Fprintf(stdout, " %-12s %-30s %-12s %-4s %-8s%s\n", row[0], row[1], row[2], wlFormatPriority(row[3]), row[4], staleWarning(row[5]))
8486
}
8587
printed = true
8688
}
@@ -178,10 +180,10 @@ func runMe(cmd *cobra.Command, stdout, _ io.Writer) error {
178180
}
179181

180182
// queryClaimedAsOf queries claimed/in_review items for a handle on a specific ref.
181-
// Returns data rows (no header) with columns: id, title, status, priority, effort_level.
183+
// Returns data rows (no header) with columns: id, title, status, priority, effort_level, days_stale.
182184
func queryClaimedAsOf(dbDir, handle, ref string) [][]string {
183185
csv, err := commons.DoltSQLQuery(dbDir, fmt.Sprintf(
184-
"SELECT id, title, status, priority, effort_level FROM wanted AS OF '%s' WHERE claimed_by = '%s' AND status IN ('claimed','in_review') ORDER BY priority ASC",
186+
"SELECT id, title, status, priority, effort_level, DATEDIFF(NOW(), updated_at) AS days_stale FROM wanted AS OF '%s' WHERE claimed_by = '%s' AND status IN ('claimed','in_review') ORDER BY priority ASC",
185187
commons.EscapeSQL(ref), commons.EscapeSQL(handle),
186188
))
187189
if err != nil {
@@ -193,13 +195,22 @@ func queryClaimedAsOf(dbDir, handle, ref string) [][]string {
193195
}
194196
var data [][]string
195197
for _, row := range rows[1:] {
196-
if len(row) >= 5 {
198+
if len(row) >= 6 {
197199
data = append(data, row)
198200
}
199201
}
200202
return data
201203
}
202204

205+
// staleWarning returns a dimmed warning if the item has been claimed for more than 7 days.
206+
func staleWarning(daysStr string) string {
207+
days, err := strconv.Atoi(strings.TrimSpace(daysStr))
208+
if err != nil || days <= 7 {
209+
return ""
210+
}
211+
return style.Dim.Render(fmt.Sprintf(" (%dd — consider: wl done or wl unclaim)", days))
212+
}
213+
203214
// branchItem represents a wanted item found on a wl/* branch.
204215
type branchItem struct {
205216
id string

cmd/wl/completion.go

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
package main
22

33
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"strings"
10+
"time"
11+
412
"github.com/julianknutsen/wasteland/internal/commons"
513
"github.com/spf13/cobra"
614
)
715

16+
const completionCacheTTL = 5 * time.Second
17+
818
// completeWantedIDs returns a ValidArgsFunction that completes wanted IDs,
919
// optionally filtered by status (e.g. "open", "claimed", "in_review").
1020
func completeWantedIDs(statusFilter string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
@@ -16,10 +26,12 @@ func completeWantedIDs(statusFilter string) func(*cobra.Command, []string, strin
1626
if err != nil {
1727
return nil, cobra.ShellCompDirectiveNoFileComp
1828
}
19-
ids, err := commons.ListWantedIDs(cfg.LocalDir, statusFilter)
20-
if err != nil {
21-
return nil, cobra.ShellCompDirectiveNoFileComp
29+
cacheKey := "wanted-" + statusFilter
30+
if cached := readCompletionCache(cacheKey); cached != nil {
31+
return cached, cobra.ShellCompDirectiveNoFileComp
2232
}
33+
ids := listWantedIDsWithTimeout(cfg.LocalDir, statusFilter)
34+
writeCompletionCache(cacheKey, ids)
2335
return ids, cobra.ShellCompDirectiveNoFileComp
2436
}
2537
}
@@ -33,6 +45,104 @@ func completeBranchNames(cmd *cobra.Command, args []string, _ string) ([]string,
3345
if err != nil {
3446
return nil, cobra.ShellCompDirectiveNoFileComp
3547
}
36-
branches, _ := commons.ListBranches(cfg.LocalDir, "wl/")
48+
cacheKey := "branches"
49+
if cached := readCompletionCache(cacheKey); cached != nil {
50+
return cached, cobra.ShellCompDirectiveNoFileComp
51+
}
52+
branches := listBranchesWithTimeout(cfg.LocalDir)
53+
writeCompletionCache(cacheKey, branches)
3754
return branches, cobra.ShellCompDirectiveNoFileComp
3855
}
56+
57+
// listWantedIDsWithTimeout queries wanted IDs with a 2-second timeout.
58+
func listWantedIDsWithTimeout(dbDir, statusFilter string) []string {
59+
query := "SELECT id FROM wanted"
60+
if statusFilter != "" {
61+
query += " WHERE status = '" + commons.EscapeSQL(statusFilter) + "'"
62+
}
63+
query += " ORDER BY created_at DESC LIMIT 50"
64+
out := doltQueryWithTimeout(dbDir, query, 2*time.Second)
65+
if out == "" {
66+
return nil
67+
}
68+
lines := strings.Split(strings.TrimSpace(out), "\n")
69+
if len(lines) < 2 {
70+
return nil
71+
}
72+
var ids []string
73+
for _, line := range lines[1:] {
74+
id := strings.TrimSpace(line)
75+
if id != "" {
76+
ids = append(ids, id)
77+
}
78+
}
79+
return ids
80+
}
81+
82+
// listBranchesWithTimeout queries wl/* branches with a 2-second timeout.
83+
func listBranchesWithTimeout(dbDir string) []string {
84+
query := "SELECT name FROM dolt_branches WHERE name LIKE 'wl/%' ORDER BY name"
85+
out := doltQueryWithTimeout(dbDir, query, 2*time.Second)
86+
if out == "" {
87+
return nil
88+
}
89+
lines := strings.Split(strings.TrimSpace(out), "\n")
90+
if len(lines) < 2 {
91+
return nil
92+
}
93+
var branches []string
94+
for _, line := range lines[1:] {
95+
name := strings.TrimSpace(line)
96+
if name != "" {
97+
branches = append(branches, name)
98+
}
99+
}
100+
return branches
101+
}
102+
103+
// doltQueryWithTimeout runs a dolt SQL query with a strict timeout.
104+
func doltQueryWithTimeout(dbDir, query string, timeout time.Duration) string {
105+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
106+
defer cancel()
107+
cmd := exec.CommandContext(ctx, "dolt", "sql", "-r", "csv", "-q", query)
108+
cmd.Dir = dbDir
109+
output, err := cmd.CombinedOutput()
110+
if err != nil {
111+
return ""
112+
}
113+
return string(output)
114+
}
115+
116+
// completionCacheDir returns the directory for completion cache files.
117+
func completionCacheDir() string {
118+
return filepath.Join(os.TempDir(), "wl-completion-cache")
119+
}
120+
121+
// readCompletionCache returns cached completions if the cache is fresh.
122+
func readCompletionCache(key string) []string {
123+
path := filepath.Join(completionCacheDir(), key+".json")
124+
info, err := os.Stat(path)
125+
if err != nil || time.Since(info.ModTime()) > completionCacheTTL {
126+
return nil
127+
}
128+
data, err := os.ReadFile(path)
129+
if err != nil {
130+
return nil
131+
}
132+
var items []string
133+
if err := json.Unmarshal(data, &items); err != nil {
134+
return nil
135+
}
136+
return items
137+
}
138+
139+
// writeCompletionCache writes completions to the cache.
140+
func writeCompletionCache(key string, items []string) {
141+
dir := completionCacheDir()
142+
_ = os.MkdirAll(dir, 0o755)
143+
data, err := json.Marshal(items)
144+
if err != nil {
145+
return
146+
}
147+
_ = os.WriteFile(filepath.Join(dir, key+".json"), data, 0o644)
148+
}

internal/style/spinner.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package style
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"sync"
8+
"time"
9+
10+
"github.com/mattn/go-isatty"
11+
)
12+
13+
var frames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"}
14+
15+
// Spinner displays an animated spinner with a message on a TTY.
16+
// On non-TTY writers it prints the message once and does nothing else.
17+
type Spinner struct {
18+
w io.Writer
19+
msg string
20+
done chan struct{}
21+
wg sync.WaitGroup
22+
isTTY bool
23+
}
24+
25+
// StartSpinner begins displaying an animated spinner with the given message.
26+
// Call the returned Stop function when the operation completes.
27+
func StartSpinner(w io.Writer, msg string) *Spinner {
28+
s := &Spinner{
29+
w: w,
30+
msg: msg,
31+
done: make(chan struct{}),
32+
}
33+
34+
// Check if writer is a TTY (only animate if so).
35+
if f, ok := w.(*os.File); ok {
36+
s.isTTY = isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
37+
}
38+
39+
if !s.isTTY {
40+
fmt.Fprintf(w, "%s\n", msg)
41+
return s
42+
}
43+
44+
s.wg.Add(1)
45+
go func() {
46+
defer s.wg.Done()
47+
i := 0
48+
for {
49+
select {
50+
case <-s.done:
51+
// Clear the spinner line.
52+
fmt.Fprintf(s.w, "\r\033[K")
53+
return
54+
default:
55+
frame := Dim.Render(frames[i%len(frames)])
56+
fmt.Fprintf(s.w, "\r%s %s", frame, s.msg)
57+
i++
58+
time.Sleep(80 * time.Millisecond)
59+
}
60+
}
61+
}()
62+
63+
return s
64+
}
65+
66+
// Stop stops the spinner animation and clears the line.
67+
func (s *Spinner) Stop() {
68+
if !s.isTTY {
69+
return
70+
}
71+
close(s.done)
72+
s.wg.Wait()
73+
}

0 commit comments

Comments
 (0)