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 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 0e9a96f..09dfc45 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,9 @@ import ( func getDefaultConfigPath() string { home, err := homedir.Dir() - _ = err // ignore + if err != nil { + fmt.Println("WARNING: Unable to determine home directory for default config path") + } return filepath.Join(home, ".dirtygit.yml") } @@ -35,6 +37,9 @@ func main() { CommandNotFound: func(ctx context.Context, cmd *cli.Command, name string) { fmt.Printf("ERROR: Unknown command '%s'\n", name) }, + Commands: []*cli.Command{ + reportCommand(), + }, Flags: []cli.Flag{ &cli.StringFlag{ Name: "config", diff --git a/report.go b/report.go new file mode 100644 index 0000000..f78038d --- /dev/null +++ b/report.go @@ -0,0 +1,171 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/urfave/cli/v3" + + "github.com/boyvinall/dirtygit/scanner" +) + +// reportBranchEntry is one local branch row in the report. +type reportBranchEntry struct { + scanner.LocalBranchRef + + // ShownInTUI is true when this branch appears in the TUI Branches pane for its repository + ShownInTUI bool `json:"shown_in_tui"` + IsLocalOnly bool `json:"is_local_only"` +} + +// 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"` + 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(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, + }) + } + + // Build a set of branch names that survived FilterLocalOnlyForConfig. + filteredSet := make(map[string]struct{}, len(rs.FilteredBranches)) + for _, lb := range rs.FilteredBranches { + filteredSet[lb.Name] = struct{}{} + } + + branches := make([]reportBranchEntry, 0, len(rs.Branches)) + for _, lb := range rs.Branches { + _, survived := filteredSet[lb.Name] + + branches = append(branches, reportBranchEntry{ + LocalBranchRef: lb, + ShownInTUI: survived, + IsLocalOnly: lb.IsLocalOnly(), + }) + } + + repos = append(repos, reportRepo{ + Path: path, + IsClean: rs.Porcelain.ToGitStatus().IsClean(), + Files: files, + CurrentBranch: rs.Branch, + Detached: rs.Detached, + Branches: branches, + }) + } + + return report{Repos: repos} +} + +func runReport(config *scanner.Config, outputFile string) error { + mgs, err := scanner.Scan(config) + if err != nil { + return err + } + + r := buildReport(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 + } + } + + printReportSummary(r) + return nil +} + +func printReportSummary(r report) { + if len(r.Repos) == 0 { + fmt.Println("No dirty or diverged repositories found.") + return + } + for _, repo := range r.Repos { + fmt.Println(repo.Path) + for _, b := range repo.Branches { + if b.ShownInTUI { + fmt.Printf(" %s\n", b.GetDisplayName()) + } + } + } +} + +func reportCommand() *cli.Command { + return &cli.Command{ + Name: "report", + 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 { + 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, cmd.String("output-file")) + }, + } +} diff --git a/scanner/branch_status.go b/scanner/branch_status.go index b6a2feb..6f52d37 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 } @@ -212,66 +212,97 @@ func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLoc return locations, nil } -func GitBranchStatus(d string) (BranchStatus, error) { - branch, detached, err := currentBranch(d) +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(dir string) (branch string, detached bool, locals []LocalBranchRef, err error) { + branch, detached, err = currentBranch(dir) if err != nil { - return BranchStatus{}, err + return } if detached { - locals, listErr := listLocalBranches(d, branch, true) - if listErr != nil { - return BranchStatus{}, listErr - } - return BranchStatus{Branch: branch, Detached: true, LocalBranches: locals}, nil + locals, err = listLocalBranches(dir, branch, true) + return } - remotes, err := listRemotes(d) + var remotes []string + remotes, err = listRemotes(dir) if err != nil { - return BranchStatus{}, err + return } - locals, err := listLocalBranches(d, branch, false) + locals, err = listLocalBranches(dir, branch, false) if err != nil { - return BranchStatus{}, err + return } if len(locals) == 0 { - locations, err := computeBranchLocations(d, branch, remotes) + var locations []BranchLocation + locations, err = computeBranchLocations(dir, branch, remotes) if err != nil { - return BranchStatus{}, err + return } - return BranchStatus{ - Branch: branch, - Detached: false, - Locations: locations, - LocalBranches: nil, - }, nil + tipHash, tipUnix := tipFromLocalBranchLocation(locations) + locals = []LocalBranchRef{{ + Name: branch, + TipHash: tipHash, + TipUnix: tipUnix, + Current: true, + Locations: locations, + }} + return } - var topLocations []BranchLocation for i := range locals { - locs, err := computeBranchLocations(d, locals[i].Name, remotes) + var locs []BranchLocation + locs, err = computeBranchLocations(dir, locals[i].Name, remotes) if err != nil { - return BranchStatus{}, err + return } locals[i].Locations = locs + } + + // TODO: This fallback is likely unreachable: the only scenario (detached HEAD) is already handled above. + // This dead path adds computeBranchLocations calls and complexity for no gain. + 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(dir, branch, remotes) if err2 != nil { - return BranchStatus{}, err2 + err = err2 + return + } + 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 + return } 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/excluded.go b/scanner/excluded.go index 8796e91..ab677a2 100644 --- a/scanner/excluded.go +++ b/scanner/excluded.go @@ -3,8 +3,6 @@ package scanner import ( "path/filepath" "strings" - - "github.com/go-git/go-git/v5" ) type Excluder struct { @@ -14,7 +12,7 @@ type Excluder struct { func (e Excluder) IsExcluded(path string) bool { dir, base := filepath.Split(path) - dirs := strings.Split(filepath.ToSlash(dir), string(filepath.Separator)) + dirs := strings.Split(filepath.ToSlash(dir), "/") for _, pattern := range e.files { m, _ := filepath.Match(pattern, base) if m { @@ -32,16 +30,6 @@ func (e Excluder) IsExcluded(path string) bool { return false } -func (e Excluder) FilterGitStatus(st git.Status) git.Status { - newStatus := make(git.Status, len(st)) - for path, status := range st { - if !e.IsExcluded(path) { - newStatus[path] = status - } - } - return newStatus -} - func (e Excluder) FilterPorcelainStatus(st PorcelainStatus) PorcelainStatus { filtered := PorcelainStatus{Entries: make([]PorcelainEntry, 0, len(st.Entries))} for _, entry := range st.Entries { diff --git a/scanner/excluded_test.go b/scanner/excluded_test.go index e8c9620..c56771a 100644 --- a/scanner/excluded_test.go +++ b/scanner/excluded_test.go @@ -2,8 +2,6 @@ package scanner import ( "testing" - - "github.com/go-git/go-git/v5" ) func TestExcluderIsExcluded(t *testing.T) { @@ -55,23 +53,3 @@ func TestExcluderFilterPorcelainStatus(t *testing.T) { t.Fatalf("filtered path = %q, want cmd/app/main.go", got.Entries[0].Path) } } - -func TestExcluderFilterGitStatus(t *testing.T) { - ex := Excluder{ - files: []string{"*.tmp"}, - dirs: []string{"dist"}, - } - st := git.Status{ - "src/main.go": &git.FileStatus{Staging: 'M', Worktree: ' '}, - "src/generated.tmp": &git.FileStatus{Staging: '?', Worktree: '?'}, - "web/dist/bundle.min.js": &git.FileStatus{Staging: 'A', Worktree: ' '}, - } - - got := ex.FilterGitStatus(st) - if len(got) != 1 { - t.Fatalf("FilterGitStatus() len = %d, want 1", len(got)) - } - if _, ok := got["src/main.go"]; !ok { - t.Fatal("expected src/main.go to remain after filtering") - } -} diff --git a/scanner/find.go b/scanner/find.go index fd10cec..81f4196 100644 --- a/scanner/find.go +++ b/scanner/find.go @@ -56,10 +56,6 @@ func walkone(ctx context.Context, dir string, config *Config, results chan strin default: } - if d.IsDir() { - log.Printf("path %s", path) - } - if slices.Contains(config.ScanDirs.Exclude, path) { return filepath.SkipDir } @@ -77,7 +73,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) @@ -127,9 +123,8 @@ func Walk(ctx context.Context, config *Config, results chan string, onRepoFound var eg errgroup.Group for i := range config.ScanDirs.Include { - j := i // copy loop variable eg.Go(func() error { - err := walkone(ctx, config.ScanDirs.Include[j], config, results, onRepoFound) + err := walkone(ctx, config.ScanDirs.Include[i], config, results, onRepoFound) if err == filepath.SkipDir { cancel() } else if err != nil { 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 14f471b..0000000 --- a/scanner/inclusion_test.go +++ /dev/null @@ -1,48 +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", 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", - 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/multi_git_status.go b/scanner/multi_git_status.go index 6e532b6..0b714ad 100644 --- a/scanner/multi_git_status.go +++ b/scanner/multi_git_status.go @@ -31,19 +31,6 @@ func (m *MultiGitStatus) AddResult(path string, rs 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 { diff --git a/scanner/scan.go b/scanner/scan.go index a8c0ce8..9418a8d 100644 --- a/scanner/scan.go +++ b/scanner/scan.go @@ -32,7 +32,7 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS err error duration time.Duration } - ch := make(chan walkResult) + ch := make(chan walkResult, 1) go func() { start := time.Now() err := Walk(ctx, config, repositories, func(string) { @@ -51,39 +51,32 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS }() results := NewMultiGitStatus() - var totalStatusDuration int64 // nanoseconds; atomic sum from concurrent workers var eg errgroup.Group 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()), CurrentPath: d, }) - statusStart := time.Now() rs, include, err := StatusForRepo(config, d) - statusDur := time.Since(statusStart) if err != nil { 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), CurrentPath: d, }) - log.Println(d, statusDur) if include { - atomic.AddInt64(&totalStatusDuration, statusDur.Nanoseconds()) results.AddResult(d, rs) } return nil @@ -95,8 +88,6 @@ 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))) return results, w.err } @@ -113,19 +104,18 @@ func StatusForRepo(config *Config, dir string) (RepoStatus, bool, error) { return RepoStatus{}, false, err } porcelain = ex.FilterPorcelainStatus(porcelain) - st := porcelain.ToGitStatus() - - branches, err := GitBranchStatus(dir) + branch, detached, branches, err := GitBranchStatus(dir) if err != nil { log.Printf("branch status scan failed for %s: %v", dir, err) } - branches.FilterLocalOnlyForConfig(config) rs := RepoStatus{ - Status: st, + Branch: branch, + Detached: detached, Porcelain: porcelain, Branches: branches, } - include := !st.IsClean() || branches.HasLocalRemoteMismatch() + rs.FilteredBranches = rs.Filter(config) + include := !porcelain.ToGitStatus().IsClean() || rs.HasUnpushedChanges(config) return rs, include, nil } diff --git a/scanner/scan_test.go b/scanner/scan_test.go index 5ab7496..abf35d7 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) @@ -223,7 +195,7 @@ func TestStatusForRepo(t *testing.T) { if !include { t.Fatal("expected repo with untracked file to be dirty") } - if rs.IsClean() { + if rs.Porcelain.ToGitStatus().IsClean() { t.Fatal("expected non-clean status") } diff --git a/scanner/types.go b/scanner/types.go index ebf12dd..e3d50ca 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -4,68 +4,93 @@ import ( "fmt" "os" "regexp" + "slices" "strings" "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 + // 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 + + // Porcelain is the parsed git status --porcelain output. Porcelain PorcelainStatus - Branches BranchStatus -} -type BranchStatus struct { - Branch string - Detached bool - Locations []BranchLocation - // LocalBranches lists refs/heads in name order (from git for-each-ref). - LocalBranches []LocalBranchRef + // Full list of local branches with remote comparison data (Branches) + Branches []LocalBranchRef + + // FilteredBranches is the subset of Branches that pass config filtering + // (e.g. hide local-only branches matching config patterns). It is what the + // UI branch pane shows and is used for mismatch detection and unpushed + // change status. It is always a subset of Branches and has the same order. + FilteredBranches []LocalBranchRef } // LocalBranchRef is one local branch tip (refs/heads/*). // 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 } -// 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 - } +func (lbr *LocalBranchRef) GetDisplayName() string { + if lbr.Current { + return "*" + lbr.Name } - 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 + 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 +// [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 } // IsLocalOnly reports whether no configured remote has a same-named branch ref @@ -88,34 +113,44 @@ func (lb LocalBranchRef) IsLocalOnly() bool { return true } -type BranchLocation struct { - Name string - Exists bool - TipHash string - TipUnix int64 - 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. +func (rs *RepoStatus) CurrentBranchLocations() []BranchLocation { + if rs == nil || rs.Detached { + return nil + } + for i := range rs.Branches { + if rs.Branches[i].Current { + return rs.Branches[i].Locations + } + } + return nil +} + +func (rs *RepoStatus) HasUnpushedChanges(c *Config) bool { + for _, lb := range rs.Branches { + if c.ShouldHideLocalOnlyBranch(lb) { + continue + } + if lb.HasUnpushedChanges() { + return true + } + } + return false } -// 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 { +// 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 } 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 +159,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 } @@ -141,49 +176,63 @@ 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 -// [Config.ShouldHideLocalOnlyBranch] matches unless [Config.AlwaysListBranch] applies. -func (b *BranchStatus) FilterLocalOnlyForConfig(c *Config) { - refs := b.LocalBranches - if c == nil || len(refs) == 0 { - return - } +// Filter filters out local-only branches that +// [Config.ShouldHideLocalOnlyBranch] matches. +// The checked-out branch is never removed so HEAD remote comparison stays available. +func (rs *RepoStatus) Filter(c *Config) []LocalBranchRef { out := make([]LocalBranchRef, 0) - for _, lb := range refs { - if c.ShouldHideLocalOnlyBranch(lb) && !c.AlwaysListBranch(lb.Name) { + for _, lb := range rs.Branches { + if lb.Current { + out = append(out, lb) + continue + } + if c != nil && slices.Contains(c.Branches.Default, lb.Name) { + 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 -// [BranchStatus.HasLocalRemoteMismatch] is true, or nil when that predicate is false. -func (b *BranchStatus) LocalRemoteMismatchReasons() []string { - if b.Detached { +func (rs *RepoStatus) LocalRemoteMismatchReasons() []string { + if rs.Detached { + return nil + } + locs := rs.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 } } if local == nil || !local.Exists { return nil } - branchName := b.Branch + branchName := rs.Branch if branchName == "" { 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 +265,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 +292,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 { @@ -273,13 +331,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 } @@ -294,17 +354,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 d586aa2..d0c6fc9 100644 --- a/scanner/types_test.go +++ b/scanner/types_test.go @@ -60,176 +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{ - 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"}, - }, - }, - 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}, - }, - }, - 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}, - }, - }, - 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}, - }, - }, - want: true, - }, - { - name: "remote branch missing", - in: BranchStatus{ - 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", - 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/gitops.go b/ui/gitops.go index 11c4802..1f6876c 100644 --- a/ui/gitops.go +++ b/ui/gitops.go @@ -23,7 +23,7 @@ func (m *model) openCurrentRepo() { return } cmd := exec.Command(argv[0], argv[1:]...) - if err := cmd.Run(); err != nil { + if err := cmd.Start(); err != nil { log.Printf("edit %q: %v", argv[0], err) } } @@ -129,7 +129,7 @@ func (m *model) refreshRepoStatusAfterGit() { return } if include { - m.repositories.Set(repo, rs) + m.repositories.AddResult(repo, rs) } else { m.repositories.Delete(repo) m.repoList = m.repositories.SortedRepoPaths() 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/mouse_line_select.go b/ui/mouse_line_select.go index 83a6ef8..5cb37b9 100644 --- a/ui/mouse_line_select.go +++ b/ui/mouse_line_select.go @@ -10,7 +10,8 @@ import ( ) // bubblesTableSlice reads the table's current [start,end) row window used for -// rendering. Field names match github.com/charmbracelet/bubbles/table v1.0.0. +// rendering via reflection on unexported fields. Field names are pinned to +// bubbles v1.0.0; TestBubblesTableInternalFields will fail if they are renamed. func bubblesTableSlice(t table.Model) (start, end int) { v := reflect.ValueOf(t) sf := v.FieldByName("start") @@ -21,7 +22,8 @@ func bubblesTableSlice(t table.Model) (start, end int) { return int(sf.Int()), int(ef.Int()) } -// bubblesTableViewportYOffset reads the embedded viewport's vertical scroll. +// bubblesTableViewportYOffset reads the embedded viewport's vertical scroll +// via reflection on unexported fields (same version caveat as bubblesTableSlice). func bubblesTableViewportYOffset(t table.Model) int { vp := reflect.ValueOf(t).FieldByName("viewport") if !vp.IsValid() { diff --git a/ui/mouse_line_select_test.go b/ui/mouse_line_select_test.go index 672ed1c..c789cae 100644 --- a/ui/mouse_line_select_test.go +++ b/ui/mouse_line_select_test.go @@ -1,13 +1,31 @@ package ui import ( + "reflect" "testing" + "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/boyvinall/dirtygit/scanner" ) +// TestBubblesTableInternalFields guards against upstream field renames that +// would cause bubblesTableSlice and bubblesTableViewportYOffset to silently +// return zeros. Field names are verified against bubbles v1.0.0 (go.mod). +func TestBubblesTableInternalFields(t *testing.T) { + typ := reflect.TypeOf(table.Model{}) + for _, name := range []string{"start", "end", "viewport"} { + if _, ok := typ.FieldByName(name); !ok { + t.Errorf("bubbles/table.Model missing field %q — update bubblesTableSlice/bubblesTableViewportYOffset in mouse_line_select.go", name) + } + } + vpField, _ := typ.FieldByName("viewport") + if _, ok := vpField.Type.FieldByName("YOffset"); !ok { + t.Error("bubbles viewport.Model missing field \"YOffset\" — update bubblesTableViewportYOffset in mouse_line_select.go") + } +} + func TestMouseRepoLineSelect(t *testing.T) { m := newTestModel() m.width = 100 @@ -47,7 +65,7 @@ func TestMouseStatusLineSelect(t *testing.T) { m.height = 30 m.repoList = []string{"/repo"} m.repositories = scanner.NewMultiGitStatus() - m.repositories.Set("/repo", scanner.RepoStatus{ + m.repositories.AddResult("/repo", scanner.RepoStatus{ Porcelain: scanner.PorcelainStatus{Entries: []scanner.PorcelainEntry{ {Path: "a.go", Worktree: 'M', Staging: ' '}, {Path: "b.go", Worktree: 'M', Staging: ' '}, @@ -90,7 +108,7 @@ func TestMouseStatusHeaderClickConsumed(t *testing.T) { m.height = 30 m.repoList = []string{"/r"} m.repositories = scanner.NewMultiGitStatus() - m.repositories.Set("/r", scanner.RepoStatus{ + m.repositories.AddResult("/r", scanner.RepoStatus{ Porcelain: scanner.PorcelainStatus{Entries: []scanner.PorcelainEntry{ {Path: "x.go", Worktree: 'M', Staging: ' '}, }}, diff --git a/ui/status_layout.go b/ui/status_layout.go index bc38219..52e8855 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,15 +306,6 @@ 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). -func branchRemoteSummary(b scanner.BranchStatus) string { - if b.Detached || len(b.Locations) == 0 { - return "-" - } - return branchRemoteSummaryFromLocations(b.Locations) -} - func branchRemoteSummaryFromLocations(locations []scanner.BranchLocation) string { if len(locations) == 0 { return "-" @@ -375,7 +366,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) @@ -387,70 +379,46 @@ 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.Locations { - 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...) + // TODO + // 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)) } func shortHash(hash string) string { @@ -500,22 +468,6 @@ func (m *model) refreshStatusContent() { }) paths = append(paths, entry.Path) } - } else if ok && len(st.Status) > 0 { - // Fallback for statuses that do not include parsed porcelain data. - paths := make([]string, 0, len(st.Status)) - for path := range st.Status { - paths = append(paths, path) - } - sort.Strings(paths) - for _, path := range paths { - status := st.Status[path] - rows = append(rows, table.Row{ - statusCodeLabel(status.Worktree), - statusCodeLabel(status.Staging), - path, - }) - paths = append(paths, path) - } } m.statusPaths = paths if len(paths) == 0 { @@ -601,7 +553,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/status_layout_test.go b/ui/status_layout_test.go index b426163..21982aa 100644 --- a/ui/status_layout_test.go +++ b/ui/status_layout_test.go @@ -168,9 +168,9 @@ func TestLayoutBodiesZoomedPaneOnly(t *testing.T) { // TestSortedRepoPaths checks repository path ordering is alphabetical. func TestSortedRepoPaths(t *testing.T) { mgs := scanner.NewMultiGitStatus() - mgs.Set("/z", scanner.RepoStatus{}) - mgs.Set("/a", scanner.RepoStatus{}) - mgs.Set("/m", scanner.RepoStatus{}) + mgs.AddResult("/z", scanner.RepoStatus{}) + mgs.AddResult("/a", scanner.RepoStatus{}) + mgs.AddResult("/m", scanner.RepoStatus{}) got := mgs.SortedRepoPaths() want := []string{"/a", "/m", "/z"} for i := range want { @@ -184,7 +184,7 @@ func TestSortedRepoPaths(t *testing.T) { func TestRefreshStatusContentUsesPorcelainAndSorts(t *testing.T) { m := newTestModel() m.repoList = []string{"/repo"} - m.repositories.Set("/repo", scanner.RepoStatus{ + m.repositories.AddResult("/repo", scanner.RepoStatus{ Porcelain: scanner.PorcelainStatus{ Entries: []scanner.PorcelainEntry{ {Staging: 'R', Worktree: ' ', OriginalPath: "z-old.go", Path: "a-new.go"}, @@ -206,27 +206,6 @@ func TestRefreshStatusContentUsesPorcelainAndSorts(t *testing.T) { } } -// TestRefreshStatusContentFallsBackToGitStatus validates status-map fallback behavior. -func TestRefreshStatusContentFallsBackToGitStatus(t *testing.T) { - m := newTestModel() - m.repoList = []string{"/repo"} - 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() - if len(rows) != 2 { - t.Fatalf("rows len = %d, want 2", len(rows)) - } - if rows[0][2] != "a.go" || rows[1][2] != "z.go" { - t.Fatalf("rows order = [%q, %q], want [a.go, z.go]", rows[0][2], rows[1][2]) - } -} - // TestStatusCodeLabel verifies human labels for git status codes. func TestStatusCodeLabel(t *testing.T) { cases := map[git.StatusCode]string{ @@ -323,32 +302,34 @@ func TestSetLogVPContentStickyBottom(t *testing.T) { } } +func createRepoStatus(branches []scanner.LocalBranchRef, c *scanner.Config) scanner.RepoStatus { + st := scanner.RepoStatus{} + st.Branches = branches + st.FilteredBranches = st.Filter(c) + for _, lb := range branches { + if lb.Current { + st.Branch = lb.Name + break + } + } + return st +} + // TestRefreshBranchContentOneRowPerBranch verifies one table row per local branch name. func TestRefreshBranchContentOneRowPerBranch(t *testing.T) { m := newTestModel() m.repoList = []string{"/repo"} - 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{ - {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}, - }}, - {Name: "zzz", TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002, Current: false, Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002}, - {Name: "origin", Exists: true, TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002}, - }}, - }, - }, - }) + m.repositories.AddResult("/repo", createRepoStatus([]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}, + {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, + {Name: "upstream", Exists: false}, + }}, + {Name: "zzz", TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002, Current: false, Locations: []scanner.BranchLocation{ + {Name: "local", Exists: true, TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002}, + {Name: "origin", Exists: true, TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002}, + }}, + }, nil)) m.refreshBranchContent(60) cols := m.branchTable.Columns() @@ -359,7 +340,7 @@ func TestRefreshBranchContentOneRowPerBranch(t *testing.T) { if len(rows) != 1 { t.Fatalf("branch rows len = %d, want 1 (in-sync branch zzz omitted)", len(rows)) } - if rows[0][0] != "aaa" { + if rows[0][0] != "*aaa" { t.Fatalf("branch name column = %#v, want aaa (only branch with remote tip mismatch)", rows[0][0]) } wantRemote := "origin +1-2, upstream: missing" @@ -388,32 +369,25 @@ branches: t.Fatalf("ParseConfigFile: %v", err) } + st := createRepoStatus([]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}, + {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, + }}, + {Name: "wip/hidden", TipHash: "dddddddddddddddd", TipUnix: 1_700_000_003, Current: false, Locations: []scanner.BranchLocation{ + {Name: "local", Exists: true, TipHash: "dddddddddddddddd", TipUnix: 1_700_000_003}, + {Name: "origin", Exists: false}, + }}, + {Name: "other-local", TipHash: "eeeeeeeeeeeeeeee", TipUnix: 1_700_000_004, Current: false, Locations: []scanner.BranchLocation{ + {Name: "local", Exists: true, TipHash: "eeeeeeeeeeeeeeee", TipUnix: 1_700_000_004}, + {Name: "origin", Exists: false}, + }}, + }, cfg) + m := newTestModel() m.config = cfg m.repoList = []string{"/repo"} - 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}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - }}, - {Name: "wip/hidden", TipHash: "dddddddddddddddd", TipUnix: 1_700_000_003, Current: false, Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "dddddddddddddddd", TipUnix: 1_700_000_003}, - {Name: "origin", Exists: false}, - }}, - {Name: "other-local", TipHash: "eeeeeeeeeeeeeeee", TipUnix: 1_700_000_004, Current: false, Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "eeeeeeeeeeeeeeee", TipUnix: 1_700_000_004}, - {Name: "origin", Exists: false}, - }}, - }, - }, - }) + m.repositories.AddResult("/repo", st) m.refreshBranchContent(60) rows := m.branchTable.Rows() @@ -421,7 +395,7 @@ branches: t.Fatalf("branch rows len = %d, want 2 (wip/hidden omitted)", len(rows)) } got := map[string]bool{rows[0][0]: true, rows[1][0]: true} - for _, name := range []string{"aaa", "other-local"} { + for _, name := range []string{"*aaa", "other-local"} { if !got[name] { t.Fatalf("branch rows = [%s %s], want aaa and other-local", rows[0][0], rows[1][0]) } @@ -450,28 +424,20 @@ branches: t.Fatalf("ParseConfigFile: %v", err) } + st := createRepoStatus([]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}, + {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, + }}, + {Name: "main", TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002, Current: false, Locations: []scanner.BranchLocation{ + {Name: "local", Exists: true, TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002}, + {Name: "origin", Exists: false}, + }}, + }, cfg) m := newTestModel() m.config = cfg m.repoList = []string{"/repo"} - 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}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - }}, - {Name: "main", TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002, Current: false, Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002}, - {Name: "origin", Exists: false}, - }}, - }, - }, - }) + m.repositories.AddResult("/repo", st) m.refreshBranchContent(60) rows := m.branchTable.Rows() @@ -479,7 +445,7 @@ branches: t.Fatalf("branch rows len = %d, want 2 (main shown despite hide regex)", len(rows)) } got := map[string]bool{rows[0][0]: true, rows[1][0]: true} - for _, name := range []string{"aaa", "main"} { + for _, name := range []string{"*aaa", "main"} { if !got[name] { t.Fatalf("branch rows = [%s %s], want aaa and main", rows[0][0], rows[1][0]) } @@ -505,34 +471,25 @@ branches: t.Fatalf("ParseConfigFile: %v", err) } + st := createRepoStatus([]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}, + {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, + {Name: "upstream", Exists: false}, + }}, + {Name: "main", TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002, Current: false, Locations: []scanner.BranchLocation{ + {Name: "local", Exists: true, TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002}, + {Name: "origin", Exists: true, TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002}, + }}, + {Name: "zzz", TipHash: "dddddddddddddddd", TipUnix: 1_700_000_003, Current: false, Locations: []scanner.BranchLocation{ + {Name: "local", Exists: true, TipHash: "dddddddddddddddd", TipUnix: 1_700_000_003}, + {Name: "origin", Exists: true, TipHash: "dddddddddddddddd", TipUnix: 1_700_000_003}, + }}, + }, cfg) m := newTestModel() m.config = cfg m.repoList = []string{"/repo"} - 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}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - {Name: "upstream", Exists: false}, - }}, - {Name: "main", TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002, Current: false, Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002}, - {Name: "origin", Exists: true, TipHash: "cccccccccccccccc", TipUnix: 1_700_000_002}, - }}, - {Name: "zzz", TipHash: "dddddddddddddddd", TipUnix: 1_700_000_003, Current: false, Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "dddddddddddddddd", TipUnix: 1_700_000_003}, - {Name: "origin", Exists: true, TipHash: "dddddddddddddddd", TipUnix: 1_700_000_003}, - }}, - }, - }, - }) + m.repositories.AddResult("/repo", st) m.refreshBranchContent(60) rows := m.branchTable.Rows() @@ -540,7 +497,7 @@ branches: t.Fatalf("branch rows len = %d, want 2 (zzz in sync omitted, main listed as default)", len(rows)) } got := map[string]bool{rows[0][0]: true, rows[1][0]: true} - for _, name := range []string{"aaa", "main"} { + for _, name := range []string{"*aaa", "main"} { if !got[name] { t.Fatalf("branch rows = [%s %s], want aaa and main", rows[0][0], rows[1][0]) } diff --git a/ui/update.go b/ui/update.go index f69fed9..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() { @@ -299,7 +286,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 { @@ -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..6346149 100644 --- a/ui/update_view_test.go +++ b/ui/update_view_test.go @@ -352,7 +352,7 @@ func TestHandleScanTickFinishesScan(t *testing.T) { m.scanProgressCh <- scanner.ScanProgress{ReposFound: 1, ReposChecked: 0} m.scanProgressCh <- scanner.ScanProgress{ReposFound: 2, ReposChecked: 1} scanMgs := scanner.NewMultiGitStatus() - scanMgs.Set("/repo", scanner.RepoStatus{}) + scanMgs.AddResult("/repo", scanner.RepoStatus{}) m.scanResultCh <- scanResult{mgs: scanMgs} _, cmd := m.handleScanTick() @@ -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) { @@ -426,7 +391,7 @@ func TestDeleteRepoDKey(t *testing.T) { m.height = 30 m.focus = paneRepo m.repoList = []string{tmp} - m.repositories.Set(tmp, scanner.RepoStatus{}) + m.repositories.AddResult(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 594c17c..beba487 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. @@ -126,7 +124,6 @@ func (m *model) helpPanel() string { "s Scan / rescan", "e Open selected repository (edit.command in config)", "t Open a new terminal in the selected repo (from TERM_PROGRAM when known)", - "w Repositories focused: why this repository is in the list", "D Repo list: delete the repository directory (confirm)", " Status or Diff with a file row: delete that path under the repo (confirm)", "q Ctrl+C Quit (works from this overlay too)", @@ -147,34 +144,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 +420,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)) } diff --git a/ui/view_test.go b/ui/view_test.go index 0b4a932..693ea5b 100644 --- a/ui/view_test.go +++ b/ui/view_test.go @@ -64,15 +64,16 @@ func TestBranchTableViewFitsInnerWidth(t *testing.T) { m.width = 100 m.height = 30 m.repoList = []string{"/repo"} - m.repositories.Set("/repo", scanner.RepoStatus{ - Branches: scanner.BranchStatus{ - Branch: "main", + m.repositories.AddResult("/repo", scanner.RepoStatus{ + Branch: "main", + Branches: []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() _, bi := m.middleRowColumnInnerWidths()