Repository: jesseduffield/lazygit (language: go)

Issue:
Can I re-enable force pushing, or should I change my workflow?
When developing on a branch `feature-asdf`, I'm used to having the upstream of the branch set to `origin/main` and have `push.default` set to `current`. This means that whenever someone else merges something to `origin/main`, I immediately see it as `↑m↓n`, I can pull and rebase onto `origin/main` with a simple `p` and I could push the rebased branch with just `P` and `Enter` to confirm the force push.

After upgrading to `0.41.0`, #3387 of course broke this workflow, since now it's impossible to force push the branch after the rebase. Is there a way I can get the old behavior back, or should I perhaps do something else entirely? I switched to lazygit from Magit when I stopped using Emacs a few months ago, so it's possible I'm just trying to recreate something that I'm used to and that lazygit has a different/better way to do.


Discussion / hints:
Sorry for breaking your workflow. This is very interesting, I hadn't expected people to work this way. You seem to find it more important to see the divergence from `origin/main` than to see the divergence from your own upstream branch, which I guess makes sense if you are the only one working on your branch (and you know you only push to it from one machine).

I'm a little unsure what to do here. While I see how your workflow is convenient, it still feels very weird to me, and I'm not sure how hard we should try to support it. Happy to discuss this more though.

That said, I have always missed an easy way to see the divergence of my branch from `origin/main`; both a simple `↑2↓3` overview, and the more detailed version similar to what you see against your upstream using `u enter`. I'd be interested in discussing ways to make this information easily accessible somehow, I don't have great ideas myself. Would that be enough to get you to change your workflow to the "normal" one? I do realize that pressing `p` to rebase onto main would still be missing.
Hi @stefanhaller,

many thanks for reply and sorry for my delayed response 🙂 It’s exactly as you describe – I work mostly in a team where everyone is pushing their work into their branches on `origin` and those then get merged into `origin/main`. Merges happen frequently, so it’s valuable to see how far behind `origin/main` your branch is (e.g. to remind you to update your branch now rather than face a ton of merge conflicts later). On the other pretty much the only time two people are going to push into the same branch is during pair-programming sessions, so I’m virtually always sure that I can just wipe anything on the remote branch and replace it with whatever I have on the local branch (and I pretty much never need to pull from the remote branch).

I absolutely understand that it’s a nonstandard workflow and I plan to continue use lazygit even without support for it, it’s a great tool whose benefits for me definitely outweigh the current slight inconvenience 🙂

That said, regarding your last paragraph, as a former user of Magit, I find it’s way of dealing with this really quite convenient. When you check out `<name>`, it basically let’s you work with both with `branch.<name>.pushRemote` and `branch.<name>.merge`. It displays divergence information for both (I think). When you push, it lets you pick whether to push to the push remote (the more convenient key), the merge branch or elsewhere. The same thing works when pulling, with the more convenient key assigned to pulling from the merge branch, I think. When the branch’s push remote or merge branch is unset, it let’s you “set and pull/push” instead. I’ll attach some screenshots later when I’m at my computer.

Perhaps something along those lines might be adapted to work with lazygit and I’d be happy to discuss this further. If there eventually is an idea that you guys might want to include, I’ll be happy to take a shot at implementing it 🙂
I work on a repo that fast-forward only merge is enabled. So I need to rebase and force-push the branch before merging. Force push is used very frequently in my workflow. Can I override the `P` keybinding to mimic the old behavior?
@linsui Sorry for the late response. Is your setup similar to Jakub's, where you pull from a different branch than you push to? If not, you'll have to explain more what the problem is, because I don't see how being restricted to fast-forward-only merges is related.

@jakubledl I'm curious what your current work-around is. Did you change your workflow, or are you using a custom command to force-push, or something else? (I hope you didn't stop using lazygit because of this. 😄)

I made a quick proof-of-concept draft PR (#3528) with a change that should support your workflow correctly, so pressing shift-P should prompt you to force-push in the appropriate cases. Would be cool if you could try it out.
You are correct. I didn't notice that `--force-with-lease` is passed by default and in fact the push fails because this problem. https://stackoverflow.com/questions/56191415/why-is-git-push-force-with-lease-failing-with-rejected-stale-info-even
Did you have the problem with a stale remote tracking branch that was mentioned in the stackoverflow answers, or was it some other reason why it didn't work? Usually, `--force-with-lease` should work fine in lazygit, triangular workflow or not.
I thought my push sometimes fails due to the stale remote tracking branch issue. I usually working with the same local branch and the remote branch is deleted automatically when my MR is merged. So lazygit can't fetch the remote branch. I guess so...
I see. In that case I recommend to do `git config --global fetch.prune true`, so that remote branches are deleted automatically when you fetch (you'll see "upstream gone" in lazygit), so next time you push it will ask you to create the upstream branch again.

All of that seems unrelated to this issue though.
Good idea, thanks for your help! Yep, I thought this is an unrelated issue.
@stefanhaller So currently, I don't really have a workaround 🙂 I have the local feature branch's upstream set to the remote feature branch and I use the local `main` to see whether there are updates; if I see `main ↓3`, I checkout main, pull, checkout the feature branch and rebase it onto local `main`. Since I usually work on just a single feature branch, this works, although it is a bit less convenient.

Thanks for the draft PR, I'll be happy to give it a try in the next few days! I'll also look into custom commands, since that's a topic I haven't really explored yet.
@jakubledl you don't have to checkout a branch to pull it, you can just fast forward it with `f`.
@mark2185 Thanks for the suggestion, that's definitely quicker! On the other hand, if I checkout `main` and pull, I automatically get the one-line log overview of what's new in the Commits window (I find that useful in projects where merges happen often and a single workday may bring something like 15 new merges into main).
I like to do `u enter` on main in the branches panel for that. Still no need to check out the branch. 😄  Note that #3537 improves this considerably by enabling the commit graph in that view, which makes it easier to discern the individual merges.

Jakub, I'm also interested in your thoughts on #3536, I personally think this will help a lot, and I intend to start working on it very soon.
Hi @stefanhaller,

I gave the `support-triangular-workflow` branch a try and as far as I can tell, it works great! One thing I'm unsure about, I also tried emulating a fork-based workflow (`feature` has upstream set to `upstream/main`, the repo has `remote.pushDefault` set to `origin`, so pushing on `feature` should push to `origin/feature`). In this case, I think it once happened that pressing `p` rebased `feature` correctly onto `upstream/main`, but then pressing `P` didn't request force push, but just errored out. But once I quit lazygit and started it again, it worked (e.g. asked to confirm the force push). I can investigate this further, if you want.

Will look into #3536, thanks!
> but then pressing `P` didn't request force push, but just errored out.

I think this can always happen, no matter which workflow, when your clone's remote branches are not up to date. Lazygit makes the decision whether to ask for force-push only based on the divergence of feature vs. the local copy of the remote tracking branch; so if that one is out of date because something has been pushed to the other side since you last fetched, you won't get the force-push prompt and it will error out instead.

Fetching helps in this case. The fact that it worked after you quit and restarted lazygit supports this theory, since fetching is the first thing lazygit does after starting.

Actually, this scenario is the very reason why I did #3387, since without fetching you didn't have a chance to check what you are overwriting by force-pushing.

Fix patch:
diff --git a/docs/Custom_Command_Keybindings.md b/docs/Custom_Command_Keybindings.md
index 8604a5247d6..426d6f8f699 100644
--- a/docs/Custom_Command_Keybindings.md
+++ b/docs/Custom_Command_Keybindings.md
@@ -305,7 +305,7 @@ SelectedWorktree
 CheckedOutBranch
 ```
 
-To see what fields are available on e.g. the `SelectedFile`, see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/commands/models/file.go) (all the modelling lives in the same directory). Note that the custom commands feature does not guarantee backwards compatibility (until we hit Lazygit version 1.0 of course) which means a field you're accessing on an object may no longer be available from one release to the next. Typically however, all you'll need is `{{.SelectedFile.Name}}`, `{{.SelectedLocalCommit.Hash}}` and `{{.SelectedLocalBranch.Name}}`. In the future we will likely introduce a tighter interface that exposes a limited set of fields for each model.
+To see what fields are available on e.g. the `SelectedFile`, see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/gui/services/custom_commands/models.go) (all the modelling lives in the same file).
 
 ## Keybinding collisions
 
diff --git a/pkg/commands/git.go b/pkg/commands/git.go
index b43c8c4e55a..7e7d9354fe7 100644
--- a/pkg/commands/git.go
+++ b/pkg/commands/git.go
@@ -134,7 +134,7 @@ func NewGitCommandAux(
 	worktreeCommands := git_commands.NewWorktreeCommands(gitCommon)
 	blameCommands := git_commands.NewBlameCommands(gitCommon)
 
-	branchLoader := git_commands.NewBranchLoader(cmn, cmd, branchCommands.CurrentBranchInfo, configCommands)
+	branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands)
 	commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
 	commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.RebaseMode, gitCommon)
 	reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go
index 198368502b6..16777243a86 100644
--- a/pkg/commands/git_commands/branch_loader.go
+++ b/pkg/commands/git_commands/branch_loader.go
@@ -40,6 +40,7 @@ type BranchInfo struct {
 // BranchLoader returns a list of Branch objects for the current repo
 type BranchLoader struct {
 	*common.Common
+	*GitCommon
 	cmd                  oscommands.ICmdObjBuilder
 	getCurrentBranchInfo func() (BranchInfo, error)
 	config               BranchLoaderConfigCommands
@@ -47,12 +48,14 @@ type BranchLoader struct {
 
 func NewBranchLoader(
 	cmn *common.Common,
+	gitCommon *GitCommon,
 	cmd oscommands.ICmdObjBuilder,
 	getCurrentBranchInfo func() (BranchInfo, error),
 	config BranchLoaderConfigCommands,
 ) *BranchLoader {
 	return &BranchLoader{
 		Common:               cmn,
+		GitCommon:            gitCommon,
 		cmd:                  cmd,
 		getCurrentBranchInfo: getCurrentBranchInfo,
 		config:               config,
@@ -61,7 +64,7 @@ func NewBranchLoader(
 
 // Load the list of branches for the current repo
 func (self *BranchLoader) Load(reflogCommits []*models.Commit) ([]*models.Branch, error) {
-	branches := self.obtainBranches()
+	branches := self.obtainBranches(self.version.IsAtLeast(2, 22, 0))
 
 	if self.AppState.LocalBranchSortOrder == "recency" {
 		reflogBranches := self.obtainReflogBranches(reflogCommits)
@@ -124,7 +127,7 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit) ([]*models.Branch
 	return branches, nil
 }
 
-func (self *BranchLoader) obtainBranches() []*models.Branch {
+func (self *BranchLoader) obtainBranches(canUsePushTrack bool) []*models.Branch {
 	output, err := self.getRawBranches()
 	if err != nil {
 		panic(err)
@@ -147,7 +150,7 @@ func (self *BranchLoader) obtainBranches() []*models.Branch {
 		}
 
 		storeCommitDateAsRecency := self.AppState.LocalBranchSortOrder != "recency"
-		return obtainBranch(split, storeCommitDateAsRecency), true
+		return obtainBranch(split, storeCommitDateAsRecency, canUsePushTrack), true
 	})
 }
 
@@ -183,23 +186,31 @@ var branchFields = []string{
 	"refname:short",
 	"upstream:short",
 	"upstream:track",
+	"push:track",
 	"subject",
 	"objectname",
 	"committerdate:unix",
 }
 
 // Obtain branch information from parsed line output of getRawBranches()
-func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch {
+func obtainBranch(split []string, storeCommitDateAsRecency bool, canUsePushTrack bool) *models.Branch {
 	headMarker := split[0]
 	fullName := split[1]
 	upstreamName := split[2]
 	track := split[3]
-	subject := split[4]
-	commitHash := split[5]
-	commitDate := split[6]
+	pushTrack := split[4]
+	subject := split[5]
+	commitHash := split[6]
+	commitDate := split[7]
 
 	name := strings.TrimPrefix(fullName, "heads/")
-	pushables, pullables, gone := parseUpstreamInfo(upstreamName, track)
+	aheadForPull, behindForPull, gone := parseUpstreamInfo(upstreamName, track)
+	var aheadForPush, behindForPush string
+	if canUsePushTrack {
+		aheadForPush, behindForPush, _ = parseUpstreamInfo(upstreamName, pushTrack)
+	} else {
+		aheadForPush, behindForPush = aheadForPull, behindForPull
+	}
 
 	recency := ""
 	if storeCommitDateAsRecency {
@@ -209,14 +220,16 @@ func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch
 	}
 
 	return &models.Branch{
-		Name:         name,
-		Recency:      recency,
-		Pushables:    pushables,
-		Pullables:    pullables,
-		UpstreamGone: gone,
-		Head:         headMarker == "*",
-		Subject:      subject,
-		CommitHash:   commitHash,
+		Name:          name,
+		Recency:       recency,
+		AheadForPull:  aheadForPull,
+		BehindForPull: behindForPull,
+		AheadForPush:  aheadForPush,
+		BehindForPush: behindForPush,
+		UpstreamGone:  gone,
+		Head:          headMarker == "*",
+		Subject:       subject,
+		CommitHash:    commitHash,
 	}
 }
 
@@ -232,10 +245,10 @@ func parseUpstreamInfo(upstreamName string, track string) (string, string, bool)
 		return "?", "?", true
 	}
 
-	pushables := parseDifference(track, `ahead (\d+)`)
-	pullables := parseDifference(track, `behind (\d+)`)
+	ahead := parseDifference(track, `ahead (\d+)`)
+	behind := parseDifference(track, `behind (\d+)`)
 
-	return pushables, pullables, false
+	return ahead, behind, false
 }
 
 func parseDifference(track string, regexStr string) string {
diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go
index c5fcfdaedbd..25d806fca0e 100644
--- a/pkg/commands/models/branch.go
+++ b/pkg/commands/models/branch.go
@@ -10,10 +10,14 @@ type Branch struct {
 	DisplayName string
 	// indicator of when the branch was last checked out e.g. '2d', '3m'
 	Recency string
-	// how many commits ahead we are from the remote branch (how many commits we can push)
-	Pushables string
+	// how many commits ahead we are from the remote branch (how many commits we can push, assuming we push to our tracked remote branch)
+	AheadForPull string
 	// how many commits behind we are from the remote branch (how many commits we can pull)
-	Pullables string
+	BehindForPull string
+	// how many commits ahead we are from the branch we're pushing to (which might not be the same as our upstream branch in a triangular workflow)
+	AheadForPush string
+	// how many commits behind we are from the branch we're pushing to (which might not be the same as our upstream branch in a triangular workflow)
+	BehindForPush string
 	// whether the remote branch is 'gone' i.e. we're tracking a remote branch that has been deleted
 	UpstreamGone bool
 	// whether this is the current branch. Exactly one branch should have this be true
@@ -80,26 +84,30 @@ func (b *Branch) IsTrackingRemote() bool {
 // we know that the remote branch is not stored locally based on our pushable/pullable
 // count being question marks.
 func (b *Branch) RemoteBranchStoredLocally() bool {
-	return b.IsTrackingRemote() && b.Pushables != "?" && b.Pullables != "?"
+	return b.IsTrackingRemote() && b.AheadForPull != "?" && b.BehindForPull != "?"
 }
 
 func (b *Branch) RemoteBranchNotStoredLocally() bool {
-	return b.IsTrackingRemote() && b.Pushables == "?" && b.Pullables == "?"
+	return b.IsTrackingRemote() && b.AheadForPull == "?" && b.BehindForPull == "?"
 }
 
 func (b *Branch) MatchesUpstream() bool {
-	return b.RemoteBranchStoredLocally() && b.Pushables == "0" && b.Pullables == "0"
+	return b.RemoteBranchStoredLocally() && b.AheadForPull == "0" && b.BehindForPull == "0"
 }
 
-func (b *Branch) HasCommitsToPush() bool {
-	return b.RemoteBranchStoredLocally() && b.Pushables != "0"
+func (b *Branch) IsAheadForPull() bool {
+	return b.RemoteBranchStoredLocally() && b.AheadForPull != "0"
 }
 
-func (b *Branch) HasCommitsToPull() bool {
-	return b.RemoteBranchStoredLocally() && b.Pullables != "0"
+func (b *Branch) IsBehindForPull() bool {
+	return b.RemoteBranchStoredLocally() && b.BehindForPull != "0"
+}
+
+func (b *Branch) IsBehindForPush() bool {
+	return b.BehindForPush != "" && b.BehindForPush != "0"
 }
 
 // for when we're in a detached head state
 func (b *Branch) IsRealBranch() bool {
-	return b.Pushables != "" && b.Pullables != ""
+	return b.AheadForPull != "" && b.BehindForPull != ""
 }
diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go
index b08ddd0cd76..d7faa78118d 100644
--- a/pkg/gui/controllers/branches_controller.go
+++ b/pkg/gui/controllers/branches_controller.go
@@ -620,7 +620,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error {
 	if !branch.RemoteBranchStoredLocally() {
 		return errors.New(self.c.Tr.FwdNoLocalUpstream)
 	}
-	if branch.HasCommitsToPush() {
+	if branch.IsAheadForPull() {
 		return errors.New(self.c.Tr.FwdCommitsToPush)
 	}
 
diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go
index 403f31d94e2..7d7ca9eed7d 100644
--- a/pkg/gui/controllers/sync_controller.go
+++ b/pkg/gui/controllers/sync_controller.go
@@ -87,10 +87,10 @@ func (self *SyncController) branchCheckedOut(f func(*models.Branch) error) func(
 }
 
 func (self *SyncController) push(currentBranch *models.Branch) error {
-	// if we have pullables we'll ask if the user wants to force push
+	// if we are behind our upstream branch we'll ask if the user wants to force push
 	if currentBranch.IsTrackingRemote() {
 		opts := pushOpts{}
-		if currentBranch.HasCommitsToPull() {
+		if currentBranch.IsBehindForPush() {
 			return self.requestToForcePush(currentBranch, opts)
 		} else {
 			return self.pushAux(currentBranch, opts)
diff --git a/pkg/gui/presentation/branches.go b/pkg/gui/presentation/branches.go
index 0abf2d4cd08..406a580d5fd 100644
--- a/pkg/gui/presentation/branches.go
+++ b/pkg/gui/presentation/branches.go
@@ -196,11 +196,11 @@ func BranchStatus(
 	}
 
 	result := ""
-	if branch.HasCommitsToPush() {
-		result = fmt.Sprintf("↑%s", branch.Pushables)
+	if branch.IsAheadForPull() {
+		result = fmt.Sprintf("↑%s", branch.AheadForPull)
 	}
-	if branch.HasCommitsToPull() {
-		result = fmt.Sprintf("%s↓%s", result, branch.Pullables)
+	if branch.IsBehindForPull() {
+		result = fmt.Sprintf("%s↓%s", result, branch.BehindForPull)
 	}
 
 	return result
diff --git a/pkg/gui/services/custom_commands/models.go b/pkg/gui/services/custom_commands/models.go
new file mode 100644
index 00000000000..261bace45ac
--- /dev/null
+++ b/pkg/gui/services/custom_commands/models.go
@@ -0,0 +1,100 @@
+package custom_commands
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/commands/models"
+	"github.com/stefanhaller/git-todo-parser/todo"
+)
+
+// We create shims for all the model classes in order to get a more stable API
+// for custom commands. At the moment these are almost identical to the model
+// classes, but this allows us to add "private" fields to the model classes that
+// we don't want to expose to custom commands, or rename a model field to a
+// better name without breaking people's custom commands. In such a case we add
+// the new, better name to the shim but keep the old one for backwards
+// compatibility. We already did this for Commit.Sha, which was renamed to Hash.
+
+type Commit struct {
+	Hash          string // deprecated: use Sha
+	Sha           string
+	Name          string
+	Status        models.CommitStatus
+	Action        todo.TodoCommand
+	Tags          []string
+	ExtraInfo     string
+	AuthorName    string
+	AuthorEmail   string
+	UnixTimestamp int64
+	Divergence    models.Divergence
+	Parents       []string
+}
+
+type File struct {
+	Name                    string
+	PreviousName            string
+	HasStagedChanges        bool
+	HasUnstagedChanges      bool
+	Tracked                 bool
+	Added                   bool
+	Deleted                 bool
+	HasMergeConflicts       bool
+	HasInlineMergeConflicts bool
+	DisplayString           string
+	ShortStatus             string
+	IsWorktree              bool
+}
+
+type Branch struct {
+	Name           string
+	DisplayName    string
+	Recency        string
+	Pushables      string // deprecated: use AheadForPull
+	Pullables      string // deprecated: use BehindForPull
+	AheadForPull   string
+	BehindForPull  string
+	AheadForPush   string
+	BehindForPush  string
+	UpstreamGone   bool
+	Head           bool
+	DetachedHead   bool
+	UpstreamRemote string
+	UpstreamBranch string
+	Subject        string
+	CommitHash     string
+}
+
+type RemoteBranch struct {
+	Name       string
+	RemoteName string
+}
+
+type Remote struct {
+	Name     string
+	Urls     []string
+	Branches []*RemoteBranch
+}
+
+type Tag struct {
+	Name    string
+	Message string
+}
+
+type StashEntry struct {
+	Index   int
+	Recency string
+	Name    string
+}
+
+type CommitFile struct {
+	Name         string
+	ChangeStatus string
+}
+
+type Worktree struct {
+	IsMain        bool
+	IsCurrent     bool
+	Path          string
+	IsPathMissing bool
+	GitDir        string
+	Branch        string
+	Name          string
+}
diff --git a/pkg/gui/services/custom_commands/session_state_loader.go b/pkg/gui/services/custom_commands/session_state_loader.go
index 6a3068df8e6..6f39c5f8cd2 100644
--- a/pkg/gui/services/custom_commands/session_state_loader.go
+++ b/pkg/gui/services/custom_commands/session_state_loader.go
@@ -3,7 +3,7 @@ package custom_commands
 import (
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
 	"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
-	"github.com/stefanhaller/git-todo-parser/todo"
+	"github.com/samber/lo"
 )
 
 // loads the session state at the time that a custom command is invoked, for use
@@ -20,22 +20,7 @@ func NewSessionStateLoader(c *helpers.HelperCommon, refsHelper *helpers.RefsHelp
 	}
 }
 
-type Commit struct {
-	Hash          string
-	Sha           string
-	Name          string
-	Status        models.CommitStatus
-	Action        todo.TodoCommand
-	Tags          []string
-	ExtraInfo     string
-	AuthorName    string
-	AuthorEmail   string
-	UnixTimestamp int64
-	Divergence    models.Divergence
-	Parents       []string
-}
-
-func commitWrapperFromModelCommit(commit *models.Commit) *Commit {
+func commitShimFromModelCommit(commit *models.Commit) *Commit {
 	if commit == nil {
 		return nil
 	}
@@ -56,39 +41,160 @@ func commitWrapperFromModelCommit(commit *models.Commit) *Commit {
 	}
 }
 
+func fileShimFromModelFile(file *models.File) *File {
+	if file == nil {
+		return nil
+	}
+
+	return &File{
+		Name:                    file.Name,
+		PreviousName:            file.PreviousName,
+		HasStagedChanges:        file.HasStagedChanges,
+		HasUnstagedChanges:      file.HasUnstagedChanges,
+		Tracked:                 file.Tracked,
+		Added:                   file.Added,
+		Deleted:                 file.Deleted,
+		HasMergeConflicts:       file.HasMergeConflicts,
+		HasInlineMergeConflicts: file.HasInlineMergeConflicts,
+		DisplayString:           file.DisplayString,
+		ShortStatus:             file.ShortStatus,
+		IsWorktree:              file.IsWorktree,
+	}
+}
+
+func branchShimFromModelBranch(branch *models.Branch) *Branch {
+	if branch == nil {
+		return nil
+	}
+
+	return &Branch{
+		Name:           branch.Name,
+		DisplayName:    branch.DisplayName,
+		Recency:        branch.Recency,
+		Pushables:      branch.AheadForPull,
+		Pullables:      branch.BehindForPull,
+		AheadForPull:   branch.AheadForPull,
+		BehindForPull:  branch.BehindForPull,
+		AheadForPush:   branch.AheadForPush,
+		BehindForPush:  branch.BehindForPush,
+		UpstreamGone:   branch.UpstreamGone,
+		Head:           branch.Head,
+		DetachedHead:   branch.DetachedHead,
+		UpstreamRemote: branch.UpstreamRemote,
+		UpstreamBranch: branch.UpstreamBranch,
+		Subject:        branch.Subject,
+		CommitHash:     branch.CommitHash,
+	}
+}
+
+func remoteBranchShimFromModelRemoteBranch(remoteBranch *models.RemoteBranch) *RemoteBranch {
+	if remoteBranch == nil {
+		return nil
+	}
+
+	return &RemoteBranch{
+		Name:       remoteBranch.Name,
+		RemoteName: remoteBranch.RemoteName,
+	}
+}
+
+func remoteShimFromModelRemote(remote *models.Remote) *Remote {
+	if remote == nil {
+		return nil
+	}
+
+	return &Remote{
+		Name: remote.Name,
+		Urls: remote.Urls,
+		Branches: lo.Map(remote.Branches, func(branch *models.RemoteBranch, _ int) *RemoteBranch {
+			return remoteBranchShimFromModelRemoteBranch(branch)
+		}),
+	}
+}
+
+func tagShimFromModelRemote(tag *models.Tag) *Tag {
+	if tag == nil {
+		return nil
+	}
+
+	return &Tag{
+		Name:    tag.Name,
+		Message: tag.Message,
+	}
+}
+
+func stashEntryShimFromModelRemote(stashEntry *models.StashEntry) *StashEntry {
+	if stashEntry == nil {
+		return nil
+	}
+
+	return &StashEntry{
+		Index:   stashEntry.Index,
+		Recency: stashEntry.Recency,
+		Name:    stashEntry.Name,
+	}
+}
+
+func commitFileShimFromModelRemote(commitFile *models.CommitFile) *CommitFile {
+	if commitFile == nil {
+		return nil
+	}
+
+	return &CommitFile{
+		Name:         commitFile.Name,
+		ChangeStatus: commitFile.ChangeStatus,
+	}
+}
+
+func worktreeShimFromModelRemote(worktree *models.Worktree) *Worktree {
+	if worktree == nil {
+		return nil
+	}
+
+	return &Worktree{
+		IsMain:        worktree.IsMain,
+		IsCurrent:     worktree.IsCurrent,
+		Path:          worktree.Path,
+		IsPathMissing: worktree.IsPathMissing,
+		GitDir:        worktree.GitDir,
+		Branch:        worktree.Branch,
+		Name:          worktree.Name,
+	}
+}
+
 // SessionState captures the current state of the application for use in custom commands
 type SessionState struct {
 	SelectedLocalCommit    *Commit
 	SelectedReflogCommit   *Commit
 	SelectedSubCommit      *Commit
-	SelectedFile           *models.File
+	SelectedFile           *File
 	SelectedPath           string
-	SelectedLocalBranch    *models.Branch
-	SelectedRemoteBranch   *models.RemoteBranch
-	SelectedRemote         *models.Remote
-	SelectedTag            *models.Tag
-	SelectedStashEntry     *models.StashEntry
-	SelectedCommitFile     *models.CommitFile
+	SelectedLocalBranch    *Branch
+	SelectedRemoteBranch   *RemoteBranch
+	SelectedRemote         *Remote
+	SelectedTag            *Tag
+	SelectedStashEntry     *StashEntry
+	SelectedCommitFile     *CommitFile
 	SelectedCommitFilePath string
-	SelectedWorktree       *models.Worktree
-	CheckedOutBranch       *models.Branch
+	SelectedWorktree       *Worktree
+	CheckedOutBranch       *Branch
 }
 
 func (self *SessionStateLoader) call() *SessionState {
 	return &SessionState{
-		SelectedFile:           self.c.Contexts().Files.GetSelectedFile(),
+		SelectedFile:           fileShimFromModelFile(self.c.Contexts().Files.GetSelectedFile()),
 		SelectedPath:           self.c.Contexts().Files.GetSelectedPath(),
-		SelectedLocalCommit:    commitWrapperFromModelCommit(self.c.Contexts().LocalCommits.GetSelected()),
-		SelectedReflogCommit:   commitWrapperFromModelCommit(self.c.Contexts().ReflogCommits.GetSelected()),
-		SelectedLocalBranch:    self.c.Contexts().Branches.GetSelected(),
-		SelectedRemoteBranch:   self.c.Contexts().RemoteBranches.GetSelected(),
-		SelectedRemote:         self.c.Contexts().Remotes.GetSelected(),
-		SelectedTag:            self.c.Contexts().Tags.GetSelected(),
-		SelectedStashEntry:     self.c.Contexts().Stash.GetSelected(),
-		SelectedCommitFile:     self.c.Contexts().CommitFiles.GetSelectedFile(),
+		SelectedLocalCommit:    commitShimFromModelCommit(self.c.Contexts().LocalCommits.GetSelected()),
+		SelectedReflogCommit:   commitShimFromModelCommit(self.c.Contexts().ReflogCommits.GetSelected()),
+		SelectedLocalBranch:    branchShimFromModelBranch(self.c.Contexts().Branches.GetSelected()),
+		SelectedRemoteBranch:   remoteBranchShimFromModelRemoteBranch(self.c.Contexts().RemoteBranches.GetSelected()),
+		SelectedRemote:         remoteShimFromModelRemote(self.c.Contexts().Remotes.GetSelected()),
+		SelectedTag:            tagShimFromModelRemote(self.c.Contexts().Tags.GetSelected()),
+		SelectedStashEntry:     stashEntryShimFromModelRemote(self.c.Contexts().Stash.GetSelected()),
+		SelectedCommitFile:     commitFileShimFromModelRemote(self.c.Contexts().CommitFiles.GetSelectedFile()),
 		SelectedCommitFilePath: self.c.Contexts().CommitFiles.GetSelectedPath(),
-		SelectedSubCommit:      commitWrapperFromModelCommit(self.c.Contexts().SubCommits.GetSelected()),
-		SelectedWorktree:       self.c.Contexts().Worktrees.GetSelected(),
-		CheckedOutBranch:       self.refsHelper.GetCheckedOutRef(),
+		SelectedSubCommit:      commitShimFromModelCommit(self.c.Contexts().SubCommits.GetSelected()),
+		SelectedWorktree:       worktreeShimFromModelRemote(self.c.Contexts().Worktrees.GetSelected()),
+		CheckedOutBranch:       branchShimFromModelBranch(self.refsHelper.GetCheckedOutRef()),
 	}
 }
diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go
index e3df61a50e3..a8caff77d5f 100644
--- a/pkg/integration/components/shell.go
+++ b/pkg/integration/components/shell.go
@@ -195,6 +195,10 @@ func (self *Shell) CreateAnnotatedTag(name string, message string, ref string) *
 }
 
 func (self *Shell) PushBranch(upstream, branch string) *Shell {
+	return self.RunCommand([]string{"git", "push", upstream, branch})
+}
+
+func (self *Shell) PushBranchAndSetUpstream(upstream, branch string) *Shell {
 	return self.RunCommand([]string{"git", "push", "--set-upstream", upstream, branch})
 }
 


Test patch:
diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go
index 9e56666fee7..2236374e510 100644
--- a/pkg/commands/git_commands/branch_loader_test.go
+++ b/pkg/commands/git_commands/branch_loader_test.go
@@ -25,89 +25,101 @@ 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",
-				Pushables:  "?",
-				Pullables:  "?",
-				Head:       false,
-				Subject:    "subject",
-				CommitHash: "123",
+				Name:          "a_branch",
+				AheadForPull:  "?",
+				BehindForPull: "?",
+				AheadForPush:  "?",
+				BehindForPush: "?",
+				Head:          false,
+				Subject:       "subject",
+				CommitHash:    "123",
 			},
 		},
 		{
 			testName:                 "NoUpstream",
-			input:                    []string{"", "a_branch", "", "", "subject", "123", timeStamp},
+			input:                    []string{"", "a_branch", "", "", "", "subject", "123", timeStamp},
 			storeCommitDateAsRecency: false,
 			expectedBranch: &models.Branch{
-				Name:       "a_branch",
-				Pushables:  "?",
-				Pullables:  "?",
-				Head:       false,
-				Subject:    "subject",
-				CommitHash: "123",
+				Name:          "a_branch",
+				AheadForPull:  "?",
+				BehindForPull: "?",
+				AheadForPush:  "?",
+				BehindForPush: "?",
+				Head:          false,
+				Subject:       "subject",
+				CommitHash:    "123",
 			},
 		},
 		{
 			testName:                 "IsHead",
-			input:                    []string{"*", "a_branch", "", "", "subject", "123", timeStamp},
+			input:                    []string{"*", "a_branch", "", "", "", "subject", "123", timeStamp},
 			storeCommitDateAsRecency: false,
 			expectedBranch: &models.Branch{
-				Name:       "a_branch",
-				Pushables:  "?",
-				Pullables:  "?",
-				Head:       true,
-				Subject:    "subject",
-				CommitHash: "123",
+				Name:          "a_branch",
+				AheadForPull:  "?",
+				BehindForPull: "?",
+				AheadForPush:  "?",
+				BehindForPush: "?",
+				Head:          true,
+				Subject:       "subject",
+				CommitHash:    "123",
 			},
 		},
 		{
 			testName:                 "IsBehindAndAhead",
-			input:                    []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "subject", "123", timeStamp},
+			input:                    []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "[behind 2, ahead 3]", "subject", "123", timeStamp},
 			storeCommitDateAsRecency: false,
 			expectedBranch: &models.Branch{
-				Name:       "a_branch",
-				Pushables:  "3",
-				Pullables:  "2",
-				Head:       false,
-				Subject:    "subject",
-				CommitHash: "123",
+				Name:          "a_branch",
+				AheadForPull:  "3",
+				BehindForPull: "2",
+				AheadForPush:  "3",
+				BehindForPush: "2",
+				Head:          false,
+				Subject:       "subject",
+				CommitHash:    "123",
 			},
 		},
 		{
 			testName:                 "RemoteBranchIsGone",
-			input:                    []string{"", "a_branch", "a_remote/a_branch", "[gone]", "subject", "123", timeStamp},
+			input:                    []string{"", "a_branch", "a_remote/a_branch", "[gone]", "[gone]", "subject", "123", timeStamp},
 			storeCommitDateAsRecency: false,
 			expectedBranch: &models.Branch{
-				Name:         "a_branch",
-				UpstreamGone: true,
-				Pushables:    "?",
-				Pullables:    "?",
-				Head:         false,
-				Subject:      "subject",
-				CommitHash:   "123",
+				Name:          "a_branch",
+				UpstreamGone:  true,
+				AheadForPull:  "?",
+				BehindForPull: "?",
+				AheadForPush:  "?",
+				BehindForPush: "?",
+				Head:          false,
+				Subject:       "subject",
+				CommitHash:    "123",
 			},
 		},
 		{
 			testName:                 "WithCommitDateAsRecency",
-			input:                    []string{"", "a_branch", "", "", "subject", "123", timeStamp},
+			input:                    []string{"", "a_branch", "", "", "", "subject", "123", timeStamp},
 			storeCommitDateAsRecency: true,
 			expectedBranch: &models.Branch{
-				Name:       "a_branch",
-				Recency:    "2h",
-				Pushables:  "?",
-				Pullables:  "?",
-				Head:       false,
-				Subject:    "subject",
-				CommitHash: "123",
+				Name:          "a_branch",
+				Recency:       "2h",
+				AheadForPull:  "?",
+				BehindForPull: "?",
+				AheadForPush:  "?",
+				BehindForPush: "?",
+				Head:          false,
+				Subject:       "subject",
+				CommitHash:    "123",
 			},
 		},
 	}
 
 	for _, s := range scenarios {
 		t.Run(s.testName, func(t *testing.T) {
-			branch := obtainBranch(s.input, s.storeCommitDateAsRecency)
+			branch := obtainBranch(s.input, s.storeCommitDateAsRecency, true)
 			assert.EqualValues(t, s.expectedBranch, branch)
 		})
 	}
diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go
index 250b143e320..cf2f1d994f8 100644
--- a/pkg/gui/presentation/branches_test.go
+++ b/pkg/gui/presentation/branches_test.go
@@ -58,8 +58,8 @@ func Test_getBranchDisplayStrings(t *testing.T) {
 				Name:           "branch_name",
 				Recency:        "1m",
 				UpstreamRemote: "origin",
-				Pushables:      "0",
-				Pullables:      "0",
+				AheadForPull:   "0",
+				BehindForPull:  "0",
 			},
 			itemOperation:        types.ItemOperationNone,
 			fullDescription:      false,
@@ -73,8 +73,8 @@ func Test_getBranchDisplayStrings(t *testing.T) {
 				Name:           "branch_name",
 				Recency:        "1m",
 				UpstreamRemote: "origin",
-				Pushables:      "3",
-				Pullables:      "5",
+				AheadForPull:   "3",
+				BehindForPull:  "5",
 			},
 			itemOperation:        types.ItemOperationNone,
 			fullDescription:      false,
@@ -99,8 +99,8 @@ func Test_getBranchDisplayStrings(t *testing.T) {
 				CommitHash:     "1234567890",
 				UpstreamRemote: "origin",
 				UpstreamBranch: "branch_name",
-				Pushables:      "0",
-				Pullables:      "0",
+				AheadForPull:   "0",
+				BehindForPull:  "0",
 				Subject:        "commit title",
 			},
 			itemOperation:        types.ItemOperationNone,
@@ -144,8 +144,8 @@ func Test_getBranchDisplayStrings(t *testing.T) {
 				Name:           "branch_name",
 				Recency:        "1m",
 				UpstreamRemote: "origin",
-				Pushables:      "0",
-				Pullables:      "0",
+				AheadForPull:   "0",
+				BehindForPull:  "0",
 			},
 			itemOperation:        types.ItemOperationNone,
 			fullDescription:      false,
@@ -159,8 +159,8 @@ func Test_getBranchDisplayStrings(t *testing.T) {
 				Name:           "branch_name",
 				Recency:        "1m",
 				UpstreamRemote: "origin",
-				Pushables:      "3",
-				Pullables:      "5",
+				AheadForPull:   "3",
+				BehindForPull:  "5",
 			},
 			itemOperation:        types.ItemOperationNone,
 			fullDescription:      false,
@@ -212,8 +212,8 @@ func Test_getBranchDisplayStrings(t *testing.T) {
 				CommitHash:     "1234567890",
 				UpstreamRemote: "origin",
 				UpstreamBranch: "branch_name",
-				Pushables:      "0",
-				Pullables:      "0",
+				AheadForPull:   "0",
+				BehindForPull:  "0",
 				Subject:        "commit title",
 			},
 			itemOperation:        types.ItemOperationNone,
diff --git a/pkg/integration/tests/branch/delete.go b/pkg/integration/tests/branch/delete.go
index 0b6adfac4d0..f81eb060970 100644
--- a/pkg/integration/tests/branch/delete.go
+++ b/pkg/integration/tests/branch/delete.go
@@ -15,9 +15,9 @@ var Delete = NewIntegrationTest(NewIntegrationTestArgs{
 			CloneIntoRemote("origin").
 			EmptyCommit("blah").
 			NewBranch("branch-one").
-			PushBranch("origin", "branch-one").
+			PushBranchAndSetUpstream("origin", "branch-one").
 			NewBranch("branch-two").
-			PushBranch("origin", "branch-two").
+			PushBranchAndSetUpstream("origin", "branch-two").
 			EmptyCommit("deletion blocker").
 			NewBranch("branch-three")
 	},
diff --git a/pkg/integration/tests/branch/delete_remote_branch_with_credential_prompt.go b/pkg/integration/tests/branch/delete_remote_branch_with_credential_prompt.go
index f145eceaaf1..ad8f70c435c 100644
--- a/pkg/integration/tests/branch/delete_remote_branch_with_credential_prompt.go
+++ b/pkg/integration/tests/branch/delete_remote_branch_with_credential_prompt.go
@@ -18,7 +18,7 @@ var DeleteRemoteBranchWithCredentialPrompt = NewIntegrationTest(NewIntegrationTe
 
 		shell.NewBranch("mybranch")
 
-		shell.PushBranch("origin", "mybranch")
+		shell.PushBranchAndSetUpstream("origin", "mybranch")
 
 		// actually getting a password prompt is tricky: it requires SSH'ing into localhost under a newly created, restricted, user.
 		// This is not easy to do in a cross-platform way, nor is it easy to do in a docker container.
diff --git a/pkg/integration/tests/branch/rebase_to_upstream.go b/pkg/integration/tests/branch/rebase_to_upstream.go
index 5c9e0f388cf..2469eb012d4 100644
--- a/pkg/integration/tests/branch/rebase_to_upstream.go
+++ b/pkg/integration/tests/branch/rebase_to_upstream.go
@@ -15,7 +15,7 @@ var RebaseToUpstream = NewIntegrationTest(NewIntegrationTestArgs{
 			CloneIntoRemote("origin").
 			EmptyCommit("ensure-master").
 			EmptyCommit("to-be-added"). // <- this will only exist remotely
-			PushBranch("origin", "master").
+			PushBranchAndSetUpstream("origin", "master").
 			HardReset("HEAD~1").
 			NewBranchFrom("base-branch", "master").
 			EmptyCommit("base-branch-commit").
diff --git a/pkg/integration/tests/branch/reset_to_upstream.go b/pkg/integration/tests/branch/reset_to_upstream.go
index c933787e492..3cdbb561d80 100644
--- a/pkg/integration/tests/branch/reset_to_upstream.go
+++ b/pkg/integration/tests/branch/reset_to_upstream.go
@@ -15,10 +15,10 @@ var ResetToUpstream = NewIntegrationTest(NewIntegrationTestArgs{
 			CloneIntoRemote("origin").
 			NewBranch("hard-branch").
 			EmptyCommit("hard commit").
-			PushBranch("origin", "hard-branch").
+			PushBranchAndSetUpstream("origin", "hard-branch").
 			NewBranch("soft-branch").
 			EmptyCommit("soft commit").
-			PushBranch("origin", "soft-branch").
+			PushBranchAndSetUpstream("origin", "soft-branch").
 			NewBranch("base").
 			EmptyCommit("base-branch commit").
 			CreateFile("file-1", "content").
diff --git a/pkg/integration/tests/sync/force_push_triangular.go b/pkg/integration/tests/sync/force_push_triangular.go
new file mode 100644
index 00000000000..70912d1232c
--- /dev/null
+++ b/pkg/integration/tests/sync/force_push_triangular.go
@@ -0,0 +1,65 @@
+package sync
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var ForcePushTriangular = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Push to a remote, requiring a force push because the branch is behind the remote push branch but not the upstream",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	GitVersion:   AtLeast("2.22.0"),
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.SetConfig("push.default", "current")
+
+		shell.EmptyCommit("one")
+
+		shell.CloneIntoRemote("origin")
+
+		shell.NewBranch("feature")
+		shell.SetBranchUpstream("feature", "origin/master")
+		shell.EmptyCommit("two")
+		shell.PushBranch("origin", "feature")
+
+		// remove the 'two' commit so that we are behind the push branch
+		shell.HardReset("HEAD^")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Commits().
+			Lines(
+				Contains("one"),
+			)
+
+		t.Views().Status().Content(Contains("✓ repo → feature"))
+
+		t.Views().Files().IsFocused().Press(keys.Universal.Push)
+
+		t.ExpectPopup().Confirmation().
+			Title(Equals("Force push")).
+			Content(Equals("Your branch has diverged from the remote branch. Press <esc> to cancel, or <enter> to force push.")).
+			Confirm()
+
+		t.Views().Commits().
+			Lines(
+				Contains("one"),
+			)
+
+		t.Views().Status().Content(Contains("✓ repo → feature"))
+
+		t.Views().Remotes().Focus().
+			Lines(Contains("origin")).
+			PressEnter()
+
+		t.Views().RemoteBranches().IsFocused().
+			Lines(
+				Contains("feature"),
+				Contains("master"),
+			).
+			PressEnter()
+
+		t.Views().SubCommits().IsFocused().
+			Lines(Contains("one"))
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index 7084fd99b3c..e043d4a8a21 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -275,6 +275,7 @@ var tests = []*components.IntegrationTest{
 	sync.ForcePush,
 	sync.ForcePushMultipleMatching,
 	sync.ForcePushMultipleUpstream,
+	sync.ForcePushTriangular,
 	sync.Pull,
 	sync.PullAndSetUpstream,
 	sync.PullMerge,