From 9aeac0d5596df5d2fcd86c86bba38a2dfb413e8e Mon Sep 17 00:00:00 2001 From: Matt Vinall Date: Wed, 13 May 2026 20:01:58 +0100 Subject: [PATCH 1/3] refactor: remove redundant BranchStatus.Locations field - Branch location data for the checked-out branch now lives exclusively in the LocalBranches entry with Current: true - New CurrentBranchLocations() helper replaces direct access to the old top-level Locations field - Ensures a Current: true entry always exists in LocalBranches for repos where git for-each-ref doesn't enumerate the checked-out branch - FilterLocalOnlyForConfig no longer drops the current branch so HEAD remote comparison always stays available --- README.md | 1 + scanner/branch_status.go | 57 ++++++++++++++---- scanner/inclusion_test.go | 22 ++++--- scanner/scan.go | 10 ++-- scanner/types.go | 123 +++++++++++++++++++++++++++++--------- scanner/types_test.go | 75 ++++++++++++++--------- ui/status_layout.go | 18 +++--- ui/status_layout_test.go | 18 ------ ui/update.go | 2 +- ui/view_test.go | 13 ++-- 10 files changed, 229 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index af0e1de..603c930 100644 --- a/README.md +++ b/README.md @@ -199,3 +199,4 @@ A few possibilities listed below, none of this is promised or scheduled. only as nested `.git` dirs. - **Configurable diff** — options such as ignore whitespace or word diff, driven from config, for the Diff pane. - **Safer delete housekeeping** — dry-run delete, or move to Trash on macOS instead of only recursive delete. + diff --git a/scanner/branch_status.go b/scanner/branch_status.go index b6a2feb..d83bb80 100644 --- a/scanner/branch_status.go +++ b/scanner/branch_status.go @@ -212,6 +212,15 @@ func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLoc return locations, nil } +func tipFromLocalBranchLocation(locations []BranchLocation) (hash string, unix int64) { + for _, loc := range locations { + if loc.Name == "local" && loc.Exists { + return loc.TipHash, loc.TipUnix + } + } + return "", 0 +} + func GitBranchStatus(d string) (BranchStatus, error) { branch, detached, err := currentBranch(d) if err != nil { @@ -240,38 +249,64 @@ func GitBranchStatus(d string) (BranchStatus, error) { if err != nil { return BranchStatus{}, err } + tipHash, tipUnix := tipFromLocalBranchLocation(locations) return BranchStatus{ - Branch: branch, - Detached: false, - Locations: locations, - LocalBranches: nil, + Branch: branch, + Detached: false, + LocalBranches: []LocalBranchRef{{ + Name: branch, + TipHash: tipHash, + TipUnix: tipUnix, + Current: true, + Locations: locations, + }}, }, nil } - var topLocations []BranchLocation for i := range locals { locs, err := computeBranchLocations(d, locals[i].Name, remotes) if err != nil { return BranchStatus{}, err } locals[i].Locations = locs + } + + var foundCurrent bool + for i := range locals { if locals[i].Current { - topLocations = locs + foundCurrent = true + break } } - - if topLocations == nil { - var err2 error - topLocations, err2 = computeBranchLocations(d, branch, remotes) + if !foundCurrent { + locs, err2 := computeBranchLocations(d, branch, remotes) if err2 != nil { return BranchStatus{}, err2 } + matched := false + for i := range locals { + if locals[i].Name == branch { + locals[i].Locations = locs + locals[i].Current = true + matched = true + break + } + } + if !matched { + tipHash, tipUnix := tipFromLocalBranchLocation(locs) + locals = append([]LocalBranchRef{{ + Name: branch, + TipHash: tipHash, + TipUnix: tipUnix, + Current: true, + Locations: locs, + }}, locals...) + } } return BranchStatus{ Branch: branch, Detached: false, - Locations: topLocations, LocalBranches: locals, }, nil } diff --git a/scanner/inclusion_test.go b/scanner/inclusion_test.go index 14f471b..7d1e09f 100644 --- a/scanner/inclusion_test.go +++ b/scanner/inclusion_test.go @@ -19,10 +19,13 @@ func TestRepoInclusionReasons_uncommitted(t *testing.T) { Path: g, Staging: 'M', Worktree: 'M', }}, }, - Branches: BranchStatus{Branch: "main", Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "a"}, - {Name: "origin", Exists: true, TipHash: "a"}, - }}, + Branches: BranchStatus{Branch: "main", LocalBranches: []LocalBranchRef{{ + Name: "main", Current: true, + Locations: []BranchLocation{ + {Name: "local", Exists: true, TipHash: "a"}, + {Name: "origin", Exists: true, TipHash: "a"}, + }, + }}}, } lines := RepoInclusionReasons(rs) if len(lines) < 1 || !strings.Contains(lines[0], "Uncommitted") { @@ -35,10 +38,13 @@ func TestRepoInclusionReasons_branchOnly(t *testing.T) { rs := RepoStatus{ Branches: BranchStatus{ Branch: "main", - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaa"}, - {Name: "origin", Exists: true, TipHash: "bbb", Incoming: 0, Outgoing: 1}, - }, + LocalBranches: []LocalBranchRef{{ + Name: "main", Current: true, + Locations: []BranchLocation{ + {Name: "local", Exists: true, TipHash: "aaa"}, + {Name: "origin", Exists: true, TipHash: "bbb", Incoming: 0, Outgoing: 1}, + }, + }}, }, } lines := RepoInclusionReasons(rs) diff --git a/scanner/scan.go b/scanner/scan.go index a8c0ce8..e9c5063 100644 --- a/scanner/scan.go +++ b/scanner/scan.go @@ -56,9 +56,8 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS for d := range repositories { eg.Go(func() error { - // About to run git status for this repo; set CurrentPath so the scan modal - // shows which directory is active (and keeps showing it through the rest - // of this iteration via the update after GitStatus returns). + // About to run StatusForRepo for this path; set CurrentPath so the scan modal + // shows which directory is active until this worker finishes and the UI updates. reportProgress(onProgress, ScanProgress{ ReposFound: int(found.Load()), ReposChecked: int(checked.Load()), @@ -72,9 +71,8 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS return err } n := checked.Add(1) - // Git status finished for this repo; advance ReposChecked and retain - // CurrentPath until the next channel receive so the path line does not - // flicker to empty while GitBranchStatus and filtering still run. + // Per-repo StatusForRepo finished; advance ReposChecked and retain + // CurrentPath until the next progress event so the path line does not flicker. reportProgress(onProgress, ScanProgress{ ReposFound: int(found.Load()), ReposChecked: int(n), diff --git a/scanner/types.go b/scanner/types.go index ebf12dd..16eb519 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -9,6 +9,10 @@ import ( "github.com/go-git/go-git/v5" ) +// RepoStatus aggregates one repository's working tree and branch metadata: +// parsed git status --porcelain (Porcelain), HEAD and local-branch layout with +// remote tips (Branches), and an embedded [git.Status] rebuilt from Porcelain +// for code that expects go-git's map form. type RepoStatus struct { git.Status @@ -16,10 +20,14 @@ type RepoStatus struct { Branches BranchStatus } +// BranchStatus is HEAD identity plus an ordered list of local branch refs and +// per-branch local vs same-named remote comparison rows. type BranchStatus struct { - Branch string - Detached bool - Locations []BranchLocation + // Branch is the checked-out branch short name, or when Detached the short + // HEAD object name (see git rev-parse --short HEAD). + Branch string + // Detached is true when HEAD is not on a branch (detached HEAD). + Detached bool // LocalBranches lists refs/heads in name order (from git for-each-ref). LocalBranches []LocalBranchRef } @@ -28,10 +36,16 @@ type BranchStatus struct { // Locations holds local vs same-named remote refs (refs/remotes//); // it is empty when detached or before GitBranchStatus fills it. type LocalBranchRef struct { - Name string - TipHash string - TipUnix int64 - Current bool + // Name is the short branch name (the refs/heads/* ref without the prefix). + Name string + // TipHash is the full object name of the local ref tip; TipUnix is the tip + // commit's committer date in Unix seconds (from branch listing / git show). + TipHash string + TipUnix int64 + // Current is true when this row is the checked-out branch. + Current bool + // Locations compares this local branch to same-named refs on each configured + // remote; see [BranchLocation]. Empty when detached or before branch scan fills it. Locations []BranchLocation } @@ -88,11 +102,29 @@ func (lb LocalBranchRef) IsLocalOnly() bool { return true } +// BranchLocation is one side of a local branch compared to same-named remotes: +// either the local ref (Name "local") or a configured remote's +// refs/remotes//. Populated by branch status scanning; stored in +// [LocalBranchRef.Locations]. The UI and helpers use it for tip hashes, +// ahead/behind counts (Incoming/Outgoing vs local), and mismatch detection. type BranchLocation struct { - Name string - Exists bool - TipHash string - TipUnix int64 + // Either "local" or the name of a configured remote. + Name string + + // Exists is true when this location's ref (refs/heads/ for "local", + // refs/remotes// otherwise) exists and resolves to a commit; + // false when the ref is missing. + Exists bool + + // TipHash is the full hex object name of this ref's tip commit when Exists; + // empty when not Exists. + TipHash string + // TipUnix is the tip commit's committer date in Unix seconds (from git show); + // zero when not Exists. + TipUnix int64 + // UniqueCount is commits reachable from this ref but not from any other + // Exists location for the same branch (local plus each remote in the scan). + // Zero when not Exists. UniqueCount int // Incoming/Outgoing compare this ref to the local branch ref only (remote // rows). Incoming is commits reachable from this remote but not local (+N); @@ -104,6 +136,21 @@ type BranchLocation struct { HistoriesUnrelated bool } +// CurrentBranchLocations returns local vs same-named remote rows for the +// checked-out branch (the [LocalBranchRef] with Current: true). Returns nil when +// detached or when no current row exists. +func (b *BranchStatus) CurrentBranchLocations() []BranchLocation { + if b == nil || b.Detached { + return nil + } + for i := range b.LocalBranches { + if b.LocalBranches[i].Current { + return b.LocalBranches[i].Locations + } + } + return nil +} + // HasLocalRemoteMismatch reports whether the current local branch differs from // any tracked remote location for the same branch name. A clean repo that is // only behind the remote (incoming commits, nothing to push) is not a mismatch. @@ -111,11 +158,15 @@ func (b *BranchStatus) HasLocalRemoteMismatch() bool { if b.Detached { return false } + locs := b.CurrentBranchLocations() + if len(locs) == 0 { + return false + } var local *BranchLocation - for i := range b.Locations { - if b.Locations[i].Name == "local" { - local = &b.Locations[i] + for i := range locs { + if locs[i].Name == "local" { + local = &locs[i] break } } @@ -124,8 +175,8 @@ func (b *BranchStatus) HasLocalRemoteMismatch() bool { } hasRemote := false - for i := range b.Locations { - loc := b.Locations[i] + for i := range locs { + loc := locs[i] if loc.Name == "local" { continue } @@ -146,6 +197,7 @@ func (b *BranchStatus) HasLocalRemoteMismatch() bool { // FilterLocalOnlyForConfig filters out local-only branches that // [Config.ShouldHideLocalOnlyBranch] matches unless [Config.AlwaysListBranch] applies. +// The checked-out branch is never removed so HEAD remote comparison stays available. func (b *BranchStatus) FilterLocalOnlyForConfig(c *Config) { refs := b.LocalBranches if c == nil || len(refs) == 0 { @@ -153,6 +205,10 @@ func (b *BranchStatus) FilterLocalOnlyForConfig(c *Config) { } out := make([]LocalBranchRef, 0) for _, lb := range refs { + if lb.Current { + out = append(out, lb) + continue + } if c.ShouldHideLocalOnlyBranch(lb) && !c.AlwaysListBranch(lb.Name) { continue } @@ -167,10 +223,14 @@ func (b *BranchStatus) LocalRemoteMismatchReasons() []string { if b.Detached { return nil } + locs := b.CurrentBranchLocations() + if len(locs) == 0 { + return nil + } var local *BranchLocation - for i := range b.Locations { - if b.Locations[i].Name == "local" { - local = &b.Locations[i] + for i := range locs { + if locs[i].Name == "local" { + local = &locs[i] break } } @@ -182,8 +242,8 @@ func (b *BranchStatus) LocalRemoteMismatchReasons() []string { branchName = "current branch" } hasRemote := false - for i := range b.Locations { - loc := b.Locations[i] + for i := range locs { + loc := locs[i] if loc.Name == "local" { continue } @@ -216,13 +276,18 @@ func (b *BranchStatus) LocalRemoteMismatchReasons() []string { return nil } +// PorcelainEntry is one parsed line of git status --porcelain (short format). type PorcelainEntry struct { - Staging git.StatusCode - Worktree git.StatusCode - Path string + // Staging and Worktree are the two status columns (index vs working tree). + Staging git.StatusCode + Worktree git.StatusCode + // Path is the file path, or the new path for a rename. + Path string + // OriginalPath is the old path for a rename; empty when not a rename. OriginalPath string } +// PorcelainStatus is the full porcelain parse for one repo (entry order matches git output). type PorcelainStatus struct { Entries []PorcelainEntry } @@ -238,12 +303,16 @@ func (p PorcelainStatus) ToGitStatus() git.Status { return st } -// ScanProgress reports coarse scan activity for UIs (discovery vs git status). +// ScanProgress reports coarse scan activity for UIs (discovery vs per-repo work). // ReposFound may increase while ReposChecked catches up; both match the final total when complete. type ScanProgress struct { - ReposFound int + // ReposFound is how many git repositories have been discovered so far. + ReposFound int + // ReposChecked is how many of those have finished StatusForRepo (porcelain, + // branch metadata, and config filtering), not git status alone. ReposChecked int - CurrentPath string + // CurrentPath is the path currently being processed (for status display). + CurrentPath string } type Config struct { diff --git a/scanner/types_test.go b/scanner/types_test.go index d586aa2..c48761d 100644 --- a/scanner/types_test.go +++ b/scanner/types_test.go @@ -78,59 +78,77 @@ func TestBranchStatusHasLocalRemoteMismatch(t *testing.T) { { name: "no remotes", in: BranchStatus{ - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "abc"}, - }, + LocalBranches: []LocalBranchRef{{ + Current: true, + Locations: []BranchLocation{ + {Name: "local", Exists: true, TipHash: "abc"}, + }, + }}, }, want: false, }, { name: "matching local and remote", in: BranchStatus{ - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "abc"}, - {Name: "origin", Exists: true, TipHash: "abc"}, - }, + LocalBranches: []LocalBranchRef{{ + Current: true, + Locations: []BranchLocation{ + {Name: "local", Exists: true, TipHash: "abc"}, + {Name: "origin", Exists: true, TipHash: "abc"}, + }, + }}, }, want: false, }, { name: "local ahead of remote", in: BranchStatus{ - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaa"}, - {Name: "origin", Exists: true, TipHash: "bbb", Incoming: 0, Outgoing: 1}, - }, + LocalBranches: []LocalBranchRef{{ + Current: true, + Locations: []BranchLocation{ + {Name: "local", Exists: true, TipHash: "aaa"}, + {Name: "origin", Exists: true, TipHash: "bbb", Incoming: 0, Outgoing: 1}, + }, + }}, }, want: true, }, { name: "remote ahead of local (behind)", in: BranchStatus{ - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaa"}, - {Name: "origin", Exists: true, TipHash: "bbb", Incoming: 2, Outgoing: 0}, - }, + LocalBranches: []LocalBranchRef{{ + Current: true, + Locations: []BranchLocation{ + {Name: "local", Exists: true, TipHash: "aaa"}, + {Name: "origin", Exists: true, TipHash: "bbb", Incoming: 2, Outgoing: 0}, + }, + }}, }, want: false, }, { name: "remote ahead but unrelated histories", in: BranchStatus{ - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaa"}, - {Name: "origin", Exists: true, TipHash: "bbb", HistoriesUnrelated: true, Incoming: 1, Outgoing: 0}, - }, + LocalBranches: []LocalBranchRef{{ + Current: true, + Locations: []BranchLocation{ + {Name: "local", Exists: true, TipHash: "aaa"}, + {Name: "origin", Exists: true, TipHash: "bbb", HistoriesUnrelated: true, Incoming: 1, Outgoing: 0}, + }, + }}, }, want: true, }, { name: "remote branch missing", in: BranchStatus{ - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaa"}, - {Name: "origin", Exists: false}, - }, + LocalBranches: []LocalBranchRef{{ + Current: true, + Locations: []BranchLocation{ + {Name: "local", Exists: true, TipHash: "aaa"}, + {Name: "origin", Exists: false}, + }, + }}, }, want: true, }, @@ -138,10 +156,13 @@ func TestBranchStatusHasLocalRemoteMismatch(t *testing.T) { name: "tips match but local has unique-only commits", in: BranchStatus{ Branch: "main", - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "abc", UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "abc"}, - }, + LocalBranches: []LocalBranchRef{{ + Current: true, + Locations: []BranchLocation{ + {Name: "local", Exists: true, TipHash: "abc", UniqueCount: 2}, + {Name: "origin", Exists: true, TipHash: "abc"}, + }, + }}, }, want: true, }, diff --git a/ui/status_layout.go b/ui/status_layout.go index bc38219..a0016b1 100644 --- a/ui/status_layout.go +++ b/ui/status_layout.go @@ -282,7 +282,7 @@ func statusColumns(totalWidth int) []table.Column { } } -// branchRowColumns sizes the branch pane: one row per local branch name. +// branchRowColumns sizes the branch pane columns for branch summary rows. // Account for Padding(0, 1) on every header and cell (see statusColumns). func branchRowColumns(totalWidth int) []table.Column { const cols = 4 @@ -306,13 +306,16 @@ func branchRowColumns(totalWidth int) []table.Column { } } -// branchRemoteSummary compresses local vs remote tips for the checked-out branch -// when BranchStatus.Locations is populated (e.g. no local heads listed). +// branchRemoteSummary compresses local vs remote tips for the checked-out branch. func branchRemoteSummary(b scanner.BranchStatus) string { - if b.Detached || len(b.Locations) == 0 { + if b.Detached { return "-" } - return branchRemoteSummaryFromLocations(b.Locations) + locs := b.CurrentBranchLocations() + if len(locs) == 0 { + return "-" + } + return branchRemoteSummaryFromLocations(locs) } func branchRemoteSummaryFromLocations(locations []scanner.BranchLocation) string { @@ -375,7 +378,8 @@ func sortLocalBranchesByTipNewestFirst(branches []scanner.LocalBranchRef) { }) } -// refreshBranchContent rebuilds the branch pane: one table row per local branch name. +// refreshBranchContent rebuilds the branch pane: one table row per local branch +// that the pane lists (tip mismatch vs remotes, local-only hide rules, and defaults). func (m *model) refreshBranchContent(totalWidth int) { cols := branchRowColumns(totalWidth) m.branchTable.SetColumns(cols) @@ -415,7 +419,7 @@ func (m *model) refreshBranchContent(totalWidth int) { remote := branchRemoteSummary(branch) tip := "-" when := "-" - for _, loc := range branch.Locations { + for _, loc := range branch.CurrentBranchLocations() { if loc.Name == "local" && loc.Exists { tip = shortHash(loc.TipHash) when = relativeTime(loc.TipUnix) diff --git a/ui/status_layout_test.go b/ui/status_layout_test.go index b426163..116af49 100644 --- a/ui/status_layout_test.go +++ b/ui/status_layout_test.go @@ -330,11 +330,6 @@ func TestRefreshBranchContentOneRowPerBranch(t *testing.T) { m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", - Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - {Name: "upstream", Exists: false}, - }, // Names sort opposite to recency so the test proves UI order is by tip time, not name. LocalBranches: []scanner.LocalBranchRef{ {Name: "aaa", TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, Current: true, Locations: []scanner.BranchLocation{ @@ -394,10 +389,6 @@ branches: m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", - Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - }, LocalBranches: []scanner.LocalBranchRef{ {Name: "aaa", TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, Current: true, Locations: []scanner.BranchLocation{ {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, @@ -456,10 +447,6 @@ branches: m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", - Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - }, LocalBranches: []scanner.LocalBranchRef{ {Name: "aaa", TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, Current: true, Locations: []scanner.BranchLocation{ {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, @@ -511,11 +498,6 @@ branches: m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", - Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - {Name: "upstream", Exists: false}, - }, LocalBranches: []scanner.LocalBranchRef{ {Name: "aaa", TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, Current: true, Locations: []scanner.BranchLocation{ {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, diff --git a/ui/update.go b/ui/update.go index f69fed9..a27dfe8 100644 --- a/ui/update.go +++ b/ui/update.go @@ -299,7 +299,7 @@ func (m *model) cycleFocus(forward bool) { } if !found { if cur == paneDiff { - // Mouse-focused Diff: Tab continues the main layout order (Branches → Diff → Log). + // Mouse-focused Diff is outside tabFocusCycle; Tab goes to Log, Shift+Tab to Branches. if forward { i = 3 // log } else { diff --git a/ui/view_test.go b/ui/view_test.go index 0b4a932..97dc160 100644 --- a/ui/view_test.go +++ b/ui/view_test.go @@ -67,11 +67,14 @@ func TestBranchTableViewFitsInnerWidth(t *testing.T) { m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "main", - Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - {Name: "upstream", Exists: false}, - }, + LocalBranches: []scanner.LocalBranchRef{{ + Name: "main", TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, Current: true, + Locations: []scanner.BranchLocation{ + {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, + {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, + {Name: "upstream", Exists: false}, + }, + }}, }, }) m.syncViewports() From f6352c0e83b71b8bb562a163850625b67b9456f2 Mon Sep 17 00:00:00 2001 From: Matt Vinall Date: Wed, 10 Jun 2026 10:52:48 +0100 Subject: [PATCH 2/3] fix: scan had both missing and extra entries --- .gitignore | 3 + README.md | 1 - main.go | 4 + main_test.go | 36 +++++++ report.go | 255 +++++++++++++++++++++++++++++++++++++++++++++++ scanner/types.go | 98 ++++++++++-------- 6 files changed, 356 insertions(+), 41 deletions(-) create mode 100644 report.go diff --git a/.gitignore b/.gitignore index dca1827..05bec15 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ settings.local.json dist/ dirtygit dirtygit.exe + +# generated files +report*.json* diff --git a/README.md b/README.md index 603c930..af0e1de 100644 --- a/README.md +++ b/README.md @@ -199,4 +199,3 @@ A few possibilities listed below, none of this is promised or scheduled. only as nested `.git` dirs. - **Configurable diff** — options such as ignore whitespace or word diff, driven from config, for the Diff pane. - **Safer delete housekeeping** — dry-run delete, or move to Trash on macOS instead of only recursive delete. - diff --git a/main.go b/main.go index 0e9a96f..045457d 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,10 @@ func main() { CommandNotFound: func(ctx context.Context, cmd *cli.Command, name string) { fmt.Printf("ERROR: Unknown command '%s'\n", name) }, + Commands: []*cli.Command{ + reportCommand(), + validateReportCommand(), + }, Flags: []cli.Flag{ &cli.StringFlag{ Name: "config", diff --git a/main_test.go b/main_test.go index 1b03abb..06965ad 100644 --- a/main_test.go +++ b/main_test.go @@ -1,13 +1,49 @@ package main import ( + "os" "strings" "testing" + + "github.com/boyvinall/dirtygit/scanner" ) +func expandEnvInConfig(config *scanner.Config) { + for i := range config.ScanDirs.Include { + config.ScanDirs.Include[i] = os.ExpandEnv(config.ScanDirs.Include[i]) + } + for i := range config.ScanDirs.Exclude { + config.ScanDirs.Exclude[i] = os.ExpandEnv(config.ScanDirs.Exclude[i]) + } +} + func TestGetDefaultConfigPathUsesHomeAndSuffix(t *testing.T) { p := getDefaultConfigPath() if !strings.HasSuffix(p, ".dirtygit.yml") { t.Fatalf("expected path to end with .dirtygit.yml, got %q", p) } } + +func TestValidateReport_Integration(t *testing.T) { + configPath := getDefaultConfigPath() + if _, err := os.Stat(configPath); err != nil { + t.Skipf("default config not found at %s", configPath) + } + + config, err := scanner.ParseConfigFile(configPath, defaultConfig) + if err != nil { + t.Fatalf("parse config: %v", err) + } + expandEnvInConfig(config) + + mgs, err := scanner.Scan(config) + if err != nil { + t.Fatalf("scan: %v", err) + } + + r := buildReport(config, mgs) + + for _, v := range validateReport(config, r) { + t.Error(v) + } +} diff --git a/report.go b/report.go new file mode 100644 index 0000000..cbdeb45 --- /dev/null +++ b/report.go @@ -0,0 +1,255 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/urfave/cli/v3" + + "github.com/boyvinall/dirtygit/scanner" +) + +// reportLocationEntry mirrors scanner.BranchLocation for JSON output. +type reportLocationEntry struct { + Name string `json:"name"` + Exists bool `json:"exists"` + TipHash string `json:"tip_hash"` + TipUnix int64 `json:"tip_unix"` + UniqueCount int `json:"unique_count"` + Incoming int `json:"incoming"` + Outgoing int `json:"outgoing"` + HistoriesUnrelated bool `json:"histories_unrelated"` +} + +// reportBranchEntry is one local branch row in the report. +type reportBranchEntry struct { + Name string `json:"name"` + // TipHash is the full object name of the local ref tip. + TipHash string `json:"tip_hash"` + TipUnix int64 `json:"tip_unix"` + Current bool `json:"current"` + // ShownInTUI is true when this branch appears in the TUI Branches pane for its + // repository (HasTipMismatchAcrossRemotes or listed under branches.default). + ShownInTUI bool `json:"shown_in_tui"` + // ExcludedByConfig is true when this branch was removed from the branch list by + // FilterLocalOnlyForConfig (branches.hidelocalonly.regex matched and the branch + // is not in branches.default). Such branches are never shown in the TUI. + ExcludedByConfig bool `json:"excluded_by_config"` + IsLocalOnly bool `json:"is_local_only"` + Locations []reportLocationEntry `json:"locations"` +} + +// reportFileEntry is one porcelain status entry for a file. +type reportFileEntry struct { + // Staging and Worktree are the single-character git status codes (e.g. "M", "A", "?"). + Staging string `json:"staging"` + Worktree string `json:"worktree"` + Path string `json:"path"` + OriginalPath string `json:"original_path"` +} + +// reportRepo is the per-repository section of the report. +type reportRepo struct { + Path string `json:"path"` + // InclusionReasons lists why this repository appears in the TUI (non-clean or + // local/remote mismatch). Mirrors scanner.RepoInclusionReasons. + InclusionReasons []string `json:"inclusion_reasons"` + IsClean bool `json:"is_clean"` + // Files are the uncommitted working-tree changes (porcelain entries). + Files []reportFileEntry `json:"files"` + // CurrentBranch is the checked-out branch short name, or the short HEAD hash when detached. + CurrentBranch string `json:"current_branch"` + Detached bool `json:"detached"` + // Branches lists all local branches including those excluded by config (see ExcludedByConfig). + Branches []reportBranchEntry `json:"branches"` +} + +// report is the top-level JSON structure for the report subcommand. +type report struct { + // Repos are the repositories shown in the TUI repository pane (dirty or diverged), + // in alphabetical order. + Repos []reportRepo `json:"repos"` +} + +func buildReport(config *scanner.Config, mgs *scanner.MultiGitStatus) report { + paths := mgs.SortedRepoPaths() + repos := make([]reportRepo, 0, len(paths)) + + for _, path := range paths { + rs, ok := mgs.Get(path) + if !ok { + continue + } + + // Files + files := make([]reportFileEntry, 0, len(rs.Porcelain.Entries)) + for _, e := range rs.Porcelain.Entries { + files = append(files, reportFileEntry{ + Staging: string(e.Staging), + Worktree: string(e.Worktree), + Path: e.Path, + OriginalPath: e.OriginalPath, + }) + } + + // Branches — get the unfiltered list so we can annotate ExcludedByConfig. + unfiltered, err := scanner.GitBranchStatus(path) + if err != nil { + // Fall back to the already-filtered branches from the scan result. + unfiltered = rs.Branches + } + + // Build a set of branch names that survived FilterLocalOnlyForConfig. + filteredSet := make(map[string]struct{}, len(rs.Branches.LocalBranches)) + for _, lb := range rs.Branches.LocalBranches { + filteredSet[lb.Name] = struct{}{} + } + + branches := make([]reportBranchEntry, 0, len(unfiltered.LocalBranches)) + for _, lb := range unfiltered.LocalBranches { + _, survived := filteredSet[lb.Name] + excludedByConfig := !survived + + always := config != nil && config.AlwaysListBranch(lb.Name) + shownInTUI := !excludedByConfig && (lb.HasTipMismatchAcrossRemotes() || always) + + locs := make([]reportLocationEntry, 0, len(lb.Locations)) + for _, loc := range lb.Locations { + locs = append(locs, reportLocationEntry{ + Name: loc.Name, + Exists: loc.Exists, + TipHash: loc.TipHash, + TipUnix: loc.TipUnix, + UniqueCount: loc.UniqueCount, + Incoming: loc.Incoming, + Outgoing: loc.Outgoing, + HistoriesUnrelated: loc.HistoriesUnrelated, + }) + } + + branches = append(branches, reportBranchEntry{ + Name: lb.Name, + TipHash: lb.TipHash, + TipUnix: lb.TipUnix, + Current: lb.Current, + ShownInTUI: shownInTUI, + ExcludedByConfig: excludedByConfig, + IsLocalOnly: lb.IsLocalOnly(), + Locations: locs, + }) + } + + repos = append(repos, reportRepo{ + Path: path, + InclusionReasons: scanner.RepoInclusionReasons(rs), + IsClean: rs.IsClean(), + Files: files, + CurrentBranch: rs.Branches.Branch, + Detached: rs.Branches.Detached, + Branches: branches, + }) + } + + return report{Repos: repos} +} + +func runReport(config *scanner.Config) error { + mgs, err := scanner.Scan(config) + if err != nil { + return err + } + + r := buildReport(config, mgs) + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(r) +} + +// validateReport checks a report for consistency against the given config. +// Each returned string is one violation message. +func validateReport(config *scanner.Config, r report) []string { + var violations []string + for _, repo := range r.Repos { + hasShown := false + for _, b := range repo.Branches { + if b.ShownInTUI { + hasShown = true + } + if b.ExcludedByConfig && b.ShownInTUI { + violations = append(violations, fmt.Sprintf("%s: branch %q is excluded by config but shown_in_tui=true", repo.Path, b.Name)) + } + if b.IsLocalOnly { + lb := scanner.LocalBranchRef{ + Name: b.Name, + Locations: []scanner.BranchLocation{{Name: "local", Exists: true}}, + } + if config.ShouldHideLocalOnlyBranch(lb) && !config.AlwaysListBranch(b.Name) && !b.ExcludedByConfig { + violations = append(violations, fmt.Sprintf("%s: branch %q matches hidelocalonly regex but excluded_by_config is not set", repo.Path, b.Name)) + } + } + } + if !hasShown { + violations = append(violations, fmt.Sprintf("%s: no branch has shown_in_tui=true", repo.Path)) + } + } + return violations +} + +func validateReportCommand() *cli.Command { + return &cli.Command{ + Name: "validate-report", + Usage: "Validate a JSON report file from the report command", + Hidden: true, + Action: func(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() != 1 { + return fmt.Errorf("expected exactly one argument: ") + } + config, err := scanner.ParseConfigFile(cmd.Root().String("config"), defaultConfig) + if err != nil { + return err + } + data, err := os.ReadFile(cmd.Args().First()) + if err != nil { + return err + } + var r report + if err := json.Unmarshal(data, &r); err != nil { + return fmt.Errorf("failed to parse report: %w", err) + } + violations := validateReport(config, r) + for _, v := range violations { + fmt.Fprintln(os.Stderr, v) + } + if len(violations) > 0 { + return cli.Exit("", 1) + } + return nil + }, + } +} + +func reportCommand() *cli.Command { + return &cli.Command{ + Name: "report", + Usage: "Print a JSON report of all dirty/diverged repositories (equivalent to TUI state)", + Action: func(ctx context.Context, cmd *cli.Command) error { + config, err := scanner.ParseConfigFile(cmd.Root().String("config"), defaultConfig) + if err != nil { + return err + } + if cmd.Args().Len() > 0 { + config.ScanDirs.Include = cmd.Args().Slice() + } + for i := range config.ScanDirs.Include { + config.ScanDirs.Include[i] = os.ExpandEnv(config.ScanDirs.Include[i]) + } + for i := range config.ScanDirs.Exclude { + config.ScanDirs.Exclude[i] = os.ExpandEnv(config.ScanDirs.Exclude[i]) + } + return runReport(config) + }, + } +} diff --git a/scanner/types.go b/scanner/types.go index 16eb519..5989e66 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -49,6 +49,40 @@ type LocalBranchRef struct { Locations []BranchLocation } +// BranchLocation is one side of a local branch compared to same-named remotes: +// either the local ref (Name "local") or a configured remote's +// refs/remotes//. Populated by branch status scanning; stored in +// [LocalBranchRef.Locations]. The UI and helpers use it for tip hashes, +// ahead/behind counts (Incoming/Outgoing vs local), and mismatch detection. +type BranchLocation struct { + // Either "local" or the name of a configured remote. + Name string + + // Exists is true when this location's ref (refs/heads/ for "local", + // refs/remotes// otherwise) exists and resolves to a commit; + // false when the ref is missing. + Exists bool + + // TipHash is the full hex object name of this ref's tip commit when Exists; + // empty when not Exists. + TipHash string + // TipUnix is the tip commit's committer date in Unix seconds (from git show); + // zero when not Exists. + TipUnix int64 + // UniqueCount is commits reachable from this ref but not from any other + // Exists location for the same branch (local plus each remote in the scan). + // Zero when not Exists. + UniqueCount int + // Incoming/Outgoing compare this ref to the local branch ref only (remote + // rows). Incoming is commits reachable from this remote but not local (+N); + // Outgoing is commits on local not reachable from this remote (UI: -M). + Incoming int + Outgoing int + // HistoriesUnrelated means git found no merge base between local and this + // remote tip; the UI shows "differs" instead of numeric deltas. + HistoriesUnrelated bool +} + // HasTipMismatchAcrossRemotes reports whether the branch should appear in the // branch pane: true when Locations is empty (e.g. detached), there are no // configured remotes, any same-named remote ref is missing, or any remote tip @@ -102,40 +136,6 @@ func (lb LocalBranchRef) IsLocalOnly() bool { return true } -// BranchLocation is one side of a local branch compared to same-named remotes: -// either the local ref (Name "local") or a configured remote's -// refs/remotes//. Populated by branch status scanning; stored in -// [LocalBranchRef.Locations]. The UI and helpers use it for tip hashes, -// ahead/behind counts (Incoming/Outgoing vs local), and mismatch detection. -type BranchLocation struct { - // Either "local" or the name of a configured remote. - Name string - - // Exists is true when this location's ref (refs/heads/ for "local", - // refs/remotes// otherwise) exists and resolves to a commit; - // false when the ref is missing. - Exists bool - - // TipHash is the full hex object name of this ref's tip commit when Exists; - // empty when not Exists. - TipHash string - // TipUnix is the tip commit's committer date in Unix seconds (from git show); - // zero when not Exists. - TipUnix int64 - // UniqueCount is commits reachable from this ref but not from any other - // Exists location for the same branch (local plus each remote in the scan). - // Zero when not Exists. - UniqueCount int - // Incoming/Outgoing compare this ref to the local branch ref only (remote - // rows). Incoming is commits reachable from this remote but not local (+N); - // Outgoing is commits on local not reachable from this remote (UI: -M). - Incoming int - Outgoing int - // HistoriesUnrelated means git found no merge base between local and this - // remote tip; the UI shows "differs" instead of numeric deltas. - HistoriesUnrelated bool -} - // CurrentBranchLocations returns local vs same-named remote rows for the // checked-out branch (the [LocalBranchRef] with Current: true). Returns nil when // detached or when no current row exists. @@ -158,7 +158,22 @@ func (b *BranchStatus) HasLocalRemoteMismatch() bool { if b.Detached { return false } - locs := b.CurrentBranchLocations() + + return len(b.GetMismatchedBranches()) > 0 +} + +func (b *BranchStatus) GetMismatchedBranches() []LocalBranchRef { + mismatchedBranches := []LocalBranchRef{} + for i := range b.LocalBranches { + if b.LocalBranches[i].IsMisMatchAcrossRemotes() { + mismatchedBranches = append(mismatchedBranches, b.LocalBranches[i]) + } + } + return mismatchedBranches +} + +func (lb *LocalBranchRef) IsMisMatchAcrossRemotes() bool { + locs := lb.Locations if len(locs) == 0 { return false } @@ -192,7 +207,10 @@ func (b *BranchStatus) HasLocalRemoteMismatch() bool { } } - return hasRemote && local.UniqueCount > 0 + if hasRemote && local.UniqueCount > 0 { + return true + } + return false } // FilterLocalOnlyForConfig filters out local-only branches that @@ -205,10 +223,10 @@ func (b *BranchStatus) FilterLocalOnlyForConfig(c *Config) { } out := make([]LocalBranchRef, 0) for _, lb := range refs { - if lb.Current { - out = append(out, lb) - continue - } + // if lb.Current { + // out = append(out, lb) + // continue + // } if c.ShouldHideLocalOnlyBranch(lb) && !c.AlwaysListBranch(lb.Name) { continue } From 38c53af8fa1627acf6e5db4df6a8d57729ba7ec7 Mon Sep 17 00:00:00 2001 From: Matt Vinall Date: Thu, 11 Jun 2026 05:59:16 +0100 Subject: [PATCH 3/3] refactor: wip --- README.md | 12 ++- main.go | 1 - report.go | 169 ++++++++++----------------------- scanner/branch_status.go | 60 ++++++------ scanner/config.go | 22 ----- scanner/find.go | 4 +- scanner/inclusion.go | 26 ------ scanner/inclusion_test.go | 54 ----------- scanner/scan.go | 16 ++-- scanner/scan_test.go | 28 ------ scanner/types.go | 115 +++++++---------------- scanner/types_test.go | 191 -------------------------------------- ui/app.go | 1 - ui/model.go | 3 +- ui/status_layout.go | 80 ++++++---------- ui/update.go | 22 ----- ui/update_view_test.go | 35 ------- ui/view.go | 33 ------- 18 files changed, 162 insertions(+), 710 deletions(-) delete mode 100644 scanner/inclusion.go delete mode 100644 scanner/inclusion_test.go diff --git a/README.md b/README.md index af0e1de..6f14e48 100644 --- a/README.md +++ b/README.md @@ -184,9 +184,17 @@ Run the following to see all available make targets: make help ``` -## Future +## TODO -A few possibilities listed below, none of this is promised or scheduled. +This stuff needs doing: + +- replace runGit and other git operations with a git package + +consider also: + +- ? unify view current working directory with other branches + +and maybe in future: - **More tool integration** - beyond `e` (edit) and `t` (terminal), maybe a git gui. And better configurability. - **Machine-readable output** — JSON or similar (flag or subcommand) for scripting and CI (e.g. exit non-zero if diff --git a/main.go b/main.go index 045457d..66b9642 100644 --- a/main.go +++ b/main.go @@ -37,7 +37,6 @@ func main() { }, Commands: []*cli.Command{ reportCommand(), - validateReportCommand(), }, Flags: []cli.Flag{ &cli.StringFlag{ diff --git a/report.go b/report.go index cbdeb45..86f3ee9 100644 --- a/report.go +++ b/report.go @@ -25,20 +25,12 @@ type reportLocationEntry struct { // reportBranchEntry is one local branch row in the report. type reportBranchEntry struct { - Name string `json:"name"` - // TipHash is the full object name of the local ref tip. - TipHash string `json:"tip_hash"` - TipUnix int64 `json:"tip_unix"` - Current bool `json:"current"` + scanner.LocalBranchRef + // ShownInTUI is true when this branch appears in the TUI Branches pane for its // repository (HasTipMismatchAcrossRemotes or listed under branches.default). - ShownInTUI bool `json:"shown_in_tui"` - // ExcludedByConfig is true when this branch was removed from the branch list by - // FilterLocalOnlyForConfig (branches.hidelocalonly.regex matched and the branch - // is not in branches.default). Such branches are never shown in the TUI. - ExcludedByConfig bool `json:"excluded_by_config"` - IsLocalOnly bool `json:"is_local_only"` - Locations []reportLocationEntry `json:"locations"` + ShownInTUI bool `json:"shown_in_tui"` + IsLocalOnly bool `json:"is_local_only"` } // reportFileEntry is one porcelain status entry for a file. @@ -52,11 +44,8 @@ type reportFileEntry struct { // reportRepo is the per-repository section of the report. type reportRepo struct { - Path string `json:"path"` - // InclusionReasons lists why this repository appears in the TUI (non-clean or - // local/remote mismatch). Mirrors scanner.RepoInclusionReasons. - InclusionReasons []string `json:"inclusion_reasons"` - IsClean bool `json:"is_clean"` + Path string `json:"path"` + IsClean bool `json:"is_clean"` // Files are the uncommitted working-tree changes (porcelain entries). Files []reportFileEntry `json:"files"` // CurrentBranch is the checked-out branch short name, or the short HEAD hash when detached. @@ -94,147 +83,83 @@ func buildReport(config *scanner.Config, mgs *scanner.MultiGitStatus) report { }) } - // Branches — get the unfiltered list so we can annotate ExcludedByConfig. - unfiltered, err := scanner.GitBranchStatus(path) - if err != nil { - // Fall back to the already-filtered branches from the scan result. - unfiltered = rs.Branches - } - // Build a set of branch names that survived FilterLocalOnlyForConfig. - filteredSet := make(map[string]struct{}, len(rs.Branches.LocalBranches)) - for _, lb := range rs.Branches.LocalBranches { + filteredSet := make(map[string]struct{}, len(rs.FilteredBranches)) + for _, lb := range rs.FilteredBranches { filteredSet[lb.Name] = struct{}{} } - branches := make([]reportBranchEntry, 0, len(unfiltered.LocalBranches)) - for _, lb := range unfiltered.LocalBranches { + branches := make([]reportBranchEntry, 0, len(rs.Branches.LocalBranches)) + for _, lb := range rs.Branches.LocalBranches { _, survived := filteredSet[lb.Name] - excludedByConfig := !survived - - always := config != nil && config.AlwaysListBranch(lb.Name) - shownInTUI := !excludedByConfig && (lb.HasTipMismatchAcrossRemotes() || always) - - locs := make([]reportLocationEntry, 0, len(lb.Locations)) - for _, loc := range lb.Locations { - locs = append(locs, reportLocationEntry{ - Name: loc.Name, - Exists: loc.Exists, - TipHash: loc.TipHash, - TipUnix: loc.TipUnix, - UniqueCount: loc.UniqueCount, - Incoming: loc.Incoming, - Outgoing: loc.Outgoing, - HistoriesUnrelated: loc.HistoriesUnrelated, - }) - } branches = append(branches, reportBranchEntry{ - Name: lb.Name, - TipHash: lb.TipHash, - TipUnix: lb.TipUnix, - Current: lb.Current, - ShownInTUI: shownInTUI, - ExcludedByConfig: excludedByConfig, - IsLocalOnly: lb.IsLocalOnly(), - Locations: locs, + LocalBranchRef: lb, + ShownInTUI: survived, + IsLocalOnly: lb.IsLocalOnly(), }) } repos = append(repos, reportRepo{ - Path: path, - InclusionReasons: scanner.RepoInclusionReasons(rs), - IsClean: rs.IsClean(), - Files: files, - CurrentBranch: rs.Branches.Branch, - Detached: rs.Branches.Detached, - Branches: branches, + Path: path, + IsClean: rs.IsClean(), + Files: files, + CurrentBranch: rs.Branches.Branch, + Detached: rs.Branches.Detached, + Branches: branches, }) } return report{Repos: repos} } -func runReport(config *scanner.Config) error { +func runReport(config *scanner.Config, outputFile string) error { mgs, err := scanner.Scan(config) if err != nil { return err } r := buildReport(config, mgs) + if outputFile != "" { + f, err := os.Create(outputFile) + if err != nil { + return err + } + defer f.Close() + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + err = enc.Encode(r) + if err != nil { + return err + } + } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(r) + printReportSummary(r) + return nil } -// validateReport checks a report for consistency against the given config. -// Each returned string is one violation message. -func validateReport(config *scanner.Config, r report) []string { - var violations []string +func printReportSummary(r report) { for _, repo := range r.Repos { - hasShown := false + fmt.Println(repo.Path) for _, b := range repo.Branches { if b.ShownInTUI { - hasShown = true - } - if b.ExcludedByConfig && b.ShownInTUI { - violations = append(violations, fmt.Sprintf("%s: branch %q is excluded by config but shown_in_tui=true", repo.Path, b.Name)) + fmt.Printf(" %s\n", b.GetDisplayName()) } - if b.IsLocalOnly { - lb := scanner.LocalBranchRef{ - Name: b.Name, - Locations: []scanner.BranchLocation{{Name: "local", Exists: true}}, - } - if config.ShouldHideLocalOnlyBranch(lb) && !config.AlwaysListBranch(b.Name) && !b.ExcludedByConfig { - violations = append(violations, fmt.Sprintf("%s: branch %q matches hidelocalonly regex but excluded_by_config is not set", repo.Path, b.Name)) - } - } - } - if !hasShown { - violations = append(violations, fmt.Sprintf("%s: no branch has shown_in_tui=true", repo.Path)) } } - return violations -} - -func validateReportCommand() *cli.Command { - return &cli.Command{ - Name: "validate-report", - Usage: "Validate a JSON report file from the report command", - Hidden: true, - Action: func(ctx context.Context, cmd *cli.Command) error { - if cmd.Args().Len() != 1 { - return fmt.Errorf("expected exactly one argument: ") - } - config, err := scanner.ParseConfigFile(cmd.Root().String("config"), defaultConfig) - if err != nil { - return err - } - data, err := os.ReadFile(cmd.Args().First()) - if err != nil { - return err - } - var r report - if err := json.Unmarshal(data, &r); err != nil { - return fmt.Errorf("failed to parse report: %w", err) - } - violations := validateReport(config, r) - for _, v := range violations { - fmt.Fprintln(os.Stderr, v) - } - if len(violations) > 0 { - return cli.Exit("", 1) - } - return nil - }, - } } func reportCommand() *cli.Command { return &cli.Command{ Name: "report", - Usage: "Print a JSON report of all dirty/diverged repositories (equivalent to TUI state)", + Usage: "Report on dirty/diverged repositories (equivalent to TUI state)", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "output-file", + Aliases: []string{"o"}, + Usage: "Write json report to this file", + }, + }, Action: func(ctx context.Context, cmd *cli.Command) error { config, err := scanner.ParseConfigFile(cmd.Root().String("config"), defaultConfig) if err != nil { @@ -249,7 +174,7 @@ func reportCommand() *cli.Command { for i := range config.ScanDirs.Exclude { config.ScanDirs.Exclude[i] = os.ExpandEnv(config.ScanDirs.Exclude[i]) } - return runReport(config) + return runReport(config, cmd.String("output-file")) }, } } diff --git a/scanner/branch_status.go b/scanner/branch_status.go index d83bb80..8608c30 100644 --- a/scanner/branch_status.go +++ b/scanner/branch_status.go @@ -9,9 +9,9 @@ import ( ) // haveMergeBase reports whether git finds a common ancestor for the two commits. -func haveMergeBase(d, commitA, commitB string) (bool, error) { +func haveMergeBase(dir, commitA, commitB string) (bool, error) { cmd := exec.Command("git", "merge-base", commitA, commitB) - cmd.Dir = d + cmd.Dir = dir err := cmd.Run() if err == nil { return true, nil @@ -24,8 +24,8 @@ func haveMergeBase(d, commitA, commitB string) (bool, error) { // listLocalBranches returns all refs/heads sorted by name. When detached is false, // currentName is the checked-out branch name and that row has Current set. -func listLocalBranches(d, currentName string, detached bool) ([]LocalBranchRef, error) { - out, err := runGit(d, "for-each-ref", "refs/heads", "--sort=refname", +func listLocalBranches(dir, currentName string, detached bool) ([]LocalBranchRef, error) { + out, err := runGit(dir, "for-each-ref", "refs/heads", "--sort=refname", "--format=%(refname:short)\t%(objectname)\t%(committerdate:unix)") if err != nil { return nil, err @@ -60,30 +60,30 @@ func listLocalBranches(d, currentName string, detached bool) ([]LocalBranchRef, return refs, nil } -func runGit(d string, args ...string) (string, error) { +func runGit(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) - cmd.Dir = d + cmd.Dir = dir out, err := cmd.Output() if err != nil { - return "", fmt.Errorf("%s: %w", d, err) + return "", fmt.Errorf("%s: %w", dir, err) } return strings.TrimSpace(string(out)), nil } -func currentBranch(d string) (name string, detached bool, err error) { - name, err = runGit(d, "symbolic-ref", "--quiet", "--short", "HEAD") +func currentBranch(dir string) (name string, detached bool, err error) { + name, err = runGit(dir, "symbolic-ref", "--quiet", "--short", "HEAD") if err == nil { return name, false, nil } - head, headErr := runGit(d, "rev-parse", "--short", "HEAD") + head, headErr := runGit(dir, "rev-parse", "--short", "HEAD") if headErr != nil { return "", false, headErr } return head, true, nil } -func listRemotes(d string) ([]string, error) { - out, err := runGit(d, "remote") +func listRemotes(dir string) ([]string, error) { + out, err := runGit(dir, "remote") if err != nil { return nil, err } @@ -95,8 +95,8 @@ func listRemotes(d string) ([]string, error) { return remotes, nil } -func refTip(d, ref string) (hash string, unix int64, exists bool, err error) { - out, err := runGit(d, "show", "-s", "--format=%H %ct", ref) +func refTip(dir, ref string) (hash string, unix int64, exists bool, err error) { + out, err := runGit(dir, "show", "-s", "--format=%H %ct", ref) if err != nil { return "", 0, false, nil } @@ -120,13 +120,13 @@ func branchLocationRef(locationName, branchName string) string { return "refs/remotes/" + locationName + "/" + branchName } -func uniqueCommitCount(d, ref string, otherRefs []string) (count int, err error) { +func uniqueCommitCount(dir, ref string, otherRefs []string) (count int, err error) { if len(otherRefs) == 0 { return 0, nil } args := []string{"rev-list", "--count", ref, "--not"} args = append(args, otherRefs...) - out, err := runGit(d, args...) + out, err := runGit(dir, args...) if err != nil { return 0, err } @@ -139,7 +139,7 @@ func uniqueCommitCount(d, ref string, otherRefs []string) (count int, err error) // computeBranchLocations compares refs/heads/ to refs/remotes// // for each configured remote and fills UniqueCount per location. -func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLocation, error) { +func computeBranchLocations(dir, branchName string, remotes []string) ([]BranchLocation, error) { locations := make([]BranchLocation, 0, 1+len(remotes)) locations = append(locations, BranchLocation{Name: "local"}) for _, remote := range remotes { @@ -148,7 +148,7 @@ func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLoc for i := range locations { ref := branchLocationRef(locations[i].Name, branchName) - hash, unix, exists, err := refTip(d, ref) + hash, unix, exists, err := refTip(dir, ref) if err != nil { return nil, err } @@ -174,7 +174,7 @@ func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLoc others = append(others, otherRef) } } - count, err := uniqueCommitCount(d, ref, others) + count, err := uniqueCommitCount(dir, ref, others) if err != nil { return nil, err } @@ -187,7 +187,7 @@ func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLoc if !locations[i].Exists { continue } - related, err := haveMergeBase(d, locations[0].TipHash, locations[i].TipHash) + related, err := haveMergeBase(dir, locations[0].TipHash, locations[i].TipHash) if err != nil { return nil, err } @@ -196,11 +196,11 @@ func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLoc continue } remoteRef := branchLocationRef(locations[i].Name, branchName) - incoming, err := uniqueCommitCount(d, remoteRef, []string{localRef}) + incoming, err := uniqueCommitCount(dir, remoteRef, []string{localRef}) if err != nil { return nil, err } - outgoing, err := uniqueCommitCount(d, localRef, []string{remoteRef}) + outgoing, err := uniqueCommitCount(dir, localRef, []string{remoteRef}) if err != nil { return nil, err } @@ -221,31 +221,31 @@ func tipFromLocalBranchLocation(locations []BranchLocation) (hash string, unix i return "", 0 } -func GitBranchStatus(d string) (BranchStatus, error) { - branch, detached, err := currentBranch(d) +func GitBranchStatus(dir string) (BranchStatus, error) { + branch, detached, err := currentBranch(dir) if err != nil { return BranchStatus{}, err } if detached { - locals, listErr := listLocalBranches(d, branch, true) + locals, listErr := listLocalBranches(dir, branch, true) if listErr != nil { return BranchStatus{}, listErr } return BranchStatus{Branch: branch, Detached: true, LocalBranches: locals}, nil } - remotes, err := listRemotes(d) + remotes, err := listRemotes(dir) if err != nil { return BranchStatus{}, err } - locals, err := listLocalBranches(d, branch, false) + locals, err := listLocalBranches(dir, branch, false) if err != nil { return BranchStatus{}, err } if len(locals) == 0 { - locations, err := computeBranchLocations(d, branch, remotes) + locations, err := computeBranchLocations(dir, branch, remotes) if err != nil { return BranchStatus{}, err } @@ -264,7 +264,7 @@ func GitBranchStatus(d string) (BranchStatus, error) { } for i := range locals { - locs, err := computeBranchLocations(d, locals[i].Name, remotes) + locs, err := computeBranchLocations(dir, locals[i].Name, remotes) if err != nil { return BranchStatus{}, err } @@ -279,7 +279,7 @@ func GitBranchStatus(d string) (BranchStatus, error) { } } if !foundCurrent { - locs, err2 := computeBranchLocations(d, branch, remotes) + locs, err2 := computeBranchLocations(dir, branch, remotes) if err2 != nil { return BranchStatus{}, err2 } diff --git a/scanner/config.go b/scanner/config.go index fce14e2..59b23b1 100644 --- a/scanner/config.go +++ b/scanner/config.go @@ -29,7 +29,6 @@ func ParseConfigFile(filename, defaultConfig string) (*Config, error) { if err := compileLocalOnlyHideRegexes(&config); err != nil { return nil, err } - prepareBranchDefaultSet(&config) return &config, nil } @@ -53,24 +52,3 @@ func compileLocalOnlyHideRegexes(c *Config) error { c.localOnlyHideCompiled = out return nil } - -func prepareBranchDefaultSet(c *Config) { - names := c.Branches.Default - if len(names) == 0 { - c.branchDefaultAlways = nil - return - } - m := make(map[string]struct{}, len(names)) - for _, n := range names { - n = strings.TrimSpace(n) - if n == "" { - continue - } - m[n] = struct{}{} - } - if len(m) == 0 { - c.branchDefaultAlways = nil - return - } - c.branchDefaultAlways = m -} diff --git a/scanner/find.go b/scanner/find.go index fd10cec..8025da6 100644 --- a/scanner/find.go +++ b/scanner/find.go @@ -57,7 +57,7 @@ func walkone(ctx context.Context, dir string, config *Config, results chan strin } if d.IsDir() { - log.Printf("path %s", path) + // log.Printf("path %s", path) } if slices.Contains(config.ScanDirs.Exclude, path) { @@ -77,7 +77,7 @@ func walkone(ctx context.Context, dir string, config *Config, results chan strin return metaErr } if ok { - log.Printf("git %s", path) + // log.Printf("git %s", path) repo := filepath.Dir(path) if onRepoFound != nil { onRepoFound(repo) diff --git a/scanner/inclusion.go b/scanner/inclusion.go deleted file mode 100644 index cd9426f..0000000 --- a/scanner/inclusion.go +++ /dev/null @@ -1,26 +0,0 @@ -package scanner - -import "fmt" - -// RepoInclusionReasons returns human-readable lines explaining why a repository -// is included in the result list, using the same rules as [ScanWithProgress] and -// [StatusForRepo] (uncommitted work and/or a local/remote branch mismatch for the -// current branch). -func RepoInclusionReasons(rs RepoStatus) []string { - var out []string - if !rs.IsClean() { - n := len(rs.Porcelain.Entries) - if n == 0 { - n = len(rs.Status) - } - if n == 0 { - out = append(out, "The working tree or index is not clean (uncommitted change).") - } else { - out = append(out, fmt.Sprintf("Uncommitted changes: %d path(s) in the working tree and/or index (after .dirtygit config filters).", n)) - } - } - if r := rs.Branches.LocalRemoteMismatchReasons(); len(r) > 0 { - out = append(out, r...) - } - return out -} diff --git a/scanner/inclusion_test.go b/scanner/inclusion_test.go deleted file mode 100644 index 7d1e09f..0000000 --- a/scanner/inclusion_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package scanner - -import ( - "strings" - "testing" - - "github.com/go-git/go-git/v5" -) - -func TestRepoInclusionReasons_uncommitted(t *testing.T) { - t.Parallel() - g := "f.go" - rs := RepoStatus{ - Status: git.Status{ - g: &git.FileStatus{Staging: 'M', Worktree: 'M'}, - }, - Porcelain: PorcelainStatus{ - Entries: []PorcelainEntry{{ - Path: g, Staging: 'M', Worktree: 'M', - }}, - }, - Branches: BranchStatus{Branch: "main", LocalBranches: []LocalBranchRef{{ - Name: "main", Current: true, - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "a"}, - {Name: "origin", Exists: true, TipHash: "a"}, - }, - }}}, - } - lines := RepoInclusionReasons(rs) - if len(lines) < 1 || !strings.Contains(lines[0], "Uncommitted") { - t.Fatalf("expected uncommitted first line, got %q", lines) - } -} - -func TestRepoInclusionReasons_branchOnly(t *testing.T) { - t.Parallel() - rs := RepoStatus{ - Branches: BranchStatus{ - Branch: "main", - LocalBranches: []LocalBranchRef{{ - Name: "main", Current: true, - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaa"}, - {Name: "origin", Exists: true, TipHash: "bbb", Incoming: 0, Outgoing: 1}, - }, - }}, - }, - } - lines := RepoInclusionReasons(rs) - if len(lines) != 1 || !strings.Contains(lines[0], "On remote") { - t.Fatalf("expected remote tip line, got %q", lines) - } -} diff --git a/scanner/scan.go b/scanner/scan.go index e9c5063..5988269 100644 --- a/scanner/scan.go +++ b/scanner/scan.go @@ -78,7 +78,7 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS ReposChecked: int(n), CurrentPath: d, }) - log.Println(d, statusDur) + // log.Println(d, statusDur) if include { atomic.AddInt64(&totalStatusDuration, statusDur.Nanoseconds()) @@ -93,8 +93,8 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS if statusErr != nil { return nil, statusErr } - log.Println("walkDuration:", w.duration) - log.Println("statusDuration:", time.Duration(atomic.LoadInt64(&totalStatusDuration))) + // log.Println("walkDuration:", w.duration) + // log.Println("statusDuration:", time.Duration(atomic.LoadInt64(&totalStatusDuration))) return results, w.err } @@ -117,13 +117,13 @@ func StatusForRepo(config *Config, dir string) (RepoStatus, bool, error) { if err != nil { log.Printf("branch status scan failed for %s: %v", dir, err) } - branches.FilterLocalOnlyForConfig(config) rs := RepoStatus{ - Status: st, - Porcelain: porcelain, - Branches: branches, + Status: st, + Porcelain: porcelain, + Branches: branches, + FilteredBranches: branches.Filter(config), } - include := !st.IsClean() || branches.HasLocalRemoteMismatch() + include := !st.IsClean() || rs.Branches.HasUnpushedChanges(config) return rs, include, nil } diff --git a/scanner/scan_test.go b/scanner/scan_test.go index 5ab7496..59ba9f2 100644 --- a/scanner/scan_test.go +++ b/scanner/scan_test.go @@ -180,34 +180,6 @@ branches: } } -func TestParseConfigFileDefaultBranches(t *testing.T) { - tmp := t.TempDir() - cfgPath := filepath.Join(tmp, "defaults.yml") - content := ` -scandirs: - include: - - /opt/repos -branches: - default: - - main - - master -` - if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { - t.Fatalf("write config: %v", err) - } - - cfg, err := ParseConfigFile(cfgPath, "") - if err != nil { - t.Fatalf("ParseConfigFile() error = %v", err) - } - if !cfg.AlwaysListBranch("main") || !cfg.AlwaysListBranch("master") { - t.Fatalf("AlwaysListBranch: main=%v master=%v", cfg.AlwaysListBranch("main"), cfg.AlwaysListBranch("master")) - } - if cfg.AlwaysListBranch("develop") { - t.Fatal("AlwaysListBranch(develop) should be false") - } -} - func TestStatusForRepo(t *testing.T) { tmp := t.TempDir() gitMinimalInit(t, tmp) diff --git a/scanner/types.go b/scanner/types.go index 5989e66..25d5993 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -16,8 +16,9 @@ import ( type RepoStatus struct { git.Status - Porcelain PorcelainStatus - Branches BranchStatus + Porcelain PorcelainStatus + Branches BranchStatus + FilteredBranches []LocalBranchRef } // BranchStatus is HEAD identity plus an ordered list of local branch refs and @@ -49,6 +50,13 @@ type LocalBranchRef struct { Locations []BranchLocation } +func (lbr *LocalBranchRef) GetDisplayName() string { + if lbr.Current { + return "*" + lbr.Name + } + return lbr.Name +} + // BranchLocation is one side of a local branch compared to same-named remotes: // either the local ref (Name "local") or a configured remote's // refs/remotes//. Populated by branch status scanning; stored in @@ -83,39 +91,6 @@ type BranchLocation struct { HistoriesUnrelated bool } -// HasTipMismatchAcrossRemotes reports whether the branch should appear in the -// branch pane: true when Locations is empty (e.g. detached), there are no -// configured remotes, any same-named remote ref is missing, or any remote tip -// differs from the local tip. False only when every remote has the ref and each -// tip matches local. -func (lb LocalBranchRef) HasTipMismatchAcrossRemotes() bool { - if len(lb.Locations) == 0 { - return true - } - var local *BranchLocation - for i := range lb.Locations { - if lb.Locations[i].Name == "local" { - local = &lb.Locations[i] - break - } - } - if local == nil || !local.Exists { - return true - } - hasRemote := false - for i := range lb.Locations { - loc := lb.Locations[i] - if loc.Name == "local" { - continue - } - hasRemote = true - if !loc.Exists || loc.TipHash != local.TipHash { - return true - } - } - return !hasRemote -} - // IsLocalOnly reports whether no configured remote has a same-named branch ref // (refs/remotes// missing for every remote). Repositories with no // remotes still populate only the local slot, which counts as local-only here. @@ -151,28 +126,20 @@ func (b *BranchStatus) CurrentBranchLocations() []BranchLocation { return nil } -// HasLocalRemoteMismatch reports whether the current local branch differs from -// any tracked remote location for the same branch name. A clean repo that is -// only behind the remote (incoming commits, nothing to push) is not a mismatch. -func (b *BranchStatus) HasLocalRemoteMismatch() bool { - if b.Detached { - return false - } - - return len(b.GetMismatchedBranches()) > 0 -} - -func (b *BranchStatus) GetMismatchedBranches() []LocalBranchRef { - mismatchedBranches := []LocalBranchRef{} - for i := range b.LocalBranches { - if b.LocalBranches[i].IsMisMatchAcrossRemotes() { - mismatchedBranches = append(mismatchedBranches, b.LocalBranches[i]) +func (b *BranchStatus) HasUnpushedChanges(c *Config) bool { + for _, lb := range b.LocalBranches { + if c.ShouldHideLocalOnlyBranch(lb) { + continue + } + if lb.HasUnpushedChanges() { + return true } } - return mismatchedBranches + return false } -func (lb *LocalBranchRef) IsMisMatchAcrossRemotes() bool { +// HasUnpushedChanges reports whether the local branch has any commits not on any of the remotes, +func (lb *LocalBranchRef) HasUnpushedChanges() bool { locs := lb.Locations if len(locs) == 0 { return false @@ -213,26 +180,25 @@ func (lb *LocalBranchRef) IsMisMatchAcrossRemotes() bool { return false } -// FilterLocalOnlyForConfig filters out local-only branches that -// [Config.ShouldHideLocalOnlyBranch] matches unless [Config.AlwaysListBranch] applies. +// Filter filters out local-only branches that +// [Config.ShouldHideLocalOnlyBranch] matches. // The checked-out branch is never removed so HEAD remote comparison stays available. -func (b *BranchStatus) FilterLocalOnlyForConfig(c *Config) { - refs := b.LocalBranches - if c == nil || len(refs) == 0 { - return - } +func (b *BranchStatus) Filter(c *Config) []LocalBranchRef { out := make([]LocalBranchRef, 0) - for _, lb := range refs { - // if lb.Current { - // out = append(out, lb) - // continue - // } - if c.ShouldHideLocalOnlyBranch(lb) && !c.AlwaysListBranch(lb.Name) { + for _, lb := range b.LocalBranches { + if lb.Current { + out = append(out, lb) + continue + } + if !lb.HasUnpushedChanges() { + continue + } + if c.ShouldHideLocalOnlyBranch(lb) { continue } out = append(out, lb) } - b.LocalBranches = out + return out } // LocalRemoteMismatchReasons returns a short line explaining why @@ -360,13 +326,15 @@ type Config struct { Command []string `yaml:"command"` } `yaml:"edit"` localOnlyHideCompiled []*regexp.Regexp - branchDefaultAlways map[string]struct{} } // ShouldHideLocalOnlyBranch returns true when lb is local-only (see // LocalBranchRef.IsLocalOnly) and its short branch name matches any pattern in // branches.hidelocalonly.regex. func (c *Config) ShouldHideLocalOnlyBranch(lb LocalBranchRef) bool { + if c == nil { + return false + } if len(c.localOnlyHideCompiled) == 0 { return false } @@ -381,17 +349,6 @@ func (c *Config) ShouldHideLocalOnlyBranch(lb LocalBranchRef) bool { return false } -// AlwaysListBranch reports whether name is listed under branches.default -// (after trim); those branches are listed in the pane whenever they exist -// locally, regardless of remote tip agreement. -func (c *Config) AlwaysListBranch(name string) bool { - if len(c.branchDefaultAlways) == 0 { - return false - } - _, ok := c.branchDefaultAlways[name] - return ok -} - const editRepoPlaceholder = "{repo}" // EditArgv returns the argv for opening absRepo in an external editor or IDE. diff --git a/scanner/types_test.go b/scanner/types_test.go index c48761d..d0c6fc9 100644 --- a/scanner/types_test.go +++ b/scanner/types_test.go @@ -60,197 +60,6 @@ func TestConfigEditArgv(t *testing.T) { }) } -func TestBranchStatusHasLocalRemoteMismatch(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - in BranchStatus - want bool - }{ - { - name: "detached head", - in: BranchStatus{ - Detached: true, - }, - want: false, - }, - { - name: "no remotes", - in: BranchStatus{ - LocalBranches: []LocalBranchRef{{ - Current: true, - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "abc"}, - }, - }}, - }, - want: false, - }, - { - name: "matching local and remote", - in: BranchStatus{ - LocalBranches: []LocalBranchRef{{ - Current: true, - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "abc"}, - {Name: "origin", Exists: true, TipHash: "abc"}, - }, - }}, - }, - want: false, - }, - { - name: "local ahead of remote", - in: BranchStatus{ - LocalBranches: []LocalBranchRef{{ - Current: true, - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaa"}, - {Name: "origin", Exists: true, TipHash: "bbb", Incoming: 0, Outgoing: 1}, - }, - }}, - }, - want: true, - }, - { - name: "remote ahead of local (behind)", - in: BranchStatus{ - LocalBranches: []LocalBranchRef{{ - Current: true, - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaa"}, - {Name: "origin", Exists: true, TipHash: "bbb", Incoming: 2, Outgoing: 0}, - }, - }}, - }, - want: false, - }, - { - name: "remote ahead but unrelated histories", - in: BranchStatus{ - LocalBranches: []LocalBranchRef{{ - Current: true, - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaa"}, - {Name: "origin", Exists: true, TipHash: "bbb", HistoriesUnrelated: true, Incoming: 1, Outgoing: 0}, - }, - }}, - }, - want: true, - }, - { - name: "remote branch missing", - in: BranchStatus{ - LocalBranches: []LocalBranchRef{{ - Current: true, - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaa"}, - {Name: "origin", Exists: false}, - }, - }}, - }, - want: true, - }, - { - name: "tips match but local has unique-only commits", - in: BranchStatus{ - Branch: "main", - LocalBranches: []LocalBranchRef{{ - Current: true, - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "abc", UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "abc"}, - }, - }}, - }, - want: true, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if got := tt.in.HasLocalRemoteMismatch(); got != tt.want { - t.Fatalf("HasLocalRemoteMismatch() = %v, want %v", got, tt.want) - } - nonempty := len(tt.in.LocalRemoteMismatchReasons()) > 0 - if nonempty != tt.want { - t.Fatalf("len(LocalRemoteMismatchReasons())>0 = %v, want %v, reasons=%#v", nonempty, tt.want, tt.in.LocalRemoteMismatchReasons()) - } - }) - } -} - -func TestLocalBranchRefHasTipMismatchAcrossRemotes(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - lb LocalBranchRef - want bool - }{ - { - name: "empty locations", - lb: LocalBranchRef{Name: "main"}, - want: true, - }, - { - name: "no remotes in locations", - lb: LocalBranchRef{ - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "abc"}, - }, - }, - want: true, - }, - { - name: "all remotes match local", - lb: LocalBranchRef{ - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "abc"}, - {Name: "origin", Exists: true, TipHash: "abc"}, - {Name: "fork", Exists: true, TipHash: "abc"}, - }, - }, - want: false, - }, - { - name: "one remote differs", - lb: LocalBranchRef{ - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "abc"}, - {Name: "origin", Exists: true, TipHash: "abc"}, - {Name: "fork", Exists: true, TipHash: "def"}, - }, - }, - want: true, - }, - { - name: "remote ref missing", - lb: LocalBranchRef{ - Locations: []BranchLocation{ - {Name: "local", Exists: true, TipHash: "abc"}, - {Name: "origin", Exists: true, TipHash: "abc"}, - {Name: "fork", Exists: false}, - }, - }, - want: true, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if got := tt.lb.HasTipMismatchAcrossRemotes(); got != tt.want { - t.Fatalf("HasTipMismatchAcrossRemotes() = %v, want %v", got, tt.want) - } - }) - } -} - func TestLocalBranchRefIsLocalOnly(t *testing.T) { t.Parallel() diff --git a/ui/app.go b/ui/app.go index 97d1b8a..d4aae02 100644 --- a/ui/app.go +++ b/ui/app.go @@ -61,7 +61,6 @@ func (m *model) beginScan() tea.Cmd { return nil } m.err = nil - m.whyRepoOpen = false m.deleteRepoConfirmOpen = false m.deleteStatusFileConfirmOpen = false m.deleteStatusFilePendingRel = "" diff --git a/ui/model.go b/ui/model.go index 4fd8f47..0f79985 100644 --- a/ui/model.go +++ b/ui/model.go @@ -112,8 +112,7 @@ type model struct { err error - helpOpen bool - whyRepoOpen bool + helpOpen bool // deleteRepoConfirmOpen shows the recursive-delete confirmation for the selected repository path. deleteRepoConfirmOpen bool // deleteStatusFileConfirmOpen asks before deleting the selected status path from disk. diff --git a/ui/status_layout.go b/ui/status_layout.go index a0016b1..27f8b95 100644 --- a/ui/status_layout.go +++ b/ui/status_layout.go @@ -391,68 +391,44 @@ func (m *model) refreshBranchContent(totalWidth int) { m.branchTable.SetHeight(layoutMinBodyLines) return } - branch := st.Branches - - if branch.Detached { - locals := append([]scanner.LocalBranchRef(nil), branch.LocalBranches...) - sortLocalBranchesByTipNewestFirst(locals) - rows := make([]table.Row, 0, 1+len(locals)) - rows = append(rows, table.Row{"(detached HEAD)", shortHash(branch.Branch), "-", "-"}) - for _, lb := range locals { - always := m.config != nil && m.config.AlwaysListBranch(lb.Name) - if m.config != nil && m.config.ShouldHideLocalOnlyBranch(lb) && !always { - continue - } - rows = append(rows, table.Row{ - lb.Name, - shortHash(lb.TipHash), - relativeTime(lb.TipUnix), - "-", - }) - } - m.branchTable.SetRows(rows) - m.branchTable.SetHeight(max(4, len(rows)+1)) - return - } - - if len(branch.LocalBranches) == 0 { - remote := branchRemoteSummary(branch) - tip := "-" - when := "-" - for _, loc := range branch.CurrentBranchLocations() { - if loc.Name == "local" && loc.Exists { - tip = shortHash(loc.TipHash) - when = relativeTime(loc.TipUnix) - break - } - } - m.branchTable.SetRows([]table.Row{{branch.Branch, tip, when, remote}}) - m.branchTable.SetHeight(4) - return - } - locals := append([]scanner.LocalBranchRef(nil), branch.LocalBranches...) + // branch := st.Branches + // if branch.Detached { + // locals := append([]scanner.LocalBranchRef(nil), branch.LocalBranches...) + // sortLocalBranchesByTipNewestFirst(locals) + // rows := make([]table.Row, 0, 1+len(locals)) + // rows = append(rows, table.Row{"(detached HEAD)", shortHash(branch.Branch), "-", "-"}) + // for _, lb := range locals { + // rows = append(rows, table.Row{ + // lb.Name, + // shortHash(lb.TipHash), + // relativeTime(lb.TipUnix), + // "-", + // }) + // } + // m.branchTable.SetRows(rows) + // m.branchTable.SetHeight(max(4, len(rows)+1)) + // return + // } + + // create a deep copy and sort it + locals := append([]scanner.LocalBranchRef(nil), st.FilteredBranches...) sortLocalBranchesByTipNewestFirst(locals) + + // show only the dirty branches in the UI rows := make([]table.Row, 0, len(locals)) for _, lb := range locals { - always := m.config != nil && m.config.AlwaysListBranch(lb.Name) - if m.config != nil && m.config.ShouldHideLocalOnlyBranch(lb) && !always { - continue - } - if !lb.HasTipMismatchAcrossRemotes() && !always { - continue - } + // TODO: check detached head remote := branchRemoteSummaryFromLocations(lb.Locations) + rows = append(rows, table.Row{ - lb.Name, + lb.GetDisplayName(), shortHash(lb.TipHash), relativeTime(lb.TipUnix), remote, }) } - if len(rows) == 0 { - rows = []table.Row{{"(in sync with remotes)", "-", "-", "-"}} - } + m.branchTable.SetRows(rows) m.branchTable.SetHeight(max(4, len(rows)+1)) } @@ -605,7 +581,7 @@ func (m *model) repoPaneReady() bool { // interactiveAppReady is true when the main TUI (not a modal) is on screen and idle. func (m *model) interactiveAppReady() bool { return !m.helpOpen && !m.deleteRepoConfirmOpen && !m.deleteStatusFileConfirmOpen && - !m.checkoutStatusFileConfirmOpen && !m.whyRepoOpen && !m.scanning && m.err == nil + !m.checkoutStatusFileConfirmOpen && !m.scanning && m.err == nil } // mouseFocusClickReady is true when left-click to change pane focus is allowed. diff --git a/ui/update.go b/ui/update.go index a27dfe8..2682893 100644 --- a/ui/update.go +++ b/ui/update.go @@ -99,19 +99,6 @@ func (m *model) handleHelpOverlayKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } -// handleWhyRepoOverlayKey processes keys while the "why listed" modal is open. -func (m *model) handleWhyRepoOverlayKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "esc", "w": - m.whyRepoOpen = false - return m, nil - default: - return m, nil - } -} - // handleDeleteRepoConfirmKey processes keys while the delete-directory confirmation is open. func (m *model) handleDeleteRepoConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { @@ -367,12 +354,6 @@ func (m *model) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { case "t": m.openTerminalInCurrentRepo() return m, nil, true - case "w": - if m.repoPaneReady() { - m.whyRepoOpen = true - return m, nil, true - } - return m, nil, false case "D": if path, ok := m.selectedStatusPathForOps(); ok { m.deleteStatusFileConfirmOpen = true @@ -604,9 +585,6 @@ func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.checkoutStatusFileConfirmOpen { return m.handleCheckoutStatusFileConfirmKey(msg) } - if m.whyRepoOpen { - return m.handleWhyRepoOverlayKey(msg) - } if m.scanning { return m.handleScanningKey(msg) } diff --git a/ui/update_view_test.go b/ui/update_view_test.go index 0b6c6c4..ba82732 100644 --- a/ui/update_view_test.go +++ b/ui/update_view_test.go @@ -382,41 +382,6 @@ func TestHandleSpinnerTickWhenScanning(t *testing.T) { } } -// TestWhyInclusionWKey checks that w opens the inclusion modal from the repository pane and Esc closes it. -func TestWhyInclusionWKey(t *testing.T) { - m := newTestModel() - m.width = 100 - m.height = 30 - m.focus = paneRepo - m.repoList = []string{"/r"} - m.cursor = 0 - m.repositories.Set("/r", scanner.RepoStatus{}) - - _, _, handled := m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) - if !handled { - t.Fatal("w should be handled in repo pane") - } - if !m.whyRepoOpen { - t.Fatal("w should open why-inclusion modal") - } - - s := m.renderWhyInclusionOverlay() - if !strings.Contains(s, "Why is this repository") { - t.Fatalf("modal should show title, got: %q", s) - } - - mod, _ := m.handleWhyRepoOverlayKey(tea.KeyMsg{Type: tea.KeyEsc}) - if mod.(*model).whyRepoOpen { - t.Fatal("esc should close why-inclusion modal") - } - - m.focus = paneStatus - _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) - if handled { - t.Fatal("w with status pane focused should not be handled as command (pass through to navigation)") - } -} - // TestDeleteRepoDKey checks D opens the delete confirm modal, default highlights No, and // successful confirmation removes the path from disk and the repo list. func TestDeleteRepoDKey(t *testing.T) { diff --git a/ui/view.go b/ui/view.go index 594c17c..f7b7d0b 100644 --- a/ui/view.go +++ b/ui/view.go @@ -6,8 +6,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - - "github.com/boyvinall/dirtygit/scanner" ) // placeCenteredDimModal centers content on the full terminal with a dim tiled backdrop. @@ -147,34 +145,6 @@ func (m *model) helpPanel() string { return m.framedBlock(paneRepo, w, h, "Keyboard shortcuts", padded) } -// renderWhyInclusionOverlay shows why the selected repository appears in the list. -func (m *model) renderWhyInclusionOverlay() string { - repo := m.currentRepo() - boxW := min(m.width-layoutModalSideGutter, layoutWhyAndConfirmModalMaxBox) - if boxW < layoutModalBoxMinWidth { - boxW = min(m.width-2, layoutModalBoxMinWidth) - } - innerW := max(layoutMinInnerContentWidth, boxW-layoutModalSideGutter) - if repo == "" { - return m.placeCenteredDimModal(roundedModal(boxW).Render("No repository selected.")) - } - rs, ok := m.repositories.Get(repo) - if !ok { - return m.placeCenteredDimModal(roundedModal(boxW).Render("No status data for this path.")) - } - lines := scanner.RepoInclusionReasons(rs) - if len(lines) == 0 { - lines = []string{"No inclusion reason (unexpected for a listed repository)."} - } - reasons := strings.Join(lines, "\n\n") - wrapped := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW).Render(reasons) - t := styleBold.Render("Why is this repository listed?") - sub := styleDim.Render(truncateASCII(repo, innerW)) - foot := styleDim.Render("w or Esc to close") - inner := strings.Join([]string{t, "", sub, "", wrapped, "", foot}, "\n") - return m.placeCenteredDimModal(roundedModal(boxW).Render(inner)) -} - // renderDeleteRepoConfirmOverlay asks whether to recursively delete the selected repository directory. func (m *model) renderDeleteRepoConfirmOverlay() string { repo := m.currentRepo() @@ -451,9 +421,6 @@ func (m *model) View() string { if m.checkoutStatusFileConfirmOpen { return m.renderCheckoutStatusFileConfirmOverlay() } - if m.whyRepoOpen { - return m.renderWhyInclusionOverlay() - } if m.height < layoutMinTermHeight { return styleErr.Render(fmt.Sprintf("Need bigger screen (min height %d).", layoutMinTermHeight)) }