Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
135 changes: 135 additions & 0 deletions internal/cache/cache_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading