Skip to content

Commit 176f1e8

Browse files
Add weather-themed output, freeze handling, branch/tag flags, and tests
- Weather-themed outcome symbols and labels (↓ synced, ⚡ realigned, · current, ~ fog/skipped, ❄ frozen, ✗ failed) - Graceful fetch-failure freeze: auth/network errors mark all branches as RainOutcomeFrozen (exits 0, NBD tone) instead of hard-failing - fetchFailureReason() classifies auth vs network vs generic errors - RainOptions: BranchMode, SyncTags, MainlinePatterns fields - matchesPatterns() for user forecast patterns (exact + prefix match) - BranchSyncMode: mainline/checked-out/all-local/all-branches - createLocalTrackingFromRemotes() for all-branches mode - --branch-mode and --tags CLI flags wired through to RainOptions - Fix loader.go setDefaults() missing branch_mode/sync_tags/mainline_patterns - Fix cmd/root_test.go assertions to match new weather-themed labels - Unit tests: internal/config and internal/safety - UAT script: scripts/uat.sh — 9 end-to-end scenarios with real git repos Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4ff780f commit 176f1e8

7 files changed

Lines changed: 803 additions & 76 deletions

File tree

cmd/root.go

Lines changed: 120 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -202,81 +202,141 @@ func runRain(_ *cobra.Command, _ []string) error {
202202

203203
totalUpdated := 0
204204
totalSkipped := 0
205+
totalFrozen := 0
205206
totalFailed := 0
206-
repoFailures := 0
207207

208208
for _, repo := range active {
209-
fmt.Printf("Repo: %s\n", repo.Name)
209+
fmt.Printf(" %s\n", repo.Name)
210210

211211
if rainFetchOnly {
212212
if fetchErr := fetchOnly(repo.Path); fetchErr != nil {
213-
repoFailures++
214-
fmt.Printf(" ❌ fetch failed: %s\n\n", safety.SanitizeText(fetchErr.Error()))
213+
fmt.Printf(" ❄ (all branches): %s\n\n",
214+
safety.SanitizeText(fetchFailureMessage(fetchErr.Error())))
215+
totalFrozen++
215216
continue
216217
}
217-
fmt.Println(" fetched")
218+
fmt.Println(" fetched")
218219
fmt.Println()
219220
totalUpdated++
220221
continue
221222
}
222223

223224
res, rainErr := git.RainRepository(repo.Path, rainOpts)
224225
if rainErr != nil {
225-
repoFailures++
226-
fmt.Printf(" ❌ failed: %s\n\n", safety.SanitizeText(rainErr.Error()))
226+
fmt.Printf(" ✗ failed: %s\n\n", safety.SanitizeText(rainErr.Error()))
227+
totalFailed++
227228
continue
228229
}
229230
if len(res.Branches) == 0 {
230-
fmt.Println(" no local branches found")
231+
fmt.Println(" · no local branches")
231232
fmt.Println()
232233
continue
233234
}
234235

235236
for _, br := range res.Branches {
236-
symbol := "⊘"
237-
switch br.Outcome {
238-
case git.RainOutcomeUpdated:
239-
symbol = "✓"
240-
case git.RainOutcomeUpdatedRisky:
241-
symbol = "⚠️"
242-
case git.RainOutcomeFailed:
243-
symbol = "❌"
244-
}
245-
line := fmt.Sprintf(" %s %s", symbol, br.Branch)
237+
symbol := weatherSymbol(br.Outcome)
238+
line := fmt.Sprintf(" %s %s", symbol, br.Branch)
246239
if br.Upstream != "" {
247-
line += " <- " + br.Upstream
240+
line += " " + br.Upstream
248241
}
249-
line += ": " + br.Outcome
242+
line += ": " + outcomeLabel(br.Outcome)
250243
if br.Message != "" {
251-
line += " (" + safety.SanitizeText(strings.TrimSpace(br.Message)) + ")"
244+
line += " " + safety.SanitizeText(strings.TrimSpace(br.Message))
252245
}
253246
if br.BackupBranch != "" {
254-
line += " backup=" + br.BackupBranch
247+
line += " (backup: " + br.BackupBranch + ")"
255248
}
256249
fmt.Println(line)
257250
}
258251
fmt.Println()
259252

260253
totalUpdated += res.Updated
261254
totalSkipped += res.Skipped
255+
totalFrozen += res.Frozen
262256
totalFailed += res.Failed
263-
if res.Failed > 0 {
264-
repoFailures++
265-
}
266257
}
267258

268-
fmt.Println(strings.Repeat("=", 50))
269-
fmt.Println("🌧️ Rain complete")
270-
fmt.Printf("Updated branches: %d\n", totalUpdated)
271-
fmt.Printf("Skipped branches: %d\n", totalSkipped)
272-
fmt.Printf("Failed branches: %d\n", totalFailed)
273-
274-
if repoFailures > 0 || totalFailed > 0 {
275-
return fmt.Errorf("rain completed with failures")
259+
fmt.Println(strings.Repeat("─", 48))
260+
if totalFailed > 0 {
261+
fmt.Printf("🌧 rain stopped — %d updated, %d skipped, %d frozen, %d failed\n",
262+
totalUpdated, totalSkipped, totalFrozen, totalFailed)
263+
return fmt.Errorf("%d branch(es) failed — check output above", totalFailed)
276264
}
265+
if totalFrozen > 0 {
266+
fmt.Printf("🌧 rain delivered — %d updated, %d skipped, %d frozen (try again when reachable)\n",
267+
totalUpdated, totalSkipped, totalFrozen)
268+
return nil
269+
}
270+
fmt.Printf("🌧 rain delivered — %d updated, %d skipped\n", totalUpdated, totalSkipped)
277271
return nil
278272
}
279273

274+
// weatherSymbol returns the terminal symbol for a branch outcome.
275+
func weatherSymbol(outcome string) string {
276+
switch outcome {
277+
case git.RainOutcomeUpdated:
278+
return "↓"
279+
case git.RainOutcomeUpdatedRisky:
280+
return "⚡"
281+
case git.RainOutcomeUpToDate:
282+
return "·"
283+
case git.RainOutcomeFrozen:
284+
return "❄"
285+
case git.RainOutcomeFailed:
286+
return "✗"
287+
default:
288+
return "~" // fog — skipped for any local-state reason
289+
}
290+
}
291+
292+
// outcomeLabel returns a calm, readable label for a branch outcome.
293+
func outcomeLabel(outcome string) string {
294+
switch outcome {
295+
case git.RainOutcomeUpdated:
296+
return "synced"
297+
case git.RainOutcomeUpdatedRisky:
298+
return "realigned"
299+
case git.RainOutcomeUpToDate:
300+
return "current"
301+
case git.RainOutcomeFrozen:
302+
return "frozen"
303+
case git.RainOutcomeFailed:
304+
return "failed"
305+
case git.RainOutcomeSkippedNoUpstream:
306+
return "no upstream"
307+
case git.RainOutcomeSkippedAmbiguousUpstream:
308+
return "ambiguous upstream"
309+
case git.RainOutcomeSkippedUpstreamMissing:
310+
return "upstream missing"
311+
case git.RainOutcomeSkippedCheckedOut:
312+
return "checked out elsewhere"
313+
case git.RainOutcomeSkippedLocalAhead:
314+
return "local ahead"
315+
case git.RainOutcomeSkippedDiverged:
316+
return "diverged"
317+
case git.RainOutcomeSkippedUnsafeMerge:
318+
return "unsafe merge"
319+
case git.RainOutcomeSkippedUnsafeDirty:
320+
return "dirty worktree"
321+
default:
322+
return outcome
323+
}
324+
}
325+
326+
// fetchFailureMessage extracts a calm message from a fetchOnly error.
327+
func fetchFailureMessage(errMsg string) string {
328+
s := strings.ToLower(errMsg)
329+
if strings.Contains(s, "authentication") || strings.Contains(s, "permission denied") ||
330+
strings.Contains(s, "could not read") || strings.Contains(s, "401") || strings.Contains(s, "403") {
331+
return "could not authenticate with remote — check your credentials and try again"
332+
}
333+
if strings.Contains(s, "could not resolve") || strings.Contains(s, "connection") ||
334+
strings.Contains(s, "timed out") || strings.Contains(s, "network") {
335+
return "could not reach remote — check your network and try again"
336+
}
337+
return "fetch did not complete — try again when the remote is reachable"
338+
}
339+
280340
func runDryRun(scanOpts git.ScanOptions, rainOpts git.RainOptions) error {
281341
repos, err := git.ScanRepositories(scanOpts)
282342
if err != nil {
@@ -411,78 +471,72 @@ func runRainOnRepos(repos []git.Repository, opts git.RainOptions) error {
411471

412472
totalUpdated := 0
413473
totalSkipped := 0
474+
totalFrozen := 0
414475
totalFailed := 0
415-
repoFailures := 0
416476

417477
for _, repo := range repos {
418-
fmt.Printf("Repo: %s\n", repo.Name)
478+
fmt.Printf(" %s\n", repo.Name)
419479

420480
if rainFetchOnly {
421481
if fetchErr := fetchOnly(repo.Path); fetchErr != nil {
422-
repoFailures++
423-
fmt.Printf(" ❌ fetch failed: %s\n\n", safety.SanitizeText(fetchErr.Error()))
482+
fmt.Printf(" ❄ (all branches): %s\n\n",
483+
safety.SanitizeText(fetchFailureMessage(fetchErr.Error())))
484+
totalFrozen++
424485
continue
425486
}
426-
fmt.Println(" fetched")
487+
fmt.Println(" fetched")
427488
fmt.Println()
428489
totalUpdated++
429490
continue
430491
}
431492

432493
res, rainErr := git.RainRepository(repo.Path, opts)
433494
if rainErr != nil {
434-
repoFailures++
435-
fmt.Printf(" ❌ failed: %s\n\n", safety.SanitizeText(rainErr.Error()))
495+
fmt.Printf(" ✗ failed: %s\n\n", safety.SanitizeText(rainErr.Error()))
496+
totalFailed++
436497
continue
437498
}
438499
if len(res.Branches) == 0 {
439-
fmt.Println(" no local branches found")
500+
fmt.Println(" · no local branches")
440501
fmt.Println()
441502
continue
442503
}
443504

444505
for _, br := range res.Branches {
445-
symbol := "⊘"
446-
switch br.Outcome {
447-
case git.RainOutcomeUpdated:
448-
symbol = "✓"
449-
case git.RainOutcomeUpdatedRisky:
450-
symbol = "⚠️"
451-
case git.RainOutcomeFailed:
452-
symbol = "❌"
453-
}
454-
line := fmt.Sprintf(" %s %s", symbol, br.Branch)
506+
symbol := weatherSymbol(br.Outcome)
507+
line := fmt.Sprintf(" %s %s", symbol, br.Branch)
455508
if br.Upstream != "" {
456-
line += " <- " + br.Upstream
509+
line += " " + br.Upstream
457510
}
458-
line += ": " + br.Outcome
511+
line += ": " + outcomeLabel(br.Outcome)
459512
if br.Message != "" {
460-
line += " (" + safety.SanitizeText(strings.TrimSpace(br.Message)) + ")"
513+
line += " " + safety.SanitizeText(strings.TrimSpace(br.Message))
461514
}
462515
if br.BackupBranch != "" {
463-
line += " backup=" + br.BackupBranch
516+
line += " (backup: " + br.BackupBranch + ")"
464517
}
465518
fmt.Println(line)
466519
}
467520
fmt.Println()
468521

469522
totalUpdated += res.Updated
470523
totalSkipped += res.Skipped
524+
totalFrozen += res.Frozen
471525
totalFailed += res.Failed
472-
if res.Failed > 0 {
473-
repoFailures++
474-
}
475526
}
476527

477-
fmt.Println(strings.Repeat("=", 50))
478-
fmt.Println("🌧️ Rain complete")
479-
fmt.Printf("Updated branches: %d\n", totalUpdated)
480-
fmt.Printf("Skipped branches: %d\n", totalSkipped)
481-
fmt.Printf("Failed branches: %d\n", totalFailed)
482-
483-
if repoFailures > 0 || totalFailed > 0 {
484-
return fmt.Errorf("rain completed with failures")
528+
fmt.Println(strings.Repeat("─", 48))
529+
if totalFailed > 0 {
530+
fmt.Printf("🌧 rain stopped — %d updated, %d skipped, %d frozen, %d failed\n",
531+
totalUpdated, totalSkipped, totalFrozen, totalFailed)
532+
return fmt.Errorf("%d branch(es) failed — check output above", totalFailed)
533+
}
534+
if totalFrozen > 0 {
535+
fmt.Printf("🌧 rain delivered — %d updated, %d skipped, %d frozen (try again when reachable)\n",
536+
totalUpdated, totalSkipped, totalFrozen)
537+
return nil
485538
}
539+
fmt.Printf("🌧 rain delivered — %d updated, %d skipped\n", totalUpdated, totalSkipped)
486540
return nil
487541
}
488542

cmd/root_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@ func TestRunRain_SafeModeSkipsLocalAheadBranch(t *testing.T) {
8787
if runErr != nil {
8888
t.Fatalf("runRain() safe mode error = %v", runErr)
8989
}
90-
if !strings.Contains(output, "skipped-local-ahead") {
91-
t.Fatalf("expected safe mode output to mention skipped-local-ahead, got:\n%s", output)
90+
if !strings.Contains(output, "local ahead") {
91+
t.Fatalf("expected safe mode output to mention 'local ahead', got:\n%s", output)
9292
}
9393
if got := testutil.GetCurrentSHA(t, repo.Path()); got != localAheadSHA {
9494
t.Fatalf("safe mode should preserve local-ahead SHA (want=%s got=%s)", localAheadSHA, got)
@@ -125,8 +125,8 @@ func TestRunRain_RiskyFlagResetsLocalAheadBranch(t *testing.T) {
125125
if runErr != nil {
126126
t.Fatalf("runRain() risky mode error = %v", runErr)
127127
}
128-
if !strings.Contains(output, "updated-risky") {
129-
t.Fatalf("expected risky output to mention updated-risky, got:\n%s", output)
128+
if !strings.Contains(output, "realigned") {
129+
t.Fatalf("expected risky output to mention 'realigned', got:\n%s", output)
130130
}
131131
if got := testutil.GetCurrentSHA(t, repo.Path()); got != remoteSHA {
132132
t.Fatalf("risky mode should reset branch to remote SHA (want=%s got=%s)", remoteSHA, got)

0 commit comments

Comments
 (0)