diff --git a/README.md b/README.md index 7938e2a..f0fa808 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ reaches your git server. > Use **System Settings → Privacy & Security** and click **Open Anyway** for `dirtygit`, or right-click the > binary in Finder and choose **Open** once. > -> This will not be necessary if you install from source. +> This will not be necessary if you install from [#source](#source). ### Homebrew diff --git a/scanner/scan.go b/scanner/scan.go index e4c46f1..8335e01 100644 --- a/scanner/scan.go +++ b/scanner/scan.go @@ -98,7 +98,7 @@ func ScanWithProgress(config *Config, onProgress func(ScanProgress)) (*MultiGitS log.Printf("branch status scan failed for %s: %v", d, err) } - if !st.IsClean() || branches.HasLocalRemoteMismatch() { + if !st.IsClean() || branches.HasLocalRemoteMismatchRespectingConfig(config) { atomic.AddInt64(&totalStatusDuration, duration.Nanoseconds()) results.AddResult(d, RepoStatus{ Status: st, @@ -148,6 +148,6 @@ func StatusForRepo(config *Config, dir string) (RepoStatus, bool, error) { Branches: branches, ScanTime: time.Since(start), } - include := !st.IsClean() || branches.HasLocalRemoteMismatch() + include := !st.IsClean() || branches.HasLocalRemoteMismatchRespectingConfig(config) return rs, include, nil } diff --git a/scanner/types.go b/scanner/types.go index 5bad586..17cea52 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -2,6 +2,7 @@ package scanner import ( "fmt" + "log" "os" "regexp" "strings" @@ -149,6 +150,45 @@ func (b BranchStatus) HasLocalRemoteMismatch() bool { return hasRemote && local.UniqueCount > 0 } +// currentLocalRefForHidePolicy returns the checked-out branch as a [LocalBranchRef] +// for [Config.ShouldHideLocalOnlyBranch], or false when detached or indeterminate. +func (b BranchStatus) currentLocalRefForHidePolicy() (LocalBranchRef, bool) { + if b.Detached { + return LocalBranchRef{}, false + } + for i := range b.LocalBranches { + if b.LocalBranches[i].Current { + return b.LocalBranches[i], true + } + } + if b.Branch != "" && len(b.Locations) > 0 { + return LocalBranchRef{Name: b.Branch, Locations: b.Locations}, true + } + return LocalBranchRef{}, false +} + +// HasLocalRemoteMismatchRespectingConfig is like [BranchStatus.HasLocalRemoteMismatch] +// but returns false when the only effective concern is a local-only current branch +// that [Config.ShouldHideLocalOnlyBranch] would filter out. Branches listed under +// branches.default still count as a mismatch (same as the branch pane). +func (b BranchStatus) HasLocalRemoteMismatchRespectingConfig(c *Config) bool { + if !b.HasLocalRemoteMismatch() { + return false + } + if c == nil { + return true + } + lb, ok := b.currentLocalRefForHidePolicy() + if !ok { + return true + } + if c.ShouldHideLocalOnlyBranch(lb) && !c.AlwaysListBranch(lb.Name) { + log.Println("hiding local-only branch", lb.Name) + return false + } + return true +} + // LocalRemoteMismatchReasons returns a short line explaining why // [BranchStatus.HasLocalRemoteMismatch] is true, or nil when that predicate is false. func (b BranchStatus) LocalRemoteMismatchReasons() []string { diff --git a/scanner/types_test.go b/scanner/types_test.go index d586aa2..63abc8d 100644 --- a/scanner/types_test.go +++ b/scanner/types_test.go @@ -1,6 +1,8 @@ package scanner import ( + "os" + "path/filepath" "reflect" "testing" ) @@ -162,6 +164,109 @@ func TestBranchStatusHasLocalRemoteMismatch(t *testing.T) { } } +func TestBranchStatusHasLocalRemoteMismatchRespectingConfig(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "cfg.yml") + content := ` +scandirs: + include: + - /x +branches: + hidelocalonly: + regex: + - "^wip/" + default: + - wip/release +` + if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := ParseConfigFile(cfgPath, "") + if err != nil { + t.Fatal(err) + } + + localOnlyLocs := []BranchLocation{ + {Name: "local", Exists: true, TipHash: "aaa"}, + {Name: "origin", Exists: false}, + } + + t.Run("nil config unchanged from HasLocalRemoteMismatch", func(t *testing.T) { + t.Parallel() + bs := BranchStatus{ + Branch: "wip/foo", + Locations: localOnlyLocs, + LocalBranches: []LocalBranchRef{ + {Name: "wip/foo", Current: true, Locations: localOnlyLocs}, + }, + } + if !bs.HasLocalRemoteMismatch() { + t.Fatal("sanity: raw mismatch expected") + } + if !bs.HasLocalRemoteMismatchRespectingConfig(nil) { + t.Fatal("nil config should keep mismatch visible") + } + }) + + t.Run("hide local-only wip branch suppresses mismatch", func(t *testing.T) { + t.Parallel() + bs := BranchStatus{ + Branch: "wip/foo", + Locations: localOnlyLocs, + LocalBranches: []LocalBranchRef{ + {Name: "wip/foo", Current: true, Locations: localOnlyLocs}, + }, + } + if bs.HasLocalRemoteMismatchRespectingConfig(cfg) { + t.Fatal("expected hidden local-only branch not to count as mismatch for scan") + } + }) + + t.Run("main branch still mismatches with hide rules", func(t *testing.T) { + t.Parallel() + bs := BranchStatus{ + Branch: "main", + Locations: localOnlyLocs, + LocalBranches: []LocalBranchRef{ + {Name: "main", Current: true, Locations: localOnlyLocs}, + }, + } + if !bs.HasLocalRemoteMismatchRespectingConfig(cfg) { + t.Fatal("expected main to still count as mismatch") + } + }) + + t.Run("branches default overrides hide for scan", func(t *testing.T) { + t.Parallel() + bs := BranchStatus{ + Branch: "wip/release", + Locations: localOnlyLocs, + LocalBranches: []LocalBranchRef{ + {Name: "wip/release", Current: true, Locations: localOnlyLocs}, + }, + } + if !bs.HasLocalRemoteMismatchRespectingConfig(cfg) { + t.Fatal("wip/release is under branches.default and must still count") + } + }) + + t.Run("synthetic current ref when LocalBranches empty", func(t *testing.T) { + t.Parallel() + bs := BranchStatus{ + Branch: "wip/foo", + Detached: false, + Locations: localOnlyLocs, + LocalBranches: nil, + NewestLocation: "local", + } + if bs.HasLocalRemoteMismatchRespectingConfig(cfg) { + t.Fatal("expected synthetic current ref to honor hide policy") + } + }) +} + func TestLocalBranchRefHasTipMismatchAcrossRemotes(t *testing.T) { t.Parallel() diff --git a/ui/status_layout_test.go b/ui/status_layout_test.go index f6915f6..b2c838b 100644 --- a/ui/status_layout_test.go +++ b/ui/status_layout_test.go @@ -429,14 +429,17 @@ branches: } } -func TestRefreshBranchContentAlwaysListsDefaultBranchesInSync(t *testing.T) { +func TestRefreshBranchContentDefaultBranchOverridesLocalOnlyHide(t *testing.T) { tmp := t.TempDir() - cfgPath := filepath.Join(tmp, "dirtygit-defaults.yml") + cfgPath := filepath.Join(tmp, "dirtygit-default-hide.yml") content := ` scandirs: include: - /tmp branches: + hidelocalonly: + regex: + - "^main$" default: - main ` @@ -458,21 +461,15 @@ branches: 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, NewestUniqueUnix: 1_700_000_001, 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, NewestUniqueUnix: 1_700_000_001, 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}, + {Name: "origin", Exists: false}, }}, }, }, @@ -481,7 +478,7 @@ branches: m.refreshBranchContent(60) rows := m.branchTable.Rows() if len(rows) != 2 { - t.Fatalf("branch rows len = %d, want 2 (zzz in sync omitted, main listed as default)", len(rows)) + 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"} { @@ -491,17 +488,14 @@ branches: } } -func TestRefreshBranchContentDefaultBranchOverridesLocalOnlyHide(t *testing.T) { +func TestRefreshBranchContentAlwaysListsDefaultBranchesInSync(t *testing.T) { tmp := t.TempDir() - cfgPath := filepath.Join(tmp, "dirtygit-default-hide.yml") + cfgPath := filepath.Join(tmp, "dirtygit-defaults.yml") content := ` scandirs: include: - /tmp branches: - hidelocalonly: - regex: - - "^main$" default: - main ` @@ -523,15 +517,21 @@ branches: 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, NewestUniqueUnix: 1_700_000_001, 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, NewestUniqueUnix: 1_700_000_001, 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: false}, + {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}, }}, }, }, @@ -540,7 +540,7 @@ branches: m.refreshBranchContent(60) rows := m.branchTable.Rows() if len(rows) != 2 { - t.Fatalf("branch rows len = %d, want 2 (main shown despite hide regex)", len(rows)) + 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"} {