diff --git a/.gitignore b/.gitignore index 0ff1b62..c711f00 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ git-scope-test git-scope-final docs/images/fallback_pic docs/images/mobile -docs/images/desktop \ No newline at end of file +docs/images/desktop +coverage.out +PROJECT-STRATEGY-AND-CAREER-PLAYBOOK.md diff --git a/README.md b/README.md index 273514e..64ff1a7 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ git-scope -h # Show help ## ✨ Features + * **🎯 Attention Summary** β€” At-a-glance verdict of your whole workspace: `β–² to push Β· β–Ό behind Β· ● dirty Β· βœ“ clean`. Repos are ranked into **Action / Watch / Clean** tiers and sorted by default so what needs you lands at the top. * **πŸ“ Workspace Switch** β€” Switch root directories without quitting (`w`). Supports `~`, relative paths, and **symlinks**. * **πŸ” Fuzzy Search** β€” Find any repo by name, path, or branch (`/`). * **πŸ›‘οΈ Dirty Filter** β€” Instantly show only repos with uncommitted changes (`f`). @@ -142,7 +143,7 @@ Typical git workflows involve "tunnel vision"β€”working deep inside one reposito | `w` | **Switch Workspace** (with Tab completion) | | `/` | **Search** repositories (Fuzzy) | | `f` | **Filter** (Cycle: All / Dirty / Clean) | -| `s` | Cycle **Sort** Mode | +| `s` | Cycle **Sort** Mode (Attention / Dirty / Name / Branch / Recent) | | `1`–`4` | Sort by: Dirty / Name / Branch / Recent | | `[` / `]` | **Page Navigation** (Previous / Next) | | `Enter` | **Open** repo in Editor | @@ -154,6 +155,8 @@ Typical git workflows involve "tunnel vision"β€”working deep inside one reposito | `t` | Toggle **Timeline** view | | `q` | Quit | +> The dashboard sorts by **Attention** on launch (Action repos first). Press `s` to cycle to the classic sorts, or `1`–`4` to jump directly. + ----- ## βš™οΈ Configuration @@ -200,6 +203,7 @@ I built `git-scope` to solve the **"Multi-Repo Blindness"** problem. It gives me - [x] Symlink resolution for devcontainers/Codespaces - [x] Background file watcher (real-time updates) - [x] Bulk fetch all remotes (`F`) + - [x] Attention summary + tiered scoring (at-a-glance verdict) - [ ] Quick actions (bulk pull / stash, with confirmation) - [ ] Repo grouping (Service / Team / Stack) - [ ] Custom team dashboards diff --git a/docs/specs/attention-summary-scoring-plan.md b/docs/specs/attention-summary-scoring-plan.md new file mode 100644 index 0000000..c19a768 --- /dev/null +++ b/docs/specs/attention-summary-scoring-plan.md @@ -0,0 +1,94 @@ +# Plan: Attention Summary + Scoring + +Status: **Accepted** +Spec: `docs/specs/attention-summary-scoring.md` + +## Components & dependencies + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ internal/attention (NEW) β”‚ pure logic, no TUI import +β”‚ Tier, Classify, SubScore, β”‚ depends only on: internal/model, time +β”‚ Summary, Summarize, Less β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ consumed by + β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ tui/model.go β”‚ β”‚ tui/view.go β”‚ +β”‚ SortByAttention β”‚ β”‚ renderStats() rollupβ”‚ +β”‚ default sortMode β”‚ β”‚ Status-col glyph β”‚ +β”‚ sortRepos() case β”‚ β”‚ (+ tui/styles.go) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Dependency direction is one-way: TUI β†’ attention β†’ model. `attention` never +imports `tui`. This is what keeps it unit-testable and reusable for a future +CLI/`--json` exit-code path. + +## Build order (dependency-ordered) + +1. **`internal/attention` package + tests first** (TDD). Nothing depends on TUI + here, so it's fully verifiable in isolation before any UI wiring. +2. **Sort integration** in `tui/model.go` β€” add `SortByAttention`, flip default, + add the `sortRepos` case delegating to `attention.Less`. +3. **Status-column glyph** in `tui/model.go` row builder + `tui/styles.go` colors. +4. **Summary rollup** in `tui/view.go` `renderStats()`. +5. **Docs** β€” flip spec/plan Status to Accepted; update README (features + + keyboard table note that default sort is Attention). + +Steps 2–4 all depend on step 1. Steps 3 and 4 are independent of each other +(could be parallel) but both depend on 2 being merged-in mentally (shared file +`model.go` for 2 and 3 β†’ keep sequential to avoid edit conflicts). + +## API surface of `internal/attention` + +```go +type Tier int // Clean < Watch < Action (ordinal) +func (t Tier) String() string // "CLEAN" | "WATCH" | "ACTION" +func (t Tier) Glyph() string // "βœ“" | "●" | "β–²" + +func Classify(s model.RepoStatus, now time.Time) Tier +func SubScore(s model.RepoStatus, now time.Time) int // within-tier ordering +func Stale(s model.RepoStatus, now time.Time) bool // IsDirty && age>7d + +// Less reports whether repo a should sort before repo b (tier, then subscore desc, +// then name asc). Used by tui sortRepos. +func Less(a, b model.Repo, now time.Time) bool + +type Summary struct{ ToPush, Behind, Dirty, Clean int } +func Summarize(repos []model.Repo, now time.Time) Summary +``` + +`StaleThreshold = 7 * 24 * time.Hour` exported const (eases the future config knob +and the test boundary). + +## Risks & mitigations + +| Risk | Mitigation | +| :--- | :--- | +| Changing the default sort surprises existing users | It's a refinement of Dirty-first, not a reorder of unrelated data; legacy sorts stay on `s`/`1`–`4`; README note. | +| `now` via hidden `time.Now()` makes stale tests flaky | `now` is an injected param throughout; only the TUI call site passes `time.Now()`. | +| Sub-score integer overflow on huge ahead/behind | Counts capped (`min(.,99)`) per spec before weighting. | +| Glyph width breaks the 8-wide `Status` column alignment | Use single-rune glyphs already used elsewhere (`β–²β–Όβ—βœ“`); verify by manual run. | +| Summary double-counts diverged repos | Intentional per spec (per-signal, not a partition); documented in header semantics. | +| Scan-error repos sorting/classification | Covered: ScanError β†’ ACTION tier + sub-score bump; explicit test. | + +## Verification checkpoints + +- **After step 1:** `go test ./internal/attention/... -cover` β‰₯ 90%; package + builds with no `tui` import (verify `go list -deps`). +- **After step 2:** `go build ./...`; manual run shows ACTION repos on top at + launch; `1`–`4` still switch sorts. +- **After step 3:** manual run shows tier glyph in `Status` column, aligned. +- **After step 4:** manual run shows `β–² N to push Β· β–Ό N behind Β· ● N dirty Β· + βœ“ N clean` updating on filter/`r`/`F`. +- **Before PR:** `go test ./...` + `golangci-lint run` clean; spec success + criteria 1–7 each checked off. + +## Out of scope (deferred) + +- Config keys for weights / stale threshold (constants in v1). +- CLI/`--json`/CI exit-code consumption of `attention` (separate feature; the + package is designed to enable it later). +- Stale visual marker and diverged glyph beyond the spec's proposed defaults. diff --git a/docs/specs/attention-summary-scoring-tasks.md b/docs/specs/attention-summary-scoring-tasks.md new file mode 100644 index 0000000..97e0681 --- /dev/null +++ b/docs/specs/attention-summary-scoring-tasks.md @@ -0,0 +1,92 @@ +# Tasks: Attention Summary + Scoring + +Status: **Accepted** +Spec: `docs/specs/attention-summary-scoring.md` +Plan: `docs/specs/attention-summary-scoring-plan.md` + +Tasks are dependency-ordered. Each is a single focused session, touches ≀5 files, +and has explicit acceptance + verification. + +--- + +- [ ] **T1: `attention` package β€” Tier classification (TDD)** + - Build: `internal/attention/attention.go` with `Tier` (Clean0 && Behind>0)β†’Action; Stale 7-day boundary with injected now. + - Acceptance: tier rules match spec table exactly; scan error β†’ ACTION; + stale never changes tier. + - Verify: `go test ./internal/attention/... -cover` passes, β‰₯90%. + - Files: `internal/attention/attention.go`, `internal/attention/attention_test.go`. + +- [ ] **T2: `attention` package β€” SubScore, Less, Summarize (TDD)** + - Build: `SubScore(status, now) int` (caps + weights per spec: + 1000Β·ahead, 100Β·behind, 10Β·dirtyCount, 5Β·scanErr, 1Β·stale), + `Less(a, b model.Repo, now) bool` (tier, then subScore desc, then name asc), + `Summary{ToPush,Behind,Dirty,Clean}` + `Summarize([]model.Repo, now) Summary`. + - Tests first (failing): unpushed outranks behind outranks dirty; stale nudge + breaks a tie; caps prevent band overflow (ahead=200 stays in unpushed band); + `Less` total-order sanity on a mixed slice; `Summarize` on mixed slice incl. + diverged overlap and empty slice. + - Acceptance: ordering priority unpushedβ†’behindβ†’dirtyβ†’stale holds; Summarize + counts are per-signal (diverged repo counted in both ToPush and Behind). + - Verify: `go test ./internal/attention/... -cover` β‰₯90%; `go list -deps + ./internal/attention` shows **no** `internal/tui` import. + - Files: `internal/attention/attention.go`, `internal/attention/attention_test.go`. + +- [ ] **T3: Default sort = Attention** + - Build: add `SortByAttention` to the `SortMode` iota; init `sortMode` to it; + add `case SortByAttention` in `sortRepos()` using `attention.Less(.., time.Now())`; + include Attention in the `s` cycle; keep `1`–`4` mappings unchanged. + - Acceptance: on launch ACTION repos sort to top; `s` cycles through Attention + + the 4 legacy modes; `1`–`4` unchanged. + - Verify: `go build ./...`; `go run ./cmd/git-scope` in a multi-repo dir β†’ + ACTION repos on top; press `1`–`4` and `s` to confirm legacy sorts still work. + - Files: `internal/tui/model.go` (+ `internal/tui/update.go` if `s`/help text). + +- [ ] **T4: Status-column tier glyph** + - Build: render `Classify(...).Glyph()` in the `Status` column of the table row + builder; add tier colors in `styles.go` (Action/Watch/Clean). + - Acceptance: each row's `Status` cell shows `β–²`/`●`/`βœ“` matching its tier, + column alignment intact (8-wide). + - Verify: `go build ./...`; `go run ./cmd/git-scope` β†’ glyphs present, aligned, + colored; a known-dirty repo shows `●`, an ahead/behind repo shows `β–²`. + - Files: `internal/tui/model.go`, `internal/tui/styles.go`. + +- [ ] **T5: Summary rollup in header** + - Build: extend `renderStats()` to render + `β–² N to push Β· β–Ό N behind Β· ● N dirty Β· βœ“ N clean` from + `attention.Summarize(m.filteredRepos, time.Now())`; reuse existing badge styles. + - Acceptance: counts reflect the **filtered** set and update on filter (`f`), + rescan (`r`), and bulk fetch (`F`). + - Verify: `go build ./...`; `go run ./cmd/git-scope` β†’ header shows rollup; + toggle `f` and observe counts change; `F` updates behind count. + - Files: `internal/tui/view.go`. + +- [ ] **T6: Docs + final gate** + - Build: flip spec/plan/tasks Status β†’ Accepted; update README features list and + note in the keyboard table that the **default sort is Attention** (and `s` + includes it). + - Acceptance: spec success criteria 1–7 all checked; README accurate. + - Verify: `go test ./...` clean; `golangci-lint run` clean; `go list -deps + ./internal/attention | grep -q internal/tui` returns nothing (no TUI dep); + re-read spec Β§Success Criteria and tick each. + - Files: `README.md`, the three `docs/specs/attention-*` files. + +--- + +## Sequencing notes + +- T1 β†’ T2 are the pure core; merge-able and reviewable before any TUI change. +- T3 and T4 both edit `model.go` β†’ do T3 then T4, not in parallel. +- T5 is independent of T3/T4 (only `view.go`) but logically follows so the demo + shows glyph + header together. +- T6 closes the spec gate. + +## Definition of done (whole feature) + +All of spec Β§Success Criteria (1–7) pass; `go test ./...` and `golangci-lint run` +clean; default launch sorts by attention; header rollup + per-repo glyph render +correctly; `internal/attention` carries no TUI dependency and is β‰₯90% covered. diff --git a/docs/specs/attention-summary-scoring.md b/docs/specs/attention-summary-scoring.md new file mode 100644 index 0000000..ac0441c --- /dev/null +++ b/docs/specs/attention-summary-scoring.md @@ -0,0 +1,203 @@ +# Spec: Attention Summary + Scoring + +Status: **Accepted** +Branch: `feat/attention-summary-scoring` + +## Objective + +git-scope promises a "command center… find what needs attention," but today the +dashboard delivers **views** (search/sort/filter/graph/disk/timeline) and leaves +the user to synthesize the verdict. This feature reduces the already-collected +per-repo git state into a single, glanceable answer to: **"What do I need to act +on right now?"** + +Two deliverables: + +1. **Attention summary header** β€” an always-visible one-line rollup of the + workspace (e.g. `β–²3 to push Β· β–Ό2 behind Β· ●5 dirty Β· 12 clean`). +2. **Per-repo attention tier + ranking** β€” each repo is bucketed into an ordinal + tier (`ACTION` / `WATCH` / `CLEAN`), shown in the existing `Status` column, + and the dashboard sorts by attention so the work queue lands at the top. + +Pure synthesis and ranking. **Zero new git calls. Zero repository mutations.** +The read-only / local-first / no-network trust posture is fully preserved. + +### User stories + +- As a polyrepo dev, on launch I see at a glance how many repos need a push, are + behind remote, and are dirty β€” without scanning every row. +- As a polyrepo dev, the repos needing action are sorted to the top by default. +- As a polyrepo dev, a repo I left dirty and untouched for a week is nudged up so + it stops being forgotten. + +## Tech Stack + +- Go 1.26, existing TUI on Bubble Tea / Lip Gloss / Bubbles (unchanged versions). +- New pure package `internal/attention` (no Bubble Tea dependency). +- Input type: existing `model.RepoStatus` (`internal/model/repo.go`). No schema + change required for v1. + +## Commands + +``` +Build: go build ./... +Test: go test ./... +Test 1: go test ./internal/attention/... +Lint: golangci-lint run # v2.x, as configured in CI +Run: go run ./cmd/git-scope +``` + +## Project Structure + +``` +internal/attention/ β†’ NEW: pure scoring/tier logic (no TUI deps) + attention.go β†’ Tier type, Classify(), Summarize(), Less() + attention_test.go β†’ table-driven unit tests +internal/model/repo.go β†’ RepoStatus (input; unchanged for v1) +internal/tui/model.go β†’ add SortByAttention; default to it; row Status glyph +internal/tui/view.go β†’ extend renderStats() with the summary rollup +internal/tui/styles.go β†’ tier colors/glyph styles +docs/specs/attention-summary-scoring.md β†’ this spec +``` + +## Scoring Model (decisions locked) + +Ordinal **tiers** (chosen over a weighted numeric score for predictability), with +a derived numeric **sub-score** used only to order repos *within* a tier. + +### Tier classification (primary, user-facing) + +| Tier | Condition | Glyph | +| :------- | :----------------------------------------------------- | :---- | +| `ACTION` | `ScanError != ""` OR `Ahead > 0` OR `Behind > 0` | `β–²` | +| `WATCH` | not ACTION, AND `IsDirty` (staged/unstaged/untracked) | `●` | +| `CLEAN` | none of the above | `βœ“` | + +Rationale: ACTION = "git history out of sync with remote (push/pull needed)" or +"something is broken." WATCH = local uncommitted work, in sync with remote. +A scan error is surfaced as ACTION so failures are never hidden by a CLEAN look. + +### Sub-score (within-tier ordering only; never changes tier) + +Priority order (most β†’ least urgent): **unpushed β†’ behind β†’ dirty β†’ stale**. +Top priority is *unpushed* because it is the only state where data loss is +possible (local-only commits lost if the machine dies); behind/dirty are +recoverable or already on disk. + +``` +subScore = 1000 * min(Ahead, 99) # unpushed dominates + + 100 * min(Behind, 99) + + 10 * dirtyCount # Staged+Unstaged+Untracked, capped + + 5 * boolToInt(scanError) # broken repos float up within ACTION + + 1 * boolToInt(stale) # low-weight nudge, never dominates +``` + +`stale = IsDirty && (now - LastCommit) > 7*24h`. Staleness only nudges +within-tier order and may show a subtle marker; it **never** promotes a repo to a +higher tier (per decision: "low weight, never dominates"). The 7-day threshold is +a constant in v1 (config later). + +### Summary rollup (header) + +Aggregate counts across the **currently filtered** repo set: + +``` +β–² N to push = count(Ahead > 0) +β–Ό N behind = count(Behind > 0) +● N dirty = count(IsDirty) # already shown today +βœ“ N clean = count(CLEAN tier) +``` + +Note: "to push" and "behind" can overlap one repo (diverged) β€” counts are per +signal, not partitions, and that is intentional/honest. + +## Sort Behavior (decision: Attention becomes the default) + +- Add `SortByAttention` as a new `SortMode`. +- Initialize `sortMode = SortByAttention` (replaces `SortByDirty` default). + Attention is a strict refinement of Dirty-first, so this is an evolution, not a + jarring reorder. +- Ordering: tier rank (ACTION < WATCH < CLEAN), then sub-score desc, then name asc + for stability. +- All existing sorts (`s` cycle, `1`–`4`) remain reachable and unchanged. The `s` + cycle includes Attention. + +## Code Style + +Pure, dependency-free, table-driven-tested. Example shape: + +```go +package attention + +type Tier int + +const ( + Clean Tier = iota + Watch + Action +) + +// Classify returns the attention tier for a repo's status. +func Classify(s model.RepoStatus, now time.Time) Tier { + if s.ScanError != "" || s.Ahead > 0 || s.Behind > 0 { + return Action + } + if s.IsDirty { + return Watch + } + return Clean +} +``` + +Conventions: no comments except exported-symbol doc lines; small functions; +injected `now time.Time` for deterministic tests (no hidden `time.Now()`). + +## Testing Strategy + +- Framework: stdlib `testing`, table-driven (matches existing repo style in + `internal/scan`, `internal/gitstatus`). +- New `internal/attention/attention_test.go` covers: + - Tier classification: each tier, boundary (Ahead=0/1, Behind=0/1, dirty combos), + scan-error β†’ ACTION, diverged (Ahead>0 && Behind>0). + - Sub-score ordering: unpushed outranks behind outranks dirty; stale nudge; + caps (Ahead=200 doesn't overflow into next priority band). + - `Summarize` counts on a mixed slice, including overlap (diverged) and an + empty slice. + - Stale boundary around the 7-day threshold using an injected `now`. +- Coverage expectation: `internal/attention` β‰₯ 90% (it's pure logic). +- TUI wiring verified by `go build ./...` + manual run; no new TUI test harness + introduced in this feature. + +## Boundaries + +- **Always:** keep scoring in `internal/attention` (pure, no TUI import); inject + `now`; run `go test ./...` and `golangci-lint run` before commit; preserve + read-only behavior. +- **Ask first:** adding a field to `model.RepoStatus`; adding a config schema key; + changing existing default keybindings or removing a sort mode. +- **Never:** introduce a git mutation; add network/telemetry; remove or weaken + existing tests; hide a scan error behind a CLEAN verdict. + +## Success Criteria + +1. `go test ./internal/attention/...` passes with β‰₯90% coverage. +2. `go build ./...` and `golangci-lint run` are clean. +3. On launch, the dashboard sorts ACTION repos to the top by default; `s`/`1`–`4` + still switch to the legacy sorts. +4. The header shows the live `β–² to push Β· β–Ό behind Β· ● dirty Β· βœ“ clean` rollup, + updating with filter/rescan/`F`-fetch. +5. The `Status` column shows a per-repo tier glyph (`β–²`/`●`/`βœ“`). +6. A dirty repo with `LastCommit` > 7 days sorts above an equally-dirty fresh repo. +7. No new git subprocess calls are introduced (verify: scoring reads only + existing `RepoStatus`). + +## Open Questions + +1. **Glyph vs short label** in the 8-wide `Status` column β€” glyph-only (`β–²`) keeps + width, a short label (`ACT`) is more legible. Proposed: glyph + existing color. +2. **Stale marker** β€” show a distinct `β—·`/`*` for stale repos, or rely on order + only? Proposed: order only in v1 to avoid column clutter. +3. **Diverged repo glyph** (both ahead & behind) β€” `β‡…`, or just `β–²`? Proposed: + `β–²` (ACTION tier already captures it; ahead/behind columns show detail). + +These are low-stakes and can be resolved at Plan time unless you have a preference. diff --git a/docs/specs/roadmap-plan-items-2-7.md b/docs/specs/roadmap-plan-items-2-7.md new file mode 100644 index 0000000..bb23cf5 --- /dev/null +++ b/docs/specs/roadmap-plan-items-2-7.md @@ -0,0 +1,239 @@ +# Roadmap Plan: Items 2–7 + +Status: planned, not started. Order below is the implementation order. +Prerequisite (item 1, not covered here): release v1.4.0 from current main. + +--- + +## 2. Fix bugs #33 (ahead/behind not visible) and #23 (lazygit launch) + +### 2a. #33 β€” Ahead/behind status not visible + +**Root cause:** Reporter is on v1.3.1; ahead/behind columns landed after that +release (#16, unreleased). Residual product gap: `behind` is only accurate +after a fetch, so a stale repo silently shows "clean" β€” exactly the +reporter's complaint. + +**Plan** +1. Ship v1.4.0 (columns become visible) and comment on the issue. +2. Show remote-freshness in the UI: track last-fetch time per repo (readable + from `.git/FETCH_HEAD` mtime; no network needed) and render a stale + indicator (e.g. dim `?` in behind column or `fetched 3d ago`) when older + than a threshold (default 24h, configurable). +3. Point users at the existing bulk-fetch quick action (F) in the help bar + and README so the "why is behind wrong" loop has an in-app answer. + +**Acceptance criteria** +- [ ] A repo 1+ commits behind its upstream shows a non-zero behind count in + the TUI after a fetch. +- [ ] A repo with no upstream branch shows a distinct marker (e.g. `-`), not + `0`, in ahead/behind columns. +- [ ] A repo whose last fetch is older than the staleness threshold shows a + visible stale indicator; after pressing F it clears. +- [ ] No network calls happen during scan/render (fetch only on explicit F). +- [ ] README "ahead/behind" claim matches actual behavior, including the + fetch requirement, in one short paragraph. +- [ ] Issue #33 closed with a comment linking the release. + +### 2b. #23 β€” `editor: lazygit` doesn't launch / relaunch + +**Root causes (three separate defects):** +1. Editor invoked as ` `; lazygit rejects a bare path + (needs `--path ` or cwd set to the repo). +2. After the child editor exits once, subsequent Enter presses do nothing β€” + the bubbletea `tea.ExecProcess` return isn't restoring/re-arming state. +3. `git-scope --version` prints `v1.0.1` on Scoop builds β€” version ldflags + not injected in that build path. + +**Plan** +1. Change editor launch to run the command with `cmd.Dir = repoPath` and no + positional path arg when the configured editor has no `{path}` + placeholder issues β€” simplest fix that works for lazygit, vim, nvim, etc. + Support an optional `{path}` token in the `editor` config for tools that + need the path as an explicit arg (e.g. `code {path}`); if absent and the + editor is a known GUI editor pattern (`code`, `subl`, ...), append path + as today. Keep it to: token present β†’ substitute; token absent β†’ append + path AND set cwd. Document in README. +2. Fix the relaunch bug: audit the `tea.ExecProcess` callback path in + internal/tui β€” ensure terminal state and key handling are restored and + the editor can be launched repeatedly in one session. +3. Fix version stamping for all release artifacts (goreleaser/Scoop + manifest) so `--version` matches the installed tag. + +**Acceptance criteria** +- [ ] With `editor: lazygit`, pressing Enter on a repo opens lazygit *in + that repo*; quitting lazygit returns to a fully functional TUI. +- [ ] Pressing Enter on a second repo immediately after opens the editor + again (repeatable β‰₯3 times in one session). +- [ ] `editor: code {path}` opens VS Code at the repo; `editor: vim` opens + vim with cwd = repo. +- [ ] Works on Windows (reporter's platform) β€” verified via CI build + + reporter confirmation or a Windows smoke test. +- [ ] `git-scope --version` prints the release tag on brew, Scoop, and + install-script builds. +- [ ] Unit test covers the command-construction logic ({path} token, + cwd fallback). +- [ ] Issue #23 closed with reporter confirmation requested. + +--- + +## 3. Ship attention scoring (current branch) + +**State:** implemented on `feat/attention-summary-scoring` +(internal/attention, 95.5% coverage; TUI wiring done; specs in this folder). + +**Plan** +1. Rebase on main, run full test suite + lint. +2. Self-review the diff against the spec (attention-summary-scoring.md); + run the TUI against a real workspace with mixed repo states. +3. Update README: new hero framing ("You have 40 repos. Three need you."), + document verdicts (ACTION/WATCH/CLEAN), glyphs (β–²β—βœ“), default sort, and + how to revert to name sort. +4. New demo GIF showing the summary header + sort. +5. PR β†’ merge β†’ release v1.5.0 (its own release; don't bundle with bug + fixes so the changelog tells one story). + +**Acceptance criteria** +- [ ] Default sort is Attention; toggling back to name sort works and the + choice is persistable via config. +- [ ] Header rollup shows counts per verdict and matches the row glyphs. +- [ ] A repo with unpushed commits always ranks above a merely-dirty repo + (per spec's unpushed-first scoring) β€” covered by a unit test. +- [ ] Empty workspace / all-clean workspace render sensibly (no divide-by- + zero, header reads "all clean"). +- [ ] `go test ./... -race` and golangci-lint pass in CI. +- [ ] README + demo GIF updated; website hero copy updated to match. +- [ ] Released as v1.5.0 with changelog entry explaining the new default + sort (behavior change β€” call it out prominently). + +--- + +## 4. MCP server / agent integration + +**Goal:** make workspace state legible to AI agents without breaking the +read-only/local-first promise. Build on the existing `scan` JSON output. + +**Plan** +1. Stabilize the JSON contract first: define the output schema of + `git-scope scan` (fields incl. ahead/behind, verdict, score), add + `--format json` flag explicitly, and treat it as a versioned API + (add `"schemaVersion": 1`). +2. New subcommand `git-scope mcp` β€” stdio MCP server exposing tools: + - `list_repos` (filters: verdict, dirty, unpushed) + - `repo_status` (single repo detail) + - `attention_summary` (the header rollup as structured data) + Use the official Go MCP SDK (`modelcontextprotocol/go-sdk`); verify + current API from docs before implementing. +3. Read-only guarantee: the MCP server exposes zero mutating tools; no + fetch, no network. State this in the tool descriptions. +4. Docs: README section "Use with Claude Code / AI agents" with the + one-line `claude mcp add` setup; short blog-able writeup. + +**Acceptance criteria** +- [ ] `git-scope scan --format json` output documented (schema in + docs/), includes schemaVersion, stable field names. +- [ ] `git-scope mcp` speaks MCP over stdio; `claude mcp add git-scope -- + git-scope mcp` works and Claude Code can answer "which repos have + unpushed work?" end-to-end (manually verified, steps recorded). +- [ ] All three tools return within 2s on a 50-repo workspace using cache; + no tool performs writes or network I/O. +- [ ] Malformed tool input returns an MCP error, not a crash. +- [ ] Unit tests for tool handlers (table-driven, using fake scan data). +- [ ] README section + demo (screenshot or asciinema of Claude querying it). +- [ ] Released as v1.6.0; announcement post drafted ("MCP server for your + git workspace"). + +--- + +## 5. Watch mode + +**Goal:** `git-scope --watch` (or key `w` conflict check β€” `w` is taken by +workspace switch; use flag + maybe `W`) β€” auto-refresh so the TUI works as +a persistent dashboard. + +**Plan** +1. Timer-based rescan (default every 30s, `--watch[=interval]`), reusing + the existing rescan path; no fsnotify in v1 (watching hundreds of repos + via fsnotify is complexity + fd-limit risk; ponytail: timer first, + fsnotify only if users ask). +2. Preserve UI state across refresh: cursor position, page, sort, filter. +3. Visible "last scanned 12s ago / scanning…" indicator; pause/resume key. +4. Scan runs async (already bubbletea-friendly) so UI never blocks. + +**Acceptance criteria** +- [ ] `git-scope --watch` refreshes at the interval; default interval + documented; `--watch=10s` style override works. +- [ ] During refresh the UI stays responsive and cursor/page/sort/filter + are preserved. +- [ ] Status bar shows time since last scan and an active-scan spinner. +- [ ] A repo changed externally (touch a file, make a commit) is reflected + within one interval β€” covered by an integration test or scripted + manual check. +- [ ] CPU usage at idle with 50 repos stays negligible between ticks (no + busy loop); no memory growth over 1h run (manual soak check). +- [ ] Works combined with attention sort: rows re-rank after refresh. + +--- + +## 6. AUR + nixpkgs packaging + +**Plan** +1. AUR: publish `git-scope-bin` (from goreleaser artifacts) β€” maintain a + PKGBUILD repo; optionally automate bump via goreleaser AUR support. +2. Nix: submit derivation to nixpkgs (`buildGoModule`); follow nixpkgs + contribution flow (PR against NixOS/nixpkgs, respond to review). +3. Add both to README install section; add release-checklist note so + future tags bump AUR automatically. + +**Acceptance criteria** +- [ ] `yay -S git-scope-bin` installs a working binary with correct + `--version` on Arch (VM or Docker check). +- [ ] AUR package updates automatically (or via one scripted step) on the + next tagged release. +- [ ] nixpkgs PR submitted; `nix run nixpkgs#git-scope` works once merged + (merge timing is out of our control β€” criterion is submitted + + review feedback addressed within a week of each round). +- [ ] README install section lists AUR and Nix with copy-paste commands. + +--- + +## 7. #18 β€” mrconfig support (community-built) + +**Goal:** repos list from `~/.mrconfig` as an alternative source of truth. +Deliberately delegated: this is contributor-bait, not core work. + +**Plan (maintainer effort only)** +1. Design the seam so a contribution is easy: comment on #18 with the + agreed approach β€” a `repoSource` concept where config gains + `mrconfig: ` (or CLI `--mrconfig`); parser reads section headers + (INI-style paths) from .mrconfig and feeds the existing scan list; + scanning and mrconfig can merge (union, dedup by path). +2. Label `good first issue` + `help wanted`; include pointers to the + files to touch (internal/config, internal/scan) and the test pattern + to follow. +3. Review the PR when it comes; if no takers in ~2 months, implement it + ourselves in an afternoon (parser is ~50 lines). + +**Acceptance criteria (for the eventual PR, ours or theirs)** +- [ ] With `mrconfig: ~/.mrconfig` in config, the TUI lists exactly the + repos named in the file (plus scanned roots if both configured). +- [ ] Missing/unreadable mrconfig β†’ clear error message, not a crash; + non-repo paths in mrconfig are skipped with a warning. +- [ ] Parser has unit tests incl. comments, chain/checkout lines ignored, + `~` expansion. +- [ ] README documents the option with a myrepos interop example. +- [ ] Issue #18 has the design comment + labels within this milestone, + regardless of who implements. + +--- + +## Sequencing & releases + +| Release | Contents | Why | +|---------|----------|-----| +| v1.4.0 | existing unreleased main | unblock #33, restore cadence | +| v1.4.1 | items 2a residual + 2b | bug-fix release, closes both bugs | +| v1.5.0 | item 3 (attention scoring) | flagship feature, own story, HN post | +| v1.6.0 | item 4 (MCP) | second announcement beat | +| v1.6.x | item 5 (watch mode) | quality-of-life | +| ongoing | items 6, 7 | packaging + community, no release coupling | diff --git a/internal/attention/attention.go b/internal/attention/attention.go new file mode 100644 index 0000000..62a2fe8 --- /dev/null +++ b/internal/attention/attention.go @@ -0,0 +1,66 @@ +// Package attention reduces per-repo git state into a glanceable verdict: +// an ordinal tier (Clean/Watch/Action) plus a within-tier ranking. It is pure +// and depends only on internal/model, so it carries no TUI dependency and can +// back both the dashboard and future CLI/CI consumers. +package attention + +import ( + "time" + + "github.com/Bharath-code/git-scope/internal/model" +) + +// StaleThreshold is how long a dirty repo may sit untouched before it is +// considered stale and nudged up within its tier. +const StaleThreshold = 7 * 24 * time.Hour + +// Tier is the ordinal attention bucket for a repo. Higher is more urgent. +type Tier int + +const ( + Clean Tier = iota + Watch + Action +) + +func (t Tier) String() string { + switch t { + case Action: + return "ACTION" + case Watch: + return "WATCH" + default: + return "CLEAN" + } +} + +// Glyph returns the single-rune indicator shown in the dashboard Status column. +func (t Tier) Glyph() string { + switch t { + case Action: + return "β–²" + case Watch: + return "●" + default: + return "βœ“" + } +} + +// Classify buckets a repo by its highest-priority signal. A scan error or any +// divergence from the remote (ahead/behind) is ACTION; uncommitted work that is +// in sync with the remote is WATCH; everything else is CLEAN. +func Classify(s model.RepoStatus, now time.Time) Tier { + if s.ScanError != "" || s.Ahead > 0 || s.Behind > 0 { + return Action + } + if s.IsDirty { + return Watch + } + return Clean +} + +// Stale reports whether a dirty repo has been left untouched past StaleThreshold. +// It never promotes a repo to a higher tier; it only nudges within-tier order. +func Stale(s model.RepoStatus, now time.Time) bool { + return s.IsDirty && now.Sub(s.LastCommit) > StaleThreshold +} diff --git a/internal/attention/attention_test.go b/internal/attention/attention_test.go new file mode 100644 index 0000000..83c6102 --- /dev/null +++ b/internal/attention/attention_test.go @@ -0,0 +1,102 @@ +package attention + +import ( + "testing" + "time" + + "github.com/Bharath-code/git-scope/internal/model" +) + +var now = time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) + +func status(mut func(*model.RepoStatus)) model.RepoStatus { + s := model.RepoStatus{LastCommit: now} + if mut != nil { + mut(&s) + } + return s +} + +func TestClassify(t *testing.T) { + tests := []struct { + name string + s model.RepoStatus + want Tier + }{ + {"empty is clean", status(nil), Clean}, + {"ahead is action", status(func(s *model.RepoStatus) { s.Ahead = 1 }), Action}, + {"behind is action", status(func(s *model.RepoStatus) { s.Behind = 1 }), Action}, + {"diverged is action", status(func(s *model.RepoStatus) { s.Ahead = 2; s.Behind = 3 }), Action}, + {"scan error is action", status(func(s *model.RepoStatus) { s.ScanError = "boom" }), Action}, + {"dirty in sync is watch", status(func(s *model.RepoStatus) { s.IsDirty = true; s.Unstaged = 1 }), Watch}, + {"staged in sync is watch", status(func(s *model.RepoStatus) { s.IsDirty = true; s.Staged = 2 }), Watch}, + {"ahead beats dirty -> action", status(func(s *model.RepoStatus) { s.IsDirty = true; s.Ahead = 1 }), Action}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := Classify(tc.s, now); got != tc.want { + t.Fatalf("Classify() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestClassifyAheadBehindBoundary(t *testing.T) { + if Classify(status(func(s *model.RepoStatus) { s.Ahead = 0; s.Behind = 0 }), now) != Clean { + t.Fatal("0/0 should be clean") + } + if Classify(status(func(s *model.RepoStatus) { s.Ahead = 1 }), now) != Action { + t.Fatal("ahead=1 should be action") + } +} + +func TestStale(t *testing.T) { + old := now.Add(-(StaleThreshold + time.Hour)) + fresh := now.Add(-time.Hour) + tests := []struct { + name string + s model.RepoStatus + want bool + }{ + {"dirty and old is stale", status(func(s *model.RepoStatus) { s.IsDirty = true; s.LastCommit = old }), true}, + {"dirty and fresh not stale", status(func(s *model.RepoStatus) { s.IsDirty = true; s.LastCommit = fresh }), false}, + {"clean and old not stale", status(func(s *model.RepoStatus) { s.LastCommit = old }), false}, + {"exactly at threshold not stale", status(func(s *model.RepoStatus) { + s.IsDirty = true + s.LastCommit = now.Add(-StaleThreshold) + }), false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := Stale(tc.s, now); got != tc.want { + t.Fatalf("Stale() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestTierStringAndGlyph(t *testing.T) { + cases := []struct { + t Tier + str string + glyph string + }{ + {Clean, "CLEAN", "βœ“"}, + {Watch, "WATCH", "●"}, + {Action, "ACTION", "β–²"}, + } + for _, c := range cases { + if c.t.String() != c.str { + t.Errorf("String() = %q, want %q", c.t.String(), c.str) + } + if c.t.Glyph() != c.glyph { + t.Errorf("Glyph() = %q, want %q", c.t.Glyph(), c.glyph) + } + } +} + +func TestTierOrdinal(t *testing.T) { + if Clean >= Watch || Watch >= Action { + t.Fatal("tier ordinal must be Clean < Watch < Action") + } +} diff --git a/internal/attention/score.go b/internal/attention/score.go new file mode 100644 index 0000000..f09152f --- /dev/null +++ b/internal/attention/score.go @@ -0,0 +1,86 @@ +package attention + +import ( + "time" + + "github.com/Bharath-code/git-scope/internal/model" +) + +// Per-band weights. Counts are capped at countCap so each higher-priority band +// always outranks any amount of lower-priority signal (a single unpushed commit +// outranks any number of behind/dirty). Priority: unpushed > behind > dirty > stale. +const ( + countCap = 99 + weightAhead = 100000 + weightBehd = 1000 + weightDirty = 10 + weightErr = 5 + weightStale = 1 +) + +func capCount(n int) int { + if n > countCap { + return countCap + } + if n < 0 { + return 0 + } + return n +} + +// SubScore ranks repos within a tier. It is not meaningful across tiers (tier is +// the primary key); Less applies tier first, then SubScore. +func SubScore(s model.RepoStatus, now time.Time) int { + score := weightAhead*capCount(s.Ahead) + weightBehd*capCount(s.Behind) + score += weightDirty * capCount(s.Staged+s.Unstaged+s.Untracked) + if s.ScanError != "" { + score += weightErr + } + if Stale(s, now) { + score += weightStale + } + return score +} + +// Less reports whether repo a should sort before repo b: higher tier first, then +// higher sub-score, then name ascending for a stable, deterministic order. +func Less(a, b model.Repo, now time.Time) bool { + ta, tb := Classify(a.Status, now), Classify(b.Status, now) + if ta != tb { + return ta > tb + } + sa, sb := SubScore(a.Status, now), SubScore(b.Status, now) + if sa != sb { + return sa > sb + } + return a.Name < b.Name +} + +// Summary is the workspace-level rollup shown in the dashboard header. Counts are +// per-signal, not a partition: a diverged repo is counted in both ToPush and Behind. +type Summary struct { + ToPush int + Behind int + Dirty int + Clean int +} + +// Summarize aggregates attention signals across a set of repos. +func Summarize(repos []model.Repo, now time.Time) Summary { + var s Summary + for _, r := range repos { + if r.Status.Ahead > 0 { + s.ToPush++ + } + if r.Status.Behind > 0 { + s.Behind++ + } + if r.Status.IsDirty { + s.Dirty++ + } + if Classify(r.Status, now) == Clean { + s.Clean++ + } + } + return s +} diff --git a/internal/attention/score_test.go b/internal/attention/score_test.go new file mode 100644 index 0000000..a942f1f --- /dev/null +++ b/internal/attention/score_test.go @@ -0,0 +1,112 @@ +package attention + +import ( + "sort" + "testing" + + "github.com/Bharath-code/git-scope/internal/model" +) + +func repo(name string, mut func(*model.RepoStatus)) model.Repo { + return model.Repo{Name: name, Status: status(mut)} +} + +func TestSubScorePriority(t *testing.T) { + unpushed := SubScore(status(func(s *model.RepoStatus) { s.Ahead = 1 }), now) + behind := SubScore(status(func(s *model.RepoStatus) { s.Behind = 99 }), now) + dirty := SubScore(status(func(s *model.RepoStatus) { s.IsDirty = true; s.Unstaged = 99 }), now) + + if unpushed <= behind { + t.Errorf("unpushed (%d) must outrank behind (%d)", unpushed, behind) + } + if behind <= dirty { + t.Errorf("behind (%d) must outrank dirty (%d)", behind, dirty) + } +} + +func TestSubScoreCapsPreventBandOverflow(t *testing.T) { + // A single ahead commit must always outrank any amount of behind/dirty, + // even pathological counts, because of the per-band caps. + oneAhead := SubScore(status(func(s *model.RepoStatus) { s.Ahead = 1 }), now) + hugeBehindDirty := SubScore(status(func(s *model.RepoStatus) { + s.Behind = 200 + s.IsDirty = true + s.Staged = 200 + s.Unstaged = 200 + s.Untracked = 200 + }), now) + if oneAhead <= hugeBehindDirty { + t.Errorf("one ahead (%d) must outrank huge behind+dirty (%d)", oneAhead, hugeBehindDirty) + } +} + +func TestSubScoreStaleNudge(t *testing.T) { + old := now.Add(-(StaleThreshold + 1)) + fresh := repo("fresh", func(s *model.RepoStatus) { s.IsDirty = true; s.Unstaged = 1 }) + staleR := repo("stale", func(s *model.RepoStatus) { s.IsDirty = true; s.Unstaged = 1; s.LastCommit = old }) + + if SubScore(staleR.Status, now) <= SubScore(fresh.Status, now) { + t.Error("stale dirty repo must score above an equally-dirty fresh repo") + } +} + +func TestSubScoreStaleNeverDominates(t *testing.T) { + // Stale only nudges; it must never lift a WATCH repo above an ACTION repo's band. + staleDirty := SubScore(status(func(s *model.RepoStatus) { + s.IsDirty = true + s.Unstaged = 50 + s.LastCommit = now.Add(-1000 * StaleThreshold) + }), now) + oneBehind := SubScore(status(func(s *model.RepoStatus) { s.Behind = 1 }), now) + if staleDirty >= oneBehind { + t.Errorf("stale dirty (%d) must not reach the behind band (%d)", staleDirty, oneBehind) + } +} + +func TestLessOrdersByTierThenScoreThenName(t *testing.T) { + repos := []model.Repo{ + repo("zzz-clean", nil), + repo("dirty-b", func(s *model.RepoStatus) { s.IsDirty = true; s.Unstaged = 1 }), + repo("dirty-a", func(s *model.RepoStatus) { s.IsDirty = true; s.Unstaged = 1 }), + repo("behind", func(s *model.RepoStatus) { s.Behind = 5 }), + repo("ahead", func(s *model.RepoStatus) { s.Ahead = 1 }), + } + sort.SliceStable(repos, func(i, j int) bool { return Less(repos[i], repos[j], now) }) + + want := []string{"ahead", "behind", "dirty-a", "dirty-b", "zzz-clean"} + for i, n := range want { + if repos[i].Name != n { + t.Fatalf("position %d = %q, want %q (order: %v)", i, repos[i].Name, n, names(repos)) + } + } +} + +func names(repos []model.Repo) []string { + out := make([]string, len(repos)) + for i, r := range repos { + out[i] = r.Name + } + return out +} + +func TestSummarize(t *testing.T) { + repos := []model.Repo{ + repo("clean1", nil), + repo("clean2", nil), + repo("ahead", func(s *model.RepoStatus) { s.Ahead = 2 }), + repo("behind", func(s *model.RepoStatus) { s.Behind = 1 }), + repo("diverged", func(s *model.RepoStatus) { s.Ahead = 1; s.Behind = 1 }), + repo("dirty", func(s *model.RepoStatus) { s.IsDirty = true; s.Staged = 1 }), + } + got := Summarize(repos, now) + want := Summary{ToPush: 2, Behind: 2, Dirty: 1, Clean: 2} + if got != want { + t.Fatalf("Summarize() = %+v, want %+v", got, want) + } +} + +func TestSummarizeEmpty(t *testing.T) { + if got := Summarize(nil, now); got != (Summary{}) { + t.Fatalf("Summarize(nil) = %+v, want zero", got) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index cc9c2ce..572e828 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -4,7 +4,9 @@ import ( "fmt" "sort" "strings" + "time" + "github.com/Bharath-code/git-scope/internal/attention" "github.com/Bharath-code/git-scope/internal/config" "github.com/Bharath-code/git-scope/internal/model" "github.com/Bharath-code/git-scope/internal/stats" @@ -30,10 +32,12 @@ const ( type SortMode int const ( - SortByDirty SortMode = iota + SortByAttention SortMode = iota + SortByDirty SortByName SortByBranch SortByLastCommit + sortModeCount ) // FilterMode represents different filter options @@ -146,7 +150,7 @@ func NewModel(cfg *config.Config) Model { workspaceInput: wi, spinner: sp, state: StateLoading, - sortMode: SortByDirty, + sortMode: SortByAttention, filterMode: FilterAll, currentPage: 0, pageSize: cfg.PageSize, @@ -215,6 +219,11 @@ func (m *Model) sortRepos() { copy(m.sortedRepos, m.filteredRepos) switch m.sortMode { + case SortByAttention: + now := time.Now() + sort.SliceStable(m.sortedRepos, func(i, j int) bool { + return attention.Less(m.sortedRepos[i], m.sortedRepos[j], now) + }) case SortByDirty: sort.Slice(m.sortedRepos, func(i, j int) bool { if m.sortedRepos[i].Status.IsDirty != m.sortedRepos[j].Status.IsDirty { @@ -290,6 +299,8 @@ func (m *Model) resetPage() { // GetSortModeName returns the display name of current sort mode func (m Model) GetSortModeName() string { switch m.sortMode { + case SortByAttention: + return "Attention" case SortByDirty: return "Dirty First" case SortByName: @@ -315,20 +326,31 @@ func (m Model) GetFilterModeName() string { return "All" } +// attentionLabel renders a repo's attention tier as a glyph + short label for +// the Status column. +func attentionLabel(t attention.Tier) string { + switch t { + case attention.Action: + return "β–² Action" + case attention.Watch: + return "● Watch" + default: + return "βœ“ Clean" + } +} + // reposToRows converts repos to table rows with status indicators func reposToRows(repos []model.Repo) []table.Row { rows := make([]table.Row, 0, len(repos)) + now := time.Now() for _, r := range repos { lastCommit := "N/A" if !r.Status.LastCommit.IsZero() { lastCommit = r.Status.LastCommit.Format("Jan 02 15:04") } - // Status indicator with text - status := "βœ“ Clean" - if r.Status.IsDirty { - status = "● Dirty" - } + // Status indicator: attention tier glyph + label + status := attentionLabel(attention.Classify(r.Status, now)) rows = append(rows, table.Row{ status, diff --git a/internal/tui/styles.go b/internal/tui/styles.go index ddda5bd..d52d857 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -72,6 +72,18 @@ var ( Padding(0, 1). Bold(true) + pushBadgeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#F59E0B")). + Padding(0, 1). + Bold(true) + + behindBadgeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#60A5FA")). + Padding(0, 1). + Bold(true) + // Keybindings bar styles (Tuimorphic - always visible at bottom) keyBindingsBarStyle = lipgloss.NewStyle(). Foreground(textSecondary). diff --git a/internal/tui/update.go b/internal/tui/update.go index 4d59861..22a0c09 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -210,7 +210,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "s": if m.state == StateReady { - m.sortMode = (m.sortMode + 1) % 4 + m.sortMode = (m.sortMode + 1) % sortModeCount m.resetPage() m.updateTable() m.statusMsg = "Sorted by: " + m.GetSortModeName() diff --git a/internal/tui/view.go b/internal/tui/view.go index c66af39..6d09d20 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -3,7 +3,9 @@ package tui import ( "fmt" "strings" + "time" + "github.com/Bharath-code/git-scope/internal/attention" "github.com/charmbracelet/lipgloss" ) @@ -206,15 +208,7 @@ func (m Model) renderSearchBadge() string { func (m Model) renderStats() string { total := len(m.repos) shown := len(m.sortedRepos) - dirty := 0 - clean := 0 - for _, r := range m.repos { - if r.Status.IsDirty { - dirty++ - } else { - clean++ - } - } + sum := attention.Summarize(m.sortedRepos, time.Now()) stats := []string{} @@ -225,11 +219,18 @@ func (m Model) renderStats() string { stats = append(stats, statsBadgeStyle.Render(fmt.Sprintf("πŸ“ %d/%d repos", shown, total))) } - if dirty > 0 { - stats = append(stats, dirtyBadgeStyle.Render(fmt.Sprintf("● %d dirty", dirty))) + // Attention summary: the at-a-glance verdict across the displayed set + if sum.ToPush > 0 { + stats = append(stats, pushBadgeStyle.Render(fmt.Sprintf("β–² %d to push", sum.ToPush))) + } + if sum.Behind > 0 { + stats = append(stats, behindBadgeStyle.Render(fmt.Sprintf("β–Ό %d behind", sum.Behind))) + } + if sum.Dirty > 0 { + stats = append(stats, dirtyBadgeStyle.Render(fmt.Sprintf("● %d dirty", sum.Dirty))) } - if clean > 0 { - stats = append(stats, cleanBadgeStyle.Render(fmt.Sprintf("βœ“ %d clean", clean))) + if sum.Clean > 0 { + stats = append(stats, cleanBadgeStyle.Render(fmt.Sprintf("βœ“ %d clean", sum.Clean))) } // Filter indicator with inline hint