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") {