diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c0475ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test & Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Verify formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-formatted:" + echo "$unformatted" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Test (race + coverage) + run: go test -race -coverprofile=coverage.out -covermode=atomic ./... + + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -n 1 + + lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: golangci-lint + # v9 supports golangci-lint v2 (correct /v2 module path for goinstall). + uses: golangci/golangci-lint-action@v9 + with: + # Pinned for reproducible runs — bump deliberately, not on every push. + version: v2.12.2 + # Build golangci-lint with the repo's Go toolchain so it can analyze + # the targeted Go version (prebuilt binaries lag behind new releases). + install-mode: goinstall diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d465cae..37555ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.20' + go-version-file: go.mod - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 diff --git a/.gitignore b/.gitignore index a62e947..0ff1b62 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,12 @@ # Cache .git-scope-cache.json +# Test coverage +*.out + +# Local notes (not for publishing) +PROJECT-STRATEGY-AND-CAREER-PLAYBOOK.md + # IDE .idea/ .vscode/ diff --git a/README.md b/README.md index 1b799cd..273514e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ > **A fast TUI dashboard to view the git status of *all your repositories* in one place.** > Stop the `cd` → `git status` loop. +[![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) [![Go Report Card](https://goreportcard.com/badge/github.com/Bharath-code/git-scope)](https://goreportcard.com/report/github.com/Bharath-code/git-scope) [![GitHub Release](https://img.shields.io/github/v/release/Bharath-code/git-scope?color=8B5CF6)](https://github.com/Bharath-code/git-scope/releases) [![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 | `[` / `]` | **Page Navigation** (Previous / Next) | | `Enter` | **Open** repo in Editor | | `c` | **Clear** search & filters | +| `F` | **Fetch All** — update remotes across every repo (safe, read-only) | | `r` | **Rescan** directories | | `g` | Toggle **Contribution Graph** | | `d` | Toggle **Disk Usage** view | @@ -197,7 +199,8 @@ I built `git-scope` to solve the **"Multi-Repo Blindness"** problem. It gives me - [x] In-app workspace switching with Tab completion - [x] Symlink resolution for devcontainers/Codespaces - [x] Background file watcher (real-time updates) - - [ ] Quick actions (bulk pull/fetch) + - [x] Bulk fetch all remotes (`F`) + - [ ] Quick actions (bulk pull / stash, with confirmation) - [ ] Repo grouping (Service / Team / Stack) - [ ] Custom team dashboards diff --git a/go.mod b/go.mod index cfe65c7..b573a85 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Bharath-code/git-scope -go 1.20 +go 1.26.0 require ( github.com/charmbracelet/bubbles v0.18.0 diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..5913222 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,135 @@ +package cache + +import ( + "path/filepath" + "testing" + "time" + + "github.com/Bharath-code/git-scope/internal/model" +) + +// newTestStore returns a FileStore pointed at an isolated temp file so tests +// never touch the user's real ~/.cache directory. +func newTestStore(t *testing.T) *FileStore { + t.Helper() + return &FileStore{path: filepath.Join(t.TempDir(), "repos.json")} +} + +func sampleRepos() []model.Repo { + return []model.Repo{ + {Name: "alpha", Path: "/code/alpha", Status: model.RepoStatus{Branch: "main", IsDirty: true, Staged: 2}}, + {Name: "beta", Path: "/code/beta", Status: model.RepoStatus{Branch: "dev"}}, + } +} + +func TestSaveLoad_RoundTrip(t *testing.T) { + s := newTestStore(t) + roots := []string{"/code"} + + if err := s.Save(sampleRepos(), roots); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(loaded.Repos) != 2 { + t.Fatalf("loaded %d repos, want 2", len(loaded.Repos)) + } + if loaded.Repos[0].Name != "alpha" || !loaded.Repos[0].Status.IsDirty { + t.Errorf("first repo not round-tripped correctly: %+v", loaded.Repos[0]) + } + if len(loaded.Roots) != 1 || loaded.Roots[0] != "/code" { + t.Errorf("roots = %v, want [/code]", loaded.Roots) + } +} + +func TestLoad_MissingFileReturnsError(t *testing.T) { + s := newTestStore(t) + if _, err := s.Load(); err == nil { + t.Fatal("expected error loading non-existent cache, got nil") + } +} + +func TestIsValid(t *testing.T) { + s := newTestStore(t) + + // No data loaded yet -> invalid. + if s.IsValid(time.Hour) { + t.Error("IsValid = true before any data, want false") + } + + if err := s.Save(sampleRepos(), []string{"/code"}); err != nil { + t.Fatal(err) + } + if _, err := s.Load(); err != nil { + t.Fatal(err) + } + + if !s.IsValid(time.Hour) { + t.Error("freshly saved cache should be valid within 1h") + } + if s.IsValid(time.Nanosecond) { + t.Error("cache should be stale against a 1ns max age") + } +} + +func TestIsSameRoots(t *testing.T) { + s := newTestStore(t) + if err := s.Save(sampleRepos(), []string{"/a", "/b"}); err != nil { + t.Fatal(err) + } + if _, err := s.Load(); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + roots []string + want bool + }{ + {"identical", []string{"/a", "/b"}, true}, + {"different length", []string{"/a"}, false}, + {"different order", []string{"/b", "/a"}, false}, + {"different values", []string{"/a", "/c"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := s.IsSameRoots(tt.roots); got != tt.want { + t.Errorf("IsSameRoots(%v) = %v, want %v", tt.roots, got, tt.want) + } + }) + } +} + +func TestGetTimestamp(t *testing.T) { + s := newTestStore(t) + if !s.GetTimestamp().IsZero() { + t.Error("GetTimestamp should be zero before load") + } + + before := time.Now() + if err := s.Save(sampleRepos(), nil); err != nil { + t.Fatal(err) + } + if _, err := s.Load(); err != nil { + t.Fatal(err) + } + if ts := s.GetTimestamp(); ts.Before(before.Add(-time.Second)) { + t.Errorf("timestamp %v is older than save time %v", ts, before) + } +} + +func TestClear(t *testing.T) { + s := newTestStore(t) + if err := s.Save(sampleRepos(), nil); err != nil { + t.Fatal(err) + } + if err := s.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + if _, err := s.Load(); err == nil { + t.Error("expected error loading after Clear, got nil") + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..f7df334 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,181 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoad_MissingFileReturnsDefaults(t *testing.T) { + cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yml")) + if err != nil { + t.Fatalf("expected no error for missing file, got %v", err) + } + if cfg.Editor != "code" { + t.Errorf("default editor = %q, want %q", cfg.Editor, "code") + } + if cfg.PageSize != 15 { + t.Errorf("default pageSize = %d, want 15", cfg.PageSize) + } + if len(cfg.Roots) == 0 { + t.Error("default roots should not be empty") + } + if !containsString(cfg.Ignore, "node_modules") { + t.Errorf("default ignore should contain node_modules, got %v", cfg.Ignore) + } +} + +func TestLoad_ValidFileMergesOverDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + body := "roots:\n - /tmp/projects\neditor: nvim\n" + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Editor != "nvim" { + t.Errorf("editor = %q, want nvim", cfg.Editor) + } + if len(cfg.Roots) != 1 || cfg.Roots[0] != "/tmp/projects" { + t.Errorf("roots = %v, want [/tmp/projects]", cfg.Roots) + } + // Fields absent from the file keep their defaults. + if !containsString(cfg.Ignore, "node_modules") { + t.Errorf("ignore should fall back to defaults, got %v", cfg.Ignore) + } + if cfg.PageSize != 15 { + t.Errorf("pageSize should fall back to 15, got %d", cfg.PageSize) + } +} + +func TestLoad_NonPositivePageSizeCoercedTo15(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(path, []byte("pageSize: 0\n"), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.PageSize != 15 { + t.Errorf("pageSize = %d, want 15 (coerced)", cfg.PageSize) + } +} + +func TestLoad_InvalidYAMLReturnsError(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(path, []byte("roots: [unterminated\n"), 0644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("expected error for malformed YAML, got nil") + } +} + +func TestLoad_ExpandsTildeInRoots(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home directory available") + } + path := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(path, []byte("roots:\n - ~/code\n"), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + want := filepath.Join(home, "code") + if cfg.Roots[0] != want { + t.Errorf("expanded root = %q, want %q", cfg.Roots[0], want) + } +} + +func TestExpandPath(t *testing.T) { + home, _ := os.UserHomeDir() + cwd, _ := os.Getwd() + + tests := []struct { + name string + in string + want string + }{ + {"tilde", "~/foo", filepath.Join(home, "foo")}, + {"absolute unchanged", "/var/log", "/var/log"}, + {"dot becomes cwd", ".", cwd}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := expandPath(tt.in); got != tt.want { + t.Errorf("expandPath(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestExpandPath_RelativeBecomesAbsolute(t *testing.T) { + got := expandPath("some/relative/dir") + if !filepath.IsAbs(got) { + t.Errorf("expandPath returned non-absolute path %q", got) + } +} + +func TestConfigExists(t *testing.T) { + dir := t.TempDir() + existing := filepath.Join(dir, "config.yml") + if err := os.WriteFile(existing, []byte("editor: code\n"), 0644); err != nil { + t.Fatal(err) + } + if !ConfigExists(existing) { + t.Error("ConfigExists = false for existing file, want true") + } + if ConfigExists(filepath.Join(dir, "nope.yml")) { + t.Error("ConfigExists = true for missing file, want false") + } +} + +func TestCreateConfig_RoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "config.yml") + roots := []string{"/a", "/b"} + + if err := CreateConfig(path, roots, "vim"); err != nil { + t.Fatalf("CreateConfig: %v", err) + } + if !ConfigExists(path) { + t.Fatal("config file was not created") + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load after CreateConfig: %v", err) + } + if cfg.Editor != "vim" { + t.Errorf("editor = %q, want vim", cfg.Editor) + } + if len(cfg.Roots) != 2 { + t.Errorf("roots = %v, want 2 entries", cfg.Roots) + } +} + +func TestDefaultConfigPath(t *testing.T) { + got := DefaultConfigPath() + if got == "" { + t.Fatal("DefaultConfigPath returned empty string") + } + if !strings.HasSuffix(filepath.ToSlash(got), "git-scope/config.yml") { + t.Errorf("DefaultConfigPath = %q, want suffix git-scope/config.yml", got) + } +} + +func containsString(s []string, want string) bool { + for _, v := range s { + if v == want { + return true + } + } + return false +} diff --git a/internal/gitops/gitops.go b/internal/gitops/gitops.go new file mode 100644 index 0000000..c79d8d2 --- /dev/null +++ b/internal/gitops/gitops.go @@ -0,0 +1,154 @@ +// Package gitops performs bulk git actions across many repositories. +// +// It is deliberately separate from the read-only gitstatus package: gitstatus +// observes repositories, gitops acts on them. Actions here are restricted to +// operations that are safe to run unattended across a whole workspace. +package gitops + +import ( + "context" + "fmt" + "os/exec" + "strings" + "sync" + "time" + + "github.com/Bharath-code/git-scope/internal/model" +) + +// Status is the outcome of an action on a single repository. +type Status int + +const ( + StatusSuccess Status = iota + StatusFailed + StatusSkipped // e.g. no remote configured +) + +// ActionResult is the outcome of an action on one repository. +type ActionResult struct { + Repo string + Path string + Status Status + Err error +} + +// Summary aggregates the results of a bulk action. +type Summary struct { + Results []ActionResult + Succeeded int + Failed int + Skipped int +} + +// String renders a one-line human summary, e.g. +// "✓ 8 fetched · 2 skipped (no remote) · 1 failed". +func (s Summary) String() string { + parts := []string{fmt.Sprintf("✓ %d fetched", s.Succeeded)} + if s.Skipped > 0 { + parts = append(parts, fmt.Sprintf("%d skipped (no remote)", s.Skipped)) + } + if s.Failed > 0 { + parts = append(parts, fmt.Sprintf("%d failed", s.Failed)) + } + return strings.Join(parts, " · ") +} + +const ( + fetchTimeout = 30 * time.Second + maxConcurrency = 8 +) + +// FetchAll runs `git fetch` concurrently across the given repositories. +// +// Fetch is network-only: it updates remote-tracking refs but never modifies the +// working tree, local branches, or commits — which is why it is safe to run in +// bulk without confirmation. Repositories with no configured remote are skipped +// rather than reported as failures. Each fetch is bounded by a timeout so a +// single unreachable remote cannot stall the batch. +func FetchAll(repos []model.Repo) Summary { + results := make([]ActionResult, len(repos)) + sem := make(chan struct{}, maxConcurrency) + var wg sync.WaitGroup + + for i := range repos { + wg.Add(1) + go func(i int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + results[i] = fetchOne(repos[i]) + }(i) + } + wg.Wait() + + return summarize(results) +} + +// summarize tallies per-repo results into aggregate counts. +func summarize(results []ActionResult) Summary { + s := Summary{Results: results} + for _, r := range results { + switch r.Status { + case StatusSuccess: + s.Succeeded++ + case StatusFailed: + s.Failed++ + case StatusSkipped: + s.Skipped++ + } + } + return s +} + +func fetchOne(repo model.Repo) ActionResult { + res := ActionResult{Repo: repo.Name, Path: repo.Path} + + if !hasRemote(repo.Path) { + res.Status = StatusSkipped + return res + } + + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "git", "fetch", "--all", "--quiet") + cmd.Dir = repo.Path + out, err := cmd.CombinedOutput() + if err != nil { + res.Status = StatusFailed + res.Err = fetchError(ctx, err, out) + return res + } + + res.Status = StatusSuccess + return res +} + +// hasRemote reports whether the repository has at least one configured remote. +func hasRemote(path string) bool { + cmd := exec.Command("git", "remote") + cmd.Dir = path + out, err := cmd.Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) != "" +} + +// fetchError produces a concise error, preferring git's own message and +// surfacing timeouts explicitly. +func fetchError(ctx context.Context, err error, out []byte) error { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("timed out after %s", fetchTimeout) + } + if msg := firstLine(strings.TrimSpace(string(out))); msg != "" { + return fmt.Errorf("%s", msg) + } + return err +} + +func firstLine(s string) string { + line, _, _ := strings.Cut(s, "\n") + return line +} diff --git a/internal/gitops/gitops_test.go b/internal/gitops/gitops_test.go new file mode 100644 index 0000000..c22551b --- /dev/null +++ b/internal/gitops/gitops_test.go @@ -0,0 +1,171 @@ +package gitops + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/Bharath-code/git-scope/internal/model" +) + +func TestSummaryString(t *testing.T) { + tests := []struct { + name string + sum Summary + want string + }{ + {"all ok", Summary{Succeeded: 8}, "✓ 8 fetched"}, + {"with skips", Summary{Succeeded: 8, Skipped: 2}, "✓ 8 fetched · 2 skipped (no remote)"}, + {"with failures", Summary{Succeeded: 8, Skipped: 2, Failed: 1}, "✓ 8 fetched · 2 skipped (no remote) · 1 failed"}, + {"none", Summary{}, "✓ 0 fetched"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.sum.String(); got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSummarize(t *testing.T) { + results := []ActionResult{ + {Status: StatusSuccess}, + {Status: StatusSuccess}, + {Status: StatusSkipped}, + {Status: StatusFailed}, + } + s := summarize(results) + if s.Succeeded != 2 || s.Skipped != 1 || s.Failed != 1 { + t.Errorf("summarize = %+v, want 2/1/1", s) + } + if len(s.Results) != 4 { + t.Errorf("Results length = %d, want 4", len(s.Results)) + } +} + +func TestFirstLine(t *testing.T) { + if got := firstLine("one\ntwo"); got != "one" { + t.Errorf("firstLine multi = %q, want one", got) + } + if got := firstLine("only"); got != "only" { + t.Errorf("firstLine single = %q, want only", got) + } +} + +// --- Integration tests against real git repos --- + +func gitRun(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } +} + +// makeCloneWithRemote creates a bare "origin" plus a working clone of it, +// returning the path to the working clone (which has a configured remote). +func makeCloneWithRemote(t *testing.T) string { + t.Helper() + base := t.TempDir() + + origin := filepath.Join(base, "origin.git") + gitRun(t, base, "init", "--bare", origin) + + seed := filepath.Join(base, "seed") + if err := os.MkdirAll(seed, 0755); err != nil { + t.Fatal(err) + } + gitRun(t, seed, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(seed, "f.txt"), []byte("x\n"), 0644); err != nil { + t.Fatal(err) + } + gitRun(t, seed, "add", "f.txt") + gitRun(t, seed, "commit", "-m", "init") + gitRun(t, seed, "remote", "add", "origin", origin) + gitRun(t, seed, "push", "origin", "main") + + clone := filepath.Join(base, "clone") + gitRun(t, base, "clone", origin, clone) + return clone +} + +func makeRepoNoRemote(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitRun(t, dir, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("y\n"), 0644); err != nil { + t.Fatal(err) + } + gitRun(t, dir, "add", "f.txt") + gitRun(t, dir, "commit", "-m", "init") + return dir +} + +func TestHasRemote(t *testing.T) { + requireGit(t) + if !hasRemote(makeCloneWithRemote(t)) { + t.Error("clone with origin should report a remote") + } + if hasRemote(makeRepoNoRemote(t)) { + t.Error("local-only repo should report no remote") + } + if hasRemote(t.TempDir()) { + t.Error("non-git dir should report no remote") + } +} + +func TestFetchAll_MixedRepos(t *testing.T) { + requireGit(t) + + withRemote := makeCloneWithRemote(t) + noRemote := makeRepoNoRemote(t) + + repos := []model.Repo{ + {Name: "cloned", Path: withRemote}, + {Name: "local-only", Path: noRemote}, + } + + sum := FetchAll(repos) + + if sum.Succeeded != 1 { + t.Errorf("Succeeded = %d, want 1", sum.Succeeded) + } + if sum.Skipped != 1 { + t.Errorf("Skipped = %d, want 1", sum.Skipped) + } + if sum.Failed != 0 { + t.Errorf("Failed = %d, want 0", sum.Failed) + } + + byName := map[string]ActionResult{} + for _, r := range sum.Results { + byName[r.Repo] = r + } + if byName["cloned"].Status != StatusSuccess { + t.Errorf("cloned status = %v, want success", byName["cloned"].Status) + } + if byName["local-only"].Status != StatusSkipped { + t.Errorf("local-only status = %v, want skipped", byName["local-only"].Status) + } +} + +func TestFetchAll_Empty(t *testing.T) { + sum := FetchAll(nil) + if sum.Succeeded != 0 || sum.Failed != 0 || sum.Skipped != 0 { + t.Errorf("empty FetchAll should be all zero, got %+v", sum) + } +} diff --git a/internal/gitstatus/gitstatus_test.go b/internal/gitstatus/gitstatus_test.go new file mode 100644 index 0000000..a60ca5f --- /dev/null +++ b/internal/gitstatus/gitstatus_test.go @@ -0,0 +1,172 @@ +package gitstatus + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/Bharath-code/git-scope/internal/model" +) + +func TestParseAheadBehind(t *testing.T) { + tests := []struct { + name string + line string + ahead, behind int + ok bool + }{ + {"ahead and behind", "# branch.ab +2 -3", 2, 3, true}, + {"up to date", "# branch.ab +0 -0", 0, 0, true}, + {"ahead only", "# branch.ab +5 -0", 5, 0, true}, + {"too few fields", "# branch.ab +1", 0, 0, false}, + {"non-numeric", "# branch.ab +x -y", 0, 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a, b, ok := parseAheadBehind(tt.line) + if a != tt.ahead || b != tt.behind || ok != tt.ok { + t.Errorf("parseAheadBehind(%q) = (%d, %d, %v), want (%d, %d, %v)", + tt.line, a, b, ok, tt.ahead, tt.behind, tt.ok) + } + }) + } +} + +func TestParseXY(t *testing.T) { + tests := []struct { + name string + line string + staged, unstaged bool + }{ + {"staged only", "1 M. N... 100644 100644 100644 aa bb file", true, false}, + {"unstaged only", "1 .M N... 100644 100644 100644 aa bb file", false, true}, + {"both", "1 MM N... 100644 100644 100644 aa bb file", true, true}, + {"clean", "1 .. N... 100644 100644 100644 aa bb file", false, false}, + {"malformed", "1", false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, u := parseXY(tt.line) + if s != tt.staged || u != tt.unstaged { + t.Errorf("parseXY(%q) = (%v, %v), want (%v, %v)", tt.line, s, u, tt.staged, tt.unstaged) + } + }) + } +} + +func TestApplyFileLine(t *testing.T) { + var st model.RepoStatus + applyFileLine(&st, "1 M. N... 100644 100644 100644 aa bb staged.go") + applyFileLine(&st, "1 .M N... 100644 100644 100644 aa bb unstaged.go") + applyFileLine(&st, "? newfile.txt") + applyFileLine(&st, "? another.txt") + applyFileLine(&st, "! ignored.txt") // ignored lines are no-ops + + if st.Staged != 1 { + t.Errorf("Staged = %d, want 1", st.Staged) + } + if st.Unstaged != 1 { + t.Errorf("Unstaged = %d, want 1", st.Unstaged) + } + if st.Untracked != 2 { + t.Errorf("Untracked = %d, want 2", st.Untracked) + } +} + +func TestApplyBranchHeader(t *testing.T) { + var st model.RepoStatus + applyBranchHeader(&st, "# branch.head feature/login") + applyBranchHeader(&st, "# branch.ab +1 -4") + + if st.Branch != "feature/login" { + t.Errorf("Branch = %q, want feature/login", st.Branch) + } + if st.Ahead != 1 || st.Behind != 4 { + t.Errorf("ahead/behind = %d/%d, want 1/4", st.Ahead, st.Behind) + } +} + +// --- Integration test against a real git repo --- + +func gitRun(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func initRepoWithCommit(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + gitRun(t, dir, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hello\n"), 0644); err != nil { + t.Fatal(err) + } + gitRun(t, dir, "add", "README.md") + gitRun(t, dir, "commit", "-m", "initial") + return dir +} + +func TestStatus_CleanRepo(t *testing.T) { + dir := initRepoWithCommit(t) + + st, err := Status(dir) + if err != nil { + t.Fatalf("Status: %v", err) + } + if st.IsDirty { + t.Errorf("clean repo reported dirty: %+v", st) + } + if st.Branch != "main" { + t.Errorf("branch = %q, want main", st.Branch) + } + if st.LastCommit.IsZero() { + t.Error("LastCommit should be set after a commit") + } +} + +func TestStatus_DirtyRepo(t *testing.T) { + dir := initRepoWithCommit(t) + + // One staged change and one untracked file. + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("changed\n"), 0644); err != nil { + t.Fatal(err) + } + gitRun(t, dir, "add", "README.md") + if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new\n"), 0644); err != nil { + t.Fatal(err) + } + + st, err := Status(dir) + if err != nil { + t.Fatalf("Status: %v", err) + } + if !st.IsDirty { + t.Error("repo with changes should be dirty") + } + if st.Staged != 1 { + t.Errorf("Staged = %d, want 1", st.Staged) + } + if st.Untracked != 1 { + t.Errorf("Untracked = %d, want 1", st.Untracked) + } +} + +func TestStatus_NonRepoReturnsError(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + if _, err := Status(t.TempDir()); err == nil { + t.Error("expected error for non-git directory, got nil") + } +} diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go new file mode 100644 index 0000000..646d6e6 --- /dev/null +++ b/internal/scan/scan_test.go @@ -0,0 +1,151 @@ +package scan + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "sort" + "testing" + + "github.com/Bharath-code/git-scope/internal/model" +) + +func TestShouldIgnore(t *testing.T) { + ignoreSet := map[string]struct{}{ + "node_modules": {}, + ".cache": {}, + } + tests := []struct { + name string + dir string + want bool + }{ + {"exact match", "node_modules", true}, + {"dotfile exact", ".cache", true}, + {"suffix match", "my.cache", true}, + {"no match", "src", false}, + {"empty", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldIgnore(tt.dir, ignoreSet); got != tt.want { + t.Errorf("shouldIgnore(%q) = %v, want %v", tt.dir, got, tt.want) + } + }) + } +} + +func TestExpandPath(t *testing.T) { + home, _ := os.UserHomeDir() + + if got := expandPath("~/projects"); got != filepath.Join(home, "projects") { + t.Errorf("expandPath(~/projects) = %q, want %q", got, filepath.Join(home, "projects")) + } + + t.Setenv("GS_TEST_VAR", "/expanded") + if got := expandPath("$GS_TEST_VAR/x"); got != "/expanded/x" { + t.Errorf("expandPath env = %q, want /expanded/x", got) + } + + if got := expandPath("/absolute"); got != "/absolute" { + t.Errorf("expandPath(/absolute) = %q, want unchanged", got) + } +} + +func TestPrintJSON(t *testing.T) { + repos := []model.Repo{ + {Name: "alpha", Path: "/code/alpha", Status: model.RepoStatus{Branch: "main"}}, + } + var buf bytes.Buffer + if err := PrintJSON(&buf, repos); err != nil { + t.Fatalf("PrintJSON: %v", err) + } + + var decoded []model.Repo + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if len(decoded) != 1 || decoded[0].Name != "alpha" { + t.Errorf("decoded = %+v, want one repo named alpha", decoded) + } +} + +// --- Integration test against real git repos --- + +func gitRun(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func makeRepo(t *testing.T, parent, name string) { + t.Helper() + dir := filepath.Join(parent, name) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + gitRun(t, dir, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x\n"), 0644); err != nil { + t.Fatal(err) + } + gitRun(t, dir, "add", "f.txt") + gitRun(t, dir, "commit", "-m", "init") +} + +func TestScanRoots_FindsReposAndRespectsIgnore(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + root := t.TempDir() + + makeRepo(t, root, "service-a") + makeRepo(t, root, "service-b") + + // A plain directory with no .git -> should not be counted. + if err := os.MkdirAll(filepath.Join(root, "plain-dir"), 0755); err != nil { + t.Fatal(err) + } + + // A repo nested inside an ignored directory -> should be skipped. + makeRepo(t, filepath.Join(root, "node_modules"), "ignored-repo") + + repos, err := ScanRoots([]string{root}, []string{"node_modules"}) + if err != nil { + t.Fatalf("ScanRoots: %v", err) + } + + names := make([]string, 0, len(repos)) + for _, r := range repos { + names = append(names, r.Name) + } + sort.Strings(names) + + want := []string{"service-a", "service-b"} + if len(names) != len(want) { + t.Fatalf("found repos %v, want %v", names, want) + } + for i := range want { + if names[i] != want[i] { + t.Errorf("repo[%d] = %q, want %q", i, names[i], want[i]) + } + } +} + +func TestScanRoots_NonexistentRootIsSkipped(t *testing.T) { + repos, err := ScanRoots([]string{filepath.Join(t.TempDir(), "missing")}, nil) + if err != nil { + t.Fatalf("ScanRoots: %v", err) + } + if len(repos) != 0 { + t.Errorf("expected no repos for missing root, got %d", len(repos)) + } +} diff --git a/internal/tui/actions.go b/internal/tui/actions.go new file mode 100644 index 0000000..f6457f8 --- /dev/null +++ b/internal/tui/actions.go @@ -0,0 +1,39 @@ +package tui + +import ( + "github.com/Bharath-code/git-scope/internal/cache" + "github.com/Bharath-code/git-scope/internal/config" + "github.com/Bharath-code/git-scope/internal/gitops" + "github.com/Bharath-code/git-scope/internal/gitstatus" + "github.com/Bharath-code/git-scope/internal/model" + tea "github.com/charmbracelet/bubbletea" +) + +// fetchCompleteMsg is sent when a bulk fetch finishes. It carries both the +// outcome summary and repos with refreshed status so ahead/behind counts update. +type fetchCompleteMsg struct { + summary gitops.Summary + repos []model.Repo +} + +// fetchAllCmd fetches every repo's remote, then refreshes their git status so +// the dashboard's ahead/behind counts reflect what was just fetched. +func fetchAllCmd(cfg *config.Config, repos []model.Repo) tea.Cmd { + return func() tea.Msg { + summary := gitops.FetchAll(repos) + + // Re-read status for each repo so ahead/behind reflect the new refs. + refreshed := make([]model.Repo, len(repos)) + copy(refreshed, repos) + for i := range refreshed { + if st, err := gitstatus.Status(refreshed[i].Path); err == nil { + refreshed[i].Status = st + } + } + + // Keep the cache consistent with the refreshed statuses. + _ = cache.NewFileStore().Save(refreshed, cfg.Roots) + + return fetchCompleteMsg{summary: summary, repos: refreshed} + } +} diff --git a/internal/tui/panel.go b/internal/tui/panel.go index 1bcfc1d..9ba2fb6 100644 --- a/internal/tui/panel.go +++ b/internal/tui/panel.go @@ -27,12 +27,6 @@ var ( heatmapLevel3 = lipgloss.NewStyle().Foreground(lipgloss.Color("#26a641")) // Medium-High heatmapLevel4 = lipgloss.NewStyle().Foreground(lipgloss.Color("#39d353")) // High - // Panel styling - Tuimorphic borders - panelBorderStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#30363d")). - Padding(0, 1) - // Active panel border (when focused) panelBorderActiveStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). @@ -186,26 +180,8 @@ func getHeatmapBlock(level int) string { } } -// getPanelHelp returns help text for the active panel -func getPanelHelp(panel PanelType) string { - switch panel { - case PanelGrass: - return helpItem("g", "close") + " • " + helpItem("esc", "close") - case PanelDisk: - return helpItem("d", "close") + " • " + helpItem("esc", "close") - case PanelTimeline: - return helpItem("t", "close") + " • " + helpItem("esc", "close") - default: - return "" - } -} - // Disk usage color palette (warm gradient for size visualization) var ( - diskBarLow = lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")) // Green - small - diskBarMed = lipgloss.NewStyle().Foreground(lipgloss.Color("#eab308")) // Yellow - medium - diskBarHigh = lipgloss.NewStyle().Foreground(lipgloss.Color("#f97316")) // Orange - large - diskBarMax = lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")) // Red - huge diskNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) diskSizeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A78BFA")).Bold(true) diskNodeSizeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F97316")).Bold(true) // Orange for node_modules value diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 8244af7..ddda5bd 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -21,11 +21,8 @@ var ( errorColor = lipgloss.Color("#ef4444") // Red - error // Background layers (dark theme - GitHub style) - bgDark = lipgloss.Color("#0d1117") // Darkest - main bg - bgPanel = lipgloss.Color("#161b22") // Panel backgrounds - bgSurface = lipgloss.Color("#21262d") // Elevated surfaces - borderColor = lipgloss.Color("#30363d") // Subtle borders - borderActive = lipgloss.Color("#7C3AED") // Active/focused borders + bgSurface = lipgloss.Color("#21262d") // Elevated surfaces + borderColor = lipgloss.Color("#30363d") // Subtle borders // Text hierarchy textPrimary = lipgloss.Color("#f0f6fc") // Primary text @@ -33,11 +30,8 @@ var ( textTertiary = lipgloss.Color("#6e7681") // Tertiary/hints // Legacy aliases for compatibility - bgColor = bgPanel - surfaceColor = bgSurface - textColor = textPrimary - mutedColor = textSecondary - dangerColor = errorColor + textColor = textPrimary + mutedColor = textSecondary ) // Application styles @@ -54,19 +48,6 @@ var ( Padding(0, 2). MarginBottom(1) - // Logo ASCII art style - logoStyle = lipgloss.NewStyle(). - Foreground(primaryColor). - Bold(true) - - // Header bar style (logo + version) - headerBarStyle = lipgloss.NewStyle(). - Foreground(primaryDim). - Bold(true) - - versionStyle = lipgloss.NewStyle(). - Foreground(textTertiary) - // Subtitle with stats subtitleStyle = lipgloss.NewStyle(). Foreground(mutedColor). @@ -91,18 +72,6 @@ var ( Padding(0, 1). Bold(true) - // Table styles - bordered container - tableContainerStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(borderColor). - Padding(0, 1) - - // Dashboard border style - dashboardBorderStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(borderColor). - Padding(0, 1) - // Keybindings bar styles (Tuimorphic - always visible at bottom) keyBindingsBarStyle = lipgloss.NewStyle(). Foreground(textSecondary). @@ -152,9 +121,6 @@ var ( Foreground(secondaryColor). Bold(true) - loadingSpinnerStyle = lipgloss.NewStyle(). - Foreground(primaryColor) - // Scanning paths list pathStyle = lipgloss.NewStyle(). Foreground(textColor). @@ -164,16 +130,6 @@ var ( Foreground(primaryColor). Bold(true) - // Repo row indicators - dirtyIndicator = lipgloss.NewStyle(). - Foreground(dirtyColor). - Bold(true). - Render("●") - - cleanIndicator = lipgloss.NewStyle(). - Foreground(cleanColor). - Render("○") - // Compact legend styles dirtyDotStyle = lipgloss.NewStyle(). Foreground(dirtyColor). @@ -191,16 +147,6 @@ func helpItem(key, desc string) string { return helpKeyStyle.Render(key) + helpDescStyle.Render(" "+desc) } -// Logo returns the ASCII art logo -func logo() string { - return logoStyle.Render(` - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃ ╔═╗╦╔╦╗ ╔═╗╔═╗╔═╗╔═╗╔═╗ ┃ - ┃ ║ ╦║ ║───╚═╗║ ║ ║╠═╝║╣ ┃ - ┃ ╚═╝╩ ╩ ╚═╝╚═╝╚═╝╩ ╚═╝ ┃ - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛`) -} - // Simpler logo for compact mode func compactLogo() string { return titleStyle.Render(" 🔍 git-scope ") diff --git a/internal/tui/update.go b/internal/tui/update.go index 31b313b..4d59861 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -133,6 +133,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case fetchCompleteMsg: + m.repos = msg.repos + m.resetPage() + m.updateTable() + m.statusMsg = "⬇ " + msg.summary.String() + return m, nil + case tea.KeyMsg: // Handle search mode separately if m.state == StateSearching { @@ -184,6 +191,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "Rescanning..." return m, scanReposCmd(m.cfg, true) + case "F": + // Bulk fetch all repos (network-only, non-destructive) + if m.state == StateReady && len(m.repos) > 0 { + m.statusMsg = "🔄 Fetching all repos…" + return m, fetchAllCmd(m.cfg, m.repos) + } + case "f": // Cycle through filter modes if m.state == StateReady { diff --git a/internal/tui/view.go b/internal/tui/view.go index f88f2bf..c66af39 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -320,6 +320,7 @@ func (m Model) renderHelp() string { keyBinding("g", "grass"), keyBinding("d", "disk"), keyBinding("t", "time"), + keyBinding("F", "fetch all"), keyBinding("r", "rescan"), keyBinding("q", "quit"), }