From 64fd7d8686826a454ec05aa445baccc2fa294bd5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 19:18:15 +0200 Subject: [PATCH 1/8] Route the editing of a diff's files through DiffHelper Three places open a file of a diff in the editor: `e` and alt-click in the main view, the lazygit-edit:// hyperlinks of a diff renderer, and `e` in the commit files panel. The first two also each carry a line of the diff forward to the file's current line before opening it. Give them one pair of methods to call. The next commit changes where these files are opened from, and can then do so in one place. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers.go | 5 +++-- .../controllers/commits_files_controller.go | 2 +- pkg/gui/controllers/helpers/diff_helper.go | 18 +++++++++++++++++- pkg/gui/controllers/main_view_controller.go | 7 ++----- pkg/gui/gui.go | 5 ++--- 5 files changed, 25 insertions(+), 12 deletions(-) diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 67ff1c810..39d03ef9d 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -65,7 +65,8 @@ func (gui *Gui) resetHelpersAndControllers() { searchHelper, ) diffLineHelper := helpers.NewDiffLineHelper(helperCommon) - diffHelper := helpers.NewDiffHelper(helperCommon, diffLineHelper) + filesHelper := helpers.NewFilesHelper(helperCommon) + diffHelper := helpers.NewDiffHelper(helperCommon, diffLineHelper, filesHelper) cherryPickHelper := helpers.NewCherryPickHelper( helperCommon, rebaseHelper, @@ -92,7 +93,7 @@ func (gui *Gui) resetHelpersAndControllers() { CustomPatch: customPatchHelper, Bisect: bisectHelper, Suggestions: suggestionsHelper, - Files: helpers.NewFilesHelper(helperCommon), + Files: filesHelper, WorkingTree: helpers.NewWorkingTreeHelper(helperCommon, refsHelper, commitsHelper, gpgHelper, rebaseHelper), Tags: helpers.NewTagsHelper(helperCommon, commitsHelper, gpgHelper), BranchesHelper: helpers.NewBranchesHelper(helperCommon, worktreeHelper), diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index c6b0fb293..0c033d83e 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -432,7 +432,7 @@ func (self *CommitFilesController) open(node *filetree.CommitFileNode) error { } func (self *CommitFilesController) edit(nodes []*filetree.CommitFileNode) error { - return self.c.Helpers().Files.EditFiles(lo.FilterMap(nodes, + return self.c.Helpers().Diff.EditFilesOfDiff(lo.FilterMap(nodes, func(node *filetree.CommitFileNode, _ int) (string, bool) { return node.GetPath(), node.IsFile() })) diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index a6ef3dc28..4a4b54cff 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -16,12 +16,14 @@ import ( type DiffHelper struct { c *HelperCommon diffLineHelper *DiffLineHelper + filesHelper *FilesHelper } -func NewDiffHelper(c *HelperCommon, diffLineHelper *DiffLineHelper) *DiffHelper { +func NewDiffHelper(c *HelperCommon, diffLineHelper *DiffLineHelper, filesHelper *FilesHelper) *DiffHelper { return &DiffHelper{ c: c, diffLineHelper: diffLineHelper, + filesHelper: filesHelper, } } @@ -224,6 +226,20 @@ func (self *DiffHelper) OpenDiffToolForRef(selectedRef models.Ref) error { return err } +// EditFilesOfDiff opens files of the diff that the current side panel shows in the +// editor. +func (self *DiffHelper) EditFilesOfDiff(paths []string) error { + return self.filesHelper.EditFiles(paths) +} + +// EditFileOfDiffAtLine opens a file of the diff shown in the given view in the editor, +// at a line of that diff. The line is carried forward to where it is in the file now +// (see AdjustLineNumber). +func (self *DiffHelper) EditFileOfDiffAtLine(path string, lineNumber int, viewName string) error { + lineNumber = self.AdjustLineNumber(path, lineNumber, viewName) + return self.filesHelper.EditFileAtLine(path, lineNumber) +} + // AdjustLineNumber is used to adjust a line number in the diff that's currently // being viewed, so that it corresponds to the line number in the actual working // copy state of the file. It is used when clicking on a delta hyperlink in a diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 9135f363f..487b15549 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -1085,13 +1085,10 @@ func (self *MainViewController) editDiffLine(viewLine int, beforeEdit func()) er // it opens the file without jumping anywhere — as pressing edit on a file in a side // panel does. if info.Type == types.DiffLineFileHeader { - return self.c.Helpers().Files.EditFiles([]string{info.Path}) + return self.c.Helpers().Diff.EditFilesOfDiff([]string{info.Path}) } - // The diff may be of an older commit, whose line numbers aren't the file's current - // ones, so they have to be carried forward before we can point an editor at them. - lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName()) - return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber) + return self.c.Helpers().Diff.EditFileOfDiffAtLine(info.Path, info.NewLine, self.context.GetViewName()) } // openPullRequestAtSelectedLine opens the pull request of the branch whose commit the diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 7659a5f00..6f1a04875 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -417,10 +417,9 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context filepath := matches[1] if matches[2] != "" { lineNumber := utils.MustConvertToInt(matches[2]) - lineNumber = gui.helpers.Diff.AdjustLineNumber(filepath, lineNumber, viewname) - return gui.helpers.Files.EditFileAtLine(filepath, lineNumber) + return gui.helpers.Diff.EditFileOfDiffAtLine(filepath, lineNumber, viewname) } - return gui.helpers.Files.EditFiles([]string{filepath}) + return gui.helpers.Diff.EditFilesOfDiff([]string{filepath}) } if entry, ok := strings.CutPrefix(url, helpers.DiffStatLinkScheme); ok { From f343c8fd502d2b26eee161d0b493502ed242f888 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 19:19:48 +0200 Subject: [PATCH 2/8] Add a way to run GetDiff in another worktree The next commit carries a line of a diff forward to the file as another worktree has it, and needs to diff against that worktree's working copy for this. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/git_commands/diff.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index ef70d0422..910db7600 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -186,15 +186,26 @@ func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, mode DiffMode) *osco // want to restrict the diff to specific paths, pass them in additionalArgs // after the `--`. func (self *DiffCommands) GetDiff(staged bool, additionalArgs ...string) (string, error) { + return self.getDiffCmdObj(self.repoPaths.worktreePath, staged, additionalArgs).RunWithOutput() +} + +// GetDiffInOtherWorktree is GetDiff without --staged, run in the worktree at +// worktreePath. That has to be a worktree of our repo other than the current one. A +// diff against the working copy is then a diff against that worktree's files. +func (self *DiffCommands) GetDiffInOtherWorktree(worktreePath string, additionalArgs ...string) (string, error) { + return ForOtherRepo(self.getDiffCmdObj(worktreePath, false, additionalArgs)).RunWithOutput() +} + +func (self *DiffCommands) getDiffCmdObj(worktreePath string, staged bool, additionalArgs []string) *oscommands.CmdObj { return self.cmd.New( NewGitCmd("diff"). ParseableDiffPrefixes(). Arg("--no-ext-diff", "--no-color"). ArgIf(staged, "--staged"). - Dir(self.repoPaths.worktreePath). + Dir(worktreePath). Arg(additionalArgs...). ToArgv(), - ).DontLog().RunWithOutput() + ).DontLog() } type DiffToolCmdOptions struct { From f9a3dc7be734b38051568bef8123b9263d2e8555 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 19:20:50 +0200 Subject: [PATCH 3/8] Add a test for editing a file of a branch checked out in another worktree If you enter a branch in the branches panel, and that branch is checked out in another worktree, pressing `e` on a line of a commit's diff opens the file in the current worktree. The same happens for `e` on a file in the commit files panel entered from there. The current worktree may not have the file at all, as in this test, or have it in a different state than the branch has it. The line number is carried forward to the current worktree's version of the file too, and this gives line 0 here. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/integration/tests/test_list.go | 1 + .../edit_file_of_branch_in_other_worktree.go | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 pkg/integration/tests/worktree/edit_file_of_branch_in_other_worktree.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 496789e0c..d031d7d5b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -694,6 +694,7 @@ var tests = []*components.IntegrationTest{ worktree.DetachWorktreeFromBranch, worktree.DotfileBareRepo, worktree.DoubleNestedLinkedSubmodule, + worktree.EditFileOfBranchInOtherWorktree, worktree.ExcludeFileInWorktree, worktree.FastForwardWorktreeBranch, worktree.FastForwardWorktreeBranchShouldNotPolluteCurrentWorktree, diff --git a/pkg/integration/tests/worktree/edit_file_of_branch_in_other_worktree.go b/pkg/integration/tests/worktree/edit_file_of_branch_in_other_worktree.go new file mode 100644 index 000000000..b1fb31576 --- /dev/null +++ b/pkg/integration/tests/worktree/edit_file_of_branch_in_other_worktree.go @@ -0,0 +1,77 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var EditFileOfBranchInOtherWorktree = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Edit a file of a commit of a branch that is checked out in another worktree", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + config.GetUserConfig().OS.Edit = "echo {{filename}} > edit-command" + config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command" + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("file.txt", "1\n2\n3\n") + shell.Commit("add file") + shell.Checkout("master") + shell.AddWorktreeCheckout("mybranch", "../linked-worktree") + + // Move the lines of the file down in the linked worktree, so that a line of + // the commit's diff is at a different line of the file there + shell.UpdateFile("../linked-worktree/file.txt", "0\n1\n2\n3\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("mybranch (worktree linked-worktree)"), + ). + NavigateToLine(Contains("mybranch")). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + Lines( + Contains("add file").IsSelected(), + Contains("initial commit"), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + NavigateToLine(Contains("+2")). + Press(keys.Universal.Edit). + Tap(func() { + /* EXPECTED: + t.FileSystem().FileContent("edit-command", Contains("/linked-worktree/file.txt:3\n")) + ACTUAL: */ + t.FileSystem().FileContent("edit-command", Contains("/repo/file.txt:0\n")) + }). + PressEscape() + + t.Views().SubCommits(). + IsFocused(). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("A file.txt").IsSelected(), + ). + Press(keys.Universal.Edit). + Tap(func() { + /* EXPECTED: + t.FileSystem().FileContent("edit-command", Contains("/linked-worktree/file.txt\n")) + ACTUAL: */ + t.FileSystem().FileContent("edit-command", Contains("/repo/file.txt\n")) + }) + }, +}) From 51e91fbb85076a60756710336e52b9779d506b0d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 19:23:37 +0200 Subject: [PATCH 4/8] Open files of a branch's diff from the worktree that has it checked out If you enter a branch in the branches panel and press `e` on a line of a commit's diff, or on a file in the commit files panel entered from there, lazygit opens the file in the current worktree. If another worktree has the branch checked out, this is the wrong copy of the file. The current worktree may have it in a different state, or not have it at all. Clicking a lazygit-edit:// hyperlink of a diff renderer in such a diff has the same problem. Open the file from the worktree that has the branch checked out instead, and carry the line number forward to the file as that worktree has it. If no other worktree has the branch checked out, the file is still opened from the current worktree. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/context/commit_files_context.go | 10 +++ pkg/gui/context/sub_commits_context.go | 8 +++ pkg/gui/controllers/helpers/diff_helper.go | 63 +++++++++++++++++-- pkg/gui/types/context.go | 13 ++++ .../edit_file_of_branch_in_other_worktree.go | 6 -- 5 files changed, 88 insertions(+), 12 deletions(-) diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go index 7cce0dd69..71719c631 100644 --- a/pkg/gui/context/commit_files_context.go +++ b/pkg/gui/context/commit_files_context.go @@ -24,6 +24,7 @@ var ( _ types.IFilterableContext = (*CommitFilesContext)(nil) _ types.DiffMainViewContext = (*CommitFilesContext)(nil) _ types.PullRequestDiffContext = (*CommitFilesContext)(nil) + _ types.BranchDiffContext = (*CommitFilesContext)(nil) ) func (self *CommitFilesContext) GetDiffMainViewType() types.DiffMainViewType { @@ -40,6 +41,15 @@ func (self *CommitFilesContext) PullRequestDiff() types.PullRequestDiff { return types.PullRequestDiff{} } +// BranchOfDiff asks the panel this one was entered from, since the files listed here +// are those of the commits selected there. +func (self *CommitFilesContext) BranchOfDiff() *models.Branch { + if parent, ok := self.GetParentContext().(types.BranchDiffContext); ok { + return parent.BranchOfDiff() + } + return nil +} + func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext { viewModel := filetree.NewCommitFileTreeViewModel( func() []*models.CommitFile { return c.Model().CommitFiles }, diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index c5d70d0fe..e99b5d281 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -26,6 +26,7 @@ var ( _ types.ISearchableContext = (*SubCommitsContext)(nil) _ types.DiffMainViewContext = (*SubCommitsContext)(nil) _ types.PullRequestDiffContext = (*SubCommitsContext)(nil) + _ types.BranchDiffContext = (*SubCommitsContext)(nil) ) func (self *SubCommitsContext) GetDiffMainViewType() types.DiffMainViewType { @@ -49,6 +50,13 @@ func (self *SubCommitsContext) PullRequestDiff() types.PullRequestDiff { self.GetCommits(), startIdx, endIdx, branch.Name, self.c.Model().Branches, self.c.Model().PullRequestsMap) } +// BranchOfDiff is nil if the panel was entered from a tag, a remote branch or the +// reflog instead of a local branch. +func (self *SubCommitsContext) BranchOfDiff() *models.Branch { + branch, _ := self.GetRef().(*models.Branch) + return branch +} + func NewSubCommitsContext( c *ContextCommon, ) *SubCommitsContext { diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index 4a4b54cff..2b892e9fe 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -1,6 +1,7 @@ package helpers import ( + "path/filepath" "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -227,26 +228,70 @@ func (self *DiffHelper) OpenDiffToolForRef(selectedRef models.Ref) error { } // EditFilesOfDiff opens files of the diff that the current side panel shows in the -// editor. +// editor. They are opened from the worktree of the diff (see otherWorktreeOfDiff). func (self *DiffHelper) EditFilesOfDiff(paths []string) error { - return self.filesHelper.EditFiles(paths) + return self.filesHelper.EditFiles(lo.Map(paths, func(path string, _ int) string { + return self.pathInWorktreeOfDiff(path) + })) } // EditFileOfDiffAtLine opens a file of the diff shown in the given view in the editor, // at a line of that diff. The line is carried forward to where it is in the file now -// (see AdjustLineNumber). +// (see AdjustLineNumber). Like EditFilesOfDiff, it opens the file from the worktree of +// the diff. func (self *DiffHelper) EditFileOfDiffAtLine(path string, lineNumber int, viewName string) error { + path = self.pathInWorktreeOfDiff(path) lineNumber = self.AdjustLineNumber(path, lineNumber, viewName) return self.filesHelper.EditFileAtLine(path, lineNumber) } +// otherWorktreeOfDiff returns the path of the worktree to open files of the diff in +// the current side panel from, if that isn't the current worktree. If the diff is of a +// commit of a branch, and another worktree has that branch checked out, that is the +// other worktree. It has the files as the branch has them; the current worktree may +// have them in a different state, or not at all. +func (self *DiffHelper) otherWorktreeOfDiff() (string, bool) { + branchDiffContext, ok := self.c.Context().CurrentSide().(types.BranchDiffContext) + if !ok { + return "", false + } + branch := branchDiffContext.BranchOfDiff() + if branch == nil { + return "", false + } + worktree, ok := git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees) + if !ok || worktree.IsCurrent || worktree.IsPathMissing { + return "", false + } + return worktree.Path, true +} + +// pathInWorktreeOfDiff returns the path to open a file of the diff from. The path +// passed in is either relative to the current worktree, or an absolute path in it, as +// a diff renderer may state paths. +func (self *DiffHelper) pathInWorktreeOfDiff(path string) string { + worktreePath, ok := self.otherWorktreeOfDiff() + if !ok { + return path + } + if filepath.IsAbs(path) { + relativePath := repoRelativePath(self.c.Git().RepoPaths.WorktreePath(), path) + if relativePath == "" { + return path + } + path = relativePath + } + return filepath.Join(worktreePath, path) +} + // AdjustLineNumber is used to adjust a line number in the diff that's currently // being viewed, so that it corresponds to the line number in the actual working // copy state of the file. It is used when clicking on a delta hyperlink in a // diff, or when pressing `e` in a focused diff. It works // by getting a diff of what's being viewed in the main view against the working -// copy, and then using that diff to adjust the line number. -// path is the file path of the file being viewed +// copy, and then using that diff to adjust the line number. The working copy is that +// of the worktree the file is opened from (see otherWorktreeOfDiff). +// path is the file path of the file being viewed, in that worktree // linenumber is the line number to adjust (one-based) // viewname is the name of the view that shows the diff. We need to pass it // because the diff adjustment is slightly different depending on which view is @@ -274,7 +319,13 @@ func (self *DiffHelper) AdjustLineNumber(path string, linenumber int, viewname s func (self *DiffHelper) adjustLineNumber(linenumber int, diffArgs ...string) int { args := append([]string{"--unified=0"}, diffArgs...) - diff, err := self.c.Git().Diff.GetDiff(false, args...) + var diff string + var err error + if worktreePath, ok := self.otherWorktreeOfDiff(); ok { + diff, err = self.c.Git().Diff.GetDiffInOtherWorktree(worktreePath, args...) + } else { + diff, err = self.c.Git().Diff.GetDiff(false, args...) + } if err != nil { return linenumber } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 958ebf759..46a111a42 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -253,6 +253,19 @@ type PullRequestDiff struct { BaseHash string } +// BranchDiffContext is implemented by the side panel contexts that show, in their +// focused main view, the diff of a commit of a branch entered from the branches panel: +// the sub-commits panel, and the commit files panel entered from it. If another +// worktree has that branch checked out, files of the diff are opened from there (see +// DiffHelper.EditFilesOfDiff). +type BranchDiffContext interface { + Context + + // BranchOfDiff returns the local branch whose commits the panel shows the diff of, + // or nil if the panel wasn't entered from a local branch. + BranchOfDiff() *models.Branch +} + // DiffPaneContext is one of the two panes the main section can show, as the thing // that holds a diff with a selection in it. The panels that act on such a selection // are handed the pane it was made in, and speak to it through this. diff --git a/pkg/integration/tests/worktree/edit_file_of_branch_in_other_worktree.go b/pkg/integration/tests/worktree/edit_file_of_branch_in_other_worktree.go index b1fb31576..c7aeacf5d 100644 --- a/pkg/integration/tests/worktree/edit_file_of_branch_in_other_worktree.go +++ b/pkg/integration/tests/worktree/edit_file_of_branch_in_other_worktree.go @@ -50,10 +50,7 @@ var EditFileOfBranchInOtherWorktree = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("+2")). Press(keys.Universal.Edit). Tap(func() { - /* EXPECTED: t.FileSystem().FileContent("edit-command", Contains("/linked-worktree/file.txt:3\n")) - ACTUAL: */ - t.FileSystem().FileContent("edit-command", Contains("/repo/file.txt:0\n")) }). PressEscape() @@ -68,10 +65,7 @@ var EditFileOfBranchInOtherWorktree = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Universal.Edit). Tap(func() { - /* EXPECTED: t.FileSystem().FileContent("edit-command", Contains("/linked-worktree/file.txt\n")) - ACTUAL: */ - t.FileSystem().FileContent("edit-command", Contains("/repo/file.txt\n")) }) }, }) From 6c4409b712347340a942e4f39c3ff1f2f41370f5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 19:58:57 +0200 Subject: [PATCH 5/8] Add a test for opening a file of a branch checked out in another worktree Pressing `o` on a file in the commit files panel has the same problem as `e` had before the previous commit. If the commit is of a branch that another worktree has checked out, the file is still opened from the current worktree. The absolute path in the panel's copy menu points into the current worktree too. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/integration/tests/test_list.go | 1 + .../open_file_of_branch_in_other_worktree.go | 70 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index d031d7d5b..207a2db70 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -703,6 +703,7 @@ var tests = []*components.IntegrationTest{ worktree.LocationCandidates, worktree.NewWorktreePicker, worktree.NewWorktreePickerRemote, + worktree.OpenFileOfBranchInOtherWorktree, worktree.RemoveWorktreeAndBothBranches, worktree.RemoveWorktreeAndBranch, worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch, diff --git a/pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go b/pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go new file mode 100644 index 000000000..27ace3234 --- /dev/null +++ b/pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go @@ -0,0 +1,70 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var OpenFileOfBranchInOtherWorktree = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Open a file of a commit of a branch that is checked out in another worktree, and copy its absolute path", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().OS.Open = "echo {{filename}} > open-command" + config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("file.txt", "content") + shell.Commit("add file") + shell.Checkout("master") + shell.AddWorktreeCheckout("mybranch", "../linked-worktree") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("mybranch (worktree linked-worktree)"), + ). + NavigateToLine(Contains("mybranch")). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + Lines( + Contains("add file").IsSelected(), + Contains("initial commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("A file.txt").IsSelected(), + ). + Press(keys.Universal.OpenFile). + Tap(func() { + /* EXPECTED: + t.FileSystem().FileContent("open-command", Contains("/linked-worktree/file.txt\n")) + ACTUAL: */ + t.FileSystem().FileContent("open-command", Contains("/repo/file.txt\n")) + }). + Press(keys.Files.CopyFileInfoToClipboard). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Copy to clipboard")). + Select(Contains("Absolute path")). + Confirm() + + t.ExpectToast(Equals("File path copied to clipboard")) + + /* EXPECTED: + t.FileSystem().FileContent("clipboard", Contains("/linked-worktree/file.txt")) + ACTUAL: */ + t.FileSystem().FileContent("clipboard", Contains("/repo/file.txt")) + }) + }, +}) From 690509a8ccfed81718df3902a3040b5a9546606c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 19:59:19 +0200 Subject: [PATCH 6/8] Open files with `o` from the worktree that has the branch checked out Do the same for `o` in the commit files panel as the previous commits do for `e`. If the panel was entered from a branch that another worktree has checked out, open the file from that worktree. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/commits_files_controller.go | 2 +- pkg/gui/controllers/helpers/diff_helper.go | 7 +++++++ .../worktree/open_file_of_branch_in_other_worktree.go | 3 --- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 0c033d83e..b8995e7bd 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -428,7 +428,7 @@ func (self *CommitFilesController) canDiscardFileChanges(nodes []*filetree.Commi } func (self *CommitFilesController) open(node *filetree.CommitFileNode) error { - return self.c.Helpers().Files.OpenFile(node.GetPath()) + return self.c.Helpers().Diff.OpenFileOfDiff(node.GetPath()) } func (self *CommitFilesController) edit(nodes []*filetree.CommitFileNode) error { diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index 2b892e9fe..f620aed66 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -245,6 +245,13 @@ func (self *DiffHelper) EditFileOfDiffAtLine(path string, lineNumber int, viewNa return self.filesHelper.EditFileAtLine(path, lineNumber) } +// OpenFileOfDiff opens a file of the diff that the current side panel shows with the +// default application for it. Like EditFilesOfDiff, it opens the file from the +// worktree of the diff. +func (self *DiffHelper) OpenFileOfDiff(path string) error { + return self.filesHelper.OpenFile(self.pathInWorktreeOfDiff(path)) +} + // otherWorktreeOfDiff returns the path of the worktree to open files of the diff in // the current side panel from, if that isn't the current worktree. If the diff is of a // commit of a branch, and another worktree has that branch checked out, that is the diff --git a/pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go b/pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go index 27ace3234..78229d6e4 100644 --- a/pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go +++ b/pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go @@ -47,10 +47,7 @@ var OpenFileOfBranchInOtherWorktree = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Universal.OpenFile). Tap(func() { - /* EXPECTED: t.FileSystem().FileContent("open-command", Contains("/linked-worktree/file.txt\n")) - ACTUAL: */ - t.FileSystem().FileContent("open-command", Contains("/repo/file.txt\n")) }). Press(keys.Files.CopyFileInfoToClipboard). Tap(func() { From 6cd6e4532da71c7bb7be2a8f0ec44f0f1883b604 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 20:00:27 +0200 Subject: [PATCH 7/8] Copy the absolute path of a branch's file from the worktree that has it If the commit files panel was entered from a branch that another worktree has checked out, "Copy absolute path" in its copy menu gives the file's path in the current worktree. Make it give the path in the worktree that has the branch checked out. This is the file that `e` and `o` open, and the one users most likely want the path of. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/commits_files_controller.go | 3 +-- pkg/gui/controllers/helpers/diff_helper.go | 6 ++++++ .../tests/worktree/open_file_of_branch_in_other_worktree.go | 3 --- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index b8995e7bd..e945ef8ca 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -3,7 +3,6 @@ package controllers import ( "errors" "fmt" - "path/filepath" "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -267,7 +266,7 @@ func (self *CommitFilesController) openCopyMenu() error { copyAbsolutePathItem := &types.MenuItem{ Label: self.c.Tr.CopyAbsoluteFilePath, OnPress: func() error { - absPath, err := filepath.Abs(node.GetPath()) + absPath, err := self.c.Helpers().Diff.AbsolutePathOfFileOfDiff(node.GetPath()) if err != nil { return err } diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index f620aed66..1e94c9ac7 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -252,6 +252,12 @@ func (self *DiffHelper) OpenFileOfDiff(path string) error { return self.filesHelper.OpenFile(self.pathInWorktreeOfDiff(path)) } +// AbsolutePathOfFileOfDiff returns the absolute path of a file of the diff that the +// current side panel shows, in the worktree that EditFilesOfDiff opens it from. +func (self *DiffHelper) AbsolutePathOfFileOfDiff(path string) (string, error) { + return filepath.Abs(self.pathInWorktreeOfDiff(path)) +} + // otherWorktreeOfDiff returns the path of the worktree to open files of the diff in // the current side panel from, if that isn't the current worktree. If the diff is of a // commit of a branch, and another worktree has that branch checked out, that is the diff --git a/pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go b/pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go index 78229d6e4..e0bf265a2 100644 --- a/pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go +++ b/pkg/integration/tests/worktree/open_file_of_branch_in_other_worktree.go @@ -58,10 +58,7 @@ var OpenFileOfBranchInOtherWorktree = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Equals("File path copied to clipboard")) - /* EXPECTED: t.FileSystem().FileContent("clipboard", Contains("/linked-worktree/file.txt")) - ACTUAL: */ - t.FileSystem().FileContent("clipboard", Contains("/repo/file.txt")) }) }, }) From 3742ac606605e7b3fec419ee4c2914026068cd13 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 20:04:49 +0200 Subject: [PATCH 8/8] Show an error when editing a file of a diff that doesn't exist If you press `e` on a file of a commit, and a later commit deleted that file, the editor is opened on a path that doesn't exist. Most editors then create it as a new, empty file. This is confusing. The same happens with `o`, and with any other file that the worktree it is opened from doesn't have. An example is a file that is added in a branch that isn't checked out anywhere, since it is opened from the current worktree. Show an error instead of opening the editor. For a range selection in the commit files panel, show it only if none of the selected files exist. Otherwise open the existing ones and leave out the others silently; an error shown only after returning from the editor would come too late to be useful. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/helpers/diff_helper.go | 76 +++++++++++-- pkg/i18n/english.go | 4 + .../edit_files_missing_from_working_tree.go | 102 ++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 4 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 pkg/integration/tests/commit/edit_files_missing_from_working_tree.go diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index 1e94c9ac7..d855282ea 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -1,6 +1,7 @@ package helpers import ( + "errors" "path/filepath" "strings" @@ -11,6 +12,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/modes/diffing" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -229,27 +231,87 @@ func (self *DiffHelper) OpenDiffToolForRef(selectedRef models.Ref) error { // EditFilesOfDiff opens files of the diff that the current side panel shows in the // editor. They are opened from the worktree of the diff (see otherWorktreeOfDiff). +// Files that don't exist there are left out, and if none of them exist, it returns an +// error (see existingPathsInWorktreeOfDiff). func (self *DiffHelper) EditFilesOfDiff(paths []string) error { - return self.filesHelper.EditFiles(lo.Map(paths, func(path string, _ int) string { - return self.pathInWorktreeOfDiff(path) - })) + existingPaths, err := self.existingPathsInWorktreeOfDiff(paths) + if err != nil { + return err + } + return self.filesHelper.EditFiles(existingPaths) } // EditFileOfDiffAtLine opens a file of the diff shown in the given view in the editor, // at a line of that diff. The line is carried forward to where it is in the file now // (see AdjustLineNumber). Like EditFilesOfDiff, it opens the file from the worktree of -// the diff. +// the diff, and returns an error if the file doesn't exist there. func (self *DiffHelper) EditFileOfDiffAtLine(path string, lineNumber int, viewName string) error { - path = self.pathInWorktreeOfDiff(path) + path, err := self.existingPathInWorktreeOfDiff(path) + if err != nil { + return err + } lineNumber = self.AdjustLineNumber(path, lineNumber, viewName) return self.filesHelper.EditFileAtLine(path, lineNumber) } // OpenFileOfDiff opens a file of the diff that the current side panel shows with the // default application for it. Like EditFilesOfDiff, it opens the file from the -// worktree of the diff. +// worktree of the diff, and returns an error if the file doesn't exist there. func (self *DiffHelper) OpenFileOfDiff(path string) error { - return self.filesHelper.OpenFile(self.pathInWorktreeOfDiff(path)) + path, err := self.existingPathInWorktreeOfDiff(path) + if err != nil { + return err + } + return self.filesHelper.OpenFile(path) +} + +// existingPathsInWorktreeOfDiff returns the absolute paths of those of the given files +// of the diff that exist in the worktree of the diff. A file of a diff may be missing +// there, for example if a later commit deleted it, or if the worktree has a branch +// checked out that doesn't have it. An editor would create such a file as a new, empty +// one, so it is left out. +// +// If none of the files exist, it returns an error. If only some of them are missing, it +// leaves them out silently. The user would see such an error only after returning from +// the editor. +func (self *DiffHelper) existingPathsInWorktreeOfDiff(paths []string) ([]string, error) { + absPaths := make([]string, 0, len(paths)) + for _, path := range paths { + absPath, err := self.AbsolutePathOfFileOfDiff(path) + if err != nil { + return nil, err + } + absPaths = append(absPaths, absPath) + } + + existingPaths := lo.Filter(absPaths, func(path string, _ int) bool { + exists, err := self.c.OS().FileExists(path) + // If we can't tell, the editor reports the problem + return exists || err != nil + }) + if len(existingPaths) > 0 { + return existingPaths, nil + } + + if len(absPaths) == 1 { + return nil, errors.New(utils.ResolvePlaceholderString( + self.c.Tr.FileOfDiffDoesNotExist, map[string]string{"path": absPaths[0]})) + } + worktreePath, ok := self.otherWorktreeOfDiff() + if !ok { + worktreePath = self.c.Git().RepoPaths.WorktreePath() + } + return nil, errors.New(utils.ResolvePlaceholderString( + self.c.Tr.NoFileOfDiffExists, map[string]string{"worktreePath": worktreePath})) +} + +// existingPathInWorktreeOfDiff is existingPathsInWorktreeOfDiff for a single file. +func (self *DiffHelper) existingPathInWorktreeOfDiff(path string) (string, error) { + existingPaths, err := self.existingPathsInWorktreeOfDiff([]string{path}) + if err != nil { + return "", err + } + return existingPaths[0], nil } // AbsolutePathOfFileOfDiff returns the absolute path of a file of the diff that the diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 61f81a1f0..1cb516631 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -422,6 +422,8 @@ type TranslationSet struct { JumpToFileInDiffTooltip string OnlyOneFileInDiff string NoFileInDiffNamed string + FileOfDiffDoesNotExist string + NoFileOfDiffExists string PrevConflict string NextConflict string SelectPrevHunk string @@ -1617,6 +1619,8 @@ func EnglishTranslationSet() *TranslationSet { JumpToFileInDiffTooltip: "Pick one of the files of the diff shown in the main view, and scroll the main view to it. The focus stays in this panel.", OnlyOneFileInDiff: "There is only one file in this diff", NoFileInDiffNamed: "This diff has no file named '{{.path}}'", + FileOfDiffDoesNotExist: "File '{{.path}}' doesn't exist in the working tree", + NoFileOfDiffExists: "None of the selected files exist in the working tree at '{{.worktreePath}}'", PrevConflict: "Previous conflict", NextConflict: "Next conflict", SelectPrevHunk: "Previous hunk", diff --git a/pkg/integration/tests/commit/edit_files_missing_from_working_tree.go b/pkg/integration/tests/commit/edit_files_missing_from_working_tree.go new file mode 100644 index 000000000..2239b2602 --- /dev/null +++ b/pkg/integration/tests/commit/edit_files_missing_from_working_tree.go @@ -0,0 +1,102 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var EditFilesMissingFromWorkingTree = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Edit or open files of a commit that a later commit deleted", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInDiffView = false + config.GetUserConfig().OS.Edit = "echo {{filename}} > edit-command" + config.GetUserConfig().OS.EditAtLine = "echo {{filename}}:{{line}} > edit-command" + config.GetUserConfig().OS.Open = "echo {{filename}} > open-command" + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\n") + shell.CreateFileAndAdd("file2", "two\n") + shell.CreateFileAndAdd("file3", "three\n") + shell.Commit("add files") + shell.DeleteFileAndAdd("file2") + shell.DeleteFileAndAdd("file3") + shell.Commit("delete files") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("delete files").IsSelected(), + Contains("add files"), + ). + NavigateToLine(Contains("add files")). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Contains("A file1"), + Contains("A file2"), + Contains("A file3"), + ). + NavigateToLine(Contains("file2")). + Press(keys.Universal.Edit). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("/repo/file2' doesn't exist in the working tree")). + Confirm() + }). + Press(keys.Universal.OpenFile). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("/repo/file2' doesn't exist in the working tree")). + Confirm() + }). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + NavigateToLine(Contains("+two")). + Press(keys.Universal.Edit). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("/repo/file2' doesn't exist in the working tree")). + Confirm() + }). + PressEscape() + + t.Views().CommitFiles(). + IsFocused(). + Press(keys.Universal.ToggleRangeSelect). + SelectNextItem(). + SelectedLines( + Contains("A file2"), + Contains("A file3"), + ). + Press(keys.Universal.Edit). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("None of the selected files exist in the working tree at '")). + Confirm() + }). + // If only some of the files are missing, the others are opened without + // complaint + NavigateToLine(Contains("file1")). + SelectedLines( + Contains("A file1"), + Contains("A file2"), + ). + Press(keys.Universal.Edit). + Tap(func() { + t.FileSystem().FileContent("edit-command", + Contains("/repo/file1\n").DoesNotContain("file2")) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 207a2db70..aea6c4f13 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -135,6 +135,7 @@ var tests = []*components.IntegrationTest{ commit.DiscardRenamedFile, commit.DiscardSubmoduleChanges, commit.DoNotShowBranchMarkerForHeadCommit, + commit.EditFilesMissingFromWorkingTree, commit.FailHooksThenCommitNoHooks, commit.FindBaseCommitForFixup, commit.FindBaseCommitForFixupDisregardFixupsForSameBaseCommit,