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..66b9642 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,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/main_test.go b/main_test.go index 1b03abb..06965ad 100644 --- a/main_test.go +++ b/main_test.go @@ -1,13 +1,49 @@ package main import ( + "os" "strings" "testing" + + "github.com/boyvinall/dirtygit/scanner" ) +func expandEnvInConfig(config *scanner.Config) { + for i := range config.ScanDirs.Include { + config.ScanDirs.Include[i] = os.ExpandEnv(config.ScanDirs.Include[i]) + } + for i := range config.ScanDirs.Exclude { + config.ScanDirs.Exclude[i] = os.ExpandEnv(config.ScanDirs.Exclude[i]) + } +} + func TestGetDefaultConfigPathUsesHomeAndSuffix(t *testing.T) { p := getDefaultConfigPath() if !strings.HasSuffix(p, ".dirtygit.yml") { t.Fatalf("expected path to end with .dirtygit.yml, got %q", p) } } + +func TestValidateReport_Integration(t *testing.T) { + configPath := getDefaultConfigPath() + if _, err := os.Stat(configPath); err != nil { + t.Skipf("default config not found at %s", configPath) + } + + config, err := scanner.ParseConfigFile(configPath, defaultConfig) + if err != nil { + t.Fatalf("parse config: %v", err) + } + expandEnvInConfig(config) + + mgs, err := scanner.Scan(config) + if err != nil { + t.Fatalf("scan: %v", err) + } + + r := buildReport(config, mgs) + + for _, v := range validateReport(config, r) { + t.Error(v) + } +} diff --git a/report.go b/report.go new file mode 100644 index 0000000..86f3ee9 --- /dev/null +++ b/report.go @@ -0,0 +1,180 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/urfave/cli/v3" + + "github.com/boyvinall/dirtygit/scanner" +) + +// reportLocationEntry mirrors scanner.BranchLocation for JSON output. +type reportLocationEntry struct { + Name string `json:"name"` + Exists bool `json:"exists"` + TipHash string `json:"tip_hash"` + TipUnix int64 `json:"tip_unix"` + UniqueCount int `json:"unique_count"` + Incoming int `json:"incoming"` + Outgoing int `json:"outgoing"` + HistoriesUnrelated bool `json:"histories_unrelated"` +} + +// reportBranchEntry is one local branch row in the report. +type reportBranchEntry struct { + scanner.LocalBranchRef + + // ShownInTUI is true when this branch appears in the TUI Branches pane for its + // repository (HasTipMismatchAcrossRemotes or listed under branches.default). + ShownInTUI bool `json:"shown_in_tui"` + 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(config *scanner.Config, mgs *scanner.MultiGitStatus) report { + paths := mgs.SortedRepoPaths() + repos := make([]reportRepo, 0, len(paths)) + + for _, path := range paths { + rs, ok := mgs.Get(path) + if !ok { + continue + } + + // Files + files := make([]reportFileEntry, 0, len(rs.Porcelain.Entries)) + for _, e := range rs.Porcelain.Entries { + files = append(files, reportFileEntry{ + Staging: string(e.Staging), + Worktree: string(e.Worktree), + Path: e.Path, + OriginalPath: e.OriginalPath, + }) + } + + // 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.LocalBranches)) + for _, lb := range rs.Branches.LocalBranches { + _, survived := filteredSet[lb.Name] + + branches = append(branches, reportBranchEntry{ + LocalBranchRef: lb, + ShownInTUI: survived, + IsLocalOnly: lb.IsLocalOnly(), + }) + } + + repos = append(repos, reportRepo{ + Path: path, + IsClean: rs.IsClean(), + Files: files, + CurrentBranch: rs.Branches.Branch, + Detached: rs.Branches.Detached, + Branches: branches, + }) + } + + return report{Repos: repos} +} + +func runReport(config *scanner.Config, outputFile string) error { + mgs, err := scanner.Scan(config) + if err != nil { + return err + } + + r := buildReport(config, mgs) + if outputFile != "" { + f, err := os.Create(outputFile) + if err != nil { + return err + } + defer f.Close() + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + err = enc.Encode(r) + if err != nil { + return err + } + } + + printReportSummary(r) + return nil +} + +func printReportSummary(r report) { + 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..8608c30 100644 --- a/scanner/branch_status.go +++ b/scanner/branch_status.go @@ -9,9 +9,9 @@ import ( ) // haveMergeBase reports whether git finds a common ancestor for the two commits. -func haveMergeBase(d, commitA, commitB string) (bool, error) { +func haveMergeBase(dir, commitA, commitB string) (bool, error) { cmd := exec.Command("git", "merge-base", commitA, commitB) - cmd.Dir = d + cmd.Dir = dir err := cmd.Run() if err == nil { return true, nil @@ -24,8 +24,8 @@ func haveMergeBase(d, commitA, commitB string) (bool, error) { // listLocalBranches returns all refs/heads sorted by name. When detached is false, // currentName is the checked-out branch name and that row has Current set. -func listLocalBranches(d, currentName string, detached bool) ([]LocalBranchRef, error) { - out, err := runGit(d, "for-each-ref", "refs/heads", "--sort=refname", +func listLocalBranches(dir, currentName string, detached bool) ([]LocalBranchRef, error) { + out, err := runGit(dir, "for-each-ref", "refs/heads", "--sort=refname", "--format=%(refname:short)\t%(objectname)\t%(committerdate:unix)") if err != nil { return nil, err @@ -60,30 +60,30 @@ func listLocalBranches(d, currentName string, detached bool) ([]LocalBranchRef, return refs, nil } -func runGit(d string, args ...string) (string, error) { +func runGit(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) - cmd.Dir = d + cmd.Dir = dir out, err := cmd.Output() if err != nil { - return "", fmt.Errorf("%s: %w", d, err) + return "", fmt.Errorf("%s: %w", dir, err) } return strings.TrimSpace(string(out)), nil } -func currentBranch(d string) (name string, detached bool, err error) { - name, err = runGit(d, "symbolic-ref", "--quiet", "--short", "HEAD") +func currentBranch(dir string) (name string, detached bool, err error) { + name, err = runGit(dir, "symbolic-ref", "--quiet", "--short", "HEAD") if err == nil { return name, false, nil } - head, headErr := runGit(d, "rev-parse", "--short", "HEAD") + head, headErr := runGit(dir, "rev-parse", "--short", "HEAD") if headErr != nil { return "", false, headErr } return head, true, nil } -func listRemotes(d string) ([]string, error) { - out, err := runGit(d, "remote") +func listRemotes(dir string) ([]string, error) { + out, err := runGit(dir, "remote") if err != nil { return nil, err } @@ -95,8 +95,8 @@ func listRemotes(d string) ([]string, error) { return remotes, nil } -func refTip(d, ref string) (hash string, unix int64, exists bool, err error) { - out, err := runGit(d, "show", "-s", "--format=%H %ct", ref) +func refTip(dir, ref string) (hash string, unix int64, exists bool, err error) { + out, err := runGit(dir, "show", "-s", "--format=%H %ct", ref) if err != nil { return "", 0, false, nil } @@ -120,13 +120,13 @@ func branchLocationRef(locationName, branchName string) string { return "refs/remotes/" + locationName + "/" + branchName } -func uniqueCommitCount(d, ref string, otherRefs []string) (count int, err error) { +func uniqueCommitCount(dir, ref string, otherRefs []string) (count int, err error) { if len(otherRefs) == 0 { return 0, nil } args := []string{"rev-list", "--count", ref, "--not"} args = append(args, otherRefs...) - out, err := runGit(d, args...) + out, err := runGit(dir, args...) if err != nil { return 0, err } @@ -139,7 +139,7 @@ func uniqueCommitCount(d, ref string, otherRefs []string) (count int, err error) // computeBranchLocations compares refs/heads/ to refs/remotes// // for each configured remote and fills UniqueCount per location. -func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLocation, error) { +func computeBranchLocations(dir, branchName string, remotes []string) ([]BranchLocation, error) { locations := make([]BranchLocation, 0, 1+len(remotes)) locations = append(locations, BranchLocation{Name: "local"}) for _, remote := range remotes { @@ -148,7 +148,7 @@ func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLoc for i := range locations { ref := branchLocationRef(locations[i].Name, branchName) - hash, unix, exists, err := refTip(d, ref) + hash, unix, exists, err := refTip(dir, ref) if err != nil { return nil, err } @@ -174,7 +174,7 @@ func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLoc others = append(others, otherRef) } } - count, err := uniqueCommitCount(d, ref, others) + count, err := uniqueCommitCount(dir, ref, others) if err != nil { return nil, err } @@ -187,7 +187,7 @@ func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLoc if !locations[i].Exists { continue } - related, err := haveMergeBase(d, locations[0].TipHash, locations[i].TipHash) + related, err := haveMergeBase(dir, locations[0].TipHash, locations[i].TipHash) if err != nil { return nil, err } @@ -196,11 +196,11 @@ func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLoc continue } remoteRef := branchLocationRef(locations[i].Name, branchName) - incoming, err := uniqueCommitCount(d, remoteRef, []string{localRef}) + incoming, err := uniqueCommitCount(dir, remoteRef, []string{localRef}) if err != nil { return nil, err } - outgoing, err := uniqueCommitCount(d, localRef, []string{remoteRef}) + outgoing, err := uniqueCommitCount(dir, localRef, []string{remoteRef}) if err != nil { return nil, err } @@ -212,66 +212,101 @@ 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) (BranchStatus, error) { + branch, detached, err := currentBranch(dir) if err != nil { return BranchStatus{}, err } if detached { - locals, listErr := listLocalBranches(d, branch, true) + locals, listErr := listLocalBranches(dir, branch, true) if listErr != nil { return BranchStatus{}, listErr } return BranchStatus{Branch: branch, Detached: true, LocalBranches: locals}, nil } - remotes, err := listRemotes(d) + remotes, err := listRemotes(dir) if err != nil { return BranchStatus{}, err } - locals, err := listLocalBranches(d, branch, false) + locals, err := listLocalBranches(dir, branch, false) if err != nil { return BranchStatus{}, err } if len(locals) == 0 { - locations, err := computeBranchLocations(d, branch, remotes) + locations, err := computeBranchLocations(dir, branch, remotes) if err != nil { return BranchStatus{}, err } + tipHash, tipUnix := tipFromLocalBranchLocation(locations) return BranchStatus{ - Branch: branch, - Detached: false, - Locations: locations, - LocalBranches: nil, + Branch: branch, + Detached: false, + LocalBranches: []LocalBranchRef{{ + Name: branch, + TipHash: tipHash, + TipUnix: tipUnix, + Current: true, + Locations: locations, + }}, }, nil } - var topLocations []BranchLocation for i := range locals { - locs, err := computeBranchLocations(d, locals[i].Name, remotes) + locs, err := computeBranchLocations(dir, locals[i].Name, remotes) if err != nil { return BranchStatus{}, err } locals[i].Locations = locs + } + + var foundCurrent bool + for i := range locals { if locals[i].Current { - topLocations = locs + foundCurrent = true + break } } - - if topLocations == nil { - var err2 error - topLocations, err2 = computeBranchLocations(d, branch, remotes) + if !foundCurrent { + locs, err2 := computeBranchLocations(dir, branch, remotes) if err2 != nil { return BranchStatus{}, err2 } + matched := false + for i := range locals { + if locals[i].Name == branch { + locals[i].Locations = locs + locals[i].Current = true + matched = true + break + } + } + if !matched { + tipHash, tipUnix := tipFromLocalBranchLocation(locs) + locals = append([]LocalBranchRef{{ + Name: branch, + TipHash: tipHash, + TipUnix: tipUnix, + Current: true, + Locations: locs, + }}, locals...) + } } return BranchStatus{ Branch: branch, Detached: false, - Locations: topLocations, LocalBranches: locals, }, nil } diff --git a/scanner/config.go b/scanner/config.go index fce14e2..59b23b1 100644 --- a/scanner/config.go +++ b/scanner/config.go @@ -29,7 +29,6 @@ func ParseConfigFile(filename, defaultConfig string) (*Config, error) { if err := compileLocalOnlyHideRegexes(&config); err != nil { return nil, err } - prepareBranchDefaultSet(&config) return &config, nil } @@ -53,24 +52,3 @@ func compileLocalOnlyHideRegexes(c *Config) error { c.localOnlyHideCompiled = out return nil } - -func prepareBranchDefaultSet(c *Config) { - names := c.Branches.Default - if len(names) == 0 { - c.branchDefaultAlways = nil - return - } - m := make(map[string]struct{}, len(names)) - for _, n := range names { - n = strings.TrimSpace(n) - if n == "" { - continue - } - m[n] = struct{}{} - } - if len(m) == 0 { - c.branchDefaultAlways = nil - return - } - c.branchDefaultAlways = m -} diff --git a/scanner/find.go b/scanner/find.go index fd10cec..8025da6 100644 --- a/scanner/find.go +++ b/scanner/find.go @@ -57,7 +57,7 @@ func walkone(ctx context.Context, dir string, config *Config, results chan strin } if d.IsDir() { - log.Printf("path %s", path) + // log.Printf("path %s", path) } if slices.Contains(config.ScanDirs.Exclude, path) { @@ -77,7 +77,7 @@ func walkone(ctx context.Context, dir string, config *Config, results chan strin return metaErr } if ok { - log.Printf("git %s", path) + // log.Printf("git %s", path) repo := filepath.Dir(path) if onRepoFound != nil { onRepoFound(repo) diff --git a/scanner/inclusion.go b/scanner/inclusion.go deleted file mode 100644 index cd9426f..0000000 --- a/scanner/inclusion.go +++ /dev/null @@ -1,26 +0,0 @@ -package scanner - -import "fmt" - -// RepoInclusionReasons returns human-readable lines explaining why a repository -// is included in the result list, using the same rules as [ScanWithProgress] and -// [StatusForRepo] (uncommitted work and/or a local/remote branch mismatch for the -// current branch). -func RepoInclusionReasons(rs RepoStatus) []string { - var out []string - if !rs.IsClean() { - n := len(rs.Porcelain.Entries) - if n == 0 { - n = len(rs.Status) - } - if n == 0 { - out = append(out, "The working tree or index is not clean (uncommitted change).") - } else { - out = append(out, fmt.Sprintf("Uncommitted changes: %d path(s) in the working tree and/or index (after .dirtygit config filters).", n)) - } - } - if r := rs.Branches.LocalRemoteMismatchReasons(); len(r) > 0 { - out = append(out, r...) - } - return out -} diff --git a/scanner/inclusion_test.go b/scanner/inclusion_test.go deleted file mode 100644 index 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/scan.go b/scanner/scan.go index a8c0ce8..5988269 100644 --- a/scanner/scan.go +++ b/scanner/scan.go @@ -56,9 +56,8 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS for d := range repositories { eg.Go(func() error { - // About to run git status for this repo; set CurrentPath so the scan modal - // shows which directory is active (and keeps showing it through the rest - // of this iteration via the update after GitStatus returns). + // About to run StatusForRepo for this path; set CurrentPath so the scan modal + // shows which directory is active until this worker finishes and the UI updates. reportProgress(onProgress, ScanProgress{ ReposFound: int(found.Load()), ReposChecked: int(checked.Load()), @@ -72,15 +71,14 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS return err } n := checked.Add(1) - // Git status finished for this repo; advance ReposChecked and retain - // CurrentPath until the next channel receive so the path line does not - // flicker to empty while GitBranchStatus and filtering still run. + // Per-repo StatusForRepo finished; advance ReposChecked and retain + // CurrentPath until the next progress event so the path line does not flicker. reportProgress(onProgress, ScanProgress{ ReposFound: int(found.Load()), ReposChecked: int(n), CurrentPath: d, }) - log.Println(d, statusDur) + // log.Println(d, statusDur) if include { atomic.AddInt64(&totalStatusDuration, statusDur.Nanoseconds()) @@ -95,8 +93,8 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS if statusErr != nil { return nil, statusErr } - log.Println("walkDuration:", w.duration) - log.Println("statusDuration:", time.Duration(atomic.LoadInt64(&totalStatusDuration))) + // log.Println("walkDuration:", w.duration) + // log.Println("statusDuration:", time.Duration(atomic.LoadInt64(&totalStatusDuration))) return results, w.err } @@ -119,13 +117,13 @@ func StatusForRepo(config *Config, dir string) (RepoStatus, bool, error) { if err != nil { log.Printf("branch status scan failed for %s: %v", dir, err) } - branches.FilterLocalOnlyForConfig(config) rs := RepoStatus{ - Status: st, - Porcelain: porcelain, - Branches: branches, + Status: st, + Porcelain: porcelain, + Branches: branches, + FilteredBranches: branches.Filter(config), } - include := !st.IsClean() || branches.HasLocalRemoteMismatch() + include := !st.IsClean() || rs.Branches.HasUnpushedChanges(config) return rs, include, nil } diff --git a/scanner/scan_test.go b/scanner/scan_test.go index 5ab7496..59ba9f2 100644 --- a/scanner/scan_test.go +++ b/scanner/scan_test.go @@ -180,34 +180,6 @@ branches: } } -func TestParseConfigFileDefaultBranches(t *testing.T) { - tmp := t.TempDir() - cfgPath := filepath.Join(tmp, "defaults.yml") - content := ` -scandirs: - include: - - /opt/repos -branches: - default: - - main - - master -` - if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { - t.Fatalf("write config: %v", err) - } - - cfg, err := ParseConfigFile(cfgPath, "") - if err != nil { - t.Fatalf("ParseConfigFile() error = %v", err) - } - if !cfg.AlwaysListBranch("main") || !cfg.AlwaysListBranch("master") { - t.Fatalf("AlwaysListBranch: main=%v master=%v", cfg.AlwaysListBranch("main"), cfg.AlwaysListBranch("master")) - } - if cfg.AlwaysListBranch("develop") { - t.Fatal("AlwaysListBranch(develop) should be false") - } -} - func TestStatusForRepo(t *testing.T) { tmp := t.TempDir() gitMinimalInit(t, tmp) diff --git a/scanner/types.go b/scanner/types.go index ebf12dd..25d5993 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -9,17 +9,26 @@ import ( "github.com/go-git/go-git/v5" ) +// RepoStatus aggregates one repository's working tree and branch metadata: +// parsed git status --porcelain (Porcelain), HEAD and local-branch layout with +// remote tips (Branches), and an embedded [git.Status] rebuilt from Porcelain +// for code that expects go-git's map form. type RepoStatus struct { git.Status - Porcelain PorcelainStatus - Branches BranchStatus + Porcelain PorcelainStatus + Branches BranchStatus + FilteredBranches []LocalBranchRef } +// BranchStatus is HEAD identity plus an ordered list of local branch refs and +// per-branch local vs same-named remote comparison rows. type BranchStatus struct { - Branch string - Detached bool - Locations []BranchLocation + // Branch is the checked-out branch short name, or when Detached the short + // HEAD object name (see git rev-parse --short HEAD). + Branch string + // Detached is true when HEAD is not on a branch (detached HEAD). + Detached bool // LocalBranches lists refs/heads in name order (from git for-each-ref). LocalBranches []LocalBranchRef } @@ -28,44 +37,58 @@ type BranchStatus struct { // Locations holds local vs same-named remote refs (refs/remotes//); // it is empty when detached or before GitBranchStatus fills it. type LocalBranchRef struct { - Name string - TipHash string - TipUnix int64 - Current bool + // Name is the short branch name (the refs/heads/* ref without the prefix). + Name string + // TipHash is the full object name of the local ref tip; TipUnix is the tip + // commit's committer date in Unix seconds (from branch listing / git show). + TipHash string + TipUnix int64 + // Current is true when this row is the checked-out branch. + Current bool + // Locations compares this local branch to same-named refs on each configured + // remote; see [BranchLocation]. Empty when detached or before branch scan fills it. Locations []BranchLocation } -// HasTipMismatchAcrossRemotes reports whether the branch should appear in the -// branch pane: true when Locations is empty (e.g. detached), there are no -// configured remotes, any same-named remote ref is missing, or any remote tip -// differs from the local tip. False only when every remote has the ref and each -// tip matches local. -func (lb LocalBranchRef) HasTipMismatchAcrossRemotes() bool { - if len(lb.Locations) == 0 { - return true - } - var local *BranchLocation - for i := range lb.Locations { - if lb.Locations[i].Name == "local" { - local = &lb.Locations[i] - break - } - } - if local == nil || !local.Exists { - return true +func (lbr *LocalBranchRef) GetDisplayName() string { + if lbr.Current { + return "*" + lbr.Name } - 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 +111,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 (b *BranchStatus) CurrentBranchLocations() []BranchLocation { + if b == nil || b.Detached { + return nil + } + for i := range b.LocalBranches { + if b.LocalBranches[i].Current { + return b.LocalBranches[i].Locations + } + } + return nil } -// HasLocalRemoteMismatch reports whether the current local branch differs from -// any tracked remote location for the same branch name. A clean repo that is -// only behind the remote (incoming commits, nothing to push) is not a mismatch. -func (b *BranchStatus) HasLocalRemoteMismatch() bool { - if b.Detached { +func (b *BranchStatus) HasUnpushedChanges(c *Config) bool { + for _, lb := range b.LocalBranches { + if c.ShouldHideLocalOnlyBranch(lb) { + continue + } + if lb.HasUnpushedChanges() { + return true + } + } + return false +} + +// 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 +157,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,24 +174,31 @@ 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 (b *BranchStatus) Filter(c *Config) []LocalBranchRef { out := make([]LocalBranchRef, 0) - for _, lb := range refs { - if c.ShouldHideLocalOnlyBranch(lb) && !c.AlwaysListBranch(lb.Name) { + for _, lb := range b.LocalBranches { + if lb.Current { + out = append(out, lb) + continue + } + if !lb.HasUnpushedChanges() { + continue + } + if c.ShouldHideLocalOnlyBranch(lb) { continue } out = append(out, lb) } - b.LocalBranches = out + return out } // LocalRemoteMismatchReasons returns a short line explaining why @@ -167,10 +207,14 @@ func (b *BranchStatus) LocalRemoteMismatchReasons() []string { if b.Detached { return nil } + locs := b.CurrentBranchLocations() + if len(locs) == 0 { + return nil + } var local *BranchLocation - for i := range b.Locations { - if b.Locations[i].Name == "local" { - local = &b.Locations[i] + for i := range locs { + if locs[i].Name == "local" { + local = &locs[i] break } } @@ -182,8 +226,8 @@ func (b *BranchStatus) LocalRemoteMismatchReasons() []string { branchName = "current branch" } hasRemote := false - for i := range b.Locations { - loc := b.Locations[i] + for i := range locs { + loc := locs[i] if loc.Name == "local" { continue } @@ -216,13 +260,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 +287,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 +326,15 @@ type Config struct { Command []string `yaml:"command"` } `yaml:"edit"` localOnlyHideCompiled []*regexp.Regexp - branchDefaultAlways map[string]struct{} } // ShouldHideLocalOnlyBranch returns true when lb is local-only (see // LocalBranchRef.IsLocalOnly) and its short branch name matches any pattern in // branches.hidelocalonly.regex. func (c *Config) ShouldHideLocalOnlyBranch(lb LocalBranchRef) bool { + if c == nil { + return false + } if len(c.localOnlyHideCompiled) == 0 { return false } @@ -294,17 +349,6 @@ func (c *Config) ShouldHideLocalOnlyBranch(lb LocalBranchRef) bool { return false } -// AlwaysListBranch reports whether name is listed under branches.default -// (after trim); those branches are listed in the pane whenever they exist -// locally, regardless of remote tip agreement. -func (c *Config) AlwaysListBranch(name string) bool { - if len(c.branchDefaultAlways) == 0 { - return false - } - _, ok := c.branchDefaultAlways[name] - return ok -} - const editRepoPlaceholder = "{repo}" // EditArgv returns the argv for opening absRepo in an external editor or IDE. diff --git a/scanner/types_test.go b/scanner/types_test.go index 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/model.go b/ui/model.go index 4fd8f47..0f79985 100644 --- a/ui/model.go +++ b/ui/model.go @@ -112,8 +112,7 @@ type model struct { err error - helpOpen bool - whyRepoOpen bool + helpOpen bool // deleteRepoConfirmOpen shows the recursive-delete confirmation for the selected repository path. deleteRepoConfirmOpen bool // deleteStatusFileConfirmOpen asks before deleting the selected status path from disk. diff --git a/ui/status_layout.go b/ui/status_layout.go index bc38219..27f8b95 100644 --- a/ui/status_layout.go +++ b/ui/status_layout.go @@ -282,7 +282,7 @@ func statusColumns(totalWidth int) []table.Column { } } -// branchRowColumns sizes the branch pane: one row per local branch name. +// branchRowColumns sizes the branch pane columns for branch summary rows. // Account for Padding(0, 1) on every header and cell (see statusColumns). func branchRowColumns(totalWidth int) []table.Column { const cols = 4 @@ -306,13 +306,16 @@ func branchRowColumns(totalWidth int) []table.Column { } } -// branchRemoteSummary compresses local vs remote tips for the checked-out branch -// when BranchStatus.Locations is populated (e.g. no local heads listed). +// branchRemoteSummary compresses local vs remote tips for the checked-out branch. func branchRemoteSummary(b scanner.BranchStatus) string { - if b.Detached || len(b.Locations) == 0 { + if b.Detached { return "-" } - return branchRemoteSummaryFromLocations(b.Locations) + locs := b.CurrentBranchLocations() + if len(locs) == 0 { + return "-" + } + return branchRemoteSummaryFromLocations(locs) } func branchRemoteSummaryFromLocations(locations []scanner.BranchLocation) string { @@ -375,7 +378,8 @@ func sortLocalBranchesByTipNewestFirst(branches []scanner.LocalBranchRef) { }) } -// refreshBranchContent rebuilds the branch pane: one table row per local branch name. +// refreshBranchContent rebuilds the branch pane: one table row per local branch +// that the pane lists (tip mismatch vs remotes, local-only hide rules, and defaults). func (m *model) refreshBranchContent(totalWidth int) { cols := branchRowColumns(totalWidth) m.branchTable.SetColumns(cols) @@ -387,68 +391,44 @@ func (m *model) refreshBranchContent(totalWidth int) { m.branchTable.SetHeight(layoutMinBodyLines) return } - branch := st.Branches - - if branch.Detached { - locals := append([]scanner.LocalBranchRef(nil), branch.LocalBranches...) - sortLocalBranchesByTipNewestFirst(locals) - rows := make([]table.Row, 0, 1+len(locals)) - rows = append(rows, table.Row{"(detached HEAD)", shortHash(branch.Branch), "-", "-"}) - for _, lb := range locals { - always := m.config != nil && m.config.AlwaysListBranch(lb.Name) - if m.config != nil && m.config.ShouldHideLocalOnlyBranch(lb) && !always { - continue - } - rows = append(rows, table.Row{ - lb.Name, - shortHash(lb.TipHash), - relativeTime(lb.TipUnix), - "-", - }) - } - m.branchTable.SetRows(rows) - m.branchTable.SetHeight(max(4, len(rows)+1)) - return - } - if len(branch.LocalBranches) == 0 { - remote := branchRemoteSummary(branch) - tip := "-" - when := "-" - for _, loc := range branch.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...) + // 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)) } @@ -601,7 +581,7 @@ func (m *model) repoPaneReady() bool { // interactiveAppReady is true when the main TUI (not a modal) is on screen and idle. func (m *model) interactiveAppReady() bool { return !m.helpOpen && !m.deleteRepoConfirmOpen && !m.deleteStatusFileConfirmOpen && - !m.checkoutStatusFileConfirmOpen && !m.whyRepoOpen && !m.scanning && m.err == nil + !m.checkoutStatusFileConfirmOpen && !m.scanning && m.err == nil } // mouseFocusClickReady is true when left-click to change pane focus is allowed. diff --git a/ui/status_layout_test.go b/ui/status_layout_test.go index b426163..116af49 100644 --- a/ui/status_layout_test.go +++ b/ui/status_layout_test.go @@ -330,11 +330,6 @@ func TestRefreshBranchContentOneRowPerBranch(t *testing.T) { m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", - Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - {Name: "upstream", Exists: false}, - }, // Names sort opposite to recency so the test proves UI order is by tip time, not name. LocalBranches: []scanner.LocalBranchRef{ {Name: "aaa", TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, Current: true, Locations: []scanner.BranchLocation{ @@ -394,10 +389,6 @@ branches: m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", - Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - }, LocalBranches: []scanner.LocalBranchRef{ {Name: "aaa", TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, Current: true, Locations: []scanner.BranchLocation{ {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, @@ -456,10 +447,6 @@ branches: m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", - Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - }, LocalBranches: []scanner.LocalBranchRef{ {Name: "aaa", TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, Current: true, Locations: []scanner.BranchLocation{ {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, @@ -511,11 +498,6 @@ branches: m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "aaa", - Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - {Name: "upstream", Exists: false}, - }, LocalBranches: []scanner.LocalBranchRef{ {Name: "aaa", TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, Current: true, Locations: []scanner.BranchLocation{ {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, diff --git a/ui/update.go b/ui/update.go index f69fed9..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..ba82732 100644 --- a/ui/update_view_test.go +++ b/ui/update_view_test.go @@ -382,41 +382,6 @@ func TestHandleSpinnerTickWhenScanning(t *testing.T) { } } -// TestWhyInclusionWKey checks that w opens the inclusion modal from the repository pane and Esc closes it. -func TestWhyInclusionWKey(t *testing.T) { - m := newTestModel() - m.width = 100 - m.height = 30 - m.focus = paneRepo - m.repoList = []string{"/r"} - m.cursor = 0 - m.repositories.Set("/r", scanner.RepoStatus{}) - - _, _, handled := m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) - if !handled { - t.Fatal("w should be handled in repo pane") - } - if !m.whyRepoOpen { - t.Fatal("w should open why-inclusion modal") - } - - s := m.renderWhyInclusionOverlay() - if !strings.Contains(s, "Why is this repository") { - t.Fatalf("modal should show title, got: %q", s) - } - - mod, _ := m.handleWhyRepoOverlayKey(tea.KeyMsg{Type: tea.KeyEsc}) - if mod.(*model).whyRepoOpen { - t.Fatal("esc should close why-inclusion modal") - } - - m.focus = paneStatus - _, _, handled = m.handleCommandKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) - if handled { - t.Fatal("w with status pane focused should not be handled as command (pass through to navigation)") - } -} - // TestDeleteRepoDKey checks D opens the delete confirm modal, default highlights No, and // successful confirmation removes the path from disk and the repo list. func TestDeleteRepoDKey(t *testing.T) { diff --git a/ui/view.go b/ui/view.go index 594c17c..f7b7d0b 100644 --- a/ui/view.go +++ b/ui/view.go @@ -6,8 +6,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - - "github.com/boyvinall/dirtygit/scanner" ) // placeCenteredDimModal centers content on the full terminal with a dim tiled backdrop. @@ -147,34 +145,6 @@ func (m *model) helpPanel() string { return m.framedBlock(paneRepo, w, h, "Keyboard shortcuts", padded) } -// renderWhyInclusionOverlay shows why the selected repository appears in the list. -func (m *model) renderWhyInclusionOverlay() string { - repo := m.currentRepo() - boxW := min(m.width-layoutModalSideGutter, layoutWhyAndConfirmModalMaxBox) - if boxW < layoutModalBoxMinWidth { - boxW = min(m.width-2, layoutModalBoxMinWidth) - } - innerW := max(layoutMinInnerContentWidth, boxW-layoutModalSideGutter) - if repo == "" { - return m.placeCenteredDimModal(roundedModal(boxW).Render("No repository selected.")) - } - rs, ok := m.repositories.Get(repo) - if !ok { - return m.placeCenteredDimModal(roundedModal(boxW).Render("No status data for this path.")) - } - lines := scanner.RepoInclusionReasons(rs) - if len(lines) == 0 { - lines = []string{"No inclusion reason (unexpected for a listed repository)."} - } - reasons := strings.Join(lines, "\n\n") - wrapped := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW).Render(reasons) - t := styleBold.Render("Why is this repository listed?") - sub := styleDim.Render(truncateASCII(repo, innerW)) - foot := styleDim.Render("w or Esc to close") - inner := strings.Join([]string{t, "", sub, "", wrapped, "", foot}, "\n") - return m.placeCenteredDimModal(roundedModal(boxW).Render(inner)) -} - // renderDeleteRepoConfirmOverlay asks whether to recursively delete the selected repository directory. func (m *model) renderDeleteRepoConfirmOverlay() string { repo := m.currentRepo() @@ -451,9 +421,6 @@ func (m *model) View() string { if m.checkoutStatusFileConfirmOpen { return m.renderCheckoutStatusFileConfirmOverlay() } - if m.whyRepoOpen { - return m.renderWhyInclusionOverlay() - } if m.height < layoutMinTermHeight { return styleErr.Render(fmt.Sprintf("Need bigger screen (min height %d).", layoutMinTermHeight)) } diff --git a/ui/view_test.go b/ui/view_test.go index 0b4a932..97dc160 100644 --- a/ui/view_test.go +++ b/ui/view_test.go @@ -67,11 +67,14 @@ func TestBranchTableViewFitsInnerWidth(t *testing.T) { m.repositories.Set("/repo", scanner.RepoStatus{ Branches: scanner.BranchStatus{ Branch: "main", - Locations: []scanner.BranchLocation{ - {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, - {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, - {Name: "upstream", Exists: false}, - }, + LocalBranches: []scanner.LocalBranchRef{{ + Name: "main", TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, Current: true, + Locations: []scanner.BranchLocation{ + {Name: "local", Exists: true, TipHash: "aaaaaaaaaaaaaaaa", TipUnix: 1_700_000_000, UniqueCount: 2}, + {Name: "origin", Exists: true, TipHash: "bbbbbbbbbbbbbbbb", TipUnix: 1_700_000_001, UniqueCount: 1, Incoming: 1, Outgoing: 2}, + {Name: "upstream", Exists: false}, + }, + }}, }, }) m.syncViewports()