From f4c8287143e87a582a00afacff1e6c2c876ee1d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Fl=C3=BCgge?= Date: Sun, 15 Dec 2024 18:20:43 +0100 Subject: [PATCH] Allow to switch branches in Commit View 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. --- .../controllers/basic_commits_controller.go | 10 +-- pkg/gui/controllers/helpers/refs_helper.go | 48 +++++++++++++ pkg/i18n/english.go | 38 ++++++---- pkg/integration/tests/commit/checkout.go | 69 +++++++++++++++++++ pkg/integration/tests/reflog/checkout.go | 6 +- pkg/integration/tests/test_list.go | 1 + 6 files changed, 145 insertions(+), 27 deletions(-) create mode 100644 pkg/integration/tests/commit/checkout.go diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go index ac0ffb39414..797215746c9 100644 --- a/pkg/gui/controllers/basic_commits_controller.go +++ b/pkg/gui/controllers/basic_commits_controller.go @@ -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 { diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 8332174dcb9..9fb354a8d33 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -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, diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 8c8ce995806..2bff2d6bd96 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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", diff --git a/pkg/integration/tests/commit/checkout.go b/pkg/integration/tests/commit/checkout.go new file mode 100644 index 00000000000..455aa273ca3 --- /dev/null +++ b/pkg/integration/tests/commit/checkout.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/reflog/checkout.go b/pkg/integration/tests/reflog/checkout.go index 94ca6e7ef48..90ba93a069e 100644 --- a/pkg/integration/tests/reflog/checkout.go +++ b/pkg/integration/tests/reflog/checkout.go @@ -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( diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 782bdcb1bd6..2e33a4f4e5b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -84,6 +84,7 @@ var tests = []*components.IntegrationTest{ commit.AddCoAuthorWhileCommitting, commit.Amend, commit.AutoWrapMessage, + commit.Checkout, commit.Commit, commit.CommitMultiline, commit.CommitSwitchToEditor,