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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-10 13:58:05 +02:00
co-authored by Claude Opus 5.5
parent 6cd6e4532d
commit 3742ac6066
4 changed files with 176 additions and 7 deletions
+69 -7
View File
@@ -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
+4
View File
@@ -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",
@@ -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"))
})
},
})
+1
View File
@@ -135,6 +135,7 @@ var tests = []*components.IntegrationTest{
commit.DiscardRenamedFile,
commit.DiscardSubmoduleChanges,
commit.DoNotShowBranchMarkerForHeadCommit,
commit.EditFilesMissingFromWorkingTree,
commit.FailHooksThenCommitNoHooks,
commit.FindBaseCommitForFixup,
commit.FindBaseCommitForFixupDisregardFixupsForSameBaseCommit,