Skip to content

Commit 3e4a8b8

Browse files
julianknutsenclaude
andcommitted
Fix PR mode dirty branches and add dolt retry resilience
PR mode branches were carrying stale local-only commits because `dolt pull` merges rather than resets. Now PR mode startup hard-resets main to upstream/main, and mutations use CheckoutBranchFrom to always create clean branches from main. Also adds doltRetry with backoff to all dolt subprocess calls to handle transient failures, and shows action hints alongside result messages in the detail view. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ad9e53e commit 3e4a8b8

4 files changed

Lines changed: 170 additions & 88 deletions

File tree

cmd/wl/cmd_tui.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,16 @@ func runTUI(cmd *cobra.Command, _, stderr io.Writer) error {
3838

3939
// Sync before launching the TUI.
4040
sp := style.StartSpinner(stderr, "Syncing with upstream...")
41-
err = commons.PullUpstream(cfg.LocalDir)
41+
if cfg.ResolveMode() == federation.ModePR {
42+
// PR mode: hard-reset local main to upstream so it's a clean mirror.
43+
// Mutations live on per-item branches; main must not carry local-only commits.
44+
err = commons.ResetMainToUpstream(cfg.LocalDir)
45+
} else {
46+
err = commons.PullUpstream(cfg.LocalDir)
47+
}
4248
sp.Stop()
4349
if err != nil {
44-
return fmt.Errorf("pulling upstream: %w", err)
50+
return fmt.Errorf("syncing with upstream: %w", err)
4551
}
4652

4753
// PR mode: force-push main to origin so it matches upstream.

internal/commons/dolt.go

Lines changed: 154 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ import (
1010
"time"
1111
)
1212

13+
// doltRetry runs fn up to 3 times with backoff (1s, 2s) between attempts.
14+
func doltRetry(fn func() error) error {
15+
var err error
16+
for i := 0; i < 3; i++ {
17+
if i > 0 {
18+
time.Sleep(time.Duration(i) * time.Second)
19+
}
20+
if err = fn(); err == nil {
21+
return nil
22+
}
23+
}
24+
return err
25+
}
26+
1327
// DoltHubToken returns the DoltHub API token from the environment.
1428
func DoltHubToken() string {
1529
return os.Getenv("DOLTHUB_TOKEN")
@@ -48,45 +62,60 @@ func PushWithSync(dbDir string, stdout io.Writer) error {
4862
}
4963

5064
func pushRemote(dbDir, remote string) error {
51-
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
52-
defer cancel()
53-
cmd := exec.CommandContext(ctx, "dolt", "push", remote, "main")
54-
cmd.Dir = dbDir
55-
output, err := cmd.CombinedOutput()
56-
if err != nil {
57-
return fmt.Errorf("dolt push %s main: %w (%s)", remote, err, strings.TrimSpace(string(output)))
58-
}
59-
return nil
65+
return doltRetry(func() error {
66+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
67+
defer cancel()
68+
cmd := exec.CommandContext(ctx, "dolt", "push", remote, "main")
69+
cmd.Dir = dbDir
70+
output, err := cmd.CombinedOutput()
71+
if err != nil {
72+
return fmt.Errorf("dolt push %s main: %w (%s)", remote, err, strings.TrimSpace(string(output)))
73+
}
74+
return nil
75+
})
6076
}
6177

6278
func pullRemote(dbDir, remote string) error {
63-
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
64-
defer cancel()
65-
cmd := exec.CommandContext(ctx, "dolt", "pull", remote, "main")
66-
cmd.Dir = dbDir
67-
output, err := cmd.CombinedOutput()
68-
if err != nil {
69-
return fmt.Errorf("dolt pull %s main: %w (%s)", remote, err, strings.TrimSpace(string(output)))
70-
}
71-
return nil
79+
return doltRetry(func() error {
80+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
81+
defer cancel()
82+
cmd := exec.CommandContext(ctx, "dolt", "pull", remote, "main")
83+
cmd.Dir = dbDir
84+
output, err := cmd.CombinedOutput()
85+
if err != nil {
86+
return fmt.Errorf("dolt pull %s main: %w (%s)", remote, err, strings.TrimSpace(string(output)))
87+
}
88+
return nil
89+
})
7290
}
7391

7492
// PullUpstream pulls the latest changes from the upstream remote.
7593
func PullUpstream(dbDir string) error {
7694
return pullRemote(dbDir, "upstream")
7795
}
7896

97+
// ResetMainToUpstream fetches upstream and hard-resets local main to match.
98+
// Used in PR mode where main should always mirror upstream exactly.
99+
func ResetMainToUpstream(dbDir string) error {
100+
if err := FetchRemote(dbDir, "upstream"); err != nil {
101+
return err
102+
}
103+
return doltExec(dbDir, "reset", "--hard", "upstream/main")
104+
}
105+
79106
// FetchRemote fetches the latest refs from a named remote without merging.
80107
func FetchRemote(dbDir, remote string) error {
81-
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
82-
defer cancel()
83-
cmd := exec.CommandContext(ctx, "dolt", "fetch", remote)
84-
cmd.Dir = dbDir
85-
output, err := cmd.CombinedOutput()
86-
if err != nil {
87-
return fmt.Errorf("dolt fetch %s: %w (%s)", remote, err, strings.TrimSpace(string(output)))
88-
}
89-
return nil
108+
return doltRetry(func() error {
109+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
110+
defer cancel()
111+
cmd := exec.CommandContext(ctx, "dolt", "fetch", remote)
112+
cmd.Dir = dbDir
113+
output, err := cmd.CombinedOutput()
114+
if err != nil {
115+
return fmt.Errorf("dolt fetch %s: %w (%s)", remote, err, strings.TrimSpace(string(output)))
116+
}
117+
return nil
118+
})
90119
}
91120

92121
// doltSQLScript executes a SQL script against a dolt database directory.
@@ -103,16 +132,17 @@ func doltSQLScript(dbDir, script string) error {
103132
}
104133
_ = tmpFile.Close()
105134

106-
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
107-
defer cancel()
108-
109-
cmd := exec.CommandContext(ctx, "dolt", "sql", "--file", tmpFile.Name())
110-
cmd.Dir = dbDir
111-
output, err := cmd.CombinedOutput()
112-
if err != nil {
113-
return fmt.Errorf("%w (output: %s)", err, strings.TrimSpace(string(output)))
114-
}
115-
return nil
135+
return doltRetry(func() error {
136+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
137+
defer cancel()
138+
cmd := exec.CommandContext(ctx, "dolt", "sql", "--file", tmpFile.Name())
139+
cmd.Dir = dbDir
140+
output, err := cmd.CombinedOutput()
141+
if err != nil {
142+
return fmt.Errorf("%w (output: %s)", err, strings.TrimSpace(string(output)))
143+
}
144+
return nil
145+
})
116146
}
117147

118148
// BranchName returns the conventional branch name for a PR-mode mutation.
@@ -153,36 +183,65 @@ func CheckoutBranch(dbDir, branch string) error {
153183
return doltExec(dbDir, "checkout", branch)
154184
}
155185

186+
// CheckoutBranchFrom creates a branch from startPoint (deleting any existing
187+
// branch first), then checks it out. Used in PR mode to ensure branches start
188+
// from a clean upstream-aligned base rather than a potentially dirty HEAD.
189+
func CheckoutBranchFrom(dbDir, branch, startPoint string) error {
190+
exists, err := BranchExists(dbDir, branch)
191+
if err != nil {
192+
return fmt.Errorf("checking branch %s: %w", branch, err)
193+
}
194+
if exists {
195+
// Delete and recreate from the specified start point so the branch
196+
// doesn't carry stale commits from a previous dirty main.
197+
if err := doltExec(dbDir, "branch", "-D", branch); err != nil {
198+
return fmt.Errorf("deleting stale branch %s: %w", branch, err)
199+
}
200+
}
201+
if err := doltExec(dbDir, "branch", branch, startPoint); err != nil {
202+
return fmt.Errorf("creating branch %s from %s: %w", branch, startPoint, err)
203+
}
204+
return doltExec(dbDir, "checkout", branch)
205+
}
206+
156207
// CheckoutMain switches the working directory back to the main branch.
157208
func CheckoutMain(dbDir string) error {
158209
return doltExec(dbDir, "checkout", "main")
159210
}
160211

161212
// doltExec runs a dolt CLI command in the given database directory.
162213
func doltExec(dbDir string, args ...string) error {
163-
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
164-
defer cancel()
165-
cmd := exec.CommandContext(ctx, "dolt", args...)
166-
cmd.Dir = dbDir
167-
output, err := cmd.CombinedOutput()
168-
if err != nil {
169-
return fmt.Errorf("dolt %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
170-
}
171-
return nil
214+
return doltRetry(func() error {
215+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
216+
defer cancel()
217+
cmd := exec.CommandContext(ctx, "dolt", args...)
218+
cmd.Dir = dbDir
219+
output, err := cmd.CombinedOutput()
220+
if err != nil {
221+
return fmt.Errorf("dolt %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
222+
}
223+
return nil
224+
})
172225
}
173226

174227
// PushBranch force-pushes a named branch to origin.
175228
// Force is always used because wl/* branches on the user's own fork may
176229
// have diverged history after redo operations (unclaim then re-claim, etc.).
177230
func PushBranch(dbDir, branch string, stdout io.Writer) error {
178-
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
179-
defer cancel()
180-
cmd := exec.CommandContext(ctx, "dolt", "push", "--force", "origin", branch)
181-
cmd.Dir = dbDir
182-
output, err := cmd.CombinedOutput()
231+
err := doltRetry(func() error {
232+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
233+
defer cancel()
234+
cmd := exec.CommandContext(ctx, "dolt", "push", "--force", "origin", branch)
235+
cmd.Dir = dbDir
236+
output, err := cmd.CombinedOutput()
237+
if err != nil {
238+
return fmt.Errorf("push branch %s: %w (%s)", branch, err, strings.TrimSpace(string(output)))
239+
}
240+
return nil
241+
})
183242
if err != nil {
184-
fmt.Fprintf(stdout, " warning: push branch %s to origin failed: %v (%s)\n", branch, err, strings.TrimSpace(string(output)))
185-
return fmt.Errorf("push branch %s: %w", branch, err)
243+
fmt.Fprintf(stdout, " warning: %v\n", err)
244+
return err
186245
}
187246
fmt.Fprintf(stdout, " Pushed branch %s to origin\n", branch)
188247
return nil
@@ -236,15 +295,17 @@ func DeleteBranch(dbDir, branch string) error {
236295

237296
// DeleteRemoteBranch deletes a branch on a named remote using refspec syntax.
238297
func DeleteRemoteBranch(dbDir, remote, branch string) error {
239-
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
240-
defer cancel()
241-
cmd := exec.CommandContext(ctx, "dolt", "push", remote, ":"+branch)
242-
cmd.Dir = dbDir
243-
output, err := cmd.CombinedOutput()
244-
if err != nil {
245-
return fmt.Errorf("dolt push %s :%s: %w (%s)", remote, branch, err, strings.TrimSpace(string(output)))
246-
}
247-
return nil
298+
return doltRetry(func() error {
299+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
300+
defer cancel()
301+
cmd := exec.CommandContext(ctx, "dolt", "push", remote, ":"+branch)
302+
cmd.Dir = dbDir
303+
output, err := cmd.CombinedOutput()
304+
if err != nil {
305+
return fmt.Errorf("dolt push %s :%s: %w (%s)", remote, branch, err, strings.TrimSpace(string(output)))
306+
}
307+
return nil
308+
})
248309
}
249310

250311
// EnsureGitHubRemote adds a "github" Dolt remote pointing to the rig's
@@ -286,18 +347,24 @@ func PushBranchToRemote(dbDir, remote, branch string, stdout io.Writer) error {
286347

287348
// PushBranchToRemoteForce pushes a branch to a named remote, optionally with --force.
288349
func PushBranchToRemoteForce(dbDir, remote, branch string, force bool, stdout io.Writer) error {
289-
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
290-
defer cancel()
291-
args := []string{"push"}
292-
if force {
293-
args = append(args, "--force")
294-
}
295-
args = append(args, remote, branch)
296-
cmd := exec.CommandContext(ctx, "dolt", args...)
297-
cmd.Dir = dbDir
298-
output, err := cmd.CombinedOutput()
350+
err := doltRetry(func() error {
351+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
352+
defer cancel()
353+
args := []string{"push"}
354+
if force {
355+
args = append(args, "--force")
356+
}
357+
args = append(args, remote, branch)
358+
cmd := exec.CommandContext(ctx, "dolt", args...)
359+
cmd.Dir = dbDir
360+
output, err := cmd.CombinedOutput()
361+
if err != nil {
362+
return fmt.Errorf("dolt push %s %s: %w (%s)", remote, branch, err, strings.TrimSpace(string(output)))
363+
}
364+
return nil
365+
})
299366
if err != nil {
300-
return fmt.Errorf("dolt push %s %s: %w (%s)", remote, branch, err, strings.TrimSpace(string(output)))
367+
return err
301368
}
302369
fmt.Fprintf(stdout, " Pushed branch %s to %s\n", branch, remote)
303370
return nil
@@ -382,14 +449,18 @@ func QueryItemStatusAsOf(dbDir, wantedID, ref string) string {
382449

383450
// DoltSQLQuery executes a SQL query and returns the raw CSV output.
384451
func DoltSQLQuery(dbDir, query string) (string, error) {
385-
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
386-
defer cancel()
387-
388-
cmd := exec.CommandContext(ctx, "dolt", "sql", "-r", "csv", "-q", query)
389-
cmd.Dir = dbDir
390-
output, err := cmd.CombinedOutput()
391-
if err != nil {
392-
return "", fmt.Errorf("dolt sql query failed: %w (%s)", err, strings.TrimSpace(string(output)))
393-
}
394-
return string(output), nil
452+
var result string
453+
err := doltRetry(func() error {
454+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
455+
defer cancel()
456+
cmd := exec.CommandContext(ctx, "dolt", "sql", "-r", "csv", "-q", query)
457+
cmd.Dir = dbDir
458+
output, err := cmd.CombinedOutput()
459+
if err != nil {
460+
return fmt.Errorf("dolt sql query failed: %w (%s)", err, strings.TrimSpace(string(output)))
461+
}
462+
result = string(output)
463+
return nil
464+
})
465+
return result, err
395466
}

internal/tui/detail.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,8 @@ func (m detailModel) renderContent() string {
449449
b.WriteString(fmt.Sprintf(" %s %s", m.spinner.View(), m.executingLabel))
450450
case m.result != "":
451451
b.WriteString(" " + m.result)
452+
b.WriteByte('\n')
453+
b.WriteString(styleDim.Render(m.actionHints()))
452454
default:
453455
b.WriteString(styleDim.Render(m.actionHints()))
454456
}

internal/tui/tui.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,10 @@ func executePRMutation(cfg Config, wantedID string, t commons.Transition) action
381381
// Query main status before checking out the branch.
382382
mainStatus := commons.QueryItemStatusAsOf(cfg.DBDir, wantedID, "main")
383383

384-
if err := commons.CheckoutBranch(cfg.DBDir, branch); err != nil {
384+
// Create branch from main (which is reset to upstream at TUI startup).
385+
// This ensures the branch only contains this item's mutation, not stale
386+
// local-only commits from previous operations.
387+
if err := commons.CheckoutBranchFrom(cfg.DBDir, branch, "main"); err != nil {
385388
return actionResultMsg{err: fmt.Errorf("checkout branch: %w", err)}
386389
}
387390

@@ -536,7 +539,7 @@ func executePRDoneMutation(cfg Config, wantedID, evidence string) actionResultMs
536539
branch := commons.BranchName(cfg.RigHandle, wantedID)
537540
mainStatus := commons.QueryItemStatusAsOf(cfg.DBDir, wantedID, "main")
538541

539-
if err := commons.CheckoutBranch(cfg.DBDir, branch); err != nil {
542+
if err := commons.CheckoutBranchFrom(cfg.DBDir, branch, "main"); err != nil {
540543
return actionResultMsg{err: fmt.Errorf("checkout branch: %w", err)}
541544
}
542545

@@ -583,7 +586,7 @@ func executePRAcceptMutation(cfg Config, wantedID string, msg acceptSubmitMsg, c
583586
branch := commons.BranchName(cfg.RigHandle, wantedID)
584587
mainStatus := commons.QueryItemStatusAsOf(cfg.DBDir, wantedID, "main")
585588

586-
if err := commons.CheckoutBranch(cfg.DBDir, branch); err != nil {
589+
if err := commons.CheckoutBranchFrom(cfg.DBDir, branch, "main"); err != nil {
587590
return actionResultMsg{err: fmt.Errorf("checkout branch: %w", err)}
588591
}
589592

0 commit comments

Comments
 (0)