Skip to content

Commit 40f42c4

Browse files
committed
fix(git): resolve bare HEAD and preserve upstream selection
Resolve bare HEAD through the shared ref resolver so detached HEAD and reftables are handled consistently with the non-bare path. Populate Upstream from git while keeping the remote inventory separate, and document and test the bare repository fields. Fixes #7799
1 parent 384774c commit 40f42c4

4 files changed

Lines changed: 686 additions & 82 deletions

File tree

src/segments/git.go

Lines changed: 111 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ func (g *Git) Enabled() bool {
202202

203203
if g.options.Bool(FetchUpstreamIcon, false) {
204204
wg.Go(func() {
205-
g.UpstreamIcon = g.getUpstreamIcon()
205+
g.UpstreamIcon = g.getUpstreamIcon(remoteNameOrOrigin(g.Upstream))
206206
})
207207
}
208208

@@ -211,7 +211,7 @@ func (g *Git) Enabled() bool {
211211
g.updateHEADReference()
212212

213213
if g.options.Bool(FetchUpstreamIcon, false) {
214-
g.UpstreamIcon = g.getUpstreamIcon()
214+
g.UpstreamIcon = g.getUpstreamIcon(remoteNameOrOrigin(g.Upstream))
215215
}
216216
}
217217

@@ -316,11 +316,14 @@ func (g *Git) Kraken() string {
316316
root := g.getGitCommandOutput("rev-list", "--max-parents=0", "HEAD")
317317
root, _, _ = strings.Cut(root, "\n")
318318

319-
if g.RawUpstreamURL == "" {
320-
if g.Upstream == "" {
321-
g.Upstream = origin
322-
}
323-
g.RawUpstreamURL = g.getRemoteURL()
319+
// In a bare repository with fetch_upstream_icon, getBareRepoInfo has already made the
320+
// authoritative attribution: an empty RawUpstreamURL there means "no attributable remote",
321+
// not "not tried yet". .Upstream may legitimately be a bare branch name (remote = .), which
322+
// must never be read as a remote name.
323+
bareAttributionDone := g.IsBare && g.options.Bool(FetchUpstreamIcon, false)
324+
325+
if g.RawUpstreamURL == "" && !bareAttributionDone {
326+
g.RawUpstreamURL = g.getRemoteURL(remoteNameOrOrigin(g.Upstream))
324327
}
325328

326329
if g.Hash == "" {
@@ -424,18 +427,95 @@ func (g *Git) isBareRepo(gitDir *runtime.FileInfo) bool {
424427
}
425428

426429
func (g *Git) getBareRepoInfo() {
427-
head := g.fileContent(g.mainSCMDir, "HEAD")
428-
branchIcon := g.options.String(BranchIcon, "\uE0A0")
429-
g.Ref = strings.Replace(head, "ref: refs/heads/", "", 1)
430-
g.HEAD = fmt.Sprintf("%s%s", branchIcon, g.formatBranch(g.Ref))
430+
// The non-bare resolver already reads HEAD from mainSCMDir and already handles the symbolic
431+
// branch, the reftables sentinel and a detached HEAD. Sharing it is what keeps the two paths
432+
// from diverging again.
433+
g.updateHEADReference()
431434
if !g.options.Bool(FetchUpstreamIcon, false) {
432435
return
433436
}
434437

435-
g.Upstream = g.getGitCommandOutput("remote")
436-
if len(g.Upstream) != 0 {
437-
g.UpstreamIcon = g.getUpstreamIcon()
438+
// Assigned before any early return: a local upstream (remote = .) is a real upstream even in a
439+
// repository with no remotes at all. Assigned after updateHEADReference so a branch_template
440+
// referencing .Upstream still sees the empty value it sees today.
441+
upstream, remote := g.bareUpstream()
442+
g.Upstream = upstream
443+
444+
// An upstream implies its remote exists, so the inventory is not needed.
445+
if remote != "" {
446+
g.UpstreamIcon = g.getUpstreamIcon(remote)
447+
return
448+
}
449+
450+
remotes := g.bareRemoteNames()
451+
if len(remotes) == 0 {
452+
return // no remote to attribute; UpstreamIcon and UpstreamURL stay empty, as today
438453
}
454+
455+
g.UpstreamIcon = g.getUpstreamIcon(fallbackRemote(remotes))
456+
}
457+
458+
// bareUpstream asks git for the upstream of HEAD's branch, and for the remote that upstream
459+
// resolves to. Letting git answer is not a convenience: git applies fetch-refspec mapping,
460+
// accepts merge refs outside refs/heads/, spells a local upstream as the bare branch name, and
461+
// reads the merged configuration from every scope.
462+
func (g *Git) bareUpstream() (string, string) {
463+
// Ref can be a short hash or an exact tag name when detached, either of which can name a real
464+
// branch, so a detached HEAD must never be looked up as one.
465+
if g.Detached || g.Ref == "" {
466+
return "", ""
467+
}
468+
469+
ref := "refs/heads/" + g.Ref
470+
471+
// NUL-delimited so the fields survive RunCommand's whitespace trimming, the same reason
472+
// MainWorktree uses -z.
473+
output := g.getGitCommandOutput("for-each-ref",
474+
"--format=%(upstream:short)%00%(upstream:remotename)%00%(refname)", ref)
475+
476+
upstream, rest, _ := strings.Cut(output, "\x00")
477+
remote, refname, _ := strings.Cut(rest, "\x00")
478+
479+
// for-each-ref takes a pattern, and a pattern matches at "/" boundaries: querying
480+
// refs/heads/feature also matches refs/heads/feature/x. Verify the matched ref before using it.
481+
if refname != ref {
482+
return "", ""
483+
}
484+
485+
if remote == "." {
486+
remote = ""
487+
}
488+
489+
return upstream, remote
490+
}
491+
492+
// bareRemoteNames returns the configured remote names. It asks git rather than parsing the config
493+
// file, because git reports the merged configuration - system, global, local and include.path - and a
494+
// remote supplied through an include or a global scope is visible today.
495+
func (g *Git) bareRemoteNames() []string {
496+
// Trimmed here rather than relying on the command runner: cmd.RunWithEnv trims, but the test
497+
// mock returns its canned string verbatim, so the contract belongs in this function.
498+
output := strings.TrimSpace(g.getGitCommandOutput("remote"))
499+
if output == "" {
500+
return nil // Split would otherwise yield a one-element slice holding ""
501+
}
502+
503+
return strings.Split(output, "\n")
504+
}
505+
506+
// fallbackRemote picks a remote to attribute URLs to when HEAD has no upstream, or "" when nothing
507+
// ranks them. Push settings are deliberately absent: branch.<ref>.pushRemote and remote.pushDefault
508+
// name a push destination, not an upstream, and no other caller of getUpstreamIcon consults them.
509+
func fallbackRemote(remotes []string) string {
510+
if slices.Contains(remotes, origin) {
511+
return origin
512+
}
513+
514+
if len(remotes) == 1 {
515+
return remotes[0]
516+
}
517+
518+
return "" // several unranked remotes: keep today's generic icon and empty URL
439519
}
440520

441521
func (g *Git) setDir(dir string) {
@@ -693,10 +773,10 @@ func (g *Git) cleanUpstreamURL(url string) string {
693773
return fmt.Sprintf("https://%s/%s", match["URL"], strings.TrimSuffix(match["PATH"], ".git"))
694774
}
695775

696-
func (g *Git) getUpstreamIcon() string {
776+
func (g *Git) getUpstreamIcon(remote string) string {
697777
fallback := g.options.String(GitIcon, "\uE5FB ")
698778

699-
g.RawUpstreamURL = g.getRemoteURL()
779+
g.RawUpstreamURL = g.getRemoteURL(remote)
700780
if g.RawUpstreamURL == "" {
701781
return fallback
702782
}
@@ -1290,12 +1370,23 @@ func parseMainWorktree(output string) (string, bool) {
12901370
return mainWorktree, true
12911371
}
12921372

1293-
func (g *Git) getRemoteURL() string {
1294-
upstream := regex.ReplaceAllString("/.*", g.Upstream, "")
1295-
if upstream == "" {
1296-
upstream = origin
1373+
// remoteNameOrOrigin derives the remote to attribute URLs to from an upstream reference,
1374+
// reproducing the historical behaviour: "origin/main" -> "origin", "" -> "origin".
1375+
func remoteNameOrOrigin(upstream string) string {
1376+
if remote := regex.ReplaceAllString("/.*", upstream, ""); remote != "" {
1377+
return remote
12971378
}
12981379

1380+
return origin
1381+
}
1382+
1383+
func (g *Git) getRemoteURL(remote string) string {
1384+
if remote == "" {
1385+
return ""
1386+
}
1387+
1388+
upstream := remote
1389+
12991390
// Ask git first because it applies insteadOf rewriting and reads the merged configuration.
13001391
if url := g.getGitCommandOutput("remote", "get-url", upstream); url != "" {
13011392
return url

0 commit comments

Comments
 (0)