From fa427c32b3bb1fbe0a0c14a67d8bd6445ceb4f4d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 20 Sep 2026 16:38:29 +0200 Subject: [PATCH] Store each branch's push destination in the branch model To push a branch other than the checked-out one, lazygit has to name the remote and the remote branch in the push command; a bare `git push` only works for the current branch. The upstream isn't always the right destination. In a triangular workflow the branch is pushed to a different remote than it pulls from, and with push.default set to "current" it goes to a branch of the same name whatever the upstream is called. Read the destination from git instead of working it out from the config. The %(push) field of for-each-ref names the remote-tracking ref that a push would update, taking push.default, remote.pushDefault and branch..pushRemote into account. It is also the ref that the push:track counts are computed against, so the force-push detection and the destination agree. The field is available since git 2.5, well before the oldest version lazygit supports. Nothing uses the new fields yet. Co-Authored-By: Claude Fable 5.1 --- pkg/commands/git_commands/branch_loader.go | 30 ++++++++++-- .../git_commands/branch_loader_test.go | 48 ++++++++++++++++--- pkg/commands/models/branch.go | 7 +++ 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index 0ae10be37..e4ccc6ab7 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -349,6 +349,7 @@ var branchFields = []string{ "upstream:short", "upstream:track", "push:track", + "push", "subject", "objectname", "committerdate:unix", @@ -361,13 +362,15 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch upstreamName := split[2] track := split[3] pushTrack := split[4] - subject := split[5] - commitHash := split[6] - commitDate := split[7] + pushRef := split[5] + subject := split[6] + commitHash := split[7] + commitDate := split[8] name := strings.TrimPrefix(fullName, "heads/") aheadForPull, behindForPull, gone := parseUpstreamInfo(upstreamName, track) aheadForPush, behindForPush, _ := parseUpstreamInfo(upstreamName, pushTrack) + pushRemote, pushBranch := parsePushDestination(pushRef) recency := "" if storeCommitDateAsRecency { @@ -383,6 +386,8 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) (*models.Branch BehindForPull: behindForPull, AheadForPush: aheadForPush, BehindForPush: behindForPush, + PushRemote: pushRemote, + PushBranch: pushBranch, UpstreamGone: gone, Head: headMarker == "*", Subject: subject, @@ -410,6 +415,25 @@ func parseUpstreamInfo(upstreamName string, track string) (string, string, bool) return ahead, behind, false } +// Splits the remote-tracking ref that the %(push) field names, e.g. +// refs/remotes/origin/main, into the remote and the remote branch. Returns +// empty strings if the field is empty because git has no push destination for +// the branch, or if the ref isn't under refs/remotes/. +func parsePushDestination(pushRef string) (string, string) { + remoteAndBranch, ok := strings.CutPrefix(pushRef, "refs/remotes/") + if !ok { + return "", "" + } + + // Remote names can't contain slashes, so the first one ends the remote name + remote, branch, ok := strings.Cut(remoteAndBranch, "/") + if !ok { + return "", "" + } + + return remote, branch +} + func parseDifference(track string, regexStr string) string { re := regexp.MustCompile(regexStr) match := re.FindStringSubmatch(track) diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go index 3c1402a24..080c5e2f7 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -26,7 +26,7 @@ func TestObtainBranch(t *testing.T) { scenarios := []scenario{ { testName: "TrimHeads", - input: []string{"", "heads/a_branch", "", "", "", "subject", "123", timeStamp}, + input: []string{"", "heads/a_branch", "", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", @@ -41,7 +41,7 @@ func TestObtainBranch(t *testing.T) { }, { testName: "NoUpstream", - input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp}, + input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", @@ -56,7 +56,7 @@ func TestObtainBranch(t *testing.T) { }, { testName: "IsHead", - input: []string{"*", "a_branch", "", "", "", "subject", "123", timeStamp}, + input: []string{"*", "a_branch", "", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", @@ -71,7 +71,7 @@ func TestObtainBranch(t *testing.T) { }, { testName: "IsBehindAndAhead", - input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "subject", "123", timeStamp}, + input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", @@ -79,6 +79,40 @@ func TestObtainBranch(t *testing.T) { BehindForPull: "2", AheadForPush: "3", BehindForPush: "2", + PushRemote: "a_remote", + PushBranch: "a_branch", + Head: false, + Subject: "subject", + CommitHash: "123", + }, + }, + { + testName: "PushDestinationDiffersFromUpstream", + input: []string{"", "a_branch", "a_remote/a_branch", "[ahead 3]", "[ahead 5]", "refs/remotes/my_fork/feature/a_branch", "subject", "123", timeStamp}, + storeCommitDateAsRecency: false, + expectedBranch: &models.Branch{ + Name: "a_branch", + AheadForPull: "3", + BehindForPull: "0", + AheadForPush: "5", + BehindForPush: "0", + PushRemote: "my_fork", + PushBranch: "feature/a_branch", + Head: false, + Subject: "subject", + CommitHash: "123", + }, + }, + { + testName: "PushDestinationNotARemoteTrackingRef", + input: []string{"", "a_branch", "a_remote/a_branch", "", "", "refs/published/a_branch", "subject", "123", timeStamp}, + storeCommitDateAsRecency: false, + expectedBranch: &models.Branch{ + Name: "a_branch", + AheadForPull: "0", + BehindForPull: "0", + AheadForPush: "0", + BehindForPush: "0", Head: false, Subject: "subject", CommitHash: "123", @@ -86,7 +120,7 @@ func TestObtainBranch(t *testing.T) { }, { testName: "RemoteBranchIsGone", - input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "subject", "123", timeStamp}, + input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "refs/remotes/a_remote/a_branch", "subject", "123", timeStamp}, storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", @@ -95,6 +129,8 @@ func TestObtainBranch(t *testing.T) { BehindForPull: "?", AheadForPush: "?", BehindForPush: "?", + PushRemote: "a_remote", + PushBranch: "a_branch", Head: false, Subject: "subject", CommitHash: "123", @@ -102,7 +138,7 @@ func TestObtainBranch(t *testing.T) { }, { testName: "WithCommitDateAsRecency", - input: []string{"", "a_branch", "", "", "", "subject", "123", timeStamp}, + input: []string{"", "a_branch", "", "", "", "", "subject", "123", timeStamp}, storeCommitDateAsRecency: true, expectedBranch: &models.Branch{ Name: "a_branch", diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go index 4dc48a88d..37a30deb7 100644 --- a/pkg/commands/models/branch.go +++ b/pkg/commands/models/branch.go @@ -31,6 +31,13 @@ type Branch struct { // 'git@github.com:tiwood/lazygit.git' UpstreamRemote string UpstreamBranch string + // The remote and the remote branch that `git push` would push this branch + // to, as git determines them from push.default, remote.pushDefault and + // branch..pushRemote. In a triangular workflow these differ from the + // upstream. Both are empty if git has no push destination for the branch, + // e.g. because push.default is "upstream" and the branch has no upstream. + PushRemote string + PushBranch string // subject line in commit message Subject string // commit hash