Skip to content

Commit 303c63e

Browse files
feat: branch sync modes, user mainline patterns (forecasts), and tags flag
Branch sync modes (--branch-mode / config: branch_mode): mainline - main/master/trunk/develop/dev + release/*, hotfix/*, support/* [default] checked-out - only branches checked out in any worktree all-local - every local branch with a tracked upstream all-branches - all remote branches; creates local tracking refs if missing User-defined mainline patterns (config: mainline_patterns): Exact names and prefix strings (e.g. "JIRA-", "feat/") extend the built-in mainline list when branch_mode = "mainline". Tags (--tags / config: sync_tags): Fetches all tags from remotes. Off by default. Also: - Rename RepoMode constants from push-centric git-fire names to sync-default/sync-all/sync-current-branch for git-rain - Fix cycleRepoMode in TUI to use new constant names - Add 4 new branch mode tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d9fb5a9 commit 303c63e

7 files changed

Lines changed: 471 additions & 39 deletions

File tree

cmd/root.go

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ var (
3333
rainInit bool
3434
rainConfigFile string
3535
rainSelect bool
36+
rainBranchMode string
37+
rainSyncTags bool
3638
forceUnlockRegistry bool
3739
)
3840

@@ -71,6 +73,8 @@ func init() {
7173
rootCmd.Flags().BoolVar(&rainInit, "init", false, "Generate example ~/.config/git-rain/config.toml")
7274
rootCmd.Flags().StringVar(&rainConfigFile, "config", "", "Use an explicit config file path")
7375
rootCmd.Flags().BoolVar(&rainSelect, "select", false, "Interactive TUI repo selector before syncing")
76+
rootCmd.Flags().StringVar(&rainBranchMode, "branch-mode", "", `Branch sync mode: mainline (default), checked-out, all-local, all-branches`)
77+
rootCmd.Flags().BoolVar(&rainSyncTags, "tags", false, "Fetch all tags from remotes (default: off)")
7478
rootCmd.PersistentFlags().BoolVar(&forceUnlockRegistry, "force-unlock-registry", false, "Remove stale registry lock file without prompting")
7579
}
7680

@@ -95,6 +99,21 @@ func runRain(_ *cobra.Command, _ []string) error {
9599
}
96100
riskyMode := cfg.Global.RiskyMode || rainRisky
97101

102+
branchModeStr := cfg.Global.BranchMode
103+
if rainBranchMode != "" {
104+
branchModeStr = rainBranchMode
105+
}
106+
rainOpts := git.RainOptions{
107+
RiskyMode: riskyMode,
108+
BranchMode: git.ParseBranchSyncMode(branchModeStr),
109+
SyncTags: cfg.Global.SyncTags || rainSyncTags,
110+
MainlinePatterns: cfg.Global.MainlinePatterns,
111+
}
112+
113+
if rainOpts.BranchMode == git.BranchSyncAllBranches {
114+
fmt.Println("⚠️ all-branches mode: remote branches will be created locally — this can produce many local branches")
115+
}
116+
98117
reg := &registry.Registry{}
99118
regPath := ""
100119
if p, pathErr := registry.DefaultRegistryPath(); pathErr != nil {
@@ -136,11 +155,11 @@ func runRain(_ *cobra.Command, _ []string) error {
136155
}
137156

138157
if rainDryRun {
139-
return runDryRun(opts, riskyMode)
158+
return runDryRun(opts, rainOpts)
140159
}
141160

142161
if rainSelect {
143-
return runSelectStream(cfg, reg, regPath, opts, riskyMode)
162+
return runSelectStream(cfg, reg, regPath, opts, rainOpts)
144163
}
145164

146165
repos, err := git.ScanRepositories(opts)
@@ -201,7 +220,7 @@ func runRain(_ *cobra.Command, _ []string) error {
201220
continue
202221
}
203222

204-
res, rainErr := git.RainRepository(repo.Path, git.RainOptions{RiskyMode: riskyMode})
223+
res, rainErr := git.RainRepository(repo.Path, rainOpts)
205224
if rainErr != nil {
206225
repoFailures++
207226
fmt.Printf(" ❌ failed: %s\n\n", safety.SanitizeText(rainErr.Error()))
@@ -258,18 +277,19 @@ func runRain(_ *cobra.Command, _ []string) error {
258277
return nil
259278
}
260279

261-
func runDryRun(opts git.ScanOptions, riskyMode bool) error {
262-
repos, err := git.ScanRepositories(opts)
280+
func runDryRun(scanOpts git.ScanOptions, rainOpts git.RainOptions) error {
281+
repos, err := git.ScanRepositories(scanOpts)
263282
if err != nil {
264283
return fmt.Errorf("repository scan failed: %w", err)
265284
}
266285

267286
fmt.Println("🌧️ Git Rain — dry run")
268-
if riskyMode {
287+
if rainOpts.RiskyMode {
269288
fmt.Println("⚠️ Risky mode would be enabled")
270289
} else {
271290
fmt.Println("✓ Safe mode: local-only commits would be preserved")
272291
}
292+
fmt.Printf("Branch mode: %s\n", rainOpts.BranchMode)
273293
fmt.Println()
274294

275295
if len(repos) == 0 {
@@ -292,7 +312,7 @@ func runDryRun(opts git.ScanOptions, riskyMode bool) error {
292312
// runSelectStream launches the interactive TUI repo selector (streaming mode).
293313
// The filesystem scan runs concurrently; repos stream into the selector as they
294314
// are discovered. After selection, rain runs on the chosen repos.
295-
func runSelectStream(cfg *config.Config, reg *registry.Registry, regPath string, opts git.ScanOptions, riskyMode bool) error {
315+
func runSelectStream(cfg *config.Config, reg *registry.Registry, regPath string, opts git.ScanOptions, rainOpts git.RainOptions) error {
296316
ctx, cancelScan := context.WithCancel(context.Background())
297317
defer cancelScan()
298318
opts.Ctx = ctx
@@ -376,13 +396,13 @@ func runSelectStream(cfg *config.Config, reg *registry.Registry, regPath string,
376396
}
377397
fmt.Println()
378398

379-
return runRainOnRepos(selected, riskyMode)
399+
return runRainOnRepos(selected, rainOpts)
380400
}
381401

382402
// runRainOnRepos runs the rain operation on a pre-selected list of repos.
383-
func runRainOnRepos(repos []git.Repository, riskyMode bool) error {
403+
func runRainOnRepos(repos []git.Repository, opts git.RainOptions) error {
384404
fmt.Println("🌧️ Git Rain")
385-
if riskyMode {
405+
if opts.RiskyMode {
386406
fmt.Println("⚠️ Risky mode enabled: local-only commits may be realigned after backup branch creation")
387407
} else {
388408
fmt.Println("✓ Safe mode: local-only commits are preserved")
@@ -409,7 +429,7 @@ func runRainOnRepos(repos []git.Repository, riskyMode bool) error {
409429
continue
410430
}
411431

412-
res, rainErr := git.RainRepository(repo.Path, git.RainOptions{RiskyMode: riskyMode})
432+
res, rainErr := git.RainRepository(repo.Path, opts)
413433
if rainErr != nil {
414434
repoFailures++
415435
fmt.Printf(" ❌ failed: %s\n\n", safety.SanitizeText(rainErr.Error()))

internal/config/defaults.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ func DefaultConfig() Config {
2424
ScanDepth: 10,
2525
ScanWorkers: 8,
2626
FetchWorkers: DefaultFetchWorkers,
27-
DefaultMode: "push-known-branches",
27+
DefaultMode: "sync-default",
2828
RescanSubmodules: false,
2929
DisableScan: false,
3030
RiskyMode: false,
31+
BranchMode: "mainline",
32+
SyncTags: false,
33+
MainlinePatterns: []string{},
3134
},
35+
3236
UI: UIConfig{
3337
ShowRainAnimation: true,
3438
RainAnimationMode: UIRainAnimationBasic,
@@ -85,6 +89,21 @@ disable_scan = false
8589
# false = preserve local-only commits; true = permit hard resets to remote.
8690
risky_mode = false
8791
92+
# Which branches to sync per run.
93+
# "mainline" - main/master/trunk/develop/dev + gitflow patterns (default)
94+
# "checked-out" - only branches currently checked out in any worktree
95+
# "all-local" - every local branch with a tracked upstream
96+
# "all-branches" - all remote branches; creates local tracking refs if missing (many branches!)
97+
branch_mode = "mainline"
98+
99+
# Fetch all tags from remotes. Off by default — can pull large or unwanted history.
100+
sync_tags = false
101+
102+
# Additional branch name patterns treated as mainline when branch_mode = "mainline".
103+
# Exact names and "/" prefixes are both supported.
104+
# Examples: "develop", "feat/", "JIRA-"
105+
mainline_patterns = []
106+
88107
[ui]
89108
# Show rain animation in the interactive repo selector (toggle live with 'r')
90109
show_rain_animation = true

internal/config/types.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ type GlobalConfig struct {
3030
// Allow destructive local branch realignment during rain.
3131
// When false, local-only commits are never rewritten.
3232
RiskyMode bool `mapstructure:"risky_mode" toml:"risky_mode"`
33+
34+
// BranchMode controls which branches are synced per run.
35+
// Options: "mainline" (default), "checked-out", "all-local", "all-branches".
36+
BranchMode string `mapstructure:"branch_mode" toml:"branch_mode"`
37+
38+
// SyncTags fetches all tags from remotes in addition to branch refs.
39+
// Off by default — tag fetches can pull large or unwanted histories.
40+
SyncTags bool `mapstructure:"sync_tags" toml:"sync_tags"`
41+
42+
// MainlinePatterns are additional branch name patterns treated as mainline
43+
// when branch_mode = "mainline". Supports exact names and prefix globs
44+
// ending in "/" (e.g. "feat/", "JIRA-").
45+
MainlinePatterns []string `mapstructure:"mainline_patterns" toml:"mainline_patterns"`
3346
}
3447

3548
// UIConfig contains TUI/display settings

internal/git/rain.go

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,76 @@ package git
22

33
import (
44
"fmt"
5+
"os"
56
"os/exec"
67
"strconv"
78
"strings"
89
"time"
910
)
1011

12+
// mainlineBranchNames are exact branch names considered mainline.
13+
var mainlineBranchNames = map[string]bool{
14+
"main": true,
15+
"master": true,
16+
"trunk": true,
17+
"develop": true,
18+
"dev": true,
19+
}
20+
21+
// mainlineGitflowPrefixes are branch name prefixes considered mainline.
22+
var mainlineGitflowPrefixes = []string{
23+
"release/",
24+
"hotfix/",
25+
"support/",
26+
}
27+
28+
// matchesPatterns reports whether name matches any user-defined pattern.
29+
// Each pattern is tested as both an exact match and a prefix, so "JIRA-"
30+
// matches "JIRA-123" and "feat/" matches "feat/new-thing". Patterns ending
31+
// in "/" are prefix-only (never an exact branch name).
32+
func matchesPatterns(name string, patterns []string) bool {
33+
for _, p := range patterns {
34+
if p == "" {
35+
continue
36+
}
37+
if name == p || strings.HasPrefix(name, p) {
38+
return true
39+
}
40+
}
41+
return false
42+
}
43+
44+
// isMainlineBranch reports whether a branch name is a mainline or gitflow branch.
45+
func isMainlineBranch(name string) bool {
46+
if mainlineBranchNames[name] {
47+
return true
48+
}
49+
for _, prefix := range mainlineGitflowPrefixes {
50+
if strings.HasPrefix(name, prefix) {
51+
return true
52+
}
53+
}
54+
return false
55+
}
56+
1157
// RainOptions controls how rain/hydrate updates local branches from remotes.
1258
type RainOptions struct {
1359
// RiskyMode allows destructive realignment of local-only commits after
1460
// creating a backup branch.
1561
RiskyMode bool
62+
63+
// BranchMode controls which branches are eligible for syncing.
64+
// Defaults to BranchSyncMainline when zero.
65+
BranchMode BranchSyncMode
66+
67+
// SyncTags fetches all tags from the remote in addition to branch refs.
68+
// Off by default because tag fetches can pull large or unwanted history.
69+
SyncTags bool
70+
71+
// MainlinePatterns extends the built-in mainline branch list with user-defined
72+
// patterns. Each entry is either an exact branch name or a prefix ending in "/"
73+
// (e.g. "feat/", "JIRA-"). Only used when BranchMode == BranchSyncMainline.
74+
MainlinePatterns []string
1675
}
1776

1877
const (
@@ -77,7 +136,11 @@ func RainRepository(repoPath string, opts RainOptions) (RainResult, error) {
77136
return result, err
78137
}
79138
if hasRemote {
80-
cmd := exec.Command("git", "fetch", "--all", "--prune")
139+
fetchArgs := []string{"fetch", "--all", "--prune"}
140+
if opts.SyncTags {
141+
fetchArgs = append(fetchArgs, "--tags")
142+
}
143+
cmd := exec.Command("git", fetchArgs...)
81144
cmd.Dir = repoPath
82145
if output, fetchErr := cmd.CombinedOutput(); fetchErr != nil {
83146
return result, commandError("git fetch --all --prune", fetchErr, output)
@@ -89,6 +152,40 @@ func RainRepository(repoPath string, opts RainOptions) (RainResult, error) {
89152
return result, err
90153
}
91154

155+
// Resolve effective branch sync mode.
156+
mode := opts.BranchMode
157+
if mode == "" {
158+
mode = BranchSyncMainline
159+
}
160+
161+
// all-branches: create local tracking refs for remote branches not yet local.
162+
if mode == BranchSyncAllBranches && hasRemote {
163+
newBranches, trackErr := createLocalTrackingFromRemotes(repoPath, branches)
164+
if trackErr != nil {
165+
fmt.Fprintf(os.Stderr, "note: could not enumerate remote branches: %v\n", trackErr)
166+
} else {
167+
branches = append(branches, newBranches...)
168+
}
169+
}
170+
171+
// Filter branches to those the selected mode covers.
172+
filtered := branches[:0]
173+
for _, b := range branches {
174+
switch mode {
175+
case BranchSyncMainline:
176+
if isMainlineBranch(b.Branch) || matchesPatterns(b.Branch, opts.MainlinePatterns) {
177+
filtered = append(filtered, b)
178+
}
179+
case BranchSyncCheckedOut:
180+
if checkedOut[b.Branch] {
181+
filtered = append(filtered, b)
182+
}
183+
default: // BranchSyncAllLocal, BranchSyncAllBranches
184+
filtered = append(filtered, b)
185+
}
186+
}
187+
branches = filtered
188+
92189
hasStaged, err := HasStagedChanges(repoPath)
93190
if err != nil {
94191
return result, fmt.Errorf("check staged changes: %w", err)
@@ -362,6 +459,66 @@ func updateBranchRef(repoPath, branch, oldSHA, newSHA string) error {
362459
return nil
363460
}
364461

462+
// createLocalTrackingFromRemotes enumerates remote-tracking refs and creates
463+
// local tracking branches for any remote branch that does not yet exist locally.
464+
// Returns the newly-created localBranchTracking entries.
465+
func createLocalTrackingFromRemotes(repoPath string, existing []localBranchTracking) ([]localBranchTracking, error) {
466+
localSet := make(map[string]bool, len(existing))
467+
for _, b := range existing {
468+
localSet[b.Branch] = true
469+
}
470+
471+
remotes, err := listRepoRemotes(repoPath)
472+
if err != nil {
473+
return nil, err
474+
}
475+
476+
cmd := exec.Command("git", "for-each-ref", "refs/remotes", "--format=%(refname:short)")
477+
cmd.Dir = repoPath
478+
out, err := cmd.CombinedOutput()
479+
if err != nil {
480+
return nil, commandError("git for-each-ref refs/remotes", err, out)
481+
}
482+
483+
var created []localBranchTracking
484+
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
485+
ref := strings.TrimSpace(line)
486+
if ref == "" {
487+
continue
488+
}
489+
var branchName, upstream string
490+
for _, remote := range remotes {
491+
prefix := remote + "/"
492+
if strings.HasPrefix(ref, prefix) {
493+
name := strings.TrimPrefix(ref, prefix)
494+
if name == "HEAD" {
495+
break
496+
}
497+
branchName = name
498+
upstream = ref
499+
break
500+
}
501+
}
502+
if branchName == "" || localSet[branchName] {
503+
continue
504+
}
505+
trackCmd := exec.Command("git", "branch", "--track", branchName, upstream)
506+
trackCmd.Dir = repoPath
507+
if trackOut, trackErr := trackCmd.CombinedOutput(); trackErr != nil {
508+
fmt.Fprintf(os.Stderr, "note: could not create local tracking branch %s: %v (%s)\n",
509+
branchName, trackErr, strings.TrimSpace(string(trackOut)))
510+
continue
511+
}
512+
localSet[branchName] = true
513+
created = append(created, localBranchTracking{
514+
Branch: branchName,
515+
Upstream: upstream,
516+
Current: false,
517+
})
518+
}
519+
return created, nil
520+
}
521+
365522
func createRainBackupBranch(repoPath, branch, sha string) (string, error) {
366523
timestamp := time.Now().Format("20060102-150405")
367524
shortSHA := sha

0 commit comments

Comments
 (0)