Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a5b38f9
Default to fetch-all; add --mainline-fetch for targeted mainline pulls
cursoragent Apr 16, 2026
f33f7c4
Document fetch tiers; rename flag to --fetch-mainline
cursoragent Apr 16, 2026
4ed6c37
Make fetch --prune opt-in with per-repo and global controls
cursoragent Apr 16, 2026
ab54194
fix: show prune in sync dry-run; gofmt Upsert loop
cursoragent Apr 16, 2026
13d34b9
Address PR review: prune short-circuit, dry-run sync, fetch-mainline …
cursoragent Apr 16, 2026
93b5419
refactor(cmd): dedupe repo processing between runRain and runRainOnRepos
cursoragent Apr 16, 2026
c1fbe11
Document fetch-mainline vs full-sync; test dry-run prune with --sync
cursoragent Apr 17, 2026
f70d821
Merge origin/main; lock config writes like git-fire
cursoragent Apr 17, 2026
80df3cb
docs(readme): clarify modes, dry-run irony, and file locking
bschellenberger2600 Apr 17, 2026
2f9f636
chore: canonical default_mode and drop legacy TUI strings
bschellenberger2600 Apr 17, 2026
7ce1ab6
Merge remote-tracking branch 'origin/cursor/fetch-only-default-6939' …
bschellenberger2600 Apr 17, 2026
139c85c
feat(ui): shared panel layout and TUI width clamping
cursoragent Apr 17, 2026
8518dca
fix(cmd,git): cancel --rain scan before drain; context-aware repo ana…
cursoragent Apr 17, 2026
cf8e229
docs: --rain quit/scan cancel and TUI layout notes
cursoragent Apr 17, 2026
c116659
fix(ui): restore space between label and value in settings view
cursoragent Apr 17, 2026
c3aab18
fix(ui): align rain strip width with panel to fix border gaps
cursoragent Apr 17, 2026
44721d8
fix(ui): normalize panel inner width before border for emoji terminals
cursoragent Apr 17, 2026
5511773
fix(ui): measure panel height so repo list fits terminal
cursoragent Apr 17, 2026
1997b03
fix(ui): left-align scan status bar and preserve bordered layout
cursoragent Apr 17, 2026
99c2f09
fix(ci): address lint + PR review (min, config atomic write, bridge w…
cursoragent Apr 17, 2026
40cef11
fix: address remaining PR feedback (dry-run doc, config lock, setting…
cursoragent Apr 17, 2026
399a601
test(ui): table-driven layout capacity; fix clampCellWidth for maxCel…
cursoragent Apr 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ Operating modes, lightest to heaviest:
1. **Default** — `git fetch --all` per repo. `--prune` is opt-in. Precedence per repo: CLI (`--prune` / `--no-prune`) → local `git config rain.fetchprune` → registry `fetch_prune` → global `fetch_prune`.
2. **`--fetch-mainline`** — targeted fetches for mainline branches only (incompatible with `--sync` / full-sync triggers; CLI returns an error if combined).
3. **`--sync` + `branch_mode`** — hydrate local branches from remotes.
4. **`--risky`** — on the sync path only; allows hard reset to upstream after backup refs.

Extracted from the `git-fire` codebase and promoted to a first-class tool.
4. **`--risky`** — on the full branch-hydration path; allows hard reset to upstream after backup refs.

Module: `github.com/git-rain/git-rain`
Go version: 1.24.2
Expand Down Expand Up @@ -58,8 +56,8 @@ main.go
**Key design decisions:**
- Uses native `git` binary via `exec.Command` — not go-git.
- Default run: `git fetch --all` with optional `--prune` (resolved per repo: CLI → `rain.fetchprune` → registry `fetch_prune` → `global.fetch_prune`) and optional `--tags`. Mainline-only: `internal/git.MainlineFetchRemotes` when `--fetch-mainline`. Local hydrate: `RainRepository` when `--sync`, non-mainline `branch_mode`, or risky-only config forces full sync.
- Interactive picker: `--rain` (mirrors `git-fire --fire`).
- Backup branch prefix: `git-rain-backup-` (was `git-fire-rain-backup-` in git-fire).
- Interactive picker: `--rain` (streaming scan + Bubble Tea UI). Panel width math lives in `internal/ui/panel_layout.go` (`PanelBlockWidth` / `PanelTextWidth`); keep it aligned with `boxStyle` horizontal padding. On exit, `runRainTUIStream` cancels the scan context before draining channels so in-flight `git` from `ScanRepositoriesStream` can abort; OS SIGINT maps to the same cancel path as `q` via `tea.ErrInterrupted` → `ErrCancelled`.
- Backup branch prefix: `git-rain-backup-`.
- Config env prefix: `GIT_RAIN_`.
- Safe mode (default): never rewrites local-only commits (applies to `--sync` path).
- Risky mode (`--risky` / `config: global.risky_mode`): allows hard reset to upstream after creating a `git-rain-backup-*` ref (implies full sync).
Expand Down
68 changes: 49 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,25 @@ git-fire → commit + push everything out
git-rain → fetch all remotes by default, or hydrate locals with --sync
```

`git-rain` discovers git repositories under your scan path (and known registry entries). **Default:** `git fetch --all` per repo (no `--prune` unless you opt in) so **remote-tracking refs** update without moving local branches. **Lighter fetch:** `--fetch-mainline`. **Local updates:** `--sync` with `--branch-mode` / config. **Destructive realignment:** `--risky` on the sync path only.
`git-rain` discovers git repositories under your scan path (and known registry entries). From lightest to heaviest:

> **Warning: `--prune` is opt-in.** Passing `--prune` on `git fetch` deletes **stale remote-tracking branch refs** (for example `refs/remotes/origin/old-feature` after that branch was removed on the server). That is usually what you want for a tidy clone, but it **removes those ref names locally** until the next fetch brings them back if the branch reappears. Enable pruning only when you intend it, in this order of override: **`--prune`** / **`--no-prune`** for this run, per-repo **`git config --local --bool rain.fetchprune`**, **`fetch_prune`** on a registry entry, or **`global.fetch_prune`** in config. Effective precedence for each repo is: **CLI** → **`rain.fetchprune`** → **registry `fetch_prune`** → **global `fetch_prune`**.
| Mode | What it does |
| --- | --- |
| **Default** | `git fetch --all` per repo. `--prune` is opt-in (see below). Updates **remote-tracking refs**; does not move local branches. |
| **Lighter fetch** | `--fetch-mainline` — mainline remote-tracking refs only. |
| **Local updates** | `--sync` — hydrate locals; scope from `--branch-mode` or config. |
| **Destructive realignment** | `--risky` or config `risky_mode` on the **full branch-hydration** path (same machinery as `--sync`). That path also runs without `--sync` when you pass **`--risky`**, set **`risky_mode`**, use a **non-mainline `branch_mode`** in config, or pass **any `--branch-mode` value** on the CLI (even `mainline`). Hard-reset to upstream only after backup refs. |

> **Warning: `--prune` is opt-in.** On `git fetch`, `--prune` deletes **stale remote-tracking branch refs** (for example `refs/remotes/origin/old-feature` after that branch was removed on the server). That is usually what you want for a tidy clone, but it **removes those ref names locally** until a later fetch brings them back if the branch reappears. Turn pruning on only when you mean to.

**Where `--prune` is decided** (per repo, first applicable source wins):

1. **CLI** — `--prune` or `--no-prune` for this run
2. **Local git config** — `git config --local --bool rain.fetchprune`
3. **Registry** — `fetch_prune` on the repo entry
4. **User config** — `global.fetch_prune`

Precedence chain: **CLI** → **`rain.fetchprune`** → **registry `fetch_prune`** → **global `fetch_prune`**.

Invocation note: `git-rain` and `git rain` are equivalent when `git-rain` is on your PATH.

Expand Down Expand Up @@ -51,7 +67,8 @@ Invocation note: `git-rain` and `git rain` are equivalent when `git-rain` is on
## Quick Start

```bash
# preview first — shows what would be synced without touching anything
# preview first — lists repos and whether each would get fetch-only vs branch hydration, without running git
# (still does a filesystem scan; the flag name is a little ironic — "dry" rain that still kicks up dust)
git-rain --dry-run

# default: scan repos, then git fetch --all per repo (no --prune unless configured or --prune)
Expand Down Expand Up @@ -167,7 +184,7 @@ Requires Go 1.24.2+.

4. **`--sync`** — hydrates **local** branches: `git fetch --all` (same prune/tags rules), then updates eligible locals toward upstream. Scope comes from **`--branch-mode`** or config `branch_mode`: `mainline`, `checked-out`, `all-local`, or **`all-branches`** (creates local tracking branches for remotes you do not have yet — can be many branches).

5. **`--risky`** — does not change fetch behavior by itself; on the **`--sync`** path it allows hard reset to upstream after creating `git-rain-backup-*` refs when you would otherwise skip local-only commits.
5. **`--risky`** — does not change fetch behavior by itself; on the **full branch-hydration** path (see `--sync` above — entered by `--sync`, **`--risky`**, config **`risky_mode`**, a **non-mainline `branch_mode`**, or **any `--branch-mode` flag** on the CLI) it allows hard reset to upstream after creating `git-rain-backup-*` refs when you would otherwise skip local-only commits.

6. **Report** — one summary line per repo on the default full fetch; per-branch lines on `--fetch-mainline` and `--sync`.

Expand All @@ -177,15 +194,15 @@ Requires Go 1.24.2+.
- **Safety-first defaults** — never rewrites local-only commits; dirty worktrees are skipped, not clobbered
- **Risky mode** — opt-in destructive realignment: creates a `git-rain-backup-*` ref, then hard-resets to upstream
- **Non-checked-out branches** — updated directly without touching the worktree
- **Interactive TUI (`--rain`)** — streaming repo picker (mirrors `git-fire --fire`), then the same default fetch, `--fetch-mainline`, or `--sync` behavior
- **Interactive TUI (`--rain`)** — streaming repo picker, then the same default fetch, `--fetch-mainline`, or `--sync` behavior
- **Registry** — discovered repos persist across runs; mark repos ignored to skip them permanently
- **Dry run** — preview all repos that would be fetched or synced without making any changes
- **`--fetch-mainline`** — mainline-only remote-tracking ref refresh instead of the default full `git fetch --all`

## Core Commands

```bash
# dry run — preview repos, no changes
# dry run — preview repos, no changes (still scans disk unless you add --no-scan)
git-rain --dry-run

# default run — scan repos, git fetch --all per repo
Expand Down Expand Up @@ -220,11 +237,11 @@ git-rain --init

| Flag | Description |
|---|---|
| `--dry-run` | Show what would run without making changes |
| `--rain` | Interactive TUI repo picker before running (like `git-fire --fire`) |
| `--dry-run` | No `git fetch` / branch updates — still scans disk to list repos **unless `--no-scan`** (then only registry-known paths are considered). The name is weather-themed irony: no “wet” git work, but not a no-op. |
| `--rain` | Interactive TUI repo picker before running |
| `--sync` | Update local branches from remotes (after `git fetch --all`; default run does not sync locals) |
| `--fetch-mainline` | Mainline-only remote `git fetch` per remote instead of default `git fetch --all` (not with `--sync` or other full-sync triggers) |
| `--branch-mode` | With `--sync`: `mainline`, `checked-out`, `all-local`, or `all-branches` (overrides config for this run) |
| `--branch-mode` | On the **full branch-hydration** path (same triggers as `--sync` — see table above): `mainline`, `checked-out`, `all-local`, or `all-branches` (overrides config `branch_mode` for this run) |
| `--prune` | Pass `--prune` on fetch for this run (highest precedence; cannot combine with `--no-prune`) |
| `--no-prune` | Never pass `--prune` on fetch for this run (overrides `--prune`, config, registry, and `rain.fetchprune`) |
| `--tags` | Also pass `--tags` on fetch operations |
Expand All @@ -251,13 +268,17 @@ Key options:
```toml
[global]
scan_path = "/home/you/projects" # root to discover repos under
scan_depth = 5 # max directory depth
scan_workers = 8 # parallel scan workers
risky_mode = false # destructive realignment on --sync path only
branch_mode = "mainline" # used with --sync: mainline | checked-out | all-local | all-branches
fetch_prune = false # pass --prune on fetch when true (default off; see README warning)
default_mode = "safe" # "safe" or "risky"
disable_scan = false # skip scan; use registry only
scan_depth = 5 # max directory depth (default in app: 10)
scan_workers = 8 # parallel scan workers
fetch_workers = 4 # parallel per-repo operations (default in app: 4)
risky_mode = false # allow destructive realignment on full hydration path
branch_mode = "mainline" # full hydration: mainline | checked-out | all-local | all-branches
fetch_prune = false # pass --prune on fetch when true (default off; see README warning)
sync_tags = false # pass --tags on fetch when true; CLI --tags still forces tags for the run
# Registry default for new repos (TUI / opt-out): leave-untouched | sync-default | sync-all | sync-current-branch
default_mode = "sync-default"
disable_scan = false # skip scan; use registry only
mainline_patterns = [] # extra mainline names/prefixes when branch_mode = mainline

scan_exclude = [
"node_modules",
Expand All @@ -266,16 +287,24 @@ scan_exclude = [
]
```

`global.default_mode` must be exactly one of the four values listed above; anything else fails config load.

All options can be overridden with environment variables using the `GIT_RAIN_` prefix:

```bash
GIT_RAIN_GLOBAL_RISKY_MODE=true git-rain
GIT_RAIN_GLOBAL_SCAN_PATH=/tmp/repos git-rain
```

### Config file, locks, and crashes

**Registry (`repos.toml`)** — Writes use a cross-process lock file (`repos.toml.lock`), atomic replace, and stale-lock detection (owner PID). If a process dies mid-run you may still see a leftover lock: the CLI prompts to remove it when safe, or you can use **`--force-unlock-registry`** in scripts. This is the same class of “stale lock / don’t corrupt the database” problem as other multi-repo tools; treat lock removal like any other forced unlock — only when you are sure no other `git-rain` is running.

**User config (`config.toml`)** — Writes use **`config.toml.lock`** with a **bounded wait** (so the `--rain` settings UI does not hang forever if another process holds the lock), then an **atomic replace** (PID-scoped temp file + rename). If the lock cannot be acquired in time, the TUI shows a save error and keeps in-memory settings. Avoid hand-editing `config.toml` while a session is saving; you might leave an orphan `*.tmp` after a crash — safe to delete if present.

## Interactive TUI

`git-rain --rain` opens an interactive picker. Repositories stream in as the filesystem scan finds them — no waiting for the full scan to complete before you can start picking. After you confirm, the tool runs the **default full fetch** (`git fetch --all`, prune opt-in) unless you passed **`--fetch-mainline`**, or **`--sync`** / config implies full branch hydration.
`git-rain --rain` opens an interactive picker. Repositories stream in as the filesystem scan finds them — no waiting for the full scan to complete before you can start picking. After you confirm, the tool runs the **default full fetch** (`git fetch --all`, prune opt-in) unless you passed **`--fetch-mainline`**, or **full branch hydration** is implied by **`--sync`**, **`--risky`**, **`risky_mode`** in config, a **non-mainline `branch_mode`**, or **any `--branch-mode`** on the CLI. Quitting (**`q`** or **`ctrl+c`**) cancels the in-progress scan (in-flight `git` subprocesses are aborted via the scan context); **`ctrl+c`** outside raw TTY mode is treated like cancel.

**Key bindings:**

Expand All @@ -284,7 +313,8 @@ GIT_RAIN_GLOBAL_SCAN_PATH=/tmp/repos git-rain
| `space` | Toggle repo selection |
| `a` | Select all / deselect all |
| `enter` | Confirm selection and begin fetch or sync |
| `q` / `esc` | Abort |
| `q` / `ctrl+c` | Abort picker |
| `c` / `Esc` | Back from settings (ignored list uses `Esc` / `i` / `b`) |
| `↑` / `↓` | Navigate |

## Safe Mode vs Risky Mode
Expand All @@ -296,7 +326,7 @@ GIT_RAIN_GLOBAL_SCAN_PATH=/tmp/repos git-rain
| Checked-out branch, dirty worktree | ⊘ Skipped | ⊘ Skipped |
| No upstream tracked | ⊘ Skipped | ⊘ Skipped |

In risky mode, a `git-rain-backup-<branch>-<timestamp>` ref is created before any hard reset so local work is always recoverable.
In risky mode, a backup ref named like `git-rain-backup-<sanitized-branch>-<timestamp>-<short-sha>` is created before any hard reset so local work is always recoverable.

## Registry

Expand Down
17 changes: 12 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ Modes (from lightest fetch to full local updates):
use --prune, config fetch_prune, registry fetch_prune, or git config rain.fetchprune.

2. --fetch-mainline — targeted fetches for mainline branches only (faster when
you do not need every remote ref).
you do not need every remote ref). Cannot be combined with --sync, --risky,
non-mainline --branch-mode, or global risky_mode (full-sync triggers).

3. --sync — hydrate local branches from remotes. Use --branch-mode for scope
(mainline, checked-out, all-local, all-branches); all-branches can create
Expand Down Expand Up @@ -91,7 +92,7 @@ func init() {
rootCmd.Flags().BoolVar(&rainFetchMainline, "fetch-mainline", false, "Fetch only mainline remote-tracking refs per remote (lighter than the default git fetch --all)")
rootCmd.Flags().BoolVar(&rainInit, "init", false, "Generate example ~/.config/git-rain/config.toml")
rootCmd.Flags().StringVar(&rainConfigFile, "config", "", "Use an explicit config file path")
rootCmd.Flags().BoolVar(&rainRain, "rain", false, "Interactive TUI repo picker before running (mirrors git-fire --fire)")
rootCmd.Flags().BoolVar(&rainRain, "rain", false, "Interactive TUI repo picker before running")
rootCmd.Flags().BoolVar(&rainSync, "sync", false, "Update local branches from remotes (default is git fetch --all only)")
rootCmd.Flags().StringVar(&rainBranchMode, "branch-mode", "", `Branch sync mode: mainline (default), checked-out, all-local, all-branches`)
rootCmd.Flags().BoolVar(&rainSyncTags, "tags", false, "Fetch all tags from remotes (default: off)")
Expand Down Expand Up @@ -510,7 +511,8 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string
opts.FolderProgress = folderProgress

scanChan := make(chan git.Repository, opts.Workers)
tuiRepoChan := make(chan git.Repository, opts.Workers)
// Large buffer so the bridge rarely blocks on send if the TUI stops consuming.
tuiRepoChan := make(chan git.Repository, 256)

var scanErr error
scanDone := make(chan struct{})
Expand All @@ -521,7 +523,9 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string

now := time.Now()
defaultMode := git.ParseMode(cfg.Global.DefaultMode)
bridgeDone := make(chan struct{})
go func() {
defer close(bridgeDone)
defer close(tuiRepoChan)
for repo := range scanChan {
repo, include := upsertRepoIntoRegistry(reg, repo, now, defaultMode)
Expand All @@ -547,7 +551,10 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string
regPath,
)

// Drain channels before cancelling so goroutines can't block on sends.
// Cancel scan first so filepath walk and in-flight git subprocesses unwind.
cancelScan()

// Drain the TUI channel so the bridge never blocks on send while the scanner closes.
go func() {
for range tuiRepoChan {
}
Expand All @@ -556,8 +563,8 @@ func runRainTUIStream(cfg *config.Config, reg *registry.Registry, regPath string
for range folderProgress {
}
}()
cancelScan()
<-scanDone
<-bridgeDone

if err != nil {
if errors.Is(err, ui.ErrCancelled) {
Expand Down
30 changes: 30 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,36 @@ func TestComputeFullSync(t *testing.T) {
}
}

func TestRunRain_DryRunSyncShowsPruneResolution(t *testing.T) {
tmpHome := t.TempDir()
setTestUserDirs(t, tmpHome)

scenario := testutil.NewScenario(t)
repo := scenario.CreateRepo("dry-sync-prune").
AddFile("a.txt", "x\n").
Commit("init")

resetFlags()
rainPath = filepath.Dir(repo.Path())
rainDryRun = true
rainSync = true
rainPrune = true

var runErr error
output := captureStdout(t, func() {
runErr = runRain(rootCmd, []string{})
})
if runErr != nil {
t.Fatalf("runRain() dry-run sync error = %v", runErr)
}
if !strings.Contains(output, "Fetch --prune: on for this run (--prune)") {
t.Fatalf("expected dry-run to show prune on for --sync path, got:\n%s", output)
}
if !strings.Contains(output, "Would hydrate") {
t.Fatalf("expected dry-run hydrate wording for --sync, got:\n%s", output)
}
}

func TestRunRain_DefaultFetchAllDoesNotMoveLocalBranch(t *testing.T) {
tmpHome := t.TempDir()
setTestUserDirs(t, tmpHome)
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ require (
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
Expand Down
Loading
Loading