From d3f16e5d3be7a0d240688b3a02ce859795dd9158 Mon Sep 17 00:00:00 2001 From: Paul Nodet <5941125+pnodet@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:34:39 +0200 Subject: [PATCH 1/2] Add test showing cherry-pick copy/paste doesn't survive a worktree switch The cherry-pick clipboard lives in GuiRepoState.Modes, and we keep a separate GuiRepoState per worktree (RepoStateMap is keyed by the worktree path). Switching worktrees swaps in that worktree's own state, whose clipboard is empty, so pasting is disabled. The copied commits aren't cleared; they're stranded on the previous worktree's state and reappear when switching back. --- pkg/integration/tests/test_list.go | 1 + .../worktree/cherry_pick_across_worktrees.go | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 pkg/integration/tests/worktree/cherry_pick_across_worktrees.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 1b264e50dc9..1ddbee5af04 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -490,6 +490,7 @@ var tests = []*components.IntegrationTest{ worktree.AssociateBranchRebase, worktree.BareRepo, worktree.BareRepoWorktreeConfig, + worktree.CherryPickAcrossWorktrees, worktree.Crud, worktree.CustomCommand, worktree.DetachWorktreeFromBranch, diff --git a/pkg/integration/tests/worktree/cherry_pick_across_worktrees.go b/pkg/integration/tests/worktree/cherry_pick_across_worktrees.go new file mode 100644 index 00000000000..ef5bb0d2a37 --- /dev/null +++ b/pkg/integration/tests/worktree/cherry_pick_across_worktrees.go @@ -0,0 +1,63 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CherryPickAcrossWorktrees = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Copy a commit in one worktree and paste it in another worktree of the same repo", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.EmptyCommit("base") + // the linked worktree's branch stays at "base" + shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") + shell.EmptyCommit("one") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("one").IsSelected(), + Contains("base"), + ). + Press(keys.Commits.CherryPickCopy) + + t.Views().Information().Content(Contains("1 commit copied")) + + t.Views().Worktrees(). + Focus(). + Lines( + Contains("(main worktree)").IsSelected(), + Contains("linked-worktree"), + ). + NavigateToLine(Contains("linked-worktree")). + Press(keys.Universal.Select) + + t.Views().Commits(). + Focus(). + Lines( + Contains("base"), + ). + Press(keys.Commits.PasteCommits) + + /* EXPECTED: + t.ExpectPopup().Alert(). + Title(Equals("Cherry-pick")). + Content(Contains("Are you sure you want to cherry-pick the 1 copied commit(s) onto this branch?")). + Confirm() + + t.Views().Information().Content(DoesNotContain("commit copied")) + + t.Views().Commits(). + Lines( + Contains("one"), + Contains("base").IsSelected(), + ) + ACTUAL: */ + t.ExpectToast(Equals("Disabled: No copied commits")) + }, +}) From a869854c63027a767ae5b64f7a572a0598530f41 Mon Sep 17 00:00:00 2001 From: Paul Nodet <5941125+pnodet@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:45:36 +0200 Subject: [PATCH 2/2] Share cherry-pick state across worktrees of the same repo Copied commits are referenced by hash, and hashes resolve identically in every worktree of a repo (shared object database), so there's no reason pasting shouldn't work after switching worktrees. Move the clipboard out of the per-worktree GuiRepoState into a per-repo SharedRepoState, keyed by the repo's common git dir; every worktree's Modes.CherryPicking now points at the same instance. Submodules and unrelated repos have different git dirs, so they keep isolated clipboards. As a side effect, cancelling a cherry-pick in one worktree now correctly clears it in all of them. --- pkg/gui/gui.go | 26 +++++++++++++++++-- pkg/gui/types/modes.go | 9 +++++-- .../worktree/cherry_pick_across_worktrees.go | 3 --- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e2881cca148..3ae4da7d541 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -76,7 +76,11 @@ type Gui struct { // this is a mapping of repos to gui states, so that we can restore the original // gui state when returning from a subrepo. // In repos with multiple worktrees, we store a separate repo state per worktree. - RepoStateMap map[Repo]*GuiRepoState + RepoStateMap map[Repo]*GuiRepoState + // Holds state shared between all worktrees of the same repo, keyed by the + // repo's common git dir (one entry per repo, where RepoStateMap has one + // entry per worktree). + sharedRepoStateMap map[Repo]*SharedRepoState Config config.AppConfigurer Updater *updates.Updater statusManager *status.StatusManager @@ -259,6 +263,14 @@ type GuiRepoState struct { var _ types.IRepoStateAccessor = new(GuiRepoState) +// SharedRepoState is state shared between all worktrees of the same repo. +// Unlike GuiRepoState, of which we keep one instance per worktree, there is +// only one instance of this per repo; e.g. commits copied for cherry-picking +// in one worktree can be pasted in another. +type SharedRepoState struct { + CherryPicking *cherrypicking.CherryPicking +} + func (self *GuiRepoState) GetViewsSetup() bool { return self.ViewsSetup } @@ -591,6 +603,15 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { return gui.c.Context().Current() } + repoGitDirPath := gui.git.RepoPaths.RepoGitDirPath() + sharedState := gui.sharedRepoStateMap[Repo(repoGitDirPath)] + if sharedState == nil { + sharedState = &SharedRepoState{ + CherryPicking: cherrypicking.New(), + } + gui.sharedRepoStateMap[Repo(repoGitDirPath)] = sharedState + } + contextTree := gui.contextTree() initialScreenMode := initialScreenMode(startArgs, gui.Config) @@ -614,7 +635,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { }, Modes: &types.Modes{ Filtering: filtering.New(startArgs.FilterPath, ""), - CherryPicking: cherrypicking.New(), + CherryPicking: sharedState.CherryPicking, Diffing: diffing.New(), MarkedBaseCommit: marked_base_commit.New(), }, @@ -738,6 +759,7 @@ func NewGui( showRecentRepos: showRecentRepos, RepoPathStack: &utils.StringStack{}, RepoStateMap: map[Repo]*GuiRepoState{}, + sharedRepoStateMap: map[Repo]*SharedRepoState{}, GuiLog: []string{}, // initializing this to true for the time being; it will be reset to the diff --git a/pkg/gui/types/modes.go b/pkg/gui/types/modes.go index a11ed0081f6..c2f89785791 100644 --- a/pkg/gui/types/modes.go +++ b/pkg/gui/types/modes.go @@ -8,8 +8,13 @@ import ( ) type Modes struct { - Filtering filtering.Filtering - CherryPicking *cherrypicking.CherryPicking + Filtering filtering.Filtering + + // Shared between all worktrees of the same repo (see gui.SharedRepoState). + // Mutate it through this pointer, but never replace it, otherwise it is no + // longer shared. + CherryPicking *cherrypicking.CherryPicking + Diffing diffing.Diffing MarkedBaseCommit marked_base_commit.MarkedBaseCommit } diff --git a/pkg/integration/tests/worktree/cherry_pick_across_worktrees.go b/pkg/integration/tests/worktree/cherry_pick_across_worktrees.go index ef5bb0d2a37..1c907d93024 100644 --- a/pkg/integration/tests/worktree/cherry_pick_across_worktrees.go +++ b/pkg/integration/tests/worktree/cherry_pick_across_worktrees.go @@ -44,7 +44,6 @@ var CherryPickAcrossWorktrees = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Commits.PasteCommits) - /* EXPECTED: t.ExpectPopup().Alert(). Title(Equals("Cherry-pick")). Content(Contains("Are you sure you want to cherry-pick the 1 copied commit(s) onto this branch?")). @@ -57,7 +56,5 @@ var CherryPickAcrossWorktrees = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), Contains("base").IsSelected(), ) - ACTUAL: */ - t.ExpectToast(Equals("Disabled: No copied commits")) }, })