Skip to content

Commit 4bd7e9b

Browse files
Bharath-codeclaude
andcommitted
feat: bulk fetch all repos with 'F'
Add internal/gitops package for bulk git actions, kept separate from the read-only gitstatus package. FetchAll runs 'git fetch' concurrently (bounded workers, per-repo timeout), classifying each repo as success, failed, or skipped (no remote). Press 'F' in the dashboard to fetch every repo's remote; statuses are refreshed afterward so ahead/behind counts update immediately. Fetch is network-only and non-destructive, so it runs without a confirmation prompt (reserved for future destructive actions like pull/stash). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 183ffae commit 4bd7e9b

7 files changed

Lines changed: 389 additions & 1 deletion

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
# Cache
1414
.git-scope-cache.json
1515

16+
# Test coverage
17+
*.out
18+
19+
# Local notes (not for publishing)
20+
PROJECT-STRATEGY-AND-CAREER-PLAYBOOK.md
21+
1622
# IDE
1723
.idea/
1824
.vscode/

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
> **A fast TUI dashboard to view the git status of *all your repositories* in one place.** > Stop the `cd``git status` loop.
44
5+
[![CI](https://github.com/Bharath-code/git-scope/actions/workflows/ci.yml/badge.svg)](https://github.com/Bharath-code/git-scope/actions/workflows/ci.yml)
56
[![Go Report Card](https://goreportcard.com/badge/github.com/Bharath-code/git-scope)](https://goreportcard.com/report/github.com/Bharath-code/git-scope)
67
[![GitHub Release](https://img.shields.io/github/v/release/Bharath-code/git-scope?color=8B5CF6)](https://github.com/Bharath-code/git-scope/releases)
78
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -146,6 +147,7 @@ Typical git workflows involve "tunnel vision"—working deep inside one reposito
146147
| `[` / `]` | **Page Navigation** (Previous / Next) |
147148
| `Enter` | **Open** repo in Editor |
148149
| `c` | **Clear** search & filters |
150+
| `F` | **Fetch All** — update remotes across every repo (safe, read-only) |
149151
| `r` | **Rescan** directories |
150152
| `g` | Toggle **Contribution Graph** |
151153
| `d` | Toggle **Disk Usage** view |
@@ -197,7 +199,8 @@ I built `git-scope` to solve the **"Multi-Repo Blindness"** problem. It gives me
197199
- [x] In-app workspace switching with Tab completion
198200
- [x] Symlink resolution for devcontainers/Codespaces
199201
- [x] Background file watcher (real-time updates)
200-
- [ ] Quick actions (bulk pull/fetch)
202+
- [x] Bulk fetch all remotes (`F`)
203+
- [ ] Quick actions (bulk pull / stash, with confirmation)
201204
- [ ] Repo grouping (Service / Team / Stack)
202205
- [ ] Custom team dashboards
203206

internal/gitops/gitops.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Package gitops performs bulk git actions across many repositories.
2+
//
3+
// It is deliberately separate from the read-only gitstatus package: gitstatus
4+
// observes repositories, gitops acts on them. Actions here are restricted to
5+
// operations that are safe to run unattended across a whole workspace.
6+
package gitops
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"os/exec"
12+
"strings"
13+
"sync"
14+
"time"
15+
16+
"github.com/Bharath-code/git-scope/internal/model"
17+
)
18+
19+
// Status is the outcome of an action on a single repository.
20+
type Status int
21+
22+
const (
23+
StatusSuccess Status = iota
24+
StatusFailed
25+
StatusSkipped // e.g. no remote configured
26+
)
27+
28+
// ActionResult is the outcome of an action on one repository.
29+
type ActionResult struct {
30+
Repo string
31+
Path string
32+
Status Status
33+
Err error
34+
}
35+
36+
// Summary aggregates the results of a bulk action.
37+
type Summary struct {
38+
Results []ActionResult
39+
Succeeded int
40+
Failed int
41+
Skipped int
42+
}
43+
44+
// String renders a one-line human summary, e.g.
45+
// "✓ 8 fetched · 2 skipped (no remote) · 1 failed".
46+
func (s Summary) String() string {
47+
parts := []string{fmt.Sprintf("✓ %d fetched", s.Succeeded)}
48+
if s.Skipped > 0 {
49+
parts = append(parts, fmt.Sprintf("%d skipped (no remote)", s.Skipped))
50+
}
51+
if s.Failed > 0 {
52+
parts = append(parts, fmt.Sprintf("%d failed", s.Failed))
53+
}
54+
return strings.Join(parts, " · ")
55+
}
56+
57+
const (
58+
fetchTimeout = 30 * time.Second
59+
maxConcurrency = 8
60+
)
61+
62+
// FetchAll runs `git fetch` concurrently across the given repositories.
63+
//
64+
// Fetch is network-only: it updates remote-tracking refs but never modifies the
65+
// working tree, local branches, or commits — which is why it is safe to run in
66+
// bulk without confirmation. Repositories with no configured remote are skipped
67+
// rather than reported as failures. Each fetch is bounded by a timeout so a
68+
// single unreachable remote cannot stall the batch.
69+
func FetchAll(repos []model.Repo) Summary {
70+
results := make([]ActionResult, len(repos))
71+
sem := make(chan struct{}, maxConcurrency)
72+
var wg sync.WaitGroup
73+
74+
for i := range repos {
75+
wg.Add(1)
76+
go func(i int) {
77+
defer wg.Done()
78+
sem <- struct{}{}
79+
defer func() { <-sem }()
80+
results[i] = fetchOne(repos[i])
81+
}(i)
82+
}
83+
wg.Wait()
84+
85+
return summarize(results)
86+
}
87+
88+
// summarize tallies per-repo results into aggregate counts.
89+
func summarize(results []ActionResult) Summary {
90+
s := Summary{Results: results}
91+
for _, r := range results {
92+
switch r.Status {
93+
case StatusSuccess:
94+
s.Succeeded++
95+
case StatusFailed:
96+
s.Failed++
97+
case StatusSkipped:
98+
s.Skipped++
99+
}
100+
}
101+
return s
102+
}
103+
104+
func fetchOne(repo model.Repo) ActionResult {
105+
res := ActionResult{Repo: repo.Name, Path: repo.Path}
106+
107+
if !hasRemote(repo.Path) {
108+
res.Status = StatusSkipped
109+
return res
110+
}
111+
112+
ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
113+
defer cancel()
114+
115+
cmd := exec.CommandContext(ctx, "git", "fetch", "--all", "--quiet")
116+
cmd.Dir = repo.Path
117+
out, err := cmd.CombinedOutput()
118+
if err != nil {
119+
res.Status = StatusFailed
120+
res.Err = fetchError(ctx, err, out)
121+
return res
122+
}
123+
124+
res.Status = StatusSuccess
125+
return res
126+
}
127+
128+
// hasRemote reports whether the repository has at least one configured remote.
129+
func hasRemote(path string) bool {
130+
cmd := exec.Command("git", "remote")
131+
cmd.Dir = path
132+
out, err := cmd.Output()
133+
if err != nil {
134+
return false
135+
}
136+
return strings.TrimSpace(string(out)) != ""
137+
}
138+
139+
// fetchError produces a concise error, preferring git's own message and
140+
// surfacing timeouts explicitly.
141+
func fetchError(ctx context.Context, err error, out []byte) error {
142+
if ctx.Err() == context.DeadlineExceeded {
143+
return fmt.Errorf("timed out after %s", fetchTimeout)
144+
}
145+
if msg := firstLine(strings.TrimSpace(string(out))); msg != "" {
146+
return fmt.Errorf("%s", msg)
147+
}
148+
return err
149+
}
150+
151+
func firstLine(s string) string {
152+
line, _, _ := strings.Cut(s, "\n")
153+
return line
154+
}

internal/gitops/gitops_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package gitops
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/Bharath-code/git-scope/internal/model"
10+
)
11+
12+
func TestSummaryString(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
sum Summary
16+
want string
17+
}{
18+
{"all ok", Summary{Succeeded: 8}, "✓ 8 fetched"},
19+
{"with skips", Summary{Succeeded: 8, Skipped: 2}, "✓ 8 fetched · 2 skipped (no remote)"},
20+
{"with failures", Summary{Succeeded: 8, Skipped: 2, Failed: 1}, "✓ 8 fetched · 2 skipped (no remote) · 1 failed"},
21+
{"none", Summary{}, "✓ 0 fetched"},
22+
}
23+
for _, tt := range tests {
24+
t.Run(tt.name, func(t *testing.T) {
25+
if got := tt.sum.String(); got != tt.want {
26+
t.Errorf("String() = %q, want %q", got, tt.want)
27+
}
28+
})
29+
}
30+
}
31+
32+
func TestSummarize(t *testing.T) {
33+
results := []ActionResult{
34+
{Status: StatusSuccess},
35+
{Status: StatusSuccess},
36+
{Status: StatusSkipped},
37+
{Status: StatusFailed},
38+
}
39+
s := summarize(results)
40+
if s.Succeeded != 2 || s.Skipped != 1 || s.Failed != 1 {
41+
t.Errorf("summarize = %+v, want 2/1/1", s)
42+
}
43+
if len(s.Results) != 4 {
44+
t.Errorf("Results length = %d, want 4", len(s.Results))
45+
}
46+
}
47+
48+
func TestFirstLine(t *testing.T) {
49+
if got := firstLine("one\ntwo"); got != "one" {
50+
t.Errorf("firstLine multi = %q, want one", got)
51+
}
52+
if got := firstLine("only"); got != "only" {
53+
t.Errorf("firstLine single = %q, want only", got)
54+
}
55+
}
56+
57+
// --- Integration tests against real git repos ---
58+
59+
func gitRun(t *testing.T, dir string, args ...string) {
60+
t.Helper()
61+
cmd := exec.Command("git", args...)
62+
cmd.Dir = dir
63+
cmd.Env = append(os.Environ(),
64+
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
65+
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
66+
)
67+
if out, err := cmd.CombinedOutput(); err != nil {
68+
t.Fatalf("git %v: %v\n%s", args, err, out)
69+
}
70+
}
71+
72+
func requireGit(t *testing.T) {
73+
t.Helper()
74+
if _, err := exec.LookPath("git"); err != nil {
75+
t.Skip("git not available")
76+
}
77+
}
78+
79+
// makeCloneWithRemote creates a bare "origin" plus a working clone of it,
80+
// returning the path to the working clone (which has a configured remote).
81+
func makeCloneWithRemote(t *testing.T) string {
82+
t.Helper()
83+
base := t.TempDir()
84+
85+
origin := filepath.Join(base, "origin.git")
86+
gitRun(t, base, "init", "--bare", origin)
87+
88+
seed := filepath.Join(base, "seed")
89+
if err := os.MkdirAll(seed, 0755); err != nil {
90+
t.Fatal(err)
91+
}
92+
gitRun(t, seed, "init", "-b", "main")
93+
if err := os.WriteFile(filepath.Join(seed, "f.txt"), []byte("x\n"), 0644); err != nil {
94+
t.Fatal(err)
95+
}
96+
gitRun(t, seed, "add", "f.txt")
97+
gitRun(t, seed, "commit", "-m", "init")
98+
gitRun(t, seed, "remote", "add", "origin", origin)
99+
gitRun(t, seed, "push", "origin", "main")
100+
101+
clone := filepath.Join(base, "clone")
102+
gitRun(t, base, "clone", origin, clone)
103+
return clone
104+
}
105+
106+
func makeRepoNoRemote(t *testing.T) string {
107+
t.Helper()
108+
dir := t.TempDir()
109+
gitRun(t, dir, "init", "-b", "main")
110+
if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("y\n"), 0644); err != nil {
111+
t.Fatal(err)
112+
}
113+
gitRun(t, dir, "add", "f.txt")
114+
gitRun(t, dir, "commit", "-m", "init")
115+
return dir
116+
}
117+
118+
func TestHasRemote(t *testing.T) {
119+
requireGit(t)
120+
if !hasRemote(makeCloneWithRemote(t)) {
121+
t.Error("clone with origin should report a remote")
122+
}
123+
if hasRemote(makeRepoNoRemote(t)) {
124+
t.Error("local-only repo should report no remote")
125+
}
126+
if hasRemote(t.TempDir()) {
127+
t.Error("non-git dir should report no remote")
128+
}
129+
}
130+
131+
func TestFetchAll_MixedRepos(t *testing.T) {
132+
requireGit(t)
133+
134+
withRemote := makeCloneWithRemote(t)
135+
noRemote := makeRepoNoRemote(t)
136+
137+
repos := []model.Repo{
138+
{Name: "cloned", Path: withRemote},
139+
{Name: "local-only", Path: noRemote},
140+
}
141+
142+
sum := FetchAll(repos)
143+
144+
if sum.Succeeded != 1 {
145+
t.Errorf("Succeeded = %d, want 1", sum.Succeeded)
146+
}
147+
if sum.Skipped != 1 {
148+
t.Errorf("Skipped = %d, want 1", sum.Skipped)
149+
}
150+
if sum.Failed != 0 {
151+
t.Errorf("Failed = %d, want 0", sum.Failed)
152+
}
153+
154+
byName := map[string]ActionResult{}
155+
for _, r := range sum.Results {
156+
byName[r.Repo] = r
157+
}
158+
if byName["cloned"].Status != StatusSuccess {
159+
t.Errorf("cloned status = %v, want success", byName["cloned"].Status)
160+
}
161+
if byName["local-only"].Status != StatusSkipped {
162+
t.Errorf("local-only status = %v, want skipped", byName["local-only"].Status)
163+
}
164+
}
165+
166+
func TestFetchAll_Empty(t *testing.T) {
167+
sum := FetchAll(nil)
168+
if sum.Succeeded != 0 || sum.Failed != 0 || sum.Skipped != 0 {
169+
t.Errorf("empty FetchAll should be all zero, got %+v", sum)
170+
}
171+
}

0 commit comments

Comments
 (0)