From 8dd041e7e530ceab5ad498c92a556548145e1484 Mon Sep 17 00:00:00 2001 From: Matt Vinall Date: Fri, 1 May 2026 06:14:38 +0100 Subject: [PATCH] feat: much faster scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since originally written, the stdlib filepath.Walk has significantly improved, and is actually faster, so there’s no need for an additional Dependency. Also use goroutines for the git status checks, this makes a huge difference to the scan speed. --- README.md | 2 - go.mod | 1 - go.sum | 2 - scanner/find.go | 138 ++++++++++++++++++++----------- scanner/multi_git_status.go | 91 ++++++++++++++++++++ scanner/scan.go | 93 +++++++++++++-------- scanner/scan_integration_test.go | 29 +++---- scanner/types.go | 2 - ui/app.go | 1 + ui/gitops.go | 6 +- ui/model.go | 4 +- ui/mouse_line_select_test.go | 30 ++++--- ui/status_layout.go | 14 +--- ui/status_layout_test.go | 38 ++++----- ui/update.go | 6 +- ui/update_view_test.go | 12 ++- ui/view.go | 2 +- ui/view_test.go | 4 +- 18 files changed, 301 insertions(+), 174 deletions(-) create mode 100644 scanner/multi_git_status.go diff --git a/README.md b/README.md index b8de8b0..7938e2a 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,5 @@ A few possibilities listed below, none of this is promised or scheduled. “why listed” overlay. - **Submodules and worktrees** — scan or label linked worktrees and submodules explicitly instead of treating them only as nested `.git` dirs. -- **Parallel scan** — configurable worker count for status/branch checks, plus clearer cancel behaviour while a - scan is running. - **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/go.mod b/go.mod index 1b60e8e..474a14c 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/go-git/go-git/v5 v5.18.0 - github.com/karrick/godirwalk v1.17.0 github.com/mitchellh/go-homedir v1.1.0 github.com/muesli/termenv v0.16.0 github.com/urfave/cli/v3 v3.8.0 diff --git a/go.sum b/go.sum index 9c54cc7..e2e6082 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/karrick/godirwalk v1.17.0 h1:b4kY7nqDdioR/6qnbHQyDvmA17u5G1cZ6J+CZXwSWoI= -github.com/karrick/godirwalk v1.17.0/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= diff --git a/scanner/find.go b/scanner/find.go index 5d46698..33caf73 100644 --- a/scanner/find.go +++ b/scanner/find.go @@ -2,12 +2,14 @@ package scanner import ( "context" + "errors" + "io/fs" "log" "os" "path/filepath" "slices" + "syscall" - "github.com/karrick/godirwalk" "golang.org/x/sync/errgroup" ) @@ -15,67 +17,105 @@ func skip(needle string, haystack []string) bool { return slices.Contains(haystack, needle) } +func isGitMetadataDir(path string, d fs.DirEntry) (bool, error) { + if d.Name() != ".git" { + return false, nil + } + if d.IsDir() { + return true, nil + } + if d.Type()&os.ModeSymlink != 0 { + fi, err := os.Stat(path) + if err != nil { + return false, err + } + return fi.IsDir(), nil + } + return false, nil +} + // walkone descends a single directory tree looking for git repos. // onRepoFound is called for each discovered repo (may be concurrent across roots); nil is safe. func walkone(ctx context.Context, dir string, config *Config, results chan string, onRepoFound func(string)) error { - err := godirwalk.Walk(dir, &godirwalk.Options{ - Unsorted: true, - ScratchBuffer: make([]byte, godirwalk.MinimumScratchBufferSize), - FollowSymbolicLinks: config.FollowSymlinks, - ErrorCallback: func(path string, err error) godirwalk.ErrorAction { - patherr, ok := err.(*os.PathError) - if ok { - switch patherr.Unwrap().Error() { - case "no such file or directory": - // might be symlink pointing to non-existent file - return godirwalk.SkipNode - - case "too many levels of symbolic links": - // skip invalid symlinks - return godirwalk.SkipNode - } + var walkDirFn fs.WalkDirFunc + walkDirFn = func(path string, d fs.DirEntry, err error) error { + if err != nil { + var pathErr *os.PathError + if errors.As(err, &pathErr) && errors.Is(pathErr.Err, os.ErrNotExist) { + return nil + } + if errors.Is(err, os.ErrNotExist) { + return nil + } + if errors.Is(err, syscall.ELOOP) { + return nil } log.Printf("ERROR: %s: %v", path, err) - return godirwalk.Halt - }, - Callback: func(path string, ent *godirwalk.Dirent) error { + return err + } - // early exit? + select { + case <-ctx.Done(): + return filepath.SkipDir + default: + } - select { - case <-ctx.Done(): - return filepath.SkipDir - default: - } - - // process all the SkipThis rules first + if skip(path, config.ScanDirs.Exclude) { + return filepath.SkipDir + } - if skip(path, config.ScanDirs.Exclude) { - return godirwalk.SkipThis - } - if ent.IsSymlink() && !config.FollowSymlinks { - return godirwalk.SkipThis - } - - // then process non-matching rules which still descend + if d.Type()&os.ModeSymlink != 0 && !config.FollowSymlinks { + return nil + } - if ent.Name() != ".git" { - return nil - } - isDir, _ := ent.IsDirOrSymlinkToDir() - if !isDir { + ok, metaErr := isGitMetadataDir(path, d) + if metaErr != nil { + if errors.Is(metaErr, os.ErrNotExist) || errors.Is(metaErr, syscall.ELOOP) { return nil } - + log.Printf("ERROR: %s: %v", path, metaErr) + return metaErr + } + if ok { repo := filepath.Dir(path) if onRepoFound != nil { onRepoFound(repo) } results <- repo - return godirwalk.SkipThis // don't descend further - }, - }) - return err + return filepath.SkipDir + } + + if config.FollowSymlinks && d.Type()&os.ModeSymlink != 0 { + fi, statErr := os.Stat(path) + if statErr != nil { + if errors.Is(statErr, os.ErrNotExist) || errors.Is(statErr, syscall.ELOOP) { + return nil + } + log.Printf("ERROR: %s: %v", path, statErr) + return statErr + } + if fi.IsDir() { + entries, rdErr := os.ReadDir(path) + if rdErr != nil { + if errors.Is(rdErr, os.ErrNotExist) || errors.Is(rdErr, syscall.ELOOP) { + return nil + } + log.Printf("ERROR: %s: %v", path, rdErr) + return rdErr + } + for _, ent := range entries { + child := filepath.Join(path, ent.Name()) + if werr := filepath.WalkDir(child, walkDirFn); werr != nil { + return werr + } + } + } + } + + return nil + } + + return filepath.WalkDir(dir, walkDirFn) } // Walk finds all git repositories in the directories specified in config. @@ -84,10 +124,10 @@ func Walk(ctx context.Context, config *Config, results chan string, onRepoFound ctx, cancel := context.WithCancel(ctx) defer cancel() - var errors errgroup.Group + var eg errgroup.Group for i := range config.ScanDirs.Include { j := i // copy loop variable - errors.Go(func() error { + eg.Go(func() error { err := walkone(ctx, config.ScanDirs.Include[j], config, results, onRepoFound) if err == filepath.SkipDir { cancel() @@ -97,7 +137,7 @@ func Walk(ctx context.Context, config *Config, results chan string, onRepoFound return nil }) } - err := errors.Wait() + err := eg.Wait() close(results) return err } diff --git a/scanner/multi_git_status.go b/scanner/multi_git_status.go new file mode 100644 index 0000000..6e532b6 --- /dev/null +++ b/scanner/multi_git_status.go @@ -0,0 +1,91 @@ +package scanner + +import ( + "sort" + "sync" +) + +// MultiGitStatus holds per-repository scan results. The zero value is usable: +// reads treat a nil receiver as empty; the first AddResult or Set allocates +// the inner map. Do not copy a non-zero MultiGitStatus (it contains a sync.Mutex). +type MultiGitStatus struct { + mu sync.Mutex + m map[string]RepoStatus +} + +// NewMultiGitStatus returns an empty result set ready for concurrent AddResult calls. +func NewMultiGitStatus() *MultiGitStatus { + return &MultiGitStatus{m: make(map[string]RepoStatus)} +} + +// AddResult records a dirty or diverged repository; safe for concurrent use. +func (m *MultiGitStatus) AddResult(path string, rs RepoStatus) { + if m == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + if m.m == nil { + m.m = make(map[string]RepoStatus) + } + m.m[path] = rs +} + +// Set replaces status for path (typically the UI thread after a scan completes). +func (m *MultiGitStatus) Set(path string, rs RepoStatus) { + if m == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + if m.m == nil { + m.m = make(map[string]RepoStatus) + } + m.m[path] = rs +} + +// Delete removes path from the set. +func (m *MultiGitStatus) Delete(path string) { + if m == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + delete(m.m, path) +} + +// Get returns status for path, if present. +func (m *MultiGitStatus) Get(path string) (RepoStatus, bool) { + if m == nil { + return RepoStatus{}, false + } + m.mu.Lock() + defer m.mu.Unlock() + rs, ok := m.m[path] + return rs, ok +} + +// Len returns the number of repositories recorded. +func (m *MultiGitStatus) Len() int { + if m == nil { + return 0 + } + m.mu.Lock() + defer m.mu.Unlock() + return len(m.m) +} + +// SortedRepoPaths returns repository paths in stable alphabetical order. +func (m *MultiGitStatus) SortedRepoPaths() []string { + if m == nil { + return nil + } + m.mu.Lock() + paths := make([]string, 0, len(m.m)) + for r := range m.m { + paths = append(paths, r) + } + m.mu.Unlock() + sort.Strings(paths) + return paths +} diff --git a/scanner/scan.go b/scanner/scan.go index 423255a..e4c46f1 100644 --- a/scanner/scan.go +++ b/scanner/scan.go @@ -5,6 +5,8 @@ import ( "log" "sync/atomic" "time" + + "golang.org/x/sync/errgroup" ) func reportProgress(onProgress func(ScanProgress), p ScanProgress) { @@ -14,13 +16,13 @@ func reportProgress(onProgress func(ScanProgress), p ScanProgress) { } // Scan finds all "dirty" git repositories specified by config. -func Scan(config *Config) (MultiGitStatus, error) { +func Scan(config *Config) (*MultiGitStatus, error) { return ScanWithProgress(config, nil) } // ScanWithProgress runs the same scan as [Scan] and invokes onProgress from concurrent // discovery and the status loop. Callbacks should be non-blocking (e.g. small channel send). -func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (MultiGitStatus, error) { +func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitStatus, error) { ex, e := NewExcluder(config.GitIgnore.FileGlob, config.GitIgnore.DirGlob) if e != nil { return nil, e @@ -40,6 +42,8 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (MultiGitSt start := time.Now() err := Walk(ctx, config, repositories, func(string) { n := found.Add(1) + // Discovery found another .git directory; bump ReposFound so the UI can + // show how far ahead the walk is versus status checks (ReposChecked). reportProgress(onProgress, ScanProgress{ ReposFound: int(n), ReposChecked: int(checked.Load()), @@ -51,52 +55,69 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (MultiGitSt } }() - results := make(MultiGitStatus) - totalStatusDuration := time.Duration(0) + results := NewMultiGitStatus() + var totalStatusDuration int64 // nanoseconds; atomic sum from concurrent workers + var eg errgroup.Group + for d := range repositories { - reportProgress(onProgress, ScanProgress{ - ReposFound: int(found.Load()), - ReposChecked: int(checked.Load()), - CurrentPath: d, - }) + d := d // copy loop variable + 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). + reportProgress(onProgress, ScanProgress{ + ReposFound: int(found.Load()), + ReposChecked: int(checked.Load()), + CurrentPath: d, + }) - start := time.Now() + start := time.Now() - porcelain, err := GitStatus(d) - if err != nil { - return nil, err - } - porcelain = ex.FilterPorcelainStatus(porcelain) - st := porcelain.ToGitStatus() + porcelain, err := GitStatus(d) + if err != nil { + return err + } + porcelain = ex.FilterPorcelainStatus(porcelain) + st := porcelain.ToGitStatus() - duration := time.Since(start) - log.Println(d, duration) + duration := time.Since(start) + log.Println(d, duration) - n := checked.Add(1) - reportProgress(onProgress, ScanProgress{ - ReposFound: int(found.Load()), - ReposChecked: int(n), - }) + 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. + reportProgress(onProgress, ScanProgress{ + ReposFound: int(found.Load()), + ReposChecked: int(n), + CurrentPath: d, + }) - branches, err := GitBranchStatus(d) - if err != nil { - log.Printf("branch status scan failed for %s: %v", d, err) - } + branches, err := GitBranchStatus(d) + if err != nil { + log.Printf("branch status scan failed for %s: %v", d, err) + } - if !st.IsClean() || branches.HasLocalRemoteMismatch() { - totalStatusDuration += duration - results[d] = RepoStatus{ - Status: st, - Porcelain: porcelain, - Branches: branches, - ScanTime: duration, + if !st.IsClean() || branches.HasLocalRemoteMismatch() { + atomic.AddInt64(&totalStatusDuration, duration.Nanoseconds()) + results.AddResult(d, RepoStatus{ + Status: st, + Porcelain: porcelain, + Branches: branches, + ScanTime: duration, + }) } - } + return nil + }) } + statusErr := eg.Wait() w := <-ch + if statusErr != nil { + return nil, statusErr + } log.Println("walkDuration:", w.duration) - log.Println("statusDuration:", totalStatusDuration) + log.Println("statusDuration:", time.Duration(atomic.LoadInt64(&totalStatusDuration))) return results, w.err } diff --git a/scanner/scan_integration_test.go b/scanner/scan_integration_test.go index a070c5d..990f5c1 100644 --- a/scanner/scan_integration_test.go +++ b/scanner/scan_integration_test.go @@ -30,14 +30,14 @@ func TestScanFindsDirtyRepos(t *testing.T) { if err != nil { t.Fatalf("Scan: %v", err) } - if len(mgs) != 2 { - t.Fatalf("Scan() len = %d, want 2, keys=%v", len(mgs), keysOf(mgs)) + if mgs.Len() != 2 { + t.Fatalf("Scan() len = %d, want 2, keys=%v", mgs.Len(), mgs.SortedRepoPaths()) } - if _, ok := mgs[dirtyA]; !ok { - t.Fatalf("missing dirty repo a: %v", keysOf(mgs)) + if _, ok := mgs.Get(dirtyA); !ok { + t.Fatalf("missing dirty repo a: %v", mgs.SortedRepoPaths()) } - if _, ok := mgs[dirtyB]; !ok { - t.Fatalf("missing dirty repo b: %v", keysOf(mgs)) + if _, ok := mgs.Get(dirtyB); !ok { + t.Fatalf("missing dirty repo b: %v", mgs.SortedRepoPaths()) } } @@ -50,8 +50,8 @@ func TestScanEmptyTree(t *testing.T) { if err != nil { t.Fatalf("Scan: %v", err) } - if len(mgs) != 0 { - t.Fatalf("want no repos, got %d", len(mgs)) + if mgs.Len() != 0 { + t.Fatalf("want no repos, got %d", mgs.Len()) } } @@ -86,8 +86,8 @@ func TestScanWithProgressReportsProgress(t *testing.T) { if err != nil { t.Fatalf("ScanWithProgress: %v", err) } - if len(mgs) != 1 { - t.Fatalf("want 1 dirty repo, got %d", len(mgs)) + if mgs.Len() != 1 { + t.Fatalf("want 1 dirty repo, got %d", mgs.Len()) } mu.Lock() defer mu.Unlock() @@ -100,12 +100,7 @@ func TestScanWithProgressReportsProgress(t *testing.T) { if last.ReposFound < 1 || last.ReposChecked < 1 { t.Fatalf("last progress should reflect completed work: %+v", last) } -} - -func keysOf(m MultiGitStatus) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) + if last.CurrentPath != repo { + t.Fatalf("last progress should keep CurrentPath until next repo: got %q want %q", last.CurrentPath, repo) } - return out } diff --git a/scanner/types.go b/scanner/types.go index f5d4d0f..5bad586 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -18,8 +18,6 @@ type RepoStatus struct { ScanTime time.Duration } -type MultiGitStatus map[string]RepoStatus - type BranchStatus struct { Branch string Detached bool diff --git a/ui/app.go b/ui/app.go index 18a0655..299385d 100644 --- a/ui/app.go +++ b/ui/app.go @@ -32,6 +32,7 @@ func Run(config *scanner.Config) error { m := &model{ config: config, logBuf: &logBuffer{max: 500}, + repositories: scanner.NewMultiGitStatus(), focus: paneRepo, scanResultCh: make(chan scanResult, 1), diffMode: diffModeWorktree, diff --git a/ui/gitops.go b/ui/gitops.go index 6f4d8e9..11c4802 100644 --- a/ui/gitops.go +++ b/ui/gitops.go @@ -129,10 +129,10 @@ func (m *model) refreshRepoStatusAfterGit() { return } if include { - m.repositories[repo] = rs + m.repositories.Set(repo, rs) } else { - delete(m.repositories, repo) - m.repoList = sortedRepoPaths(m.repositories) + m.repositories.Delete(repo) + m.repoList = m.repositories.SortedRepoPaths() if m.cursor >= len(m.repoList) { m.cursor = max(0, len(m.repoList)-1) } diff --git a/ui/model.go b/ui/model.go index b03e878..a702bbe 100644 --- a/ui/model.go +++ b/ui/model.go @@ -35,7 +35,7 @@ const ( // scanResult carries the finished scan data and any scan error. type scanResult struct { - mgs scanner.MultiGitStatus + mgs *scanner.MultiGitStatus err error } @@ -80,7 +80,7 @@ type model struct { width int height int - repositories scanner.MultiGitStatus + repositories *scanner.MultiGitStatus repoList []string repoScrollTop int // first visible repo index when the list exceeds pane height cursor int diff --git a/ui/mouse_line_select_test.go b/ui/mouse_line_select_test.go index 088c90f..f39a659 100644 --- a/ui/mouse_line_select_test.go +++ b/ui/mouse_line_select_test.go @@ -46,15 +46,14 @@ func TestMouseStatusLineSelect(t *testing.T) { m.width = 100 m.height = 30 m.repoList = []string{"/repo"} - m.repositories = scanner.MultiGitStatus{ - "/repo": { - Porcelain: scanner.PorcelainStatus{Entries: []scanner.PorcelainEntry{ - {Path: "a.go", Worktree: 'M', Staging: ' '}, - {Path: "b.go", Worktree: 'M', Staging: ' '}, - {Path: "c.go", Worktree: 'M', Staging: ' '}, - }}, - }, - } + m.repositories = scanner.NewMultiGitStatus() + m.repositories.Set("/repo", scanner.RepoStatus{ + Porcelain: scanner.PorcelainStatus{Entries: []scanner.PorcelainEntry{ + {Path: "a.go", Worktree: 'M', Staging: ' '}, + {Path: "b.go", Worktree: 'M', Staging: ' '}, + {Path: "c.go", Worktree: 'M', Staging: ' '}, + }}, + }) m.focus = paneStatus m.syncViewports() @@ -90,13 +89,12 @@ func TestMouseStatusHeaderClickConsumed(t *testing.T) { m.width = 100 m.height = 30 m.repoList = []string{"/r"} - m.repositories = scanner.MultiGitStatus{ - "/r": { - Porcelain: scanner.PorcelainStatus{Entries: []scanner.PorcelainEntry{ - {Path: "x.go", Worktree: 'M', Staging: ' '}, - }}, - }, - } + m.repositories = scanner.NewMultiGitStatus() + m.repositories.Set("/r", scanner.RepoStatus{ + Porcelain: scanner.PorcelainStatus{Entries: []scanner.PorcelainEntry{ + {Path: "x.go", Worktree: 'M', Staging: ' '}, + }}, + }) m.focus = paneStatus m.syncViewports() diff --git a/ui/status_layout.go b/ui/status_layout.go index 889d3c6..2e27f70 100644 --- a/ui/status_layout.go +++ b/ui/status_layout.go @@ -223,16 +223,6 @@ func (m *model) applyViewportAndPanes(syncDiff bool) { m.clampRepoScroll(repoBody) } -// sortedRepoPaths returns repository paths in stable alphabetical order. -func sortedRepoPaths(mgs scanner.MultiGitStatus) []string { - paths := make([]string, 0, len(mgs)) - for r := range mgs { - paths = append(paths, r) - } - sort.Strings(paths) - return paths -} - // newStatusTable builds the status pane table with default styling. func newStatusTable() table.Model { t := table.New( @@ -393,7 +383,7 @@ func (m *model) refreshBranchContent(totalWidth int) { m.branchTable.SetColumns(cols) repo := m.currentRepo() - st, ok := m.repositories[repo] + st, ok := m.repositories.Get(repo) if !ok { m.branchTable.SetRows([]table.Row{{"(select repository)", "-", "-", "-"}}) m.branchTable.SetHeight(layoutMinBodyLines) @@ -492,7 +482,7 @@ func relativeTime(unix int64) string { // refreshStatusContent rebuilds status rows for the selected repository. func (m *model) refreshStatusContent() { repo := m.currentRepo() - st, ok := m.repositories[repo] + st, ok := m.repositories.Get(repo) rows := make([]table.Row, 0) paths := make([]string, 0) if ok && len(st.Porcelain.Entries) > 0 { diff --git a/ui/status_layout_test.go b/ui/status_layout_test.go index fe694c0..f6915f6 100644 --- a/ui/status_layout_test.go +++ b/ui/status_layout_test.go @@ -18,7 +18,7 @@ import ( func newTestModel() *model { m := &model{ logBuf: &logBuffer{max: 50}, - repositories: make(scanner.MultiGitStatus), + repositories: scanner.NewMultiGitStatus(), focus: paneRepo, statusTable: newStatusTable(), branchTable: newBranchTable(), @@ -166,15 +166,15 @@ func TestLayoutBodiesZoomedPaneOnly(t *testing.T) { // TestSortedRepoPaths checks repository path ordering is alphabetical. func TestSortedRepoPaths(t *testing.T) { - got := sortedRepoPaths(scanner.MultiGitStatus{ - "/z": {}, - "/a": {}, - "/m": {}, - }) + mgs := scanner.NewMultiGitStatus() + mgs.Set("/z", scanner.RepoStatus{}) + mgs.Set("/a", scanner.RepoStatus{}) + mgs.Set("/m", scanner.RepoStatus{}) + got := mgs.SortedRepoPaths() want := []string{"/a", "/m", "/z"} for i := range want { if got[i] != want[i] { - t.Fatalf("sortedRepoPaths() = %v, want %v", got, want) + t.Fatalf("SortedRepoPaths() = %v, want %v", got, want) } } } @@ -183,14 +183,14 @@ func TestSortedRepoPaths(t *testing.T) { func TestRefreshStatusContentUsesPorcelainAndSorts(t *testing.T) { m := newTestModel() m.repoList = []string{"/repo"} - m.repositories["/repo"] = scanner.RepoStatus{ + m.repositories.Set("/repo", scanner.RepoStatus{ Porcelain: scanner.PorcelainStatus{ Entries: []scanner.PorcelainEntry{ {Staging: 'R', Worktree: ' ', OriginalPath: "z-old.go", Path: "a-new.go"}, {Staging: 'M', Worktree: ' ', Path: "b.go"}, }, }, - } + }) m.refreshStatusContent() rows := m.statusTable.Rows() @@ -209,12 +209,12 @@ func TestRefreshStatusContentUsesPorcelainAndSorts(t *testing.T) { func TestRefreshStatusContentFallsBackToGitStatus(t *testing.T) { m := newTestModel() m.repoList = []string{"/repo"} - m.repositories["/repo"] = scanner.RepoStatus{ + m.repositories.Set("/repo", scanner.RepoStatus{ Status: git.Status{ "z.go": &git.FileStatus{Staging: 'M', Worktree: ' '}, "a.go": &git.FileStatus{Staging: ' ', Worktree: 'D'}, }, - } + }) m.refreshStatusContent() rows := m.statusTable.Rows() @@ -326,7 +326,7 @@ func TestSetLogVPContentStickyBottom(t *testing.T) { func TestRefreshBranchContentOneRowPerBranch(t *testing.T) { m := newTestModel() m.repoList = []string{"/repo"} - m.repositories["/repo"] = scanner.RepoStatus{ + m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", NewestLocation: "origin", @@ -348,7 +348,7 @@ func TestRefreshBranchContentOneRowPerBranch(t *testing.T) { }}, }, }, - } + }) m.refreshBranchContent(60) cols := m.branchTable.Columns() @@ -391,7 +391,7 @@ branches: m := newTestModel() m.config = cfg m.repoList = []string{"/repo"} - m.repositories["/repo"] = scanner.RepoStatus{ + m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", NewestLocation: "origin", @@ -414,7 +414,7 @@ branches: }}, }, }, - } + }) m.refreshBranchContent(60) rows := m.branchTable.Rows() @@ -451,7 +451,7 @@ branches: m := newTestModel() m.config = cfg m.repoList = []string{"/repo"} - m.repositories["/repo"] = scanner.RepoStatus{ + m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", NewestLocation: "origin", @@ -476,7 +476,7 @@ branches: }}, }, }, - } + }) m.refreshBranchContent(60) rows := m.branchTable.Rows() @@ -516,7 +516,7 @@ branches: m := newTestModel() m.config = cfg m.repoList = []string{"/repo"} - m.repositories["/repo"] = scanner.RepoStatus{ + m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", NewestLocation: "origin", @@ -535,7 +535,7 @@ branches: }}, }, }, - } + }) m.refreshBranchContent(60) rows := m.branchTable.Rows() diff --git a/ui/update.go b/ui/update.go index 3982d76..5ecd60f 100644 --- a/ui/update.go +++ b/ui/update.go @@ -59,7 +59,7 @@ func (m *model) finishScan(r scanResult) { } m.repositories = r.mgs - m.repoList = sortedRepoPaths(r.mgs) + m.repoList = r.mgs.SortedRepoPaths() m.statusFileSelected = false m.diffNeedsRefresh = true if m.cursor >= len(m.repoList) { @@ -247,8 +247,8 @@ func (m *model) deleteSelectedRepoFromDisk() { log.Printf("remove %q: %v", repo, err) return } - delete(m.repositories, repo) - m.repoList = sortedRepoPaths(m.repositories) + m.repositories.Delete(repo) + m.repoList = m.repositories.SortedRepoPaths() if m.cursor >= len(m.repoList) { m.cursor = max(0, len(m.repoList)-1) } diff --git a/ui/update_view_test.go b/ui/update_view_test.go index 2290cde..aad1c5a 100644 --- a/ui/update_view_test.go +++ b/ui/update_view_test.go @@ -299,11 +299,9 @@ func TestHandleScanTickFinishesScan(t *testing.T) { m.scanProgressCh = make(chan scanner.ScanProgress, 2) m.scanProgressCh <- scanner.ScanProgress{ReposFound: 1, ReposChecked: 0} m.scanProgressCh <- scanner.ScanProgress{ReposFound: 2, ReposChecked: 1} - m.scanResultCh <- scanResult{ - mgs: scanner.MultiGitStatus{ - "/repo": {}, - }, - } + scanMgs := scanner.NewMultiGitStatus() + scanMgs.Set("/repo", scanner.RepoStatus{}) + m.scanResultCh <- scanResult{mgs: scanMgs} _, cmd := m.handleScanTick() if cmd != nil { @@ -340,7 +338,7 @@ func TestWhyInclusionWKey(t *testing.T) { m.focus = paneRepo m.repoList = []string{"/r"} m.cursor = 0 - m.repositories["/r"] = scanner.RepoStatus{} + m.repositories.Set("/r", scanner.RepoStatus{}) _, _, handled := m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) if !handled { @@ -376,7 +374,7 @@ func TestDeleteRepoDKey(t *testing.T) { m.height = 30 m.focus = paneRepo m.repoList = []string{tmp} - m.repositories[tmp] = scanner.RepoStatus{} + m.repositories.Set(tmp, scanner.RepoStatus{}) m.cursor = 0 _, _, handled := m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) diff --git a/ui/view.go b/ui/view.go index dff9b77..e1a145d 100644 --- a/ui/view.go +++ b/ui/view.go @@ -157,7 +157,7 @@ func (m *model) renderWhyInclusionOverlay() string { if repo == "" { return m.placeCenteredDimModal(roundedModal(boxW).Render("No repository selected.")) } - rs, ok := m.repositories[repo] + rs, ok := m.repositories.Get(repo) if !ok { return m.placeCenteredDimModal(roundedModal(boxW).Render("No status data for this path.")) } diff --git a/ui/view_test.go b/ui/view_test.go index 5c2d803..d9f2487 100644 --- a/ui/view_test.go +++ b/ui/view_test.go @@ -61,7 +61,7 @@ func TestBranchTableViewFitsInnerWidth(t *testing.T) { m.width = 100 m.height = 30 m.repoList = []string{"/repo"} - m.repositories["/repo"] = scanner.RepoStatus{ + m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "main", NewestLocation: "origin", @@ -71,7 +71,7 @@ func TestBranchTableViewFitsInnerWidth(t *testing.T) { {Name: "upstream", Exists: false}, }, }, - } + }) m.syncViewports() _, bi := m.statusBranchesInnerWidths() for _, line := range strings.Split(m.branchTable.View(), "\n") {