Skip to content

Commit b1fbcfb

Browse files
committed
fix(git): repair the mainSCMDir/scmDir directory-role invariant
Keep mainSCMDir checkout-specific and scmDir repository-wide across worktrees, submodules, bare repositories, and separate git directories. Read config, remotes, worktree counts, repository names, and native status from the correct directory, with regression coverage for the supported layouts. Fixes #7798
1 parent 0976794 commit b1fbcfb

3 files changed

Lines changed: 742 additions & 211 deletions

File tree

src/segments/git.go

Lines changed: 126 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -133,22 +133,22 @@ var (
133133
)
134134

135135
type Git struct {
136-
configErr error
137-
config *ini.File
136+
commonCfgErr error
138137
Working *GitStatus
139138
Staging *GitStatus
140139
commit *Commit
141140
Rebase *Rebase
142141
User *User
143-
ShortHash string
142+
commonCfg *ini.File
144143
Hash string
145-
BranchStatus string
146144
HEAD string
147145
UpstreamIcon string
148146
UpstreamURL string
149147
Ref string
150148
RawUpstreamURL string
151149
mainWorktree string
150+
BranchStatus string
151+
ShortHash string
152152
Scm
153153
FieldRefs
154154
stashCount int
@@ -157,8 +157,8 @@ type Git struct {
157157
PushBehind int
158158
Behind int
159159
worktreeCount int
160+
commonCfgOnce sync.Once
160161
mainWorktreeOnce sync.Once
161-
configOnce sync.Once
162162
IsWorkTree bool
163163
Merge bool
164164
CherryPick bool
@@ -452,11 +452,11 @@ func (g *Git) isRepo(gitdir *runtime.FileInfo) bool {
452452

453453
func (g *Git) setUser() {
454454
// user.name/user.email are very commonly set only in the user's global
455-
// gitconfig, which getGitConfig() never reads (repo-local config only).
455+
// gitconfig, which commonConfig() never reads (repo-local config only).
456456
// Trust the local read only when it has both keys; anything less falls
457457
// back to exec git, which merges every config scope the way `git
458458
// config` itself does.
459-
if cfg, err := g.getGitConfig(); err == nil {
459+
if cfg, err := g.commonConfig(); err == nil {
460460
section := cfg.Section("user")
461461
name := section.Key("name").String()
462462
email := section.Key("email").String()
@@ -487,15 +487,16 @@ func (g *Git) setUser() {
487487
func (g *Git) isBareRepo(gitDir *runtime.FileInfo) bool {
488488
defer log.Trace(time.Now())
489489

490-
if gitDir.IsDir {
491-
g.mainSCMDir = gitDir.Path
492-
} else {
490+
bareDir := gitDir.Path
491+
if !gitDir.IsDir {
493492
content := g.fileContent(gitDir.ParentFolder, ".git")
494493
dir := strings.TrimPrefix(content, "gitdir: ")
495-
g.mainSCMDir = resolveGitPath(gitDir.ParentFolder, g.convertToLinuxPath(dir))
494+
bareDir = resolveGitPath(gitDir.ParentFolder, g.convertToLinuxPath(dir))
496495
}
497496

498-
cfg, err := g.getGitConfig()
497+
g.mainSCMDir = bareDir
498+
499+
cfg, err := loadGitConfig(g.env, bareDir)
499500
if err != nil {
500501
log.Error(err)
501502
return false
@@ -574,8 +575,8 @@ func (g *Git) hasWorktree(gitdir *runtime.FileInfo) bool {
574575
g.repoRootDir = g.convertToLinuxPath(g.repoRootDir)
575576
// resolve relative paths (worktree.useRelativePaths = true)
576577
g.repoRootDir = resolveGitPath(g.scmDir, g.repoRootDir)
577-
g.scmDir = moduleDir[:worktreeIndex]
578578
g.mainSCMDir = g.scmDir
579+
g.scmDir = moduleDir[:worktreeIndex]
579580
g.IsWorkTree = true
580581
return true
581582
}
@@ -602,13 +603,9 @@ func (g *Git) hasWorktree(gitdir *runtime.FileInfo) bool {
602603
}
603604
}
604605

605-
// check for separate git folder(--separate-git-dir)
606-
// check if the folder contains a HEAD file
607606
if g.env.HasFilesInDir(g.mainSCMDir, "HEAD") {
608-
gitFolder := strings.TrimSuffix(g.scmDir, ".git")
607+
g.repoRootDir = strings.TrimSuffix(g.scmDir, ".git")
609608
g.scmDir = g.mainSCMDir
610-
g.mainSCMDir = gitFolder
611-
g.repoRootDir = gitFolder
612609
return true
613610
}
614611

@@ -651,7 +648,7 @@ func (g *Git) setPushStatus() {
651648
return
652649
}
653650

654-
pushRemote := g.getPushRemote()
651+
pushRemote := g.pushRef()
655652
if pushRemote == "" {
656653
return
657654
}
@@ -704,64 +701,68 @@ func (g *Git) setPushStatusNative(pushRemote string) bool {
704701
return true
705702
}
706703

707-
func (g *Git) getPushRemote() string {
708-
upstream := g.Upstream
709-
if idx := strings.Index(upstream, "/"); idx != -1 {
710-
upstream = upstream[:idx]
704+
// pushRef resolves the destination of a push once, so both counts below describe the
705+
// same comparison. An empty rev-list result is a genuine failure, never a retry signal.
706+
func (g *Git) pushRef() string {
707+
if ref := g.getGitCommandOutput("rev-parse", "--abbrev-ref", "@{push}"); ref != "" {
708+
return ref
711709
}
712710

713-
if upstream == "" {
714-
upstream = origin
715-
}
711+
return g.getPushRemote()
712+
}
716713

714+
func (g *Git) getPushRemote() string {
717715
branch := g.Ref
718716
if branch == "" {
719717
return ""
720718
}
721719

722-
cfg, err := g.getGitConfig()
723-
if err != nil {
724-
pushRemote := g.getGitCommandOutput("config", "--get", "remote.pushDefault")
725-
if pushRemote == "" {
726-
pushRemote = upstream
727-
}
728-
729-
return strings.TrimSpace(pushRemote) + "/" + branch
720+
pushRemote := g.getGitCommandOutput("config", "--get", fmt.Sprintf("branch.%s.pushRemote", branch))
721+
if pushRemote == "" {
722+
pushRemote = g.getGitCommandOutput("config", "--get", "remote.pushDefault")
730723
}
731724

732-
sectionName := fmt.Sprintf(`branch "%s"`, branch)
733-
section := cfg.Section(sectionName)
734-
pushRemote := section.Key("pushRemote").String()
735725
if pushRemote == "" {
736-
pushRemote = cfg.Section("remote").Key("pushDefault").String()
726+
pushRemote = regex.ReplaceAllString("/.*", g.Upstream, "")
737727
}
738728

739729
if pushRemote == "" {
740-
pushRemote = upstream
730+
pushRemote = origin
741731
}
742732

743-
return pushRemote + "/" + branch
733+
return strings.TrimSpace(pushRemote) + "/" + branch
744734
}
745735

746-
func (g *Git) getGitConfig() (*ini.File, error) {
747-
g.configOnce.Do(func() {
748-
configData := g.fileContent(g.mainSCMDir, "config")
749-
if configData == "" {
750-
log.Debug("git config file not found")
751-
g.configErr = fmt.Errorf("git config file not found")
752-
return
753-
}
736+
func loadGitConfigFile(env runtime.Environment, dir, file string) (*ini.File, error) {
737+
if dir == "" {
738+
return nil, fmt.Errorf("no git directory to read %s from", file)
739+
}
754740

755-
cfg, err := ini.Load(configData)
756-
if err != nil {
757-
g.configErr = err
758-
return
759-
}
741+
configData := strings.Trim(env.FileContent(dir+"/"+file), " \r\n")
742+
if configData == "" {
743+
return nil, fmt.Errorf("%s not found", file)
744+
}
760745

761-
g.config = cfg
746+
return ini.Load(configData)
747+
}
748+
749+
func loadGitConfig(env runtime.Environment, dir string) (*ini.File, error) {
750+
return loadGitConfigFile(env, dir, "config")
751+
}
752+
753+
// commonConfig reads the repository's shared config. It refuses to memoize a failure
754+
// against an unknown directory, which a cache-restored segment would otherwise poison.
755+
func (g *Git) commonConfig() (*ini.File, error) {
756+
commonDir := g.commonGitDir()
757+
if commonDir == "" {
758+
return nil, fmt.Errorf("common git directory is unknown")
759+
}
760+
761+
g.commonCfgOnce.Do(func() {
762+
g.commonCfg, g.commonCfgErr = loadGitConfig(g.env, commonDir)
762763
})
763764

764-
return g.config, g.configErr
765+
return g.commonCfg, g.commonCfgErr
765766
}
766767

767768
func (g *Git) cleanUpstreamURL(url string) string {
@@ -1261,15 +1262,19 @@ func (g *Git) WorktreeCount() int {
12611262
return g.worktreeCount
12621263
}
12631264

1264-
worktreesFolder := filepath.Join(g.mainSCMDir, "worktrees")
1265+
commonDir := g.commonGitDir()
1266+
if commonDir == "" {
1267+
return 0
1268+
}
1269+
1270+
worktreesFolder := filepath.Join(commonDir, "worktrees")
12651271

12661272
if !g.env.HasFolder(worktreesFolder) {
12671273
return 0
12681274
}
12691275

1270-
worktreeFolders := g.env.LsDir(worktreesFolder)
12711276
var count int
1272-
for _, folder := range worktreeFolders {
1277+
for _, folder := range g.env.LsDir(worktreesFolder) {
12731278
if folder.IsDir() {
12741279
count++
12751280
}
@@ -1339,12 +1344,18 @@ func (g *Git) ensureMainWorktreeContext() bool {
13391344
}
13401345

13411346
func (g *Git) commonGitDir() string {
1347+
// scmDir is the common git directory at every discovery exit. The worktrees cut
1348+
// below is only for partially initialized state, where scmDir is not yet set.
1349+
if g.scmDir != "" {
1350+
return filepath.ToSlash(g.scmDir)
1351+
}
1352+
13421353
mainSCMDir := filepath.ToSlash(g.mainSCMDir)
13431354
if commonDir, _, found := strings.CutLast(mainSCMDir, "/worktrees/"); found {
13441355
return commonDir
13451356
}
13461357

1347-
return filepath.ToSlash(g.scmDir)
1358+
return ""
13481359
}
13491360

13501361
// isModuleAdminDir reports whether target is a submodule administrative directory
@@ -1370,9 +1381,12 @@ func (g *Git) isModuleAdminDir(target, parent string) bool {
13701381
return true
13711382
}
13721383

1373-
cfg, err := ini.Load(g.fileContent(target, "config"))
1384+
// A missing or unreadable config is simply not a submodule git dir, not an error
1385+
// worth reporting: every --separate-git-dir target spelled with a modules component
1386+
// lands here.
1387+
cfg, err := loadGitConfig(g.env, target)
13741388
if err != nil {
1375-
log.Error(err)
1389+
log.Debug("no readable config in", target, "- not a submodule git dir")
13761390
return false
13771391
}
13781392

@@ -1432,24 +1446,23 @@ func (g *Git) getRemoteURL() string {
14321446
upstream = origin
14331447
}
14341448

1435-
cfg, err := g.getGitConfig()
1436-
if err != nil {
1437-
return g.getGitCommandOutput("remote", "get-url", upstream)
1449+
// Ask git first because it applies insteadOf rewriting and reads the merged configuration.
1450+
if url := g.getGitCommandOutput("remote", "get-url", upstream); url != "" {
1451+
return url
14381452
}
14391453

1440-
url := cfg.Section("remote \"" + upstream + "\"").Key("url").String()
1441-
if len(url) != 0 {
1442-
log.Debug("remote url found in config:", url)
1443-
return url
1454+
cfg, err := g.commonConfig()
1455+
if err != nil {
1456+
return ""
14441457
}
14451458

1446-
return g.getGitCommandOutput("remote", "get-url", upstream)
1459+
return cfg.Section("remote \"" + upstream + "\"").Key("url").String()
14471460
}
14481461

14491462
func (g *Git) Remotes() map[string]string {
14501463
var remotes = make(map[string]string)
14511464

1452-
cfg, err := g.getGitConfig()
1465+
cfg, err := g.commonConfig()
14531466
if err != nil {
14541467
return remotes
14551468
}
@@ -1497,9 +1510,50 @@ func (g *Git) repoName() string {
14971510
return path.Base(g.convertToLinuxPath(g.repoRootDir))
14981511
}
14991512

1500-
if repoRoot, _, found := strings.CutLast(g.mainSCMDir, ".git/worktrees"); found {
1501-
return path.Base(repoRoot)
1513+
commonDir := g.commonGitDir()
1514+
if commonDir == "" {
1515+
return ""
1516+
}
1517+
1518+
if parent := filepath.Dir(commonDir); g.gitEntryResolvesTo(parent, commonDir) {
1519+
return path.Base(g.convertToLinuxPath(parent))
1520+
}
1521+
1522+
for _, file := range []string{"config.worktree", "config"} {
1523+
cfg, err := loadGitConfigFile(g.env, commonDir, file)
1524+
if err != nil {
1525+
continue
1526+
}
1527+
1528+
worktree := cfg.Section("core").Key("worktree").String()
1529+
if worktree == "" {
1530+
continue
1531+
}
1532+
1533+
return path.Base(g.convertToLinuxPath(resolveGitPath(commonDir, worktree)))
15021534
}
15031535

15041536
return ""
15051537
}
1538+
1539+
func (g *Git) gitEntryResolvesTo(parent, commonDir string) bool {
1540+
gitEntry := parent + "/.git"
1541+
commonDir = filepath.ToSlash(filepath.Clean(commonDir))
1542+
1543+
if g.env.HasFolder(gitEntry) {
1544+
return filepath.ToSlash(filepath.Clean(gitEntry)) == commonDir
1545+
}
1546+
1547+
if !g.env.HasFilesInDir(parent, ".git") {
1548+
return false
1549+
}
1550+
1551+
content := strings.Trim(g.env.FileContent(gitEntry), " \r\n")
1552+
target, found := strings.CutPrefix(content, "gitdir: ")
1553+
if !found {
1554+
return false
1555+
}
1556+
1557+
target = g.convertToLinuxPath(target)
1558+
return filepath.ToSlash(filepath.Clean(resolveGitPath(parent, target))) == commonDir
1559+
}

0 commit comments

Comments
 (0)