Skip to content
Open

wip #13

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 46 additions & 11 deletions scanner/branch_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,15 @@ func computeBranchLocations(d, branchName string, remotes []string) ([]BranchLoc
return locations, nil
}

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(d string) (BranchStatus, error) {
branch, detached, err := currentBranch(d)
if err != nil {
Expand Down Expand Up @@ -240,38 +249,64 @@ func GitBranchStatus(d string) (BranchStatus, error) {
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)
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(d, 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
}
22 changes: 14 additions & 8 deletions scanner/inclusion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ func TestRepoInclusionReasons_uncommitted(t *testing.T) {
Path: g, Staging: 'M', Worktree: 'M',
}},
},
Branches: BranchStatus{Branch: "main", Locations: []BranchLocation{
{Name: "local", Exists: true, TipHash: "a"},
{Name: "origin", Exists: true, TipHash: "a"},
}},
Branches: BranchStatus{Branch: "main", LocalBranches: []LocalBranchRef{{
Name: "main", Current: true,
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") {
Expand All @@ -35,10 +38,13 @@ func TestRepoInclusionReasons_branchOnly(t *testing.T) {
rs := RepoStatus{
Branches: BranchStatus{
Branch: "main",
Locations: []BranchLocation{
{Name: "local", Exists: true, TipHash: "aaa"},
{Name: "origin", Exists: true, TipHash: "bbb", Incoming: 0, Outgoing: 1},
},
LocalBranches: []LocalBranchRef{{
Name: "main", Current: true,
Locations: []BranchLocation{
{Name: "local", Exists: true, TipHash: "aaa"},
{Name: "origin", Exists: true, TipHash: "bbb", Incoming: 0, Outgoing: 1},
},
}},
},
}
lines := RepoInclusionReasons(rs)
Expand Down
10 changes: 4 additions & 6 deletions scanner/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -72,9 +71,8 @@ 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),
Expand Down
123 changes: 96 additions & 27 deletions scanner/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,25 @@ 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
}

// 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
}
Expand All @@ -28,10 +36,16 @@ type BranchStatus struct {
// Locations holds local vs same-named remote refs (refs/remotes/<remote>/<name>);
// 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
}

Expand Down Expand Up @@ -88,11 +102,29 @@ func (lb LocalBranchRef) IsLocalOnly() bool {
return true
}

// 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/<Name>/<branch>. 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 {
Name string
Exists bool
TipHash string
TipUnix int64
// Either "local" or the name of a configured remote.
Name string

// Exists is true when this location's ref (refs/heads/<branch> for "local",
// refs/remotes/<remote>/<branch> 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);
Expand All @@ -104,18 +136,37 @@ type BranchLocation struct {
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 {
return false
}
locs := b.CurrentBranchLocations()
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
}
}
Expand All @@ -124,8 +175,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
}
Expand All @@ -146,13 +197,18 @@ func (b *BranchStatus) HasLocalRemoteMismatch() bool {

// FilterLocalOnlyForConfig filters out local-only branches that
// [Config.ShouldHideLocalOnlyBranch] matches unless [Config.AlwaysListBranch] applies.
// The checked-out branch is never removed so HEAD remote comparison stays available.
func (b *BranchStatus) FilterLocalOnlyForConfig(c *Config) {
refs := b.LocalBranches
if c == nil || len(refs) == 0 {
return
}
out := make([]LocalBranchRef, 0)
for _, lb := range refs {
if lb.Current {
out = append(out, lb)
continue
}
if c.ShouldHideLocalOnlyBranch(lb) && !c.AlwaysListBranch(lb.Name) {
continue
}
Expand All @@ -167,10 +223,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
}
}
Expand All @@ -182,8 +242,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
}
Expand Down Expand Up @@ -216,13 +276,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
}
Expand All @@ -238,12 +303,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 {
Expand Down
Loading
Loading