Skip to content

Commit d9fb5a9

Browse files
fix: lint errors, add README, LICENSE, and Makefile improvements
- Remove unused errRunAborted var (cmd/root.go) - Check w.Close() return in test helper (cmd/root_test.go) - Check fmt.Fprintf, f.Close, os.Remove returns in registry lock (lock.go) - Fix ineffectual next := offset assignment in repo_selector.go - Add README with install, usage, safe/risky mode, flags, config docs - Add MIT LICENSE - Makefile: safer _VERSION pattern to avoid empty -X in shallow CI checkouts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d99e561 commit d9fb5a9

7 files changed

Lines changed: 332 additions & 10 deletions

File tree

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Benjamin Schellenberger
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
33
REPO_BIN := $(ROOT)$(BINARY)
44
USER_BIN := $(abspath $(HOME)/.local/bin)
55
INSTALL_BIN := $(USER_BIN)/$(BINARY)
6-
VERSION ?= $(shell git -C "$(ROOT)" describe --tags --dirty 2>/dev/null || (cd "$(ROOT)" && go list -m -f '{{.Version}}' 2>/dev/null) || echo "dev")
6+
# Never pass an empty -X value: shallow CI checkouts can yield a blank describe/go list
7+
# line, which makes Cobra omit --version. Use _VERSION intermediate to guard against that.
8+
_VERSION := $(shell git -C "$(ROOT)" describe --tags --dirty 2>/dev/null || (cd "$(ROOT)" && go list -m -f '{{.Version}}' 2>/dev/null) || echo dev)
9+
VERSION ?= $(if $(strip $(_VERSION)),$(strip $(_VERSION)),dev)
710
LDFLAGS := -X github.com/git-rain/git-rain/cmd.Version=$(VERSION)
811
LDFLAGS_RELEASE := $(LDFLAGS) -s -w
912

README.md

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
# git-rain — Multi-Repo Sync CLI
2+
3+
<p align="center">
4+
<img src="https://img.shields.io/badge/status-1.0-brightgreen" alt="Status: 1.0">
5+
<img src="https://img.shields.io/badge/go-1.24.2-blue" alt="Go 1.24.2">
6+
<img src="https://img.shields.io/badge/license-MIT-blue" alt="License: MIT">
7+
</p>
8+
9+
> The reverse of [`git-fire`](https://github.com/git-fire/git-fire).
10+
11+
```
12+
git-fire → commit + push everything out
13+
git-rain → pull everything back down
14+
```
15+
16+
`git-rain` discovers all your local git repositories and syncs them from their remotes in one command — fast-forwarding branches, updating non-checked-out refs, and skipping anything that would rewrite local-only commits (unless you ask it to).
17+
18+
Invocation note: `git-rain` and `git rain` are equivalent when `git-rain` is on your PATH.
19+
20+
## Table of Contents
21+
22+
- [Quick Start](#quick-start)
23+
- [Install](#install)
24+
- [Homebrew (macOS/Linuxbrew)](#homebrew-macoslinuxbrew)
25+
- [WinGet (Windows)](#winget-windows)
26+
- [Linux native packages (.deb / .rpm)](#linux-native-packages-deb--rpm)
27+
- [Go install](#go-install)
28+
- [Binary archive (manual)](#binary-archive-manual)
29+
- [PATH setup (required)](#path-setup-required)
30+
- [Verify install](#verify-install)
31+
- [Build from source](#build-from-source)
32+
- [How It Works](#how-it-works)
33+
- [Key Features](#key-features)
34+
- [Core Commands](#core-commands)
35+
- [Flags](#flags)
36+
- [Configuration](#configuration)
37+
- [TUI Selector](#tui-selector)
38+
- [Safe Mode vs Risky Mode](#safe-mode-vs-risky-mode)
39+
- [Registry](#registry)
40+
- [Security Notes](#security-notes)
41+
- [Contributing](#contributing)
42+
- [License](#license)
43+
44+
## Quick Start
45+
46+
```bash
47+
# preview first — shows what would be synced without touching anything
48+
git-rain --dry-run
49+
50+
# sync all repos under the configured scan path
51+
git-rain
52+
53+
# interactive TUI: pick exactly which repos to sync
54+
git-rain --select
55+
```
56+
57+
## Install
58+
59+
| Method | Command | Platform |
60+
|---|---|---|
61+
| Homebrew | `brew install git-fire/tap/git-rain` | macOS, Linuxbrew |
62+
| WinGet | `winget install git-rain.git-rain` | Windows |
63+
| Linux package | Download `.deb` or `.rpm` from [GitHub Releases](https://github.com/git-fire/git-rain/releases) | Linux |
64+
| Go | `go install github.com/git-rain/git-rain@latest` | All (Go 1.24.2+) |
65+
| Binary archive | [GitHub Releases](https://github.com/git-fire/git-rain/releases) | All |
66+
67+
### Homebrew (macOS/Linuxbrew)
68+
69+
```bash
70+
brew tap git-fire/tap
71+
brew install git-rain
72+
```
73+
74+
### WinGet (Windows)
75+
76+
```powershell
77+
winget install git-rain.git-rain
78+
```
79+
80+
### Linux native packages (`.deb` / `.rpm`)
81+
82+
Download from [GitHub Releases](https://github.com/git-fire/git-rain/releases), then:
83+
84+
```bash
85+
# Debian/Ubuntu
86+
sudo dpkg -i ./git-rain_<version>_amd64.deb
87+
88+
# Fedora/RHEL/CentOS (dnf)
89+
sudo dnf install ./git-rain_<version>_amd64.rpm
90+
```
91+
92+
### Go install
93+
94+
```bash
95+
go install github.com/git-rain/git-rain@latest
96+
```
97+
98+
Or pin an explicit release:
99+
100+
```bash
101+
go install github.com/git-rain/git-rain@v1.0.0
102+
```
103+
104+
### Binary archive (manual)
105+
106+
Download and extract the right archive from [GitHub Releases](https://github.com/git-fire/git-rain/releases), then place the binary on your `PATH`.
107+
108+
### PATH setup (required)
109+
110+
**Go install (Linux/macOS):**
111+
```bash
112+
export PATH="$HOME/go/bin:$PATH"
113+
```
114+
Add that line to `~/.zshrc` or `~/.bashrc` to persist.
115+
116+
**Manual binary install (Linux/macOS):**
117+
```bash
118+
chmod +x git-rain
119+
sudo mv git-rain /usr/local/bin/
120+
```
121+
122+
**Manual binary install (Windows PowerShell):**
123+
```powershell
124+
New-Item -ItemType Directory -Force "$env:USERPROFILE\bin" | Out-Null
125+
Move-Item .\git-rain.exe "$env:USERPROFILE\bin\git-rain.exe" -Force
126+
```
127+
Then add `$env:USERPROFILE\bin` to your user `PATH` if not already present.
128+
129+
### Verify install
130+
131+
```bash
132+
git-rain --version
133+
which git-rain
134+
```
135+
136+
### Build from source
137+
138+
```bash
139+
git clone https://github.com/git-fire/git-rain.git
140+
cd git-rain
141+
make build # produces ./git-rain
142+
make install # installs to ~/.local/bin/git-rain
143+
```
144+
145+
Requires Go 1.24.2+.
146+
147+
## How It Works
148+
149+
1. **Scan** — walks your configured scan path discovering git repositories
150+
2. **Fetch** — runs `git fetch --all --prune` for each repo
151+
3. **Sync** — for each local branch with a tracked upstream:
152+
- If the branch can be fast-forwarded: updates it
153+
- If the branch has local-only commits: skips (safe mode) or backs up and resets (risky mode)
154+
- If the working tree is dirty on the checked-out branch: skips
155+
4. **Report** — per-branch outcomes: updated, up-to-date, skipped (with reason), failed
156+
157+
## Key Features
158+
159+
- **One-command sync** — discover and update all repos from a single invocation
160+
- **Safety-first defaults** — never rewrites local-only commits; dirty worktrees are skipped, not clobbered
161+
- **Risky mode** — opt-in destructive realignment: creates a `git-rain-backup-*` ref, then hard-resets to upstream
162+
- **Non-checked-out branches** — updated directly without touching the worktree
163+
- **Interactive TUI** — streaming repo selector lets you pick exactly which repos to sync
164+
- **Registry** — discovered repos persist across runs; mark repos ignored to skip them permanently
165+
- **Dry run** — preview all repos that would be synced without making any changes
166+
- **Fetch-only mode** — run `git fetch --all --prune` everywhere without touching local refs
167+
168+
## Core Commands
169+
170+
```bash
171+
# dry run — preview repos, no changes
172+
git-rain --dry-run
173+
174+
# default run — scan and sync all repos
175+
git-rain
176+
177+
# interactive repo selection before syncing
178+
git-rain --select
179+
180+
# fetch only (no local ref updates)
181+
git-rain --fetch-only
182+
183+
# sync only known registry repos, skip filesystem scan
184+
git-rain --no-scan
185+
186+
# scan a specific path
187+
git-rain --path ~/projects
188+
189+
# risky mode — realign local-only commits after creating backup branches
190+
git-rain --risky
191+
192+
# generate example config file
193+
git-rain --init
194+
```
195+
196+
## Flags
197+
198+
| Flag | Description |
199+
|---|---|
200+
| `--dry-run` | Show what would be synced without making changes |
201+
| `--select` | Interactive TUI repo selector before syncing |
202+
| `--fetch-only` | Fetch from all remotes but skip local ref updates |
203+
| `--path <dir>` | Scan path override (default: config `global.scan_path`) |
204+
| `--no-scan` | Skip filesystem scan; hydrate only known registry repos |
205+
| `--risky` | Allow destructive local branch realignment after creating backup refs |
206+
| `--init` | Generate example `~/.config/git-rain/config.toml` |
207+
| `--config <file>` | Use an explicit config file path |
208+
| `--force-unlock-registry` | Remove stale registry lock file without prompting |
209+
| `--version` | Print version and exit |
210+
211+
## Configuration
212+
213+
Config file: `~/.config/git-rain/config.toml`
214+
215+
Generate an example:
216+
217+
```bash
218+
git-rain --init
219+
```
220+
221+
Key options:
222+
223+
```toml
224+
[global]
225+
scan_path = "/home/you/projects" # root to discover repos under
226+
scan_depth = 5 # max directory depth
227+
scan_workers = 8 # parallel scan workers
228+
risky_mode = false # enable risky mode globally
229+
default_mode = "safe" # "safe" or "risky"
230+
disable_scan = false # skip scan; use registry only
231+
232+
scan_exclude = [
233+
"node_modules",
234+
".cache",
235+
"vendor",
236+
]
237+
```
238+
239+
All options can be overridden with environment variables using the `GIT_RAIN_` prefix:
240+
241+
```bash
242+
GIT_RAIN_GLOBAL_RISKY_MODE=true git-rain
243+
GIT_RAIN_GLOBAL_SCAN_PATH=/tmp/repos git-rain
244+
```
245+
246+
## TUI Selector
247+
248+
`git-rain --select` opens an interactive selector. Repositories stream in as the filesystem scan finds them — no waiting for the full scan to complete before you can start picking.
249+
250+
**Key bindings:**
251+
252+
| Key | Action |
253+
|---|---|
254+
| `space` | Toggle repo selection |
255+
| `a` | Select all / deselect all |
256+
| `enter` | Confirm selection and begin sync |
257+
| `q` / `esc` | Abort |
258+
| `` / `` | Navigate |
259+
260+
## Safe Mode vs Risky Mode
261+
262+
| Situation | Safe mode (default) | Risky mode (`--risky`) |
263+
|---|---|---|
264+
| Branch is fast-forwardable | ✓ Updated | ✓ Updated |
265+
| Branch has local-only commits | ⊘ Skipped | ⚠ Backed up + reset |
266+
| Checked-out branch, dirty worktree | ⊘ Skipped | ⊘ Skipped |
267+
| No upstream tracked | ⊘ Skipped | ⊘ Skipped |
268+
269+
In risky mode, a `git-rain-backup-<branch>-<timestamp>` ref is created before any hard reset so local work is always recoverable.
270+
271+
## Registry
272+
273+
Discovered repositories are stored in `~/.config/git-rain/repos.toml`. Each entry tracks path, name, status, and last-seen time.
274+
275+
Repo statuses:
276+
- `active` — present on disk and eligible for sync
277+
- `missing` — was discovered previously but the directory is gone
278+
- `ignored` — permanently excluded from sync
279+
280+
The registry uses a file lock to prevent concurrent `git-rain` instances from corrupting it. If a previous run exited uncleanly, `git-rain` detects the stale lock and prompts to remove it (or pass `--force-unlock-registry` for non-interactive use).
281+
282+
## Security Notes
283+
284+
`git-rain` shells out to the system `git` binary and inherits your existing git credentials (SSH agent, credential helper, etc.). No credentials are stored or transmitted by `git-rain` itself.
285+
286+
Secret detection: `git-rain` sanitizes error messages and log output to avoid echoing paths or git output that might contain tokens. This is a best-effort measure — keep secrets out of repo paths and remote URLs.
287+
288+
## Contributing
289+
290+
Contributions are welcome. Tests use [git-testkit](https://github.com/git-fire/git-testkit) for building git repository fixtures in integration-style tests. Prefer table-driven tests and real `git` invocations over mocks.
291+
292+
```bash
293+
make test-race # run all tests with race detector
294+
make lint # go vet
295+
```
296+
297+
Open an issue before starting large changes.
298+
299+
## License
300+
301+
MIT. See [LICENSE](LICENSE).

cmd/root.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,6 @@ var (
3636
forceUnlockRegistry bool
3737
)
3838

39-
// errRunAborted is returned when the user cancels the TUI.
40-
var errRunAborted = errors.New("aborted")
41-
4239
var rootCmd = &cobra.Command{
4340
Use: "git-rain",
4441
Short: "Sync all local repos from their remotes",

cmd/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func captureStdout(t *testing.T, fn func()) string {
4343
old := os.Stdout
4444
os.Stdout = w
4545
fn()
46-
w.Close()
46+
_ = w.Close()
4747
os.Stdout = old
4848
var buf bytes.Buffer
4949
if _, err := io.Copy(&buf, r); err != nil {

internal/registry/lock.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ func acquireLock(registryPath string) (release func(), err error) {
8181
for {
8282
f, createErr := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
8383
if createErr == nil {
84-
fmt.Fprintf(f, "%d\n", os.Getpid())
85-
f.Close()
86-
return func() { os.Remove(lockPath) }, nil
84+
_, _ = fmt.Fprintf(f, "%d\n", os.Getpid())
85+
_ = f.Close()
86+
return func() { _ = os.Remove(lockPath) }, nil
8787
}
8888

8989
if !os.IsExist(createErr) {
@@ -92,7 +92,7 @@ func acquireLock(registryPath string) (release func(), err error) {
9292

9393
if staleLock(lockPath) {
9494
fmt.Fprintf(os.Stderr, "⚠️ WARNING: removing stale registry lock (owner process is gone): %s\n", lockPath)
95-
os.Remove(lockPath)
95+
_ = os.Remove(lockPath)
9696
continue
9797
}
9898

internal/ui/repo_selector.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,8 +702,8 @@ func (m RepoSelectorModel) clampScroll(offset, cursor, visible, total int) int {
702702
offset = 0
703703
}
704704
itemVisible := visible
705+
var next int
705706
for {
706-
next := offset
707707
if cursor < offset {
708708
next = cursor
709709
} else if cursor >= offset+itemVisible {

0 commit comments

Comments
 (0)