1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-05-27 23:08:02 +02:00

Allow to switch branches in Commit View (#4115) (#4117)

- **PR Description**

When the user checks out a commit which has a local branch ref attached
to it, they can select between checking out the branch or checking out
the commit as detached head. If no local branch is attached to the
commit, the behavior is like before: They are asked to confirm, if they
want to checkout the commit as detached head.

Requested in #4115.

Note: I tried also to consider remote branches, but because I wasn't
able to correlate remote branches to their commits, I deferred it and
leave it open for later improvement.
This commit is contained in:
Stefan Haller 2025-01-01 14:59:20 +01:00 committed by GitHub
commit f884cc2af9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 145 additions and 27 deletions

View File

@ -280,15 +280,7 @@ func (self *BasicCommitsController) createResetMenu(commit *models.Commit) error
}
func (self *BasicCommitsController) checkout(commit *models.Commit) error {
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.CheckoutCommit,
Prompt: self.c.Tr.SureCheckoutThisCommit,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.CheckoutCommit)
return self.c.Helpers().Refs.CheckoutRef(commit.Hash, types.CheckoutRefOptions{})
},
})
return nil
return self.c.Helpers().Refs.CreateCheckoutMenu(commit)
}
func (self *BasicCommitsController) copyRange(*models.Commit) error {

View File

@ -18,6 +18,7 @@ type IRefsHelper interface {
CheckoutRef(ref string, options types.CheckoutRefOptions) error
GetCheckedOutRef() *models.Branch
CreateGitResetMenu(ref string) error
CreateCheckoutMenu(commit *models.Commit) error
ResetToRef(ref string, strength string, envVars []string) error
NewBranch(from string, fromDescription string, suggestedBranchname string) error
}
@ -271,6 +272,53 @@ func (self *RefsHelper) CreateGitResetMenu(ref string) error {
})
}
func (self *RefsHelper) CreateCheckoutMenu(commit *models.Commit) error {
branches := lo.Filter(self.c.Model().Branches, func(branch *models.Branch, _ int) bool {
return commit.Hash == branch.CommitHash && branch.Name != self.c.Model().CheckedOutBranch
})
hash := commit.Hash
var menuItems []*types.MenuItem
if len(branches) > 0 {
menuItems = append(menuItems, lo.Map(branches, func(branch *models.Branch, index int) *types.MenuItem {
var key types.Key
if index < 9 {
key = rune(index + 1 + '0') // Convert 1-based index to key
}
return &types.MenuItem{
LabelColumns: []string{fmt.Sprintf(self.c.Tr.Actions.CheckoutBranchAtCommit, branch.Name)},
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.CheckoutBranch)
return self.CheckoutRef(branch.RefName(), types.CheckoutRefOptions{})
},
Key: key,
}
})...)
} else {
menuItems = append(menuItems, &types.MenuItem{
LabelColumns: []string{self.c.Tr.Actions.CheckoutBranch},
OnPress: func() error { return nil },
DisabledReason: &types.DisabledReason{Text: self.c.Tr.NoBranchesFoundAtCommitTooltip},
Key: '1',
})
}
menuItems = append(menuItems, &types.MenuItem{
LabelColumns: []string{fmt.Sprintf(self.c.Tr.Actions.CheckoutCommitAsDetachedHead, utils.ShortHash(hash))},
OnPress: func() error {
self.c.LogAction(self.c.Tr.Actions.CheckoutCommit)
return self.CheckoutRef(hash, types.CheckoutRefOptions{})
},
Key: 'd',
})
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.Actions.CheckoutBranchOrCommit,
Items: menuItems,
})
}
func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggestedBranchName string) error {
message := utils.ResolvePlaceholderString(
self.c.Tr.NewBranchNameBranchOff,

View File

@ -527,6 +527,7 @@ type TranslationSet struct {
FetchingRemoteStatus string
CheckoutCommit string
CheckoutCommitTooltip string
NoBranchesFoundAtCommitTooltip string
SureCheckoutThisCommit string
GitFlowOptions string
NotAGitFlowBranch string
@ -860,8 +861,11 @@ type Log struct {
type Actions struct {
CheckoutCommit string
CheckoutBranchAtCommit string
CheckoutCommitAsDetachedHead string
CheckoutTag string
CheckoutBranch string
CheckoutBranchOrCommit string
ForceCheckoutBranch string
DeleteLocalBranch string
Merge string
@ -1522,21 +1526,22 @@ func EnglishTranslationSet() *TranslationSet {
DeleteRemoteTagPrompt: "Are you sure you want to delete the remote tag '{{.tagName}}' from '{{.upstream}}'?",
PushTagTitle: "Remote to push tag '{{.tagName}}' to:",
// Using 'push tag' rather than just 'push' to disambiguate from a global push
PushTag: "Push tag",
PushTagTooltip: "Push the selected tag to a remote. You'll be prompted to select a remote.",
NewTag: "New tag",
NewTagTooltip: "Create new tag from current commit. You'll be prompted to enter a tag name and optional description.",
CreatingTag: "Creating tag",
ForceTag: "Force Tag",
ForceTagPrompt: "The tag '{{.tagName}}' exists already. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to overwrite.",
FetchRemoteTooltip: "Fetch updates from the remote repository. This retrieves new commits and branches without merging them into your local branches.",
FetchingRemoteStatus: "Fetching remote",
CheckoutCommit: "Checkout commit",
CheckoutCommitTooltip: "Checkout the selected commit as a detached HEAD.",
SureCheckoutThisCommit: "Are you sure you want to checkout this commit?",
GitFlowOptions: "Show git-flow options",
NotAGitFlowBranch: "This does not seem to be a git flow branch",
NewGitFlowBranchPrompt: "New {{.branchType}} name:",
PushTag: "Push tag",
PushTagTooltip: "Push the selected tag to a remote. You'll be prompted to select a remote.",
NewTag: "New tag",
NewTagTooltip: "Create new tag from current commit. You'll be prompted to enter a tag name and optional description.",
CreatingTag: "Creating tag",
ForceTag: "Force Tag",
ForceTagPrompt: "The tag '{{.tagName}}' exists already. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to overwrite.",
FetchRemoteTooltip: "Fetch updates from the remote repository. This retrieves new commits and branches without merging them into your local branches.",
FetchingRemoteStatus: "Fetching remote",
CheckoutCommit: "Checkout commit",
CheckoutCommitTooltip: "Checkout the selected commit as a detached HEAD.",
NoBranchesFoundAtCommitTooltip: "No branches found at selected commit.",
SureCheckoutThisCommit: "Are you sure you want to checkout this commit?",
GitFlowOptions: "Show git-flow options",
NotAGitFlowBranch: "This does not seem to be a git flow branch",
NewGitFlowBranchPrompt: "New {{.branchType}} name:",
IgnoreTracked: "Ignore tracked file",
IgnoreTrackedPrompt: "Are you sure you want to ignore a tracked file?",
@ -1822,9 +1827,12 @@ func EnglishTranslationSet() *TranslationSet {
Actions: Actions{
// TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
CheckoutCommit: "Checkout commit",
CheckoutBranchAtCommit: "Checkout branch '%s'",
CheckoutCommitAsDetachedHead: "Checkout commit %s as detached head",
CheckoutTag: "Checkout tag",
CheckoutBranch: "Checkout branch",
ForceCheckoutBranch: "Force checkout branch",
CheckoutBranchOrCommit: "Checkout branch or commit",
DeleteLocalBranch: "Delete local branch",
Merge: "Merge",
SquashMerge: "Squash merge",

View File

@ -0,0 +1,69 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var Checkout = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Checkout a commit as a detached head, or checkout an existing branch at a commit",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("one")
shell.EmptyCommit("two")
shell.NewBranch("branch1")
shell.NewBranch("branch2")
shell.EmptyCommit("three")
shell.EmptyCommit("four")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("four").IsSelected(),
Contains("three"),
Contains("two"),
Contains("one"),
).
PressPrimaryAction()
t.ExpectPopup().Menu().
Title(Contains("Checkout branch or commit")).
Lines(
Contains("Checkout branch").IsSelected(),
MatchesRegexp("Checkout commit [a-f0-9]+ as detached head"),
Contains("Cancel"),
).
Tooltip(Contains("Disabled: No branches found at selected commit.")).
Select(MatchesRegexp("Checkout commit [a-f0-9]+ as detached head")).
Confirm()
t.Views().Branches().Lines(
Contains("* (HEAD detached at"),
Contains("branch2"),
Contains("branch1"),
Contains("master"),
)
t.Views().Commits().
NavigateToLine(Contains("two")).
PressPrimaryAction()
t.ExpectPopup().Menu().
Title(Contains("Checkout branch or commit")).
Lines(
Contains("Checkout branch 'branch1'").IsSelected(),
Contains("Checkout branch 'master'"),
MatchesRegexp("Checkout commit [a-f0-9]+ as detached head"),
Contains("Cancel"),
).
Select(Contains("Checkout branch 'master'")).
Confirm()
t.Views().Branches().Lines(
Contains("master"),
Contains("branch2"),
Contains("branch1"),
)
},
})

View File

@ -28,9 +28,9 @@ var Checkout = NewIntegrationTest(NewIntegrationTestArgs{
SelectNextItem().
PressPrimaryAction().
Tap(func() {
t.ExpectPopup().Confirmation().
Title(Contains("Checkout commit")).
Content(Contains("Are you sure you want to checkout this commit?")).
t.ExpectPopup().Menu().
Title(Contains("Checkout branch or commit")).
Select(MatchesRegexp("Checkout commit [a-f0-9]+ as detached head")).
Confirm()
}).
TopLines(

View File

@ -84,6 +84,7 @@ var tests = []*components.IntegrationTest{
commit.AddCoAuthorWhileCommitting,
commit.Amend,
commit.AutoWrapMessage,
commit.Checkout,
commit.Commit,
commit.CommitMultiline,
commit.CommitSwitchToEditor,