mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-28 02:07:09 -04:00
Split fast-forwarding a branch into fetching and updating it
Fast-forwarding a branch that isn't checked out ran a single `git fetch <remote> refs/heads/<branch>:<branch>`, so git's own refusal to move a local branch backwards served as the safety check. A later commit widens the set of branches the command accepts, and for that lazygit has to make that decision itself. Fetch the remote branch on its own, updating only its remote-tracking branch. Then ask git whether the branch is an ancestor of it, and only then move the branch: with `git update-ref` when it isn't checked out anywhere, guarded by the hash we knew it to have, and with `git merge --ff-only` in its worktree when it is checked out there. Keeping `git pull --ff-only` for the latter case would fetch a second time. Fast-forwarding a branch that isn't checked out in a worktree had no integration test at all, so add one. The second new test covers a repo that keeps no reflogs, because a later commit starts reading those and this case has to keep working without them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0d8b18cfb7
commit
667df9e784
@@ -285,6 +285,20 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// Fast-forwards the branch that is checked out in the given worktree to the
|
||||
// given ref. Fails if that can't be done without a merge commit. Pass empty
|
||||
// strings for the worktree to use the current one.
|
||||
func (self *BranchCommands) FastForwardMerge(refName string, worktreeGitDir string, worktreePath string) error {
|
||||
cmdArgs := NewGitCmd("merge").
|
||||
Arg("--ff-only").
|
||||
Arg(refName).
|
||||
GitDirIf(worktreeGitDir != "", worktreeGitDir).
|
||||
WorktreePathIf(worktreePath != "", worktreePath).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// Returns whether the first ref is an ancestor of the second one, which also
|
||||
// means that the second one can be fast-forward merged into the first one
|
||||
func (self *BranchCommands) IsAncestor(ancestorRefName string, refName string) bool {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
@@ -111,15 +113,20 @@ func (self *SyncCommands) Pull(task gocui.Task, opts PullOptions) error {
|
||||
return self.cmd.New(cmdArgs).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest(task).Run()
|
||||
}
|
||||
|
||||
func (self *SyncCommands) FastForward(
|
||||
// Fetches the given branch of the given remote, updating its remote-tracking
|
||||
// branch. Local branches are left alone, including the one that tracks it.
|
||||
func (self *SyncCommands) FetchRemoteBranch(
|
||||
task gocui.Task,
|
||||
branchName string,
|
||||
remoteName string,
|
||||
remoteBranchName string,
|
||||
) error {
|
||||
cmdArgs := self.fetchCommandBuilder(false).
|
||||
Arg(remoteName).
|
||||
Arg("refs/heads/" + remoteBranchName + ":" + branchName).
|
||||
// The explicit destination and the leading + make sure that the
|
||||
// remote-tracking branch is updated even when the remote branch was
|
||||
// rewritten, whatever the remote's fetch refspec says
|
||||
Arg(fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s",
|
||||
remoteBranchName, remoteName, remoteBranchName)).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
|
||||
|
||||
@@ -434,14 +434,34 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fe
|
||||
return fetchErr
|
||||
}
|
||||
|
||||
// Updates the given branch to its upstream branch, fetching that first. The
|
||||
// branch must not have any commits that its upstream doesn't have, so that
|
||||
// nothing is lost.
|
||||
func (self *BranchesHelper) FastForwardBranch(branch *models.Branch) error {
|
||||
action := self.c.Tr.Actions.FastForwardBranch
|
||||
worktree, ok := self.worktreeForBranch(branch)
|
||||
worktree, checkedOut := self.worktreeForBranch(branch)
|
||||
|
||||
return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
if ok {
|
||||
self.c.LogAction(action)
|
||||
defer func() {
|
||||
if checkedOut {
|
||||
// The files of that worktree have changed as well
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
} else {
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
|
||||
}
|
||||
}()
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.FastForwardBranch)
|
||||
|
||||
err := self.c.Git().Sync.FetchRemoteBranch(task, branch.UpstreamRemote, branch.UpstreamBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !self.c.Git().Branch.IsAncestor(branch.FullRefName(), branch.FullUpstreamRefName()) {
|
||||
return errors.New(self.c.Tr.FwdCommitsToPush)
|
||||
}
|
||||
|
||||
if checkedOut {
|
||||
worktreeGitDir := ""
|
||||
worktreePath := ""
|
||||
// if it is the current worktree path, no need to specify the path
|
||||
@@ -450,27 +470,14 @@ func (self *BranchesHelper) FastForwardBranch(branch *models.Branch) error {
|
||||
worktreePath = worktree.Path
|
||||
}
|
||||
|
||||
err := self.c.Git().Sync.Pull(
|
||||
task,
|
||||
git_commands.PullOptions{
|
||||
RemoteName: branch.UpstreamRemote,
|
||||
BranchName: branch.UpstreamBranch,
|
||||
FastForwardOnly: true,
|
||||
WorktreeGitDir: worktreeGitDir,
|
||||
WorktreePath: worktreePath,
|
||||
},
|
||||
)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return err
|
||||
return self.c.Git().Branch.FastForwardMerge(
|
||||
branch.FullUpstreamRefName(), worktreeGitDir, worktreePath)
|
||||
}
|
||||
|
||||
self.c.LogAction(action)
|
||||
|
||||
err := self.c.Git().Sync.FastForward(
|
||||
task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch,
|
||||
)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
|
||||
return err
|
||||
updateCommand := fmt.Sprintf("update %s %s %s",
|
||||
branch.FullRefName(), branch.FullUpstreamRefName(), branch.CommitHash)
|
||||
self.c.LogCommand(updateCommand, false)
|
||||
return self.c.Git().Branch.UpdateBranchRefs(updateCommand + "\n")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBehindBranch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a branch that is behind its upstream branch, without checking it out",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("one")
|
||||
shell.NewBranch("feature")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("feature", "origin/feature")
|
||||
|
||||
// remove the two commits so that there is something to fast-forward to
|
||||
shell.HardReset("HEAD~2")
|
||||
shell.Checkout("master")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ✓").IsSelected(),
|
||||
).
|
||||
PressEnter()
|
||||
|
||||
t.Views().SubCommits().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("three"),
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBehindBranchCheckedOut = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward the checked out branch when it is behind its upstream branch",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file", "content")
|
||||
shell.Commit("one")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("master", "origin/master")
|
||||
|
||||
// remove the two commits so that there is something to fast-forward to
|
||||
shell.HardReset("HEAD~2")
|
||||
|
||||
// a change that the fast-forward doesn't get in the way of
|
||||
shell.UpdateFile("file", "changed")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master ↓2").IsSelected(),
|
||||
).
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master ✓").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("three"),
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
// Moving the branch forward doesn't touch the change
|
||||
t.Views().Files().
|
||||
Lines(
|
||||
Contains("file"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBehindBranchWithoutReflogs = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a branch that is behind its upstream branch in a repo that keeps no reflogs",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
// This is the default in a bare repository, so it is what people with a
|
||||
// bare repo and linked worktrees work with
|
||||
shell.SetConfig("core.logAllRefUpdates", "false")
|
||||
|
||||
shell.EmptyCommit("one")
|
||||
shell.NewBranch("feature")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("feature", "origin/feature")
|
||||
|
||||
// remove the two commits so that there is something to fast-forward to
|
||||
shell.HardReset("HEAD~2")
|
||||
shell.Checkout("master")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ✓").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -474,6 +474,9 @@ var tests = []*components.IntegrationTest{
|
||||
submodule.Stage,
|
||||
submodule.StageAllWithDirtySubmodule,
|
||||
submodule.StageDirtyOnly,
|
||||
sync.FastForwardBehindBranch,
|
||||
sync.FastForwardBehindBranchCheckedOut,
|
||||
sync.FastForwardBehindBranchWithoutReflogs,
|
||||
sync.FetchAndAutoForwardBranchesAllBranches,
|
||||
sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree,
|
||||
sync.FetchAndAutoForwardBranchesNone,
|
||||
|
||||
Reference in New Issue
Block a user