1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-05-29 23:17:32 +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 { func (self *BasicCommitsController) checkout(commit *models.Commit) error {
self.c.Confirm(types.ConfirmOpts{ return self.c.Helpers().Refs.CreateCheckoutMenu(commit)
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
} }
func (self *BasicCommitsController) copyRange(*models.Commit) error { func (self *BasicCommitsController) copyRange(*models.Commit) error {

View File

@ -18,6 +18,7 @@ type IRefsHelper interface {
CheckoutRef(ref string, options types.CheckoutRefOptions) error CheckoutRef(ref string, options types.CheckoutRefOptions) error
GetCheckedOutRef() *models.Branch GetCheckedOutRef() *models.Branch
CreateGitResetMenu(ref string) error CreateGitResetMenu(ref string) error
CreateCheckoutMenu(commit *models.Commit) error
ResetToRef(ref string, strength string, envVars []string) error ResetToRef(ref string, strength string, envVars []string) error
NewBranch(from string, fromDescription string, suggestedBranchname 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 { func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggestedBranchName string) error {
message := utils.ResolvePlaceholderString( message := utils.ResolvePlaceholderString(
self.c.Tr.NewBranchNameBranchOff, self.c.Tr.NewBranchNameBranchOff,

View File

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

View File

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