@@ -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
426429func (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
441521func (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