From 6be2bd90311f265e0d6c1f327d42d67535ad37ae Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 7 Oct 2026 19:44:26 +0200 Subject: [PATCH 1/6] Resolve the files of the selected diff lines before acting on them The focused diff actions look up the file of a selected line in different places. In the files panel, staging and discarding do it per file while applying the patches, and editing a hunk does it for the first line. For a commit's diff, adding lines to a custom patch or discarding them from the commit does it only after the confirmation prompt and after a new patch has been started. The next commits report a line whose file can't be found as an error. For that, the lookup has to happen once, before we log an action, ask for confirmation or start a patch. So let diffLineSelection group the selected lines by their file, in the order the diff shows the files, and do the same for a commit's diff in selectionByFile. A line whose file can't be found is still skipped, as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/commit_diff_actions.go | 65 +++++++++++++------ .../controllers/working_tree_diff_actions.go | 65 ++++++++++--------- 2 files changed, 79 insertions(+), 51 deletions(-) diff --git a/pkg/gui/controllers/commit_diff_actions.go b/pkg/gui/controllers/commit_diff_actions.go index 7063deb9b..0f6bbd01d 100644 --- a/pkg/gui/controllers/commit_diff_actions.go +++ b/pkg/gui/controllers/commit_diff_actions.go @@ -104,6 +104,10 @@ func (self *CommitDiffActions) PrimaryAction(pane types.DiffPaneContext, firstBu if len(lines) == 0 { return nil } + selection := self.selectionByFile(lines) + if len(selection.paths) == 0 { + return nil + } patchBuilder := self.c.Git().Patch.PatchBuilder from, reverse := self.patchEndpoints(target) @@ -121,7 +125,7 @@ func (self *CommitDiffActions) PrimaryAction(pane types.DiffPaneContext, firstBu patchBuilder.Start(from, target.to, reverse, target.canRebase) } - if err := self.togglePatchLines(lines); err != nil { + if err := self.togglePatchLines(selection); err != nil { return err } // Taking the last line back out ends the patch rather than leaving an empty @@ -225,6 +229,10 @@ func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firs if commitIndex == -1 { return nil } + selection := self.selectionByFile(lines) + if len(selection.paths) == 0 { + return nil + } patchBuilder := self.c.Git().Patch.PatchBuilder prompt := lo.Ternary(patchBuilder.IsEmpty(), @@ -238,7 +246,7 @@ func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firs from, reverse := self.patchEndpoints(target) patchBuilder.Reset() patchBuilder.Start(from, target.to, reverse, target.canRebase) - if err := self.togglePatchLines(lines); err != nil { + if err := self.togglePatchLines(selection); err != nil { return err } if patchBuilder.IsEmpty() { @@ -318,38 +326,53 @@ func (self *CommitDiffActions) PatchInclusion() func(types.DiffLineInfo) bool { } } -// togglePatchLines takes the given lines of the commit's diff into the custom patch, or -// out of it. The first line of the selection decides which of the two happens, once for -// the whole selection: pointing at a line that is already in the patch takes the whole -// selection out of it, as toggling a selection of files in the commit files panel does. -func (self *CommitDiffActions) togglePatchLines(lines []types.DiffLineInfo) error { - patchBuilder := self.c.Git().Patch.PatchBuilder +// commitDiffSelection is the selected lines of a commit's diff, by the file they belong +// to: a patch is built a file at a time, while a selection can span several of them. +type commitDiffSelection struct { + // What the diff says about each of the files it covers. + files commitDiffFiles - // The files the selection covers, in the order the diff shows them, and per file the - // lines of it that are selected: a patch is built a file at a time, while a selection - // can span several of them. - paths := []string{} - linesByPath := map[string][]patch.LineIdentity{} + // The files the selection covers, in the order the diff shows them. + paths []string + + // Per file, the lines of it that are selected. + linesByPath map[string][]patch.LineIdentity +} + +// selectionByFile groups the given lines of the commit's diff by the file they belong +// to. A line whose path is no file of this repo is left out. +func (self *CommitDiffActions) selectionByFile(lines []types.DiffLineInfo) commitDiffSelection { + selection := commitDiffSelection{ + files: self.filesInDiff(), + linesByPath: map[string][]patch.LineIdentity{}, + } for _, line := range lines { path := self.patchBuilderPath(line.Path) if path == "" { continue } - if _, seen := linesByPath[path]; !seen { - paths = append(paths, path) + if _, seen := selection.linesByPath[path]; !seen { + selection.paths = append(selection.paths, path) } - linesByPath[path] = append(linesByPath[path], line.PatchLineIdentity()) - } - if len(paths) == 0 { - return nil + selection.linesByPath[path] = append(selection.linesByPath[path], line.PatchLineIdentity()) } + return selection +} + +// togglePatchLines takes the selected lines of the commit's diff into the custom patch, +// or out of it. The first line of the selection decides which of the two happens, once +// for the whole selection: pointing at a line that is already in the patch takes the +// whole selection out of it, as toggling a selection of files in the commit files panel +// does. +func (self *CommitDiffActions) togglePatchLines(selection commitDiffSelection) error { + patchBuilder := self.c.Git().Patch.PatchBuilder + files, paths := selection.files, selection.paths - files := self.filesInDiff() indicesByPath := map[string][]int{} wholeFileByPath := map[string]bool{} for _, path := range paths { indices, everyChange, err := patchBuilder.PatchLineIndicesForLines( - path, files.previousPath(path), linesByPath[path]) + path, files.previousPath(path), selection.linesByPath[path]) if err != nil { return err } diff --git a/pkg/gui/controllers/working_tree_diff_actions.go b/pkg/gui/controllers/working_tree_diff_actions.go index b9ff5124a..a55e93af1 100644 --- a/pkg/gui/controllers/working_tree_diff_actions.go +++ b/pkg/gui/controllers/working_tree_diff_actions.go @@ -59,14 +59,14 @@ func (self *WorkingTreeDiffActions) PrimaryAction(pane types.DiffPaneContext, fi self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) } - infos, onStagedSide, ok := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) - if !ok { + linesByFile, onStagedSide := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) + if len(linesByFile) == 0 { return nil } // Either way the patch goes to the index: forwards from the unstaged side to stage // it, backwards from the staged side to take it back out. - return self.applyDiffLineSelection(pane, firstBufferLine, infos, onStagedSide, + return self.applyDiffLineSelection(pane, firstBufferLine, linesByFile, onStagedSide, git_commands.ApplyPatchOpts{Reverse: onStagedSide, Cached: true}) } @@ -79,8 +79,8 @@ func (self *WorkingTreeDiffActions) DiscardSelection(pane types.DiffPaneContext, self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) } - infos, onStagedSide, ok := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) - if !ok { + linesByFile, onStagedSide := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) + if len(linesByFile) == 0 { return nil } @@ -93,7 +93,7 @@ func (self *WorkingTreeDiffActions) DiscardSelection(pane types.DiffPaneContext, Title: self.c.Tr.DiscardChangeTitle, Prompt: self.c.Tr.DiscardChangePrompt, HandleConfirm: func() error { - return self.applyDiffLineSelection(pane, firstBufferLine, infos, onStagedSide, + return self.applyDiffLineSelection(pane, firstBufferLine, linesByFile, onStagedSide, git_commands.ApplyPatchOpts{Reverse: true, Cached: onStagedSide}) }, }) @@ -117,18 +117,15 @@ func (self *WorkingTreeDiffActions) DiscardSelectionDisabledReason(types.DiffPan func (self *WorkingTreeDiffActions) EditHunk( pane types.DiffPaneContext, firstBufferLine int, lastBufferLine int, ) error { - infos, onStagedSide, ok := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) - if !ok { - return nil - } - file := self.fileForDiffLinePath(infos[0].Path) - if file == nil { + linesByFile, onStagedSide := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) + if len(linesByFile) == 0 { return nil } + file := linesByFile[0].file parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, onStagedSide)) lineIndices := patch.ChangeLineIndicesForLines(parsedPatch, - []patch.LineIdentity{infos[0].PatchLineIdentity()}) + []patch.LineIdentity{linesByFile[0].lines[0].PatchLineIdentity()}) if len(lineIndices) == 0 { return nil } @@ -193,19 +190,34 @@ func (self *WorkingTreeDiffActions) PatchInclusion() func(types.DiffLineInfo) bo return nil } +// fileDiffLines is the selected change lines of one file of the diff. +type fileDiffLines struct { + file *models.File + lines []types.DiffLineInfo +} + // diffLineSelection resolves what the user has selected in a pane of the focused main // view to the change lines to act on, and reports whether they are the staged side of // the diff — which is a question about the pane, so it is the same for every file of a -// directory's diff. ok is false when the selection holds no change line, in which case +// directory's diff. When the selection holds no change line, it returns no file, and // there is nothing to act on. +// +// A directory's diff spans several files, and a patch is of one file, so the lines are +// grouped by the file they belong to, in the order the diff shows the files. func (self *WorkingTreeDiffActions) diffLineSelection( pane types.DiffPaneContext, firstBufferLine int, lastBufferLine int, -) (infos []types.DiffLineInfo, onStagedSide bool, ok bool) { - infos = self.c.Helpers().DiffLine.ChangeLinesInBufferRange(pane.GetView(), firstBufferLine, lastBufferLine) - if len(infos) == 0 { - return nil, false, false +) (linesByFile []fileDiffLines, onStagedSide bool) { + infos := self.c.Helpers().DiffLine.ChangeLinesInBufferRange(pane.GetView(), firstBufferLine, lastBufferLine) + infosByPath := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path }) + paths := lo.Uniq(lo.Map(infos, func(info types.DiffLineInfo, _ int) string { return info.Path })) + for _, path := range paths { + file := self.fileForDiffLinePath(path) + if file == nil { + continue + } + linesByFile = append(linesByFile, fileDiffLines{file: file, lines: infosByPath[path]}) } - return infos, self.showsStagedSide(pane), true + return linesByFile, self.showsStagedSide(pane) } // applyDiffLineSelection applies the selected change lines, a patch per file, and @@ -215,25 +227,18 @@ func (self *WorkingTreeDiffActions) diffLineSelection( // from once the diff has changed under it. func (self *WorkingTreeDiffActions) applyDiffLineSelection( pane types.DiffPaneContext, firstBufferLine int, - infos []types.DiffLineInfo, onStagedSide bool, opts git_commands.ApplyPatchOpts, + linesByFile []fileDiffLines, onStagedSide bool, opts git_commands.ApplyPatchOpts, ) error { self.c.LogAction(self.c.Tr.Actions.ApplyPatch) - // A directory's diff spans several files, and a patch is of one file, so the - // selected lines are grouped by the file they belong to and applied file by file. - infosByFile := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path }) acted := set.New[string]() actedSideRemains := false - for path, fileInfos := range infosByFile { - file := self.fileForDiffLinePath(path) - if file == nil { - continue - } - changesLeft, err := self.applyDiffLines(file, fileInfos, onStagedSide, opts) + for _, fileLines := range linesByFile { + changesLeft, err := self.applyDiffLines(fileLines.file, fileLines.lines, onStagedSide, opts) if err != nil { return err } - acted.Add(file.GetPath()) + acted.Add(fileLines.file.GetPath()) actedSideRemains = actedSideRemains || changesLeft } if !actedSideRemains { From e5eb4086c5bf804e16ff458498a3d22ac79e2fd0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 7 Oct 2026 14:04:06 +0200 Subject: [PATCH 2/6] Don't ignore a git error when listing the files of a commit's diff If git fails to list the files of the diff that a custom patch is built from, we carry on as if the diff had no files. Then we don't know which files were renamed, added or deleted, and build the patch from the wrong lines or without the file's creation or deletion. Return the error instead, so that the user sees it. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/commit_diff_actions.go | 34 ++++++++++++++-------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/pkg/gui/controllers/commit_diff_actions.go b/pkg/gui/controllers/commit_diff_actions.go index 0f6bbd01d..5efe23549 100644 --- a/pkg/gui/controllers/commit_diff_actions.go +++ b/pkg/gui/controllers/commit_diff_actions.go @@ -104,7 +104,10 @@ func (self *CommitDiffActions) PrimaryAction(pane types.DiffPaneContext, firstBu if len(lines) == 0 { return nil } - selection := self.selectionByFile(lines) + selection, err := self.selectionByFile(lines) + if err != nil { + return err + } if len(selection.paths) == 0 { return nil } @@ -171,7 +174,10 @@ func (self *CommitDiffActions) removePatchLines( } patchBuilder := self.c.Git().Patch.PatchBuilder - files := self.filesInDiff() + files, err := self.filesInDiff() + if err != nil { + return err + } for path, ordinals := range self.c.Helpers().DiffLine.ChangeLineOrdinals(self.customPatchDiff(), lines) { filename := self.patchBuilderPath(path) if filename == "" { @@ -229,7 +235,10 @@ func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firs if commitIndex == -1 { return nil } - selection := self.selectionByFile(lines) + selection, err := self.selectionByFile(lines) + if err != nil { + return err + } if len(selection.paths) == 0 { return nil } @@ -341,11 +350,12 @@ type commitDiffSelection struct { // selectionByFile groups the given lines of the commit's diff by the file they belong // to. A line whose path is no file of this repo is left out. -func (self *CommitDiffActions) selectionByFile(lines []types.DiffLineInfo) commitDiffSelection { - selection := commitDiffSelection{ - files: self.filesInDiff(), - linesByPath: map[string][]patch.LineIdentity{}, +func (self *CommitDiffActions) selectionByFile(lines []types.DiffLineInfo) (commitDiffSelection, error) { + files, err := self.filesInDiff() + if err != nil { + return commitDiffSelection{}, err } + selection := commitDiffSelection{files: files, linesByPath: map[string][]patch.LineIdentity{}} for _, line := range lines { path := self.patchBuilderPath(line.Path) if path == "" { @@ -356,7 +366,7 @@ func (self *CommitDiffActions) selectionByFile(lines []types.DiffLineInfo) commi } selection.linesByPath[path] = append(selection.linesByPath[path], line.PatchLineIdentity()) } - return selection + return selection, nil } // togglePatchLines takes the selected lines of the commit's diff into the custom patch, @@ -438,20 +448,20 @@ func (self commitDiffFiles) isWholeFileOperation(path string) bool { // filesInDiff asks git which files the diff a patch is being built from covers, and // what it does to each. -func (self *CommitDiffActions) filesInDiff() commitDiffFiles { +func (self *CommitDiffActions) filesInDiff() (commitDiffFiles, error) { target := self.target() if target == nil { - return nil + return nil, nil } from, reverse := self.patchEndpoints(target) files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, target.to, reverse) if err != nil { - return nil + return nil, err } return lo.SliceToMap(files, func(file *models.CommitFile) (string, *models.CommitFile) { return file.Path, file - }) + }), nil } // patchEndpoints gives the two ends of the diff a patch is built from. They are the ends From a29c70f1f3531fc7e33854e5b6145836a720bd35 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 7 Oct 2026 14:11:22 +0200 Subject: [PATCH 3/6] Build the patches of all selected files before applying any of them When staging, discarding or taking lines out of a custom patch spans several files, we read the diff of a file and apply its patch before we look at the next file. The next commits report an error if the selected lines of a file can't be found. Without this change, the files before it would already be acted on by then. So find the lines in every file first, and only then apply anything. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/commit_diff_actions.go | 4 ++ .../controllers/working_tree_diff_actions.go | 62 +++++++++++++------ 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/pkg/gui/controllers/commit_diff_actions.go b/pkg/gui/controllers/commit_diff_actions.go index 5efe23549..dc6eb825a 100644 --- a/pkg/gui/controllers/commit_diff_actions.go +++ b/pkg/gui/controllers/commit_diff_actions.go @@ -178,6 +178,7 @@ func (self *CommitDiffActions) removePatchLines( if err != nil { return err } + indicesByFilename := map[string][]int{} for path, ordinals := range self.c.Helpers().DiffLine.ChangeLineOrdinals(self.customPatchDiff(), lines) { filename := self.patchBuilderPath(path) if filename == "" { @@ -193,6 +194,9 @@ func (self *CommitDiffActions) removePatchLines( if len(indices) == 0 { continue } + indicesByFilename[filename] = indices + } + for filename, indices := range indicesByFilename { if err := patchBuilder.RemoveFileLineRange(filename, files.previousPath(filename), indices); err != nil { return err } diff --git a/pkg/gui/controllers/working_tree_diff_actions.go b/pkg/gui/controllers/working_tree_diff_actions.go index a55e93af1..f42fce0c3 100644 --- a/pkg/gui/controllers/working_tree_diff_actions.go +++ b/pkg/gui/controllers/working_tree_diff_actions.go @@ -229,17 +229,20 @@ func (self *WorkingTreeDiffActions) applyDiffLineSelection( pane types.DiffPaneContext, firstBufferLine int, linesByFile []fileDiffLines, onStagedSide bool, opts git_commands.ApplyPatchOpts, ) error { + patches := lo.Map(linesByFile, func(fileLines fileDiffLines, _ int) diffLinesPatch { + return self.buildDiffLinesPatch(fileLines.file, fileLines.lines, onStagedSide, opts) + }) + self.c.LogAction(self.c.Tr.Actions.ApplyPatch) acted := set.New[string]() actedSideRemains := false - for _, fileLines := range linesByFile { - changesLeft, err := self.applyDiffLines(fileLines.file, fileLines.lines, onStagedSide, opts) - if err != nil { + for _, p := range patches { + if err := self.applyDiffLinesPatch(p, opts); err != nil { return err } - acted.Add(fileLines.file.GetPath()) - actedSideRemains = actedSideRemains || changesLeft + acted.Add(p.file.GetPath()) + actedSideRemains = actedSideRemains || p.changesLeft } if !actedSideRemains { actedSideRemains = self.anyFileHasChangesOnSide(acted, onStagedSide) @@ -273,8 +276,24 @@ func (self *WorkingTreeDiffActions) fileForDiffLinePath(path string) *models.Fil return self.context().FileTreeViewModel.GetFile(relativePath) } -// applyDiffLines applies the given change lines of one file — a line, a hunk, a range — -// as a patch built from that file's own diff: +// diffLinesPatch is how the selected change lines of one file are applied. +type diffLinesPatch struct { + file *models.File + + // Whether the lines are every change of the file's diff. Acting on all of them is + // acting on the file itself, which is staged or unstaged as a whole. + wholeFile bool + + // The patch of the lines, where they are not the whole file. Empty when none of + // them could be found in the file's diff. + patch string + + // Whether the diff the lines were found in holds changes they don't cover. + changesLeft bool +} + +// buildDiffLinesPatch builds what applies the given change lines of one file — a line, +// a hunk, a range — as a patch built from that file's own diff: // // - stage: read the unstaged diff, apply it to the index // - unstage: read the staged diff, apply it to the index backwards @@ -289,12 +308,12 @@ func (self *WorkingTreeDiffActions) fileForDiffLinePath(path string) *models.Fil // selected: a patch of the lines you picked keeps whatever context it needs around // them by itself. // -// It reports whether the diff it read holds changes the selection didn't cover. The +// It records whether the diff it read holds changes the selection didn't cover. The // caller uses this to tell whether the side acted on still has anything of this file // in it once we are done. -func (self *WorkingTreeDiffActions) applyDiffLines( +func (self *WorkingTreeDiffActions) buildDiffLinesPatch( file *models.File, infos []types.DiffLineInfo, sourceCached bool, opts git_commands.ApplyPatchOpts, -) (bool, error) { +) diffLinesPatch { parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, sourceCached)) patchLineIndices := patch.ChangeLineIndicesForLines(parsedPatch, @@ -311,10 +330,7 @@ func (self *WorkingTreeDiffActions) applyDiffLines( // and taking that back out leaves an empty file in the index rather than an // untracked one. if !changesLeft && opts.Cached { - if opts.Reverse { - return false, self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked) - } - return false, self.c.Git().WorkingTree.StageFile(file.GetPath()) + return diffLinesPatch{file: file, wholeFile: true} } patchToApply := parsedPatch. @@ -324,11 +340,21 @@ func (self *WorkingTreeDiffActions) applyDiffLines( FileNameOverride: file.GetPath(), }). FormatPlain() - if patchToApply == "" { - return changesLeft, nil - } + return diffLinesPatch{file: file, patch: patchToApply, changesLeft: changesLeft} +} - return changesLeft, self.c.Git().Patch.ApplyPatch(patchToApply, opts) +// applyDiffLinesPatch applies what buildDiffLinesPatch built, as opts says. +func (self *WorkingTreeDiffActions) applyDiffLinesPatch(p diffLinesPatch, opts git_commands.ApplyPatchOpts) error { + if p.wholeFile { + if opts.Reverse { + return self.c.Git().WorkingTree.UnStageFile(p.file.Names(), p.file.Tracked) + } + return self.c.Git().WorkingTree.StageFile(p.file.GetPath()) + } + if p.patch == "" { + return nil + } + return self.c.Git().Patch.ApplyPatch(p.patch, opts) } // changeLineCount returns how many of a patch's lines are changes rather than context From 15ac3c28c98208682326eb13b1b6d7b036cab2bc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 7 Oct 2026 19:45:35 +0200 Subject: [PATCH 4/6] Show an error if the file of the selected diff lines can't be found If the path in the diff header of a selected line doesn't name a file of the files panel, staging or discarding the line does nothing. The command log shows "Apply patch", but nothing else happens, so the user has nothing to go on when reporting it. Adding a line of a commit's diff to a custom patch fails in the same silent way if the path doesn't name a file of that diff. A diff renderer that names files differently can cause this, and so can a bug in how we read the path, like the one we had with diff.mnemonicPrefix. Show an error that names the path instead, and do nothing else. If a selection spans several files, don't act on the files we did find, because that would do only part of what the user asked for. Do the same for opening a line of a diff in its pull request. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/commit_diff_actions.go | 15 ++---- pkg/gui/controllers/diff_paths.go | 14 +++++ pkg/gui/controllers/main_view_controller.go | 2 +- .../controllers/working_tree_diff_actions.go | 25 ++++++--- pkg/i18n/english.go | 2 + ...of_a_file_missing_from_the_commits_diff.go | 51 +++++++++++++++++++ ..._of_a_file_missing_from_the_files_panel.go | 50 ++++++++++++++++++ pkg/integration/tests/test_list.go | 2 + 8 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 pkg/integration/tests/main_view/report_lines_of_a_file_missing_from_the_commits_diff.go create mode 100644 pkg/integration/tests/main_view/report_lines_of_a_file_missing_from_the_files_panel.go diff --git a/pkg/gui/controllers/commit_diff_actions.go b/pkg/gui/controllers/commit_diff_actions.go index dc6eb825a..54b88eb84 100644 --- a/pkg/gui/controllers/commit_diff_actions.go +++ b/pkg/gui/controllers/commit_diff_actions.go @@ -108,9 +108,6 @@ func (self *CommitDiffActions) PrimaryAction(pane types.DiffPaneContext, firstBu if err != nil { return err } - if len(selection.paths) == 0 { - return nil - } patchBuilder := self.c.Git().Patch.PatchBuilder from, reverse := self.patchEndpoints(target) @@ -182,7 +179,7 @@ func (self *CommitDiffActions) removePatchLines( for path, ordinals := range self.c.Helpers().DiffLine.ChangeLineOrdinals(self.customPatchDiff(), lines) { filename := self.patchBuilderPath(path) if filename == "" { - continue + return diffLinesFileNotFoundError(self.c, path) } included := patchBuilder.IncludedChangeLineIndices(filename) indices := []int{} @@ -243,9 +240,6 @@ func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firs if err != nil { return err } - if len(selection.paths) == 0 { - return nil - } patchBuilder := self.c.Git().Patch.PatchBuilder prompt := lo.Ternary(patchBuilder.IsEmpty(), @@ -353,7 +347,8 @@ type commitDiffSelection struct { } // selectionByFile groups the given lines of the commit's diff by the file they belong -// to. A line whose path is no file of this repo is left out. +// to. If a line belongs to no file of the diff, the diff on screen and the one the +// patch is built from disagree, so this fails and we build nothing. func (self *CommitDiffActions) selectionByFile(lines []types.DiffLineInfo) (commitDiffSelection, error) { files, err := self.filesInDiff() if err != nil { @@ -362,8 +357,8 @@ func (self *CommitDiffActions) selectionByFile(lines []types.DiffLineInfo) (comm selection := commitDiffSelection{files: files, linesByPath: map[string][]patch.LineIdentity{}} for _, line := range lines { path := self.patchBuilderPath(line.Path) - if path == "" { - continue + if _, ok := files[path]; !ok { + return commitDiffSelection{}, diffLinesFileNotFoundError(self.c, line.Path) } if _, seen := selection.linesByPath[path]; !seen { selection.paths = append(selection.paths, path) diff --git a/pkg/gui/controllers/diff_paths.go b/pkg/gui/controllers/diff_paths.go index 91215ba2c..017286c2d 100644 --- a/pkg/gui/controllers/diff_paths.go +++ b/pkg/gui/controllers/diff_paths.go @@ -1,11 +1,13 @@ package controllers import ( + "errors" "path" "path/filepath" "strings" "github.com/jesseduffield/lazygit/pkg/gui/filetree" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -132,6 +134,18 @@ func isInDir(path string, dir string) bool { return dir == "." || strings.HasPrefix(path, dir+"/") } +// diffLinesFileNotFoundError reports that the file selected diff lines belong to can't +// be found. It names the file by its path in the repo, or by the path the diff line +// carries for one outside of it. +func diffLinesFileNotFoundError(c *ControllerCommon, absolutePath string) error { + path := repoRelativePath(c.Git().RepoPaths.WorktreePath(), absolutePath) + if path == "" { + path = absolutePath + } + return errors.New(utils.ResolvePlaceholderString(c.Tr.DiffLinesFileNotFound, + map[string]string{"path": path})) +} + // repoRelativePath turns the absolute path a diff line carries into the one git speaks // of the file: relative to the worktree, with forward slashes. It is "" for a path that // is no file of this repo, which a diff renderer's own naming of a line can produce. diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 71aee5888..7129e1c43 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -1105,7 +1105,7 @@ func (self *MainViewController) openPullRequestAtSelectedLine() error { } relativePath := repoRelativePath(self.c.Git().RepoPaths.WorktreePath(), info.Path) if relativePath == "" { - return nil + return diffLinesFileNotFoundError(self.c, info.Path) } self.c.LogAction(self.c.Tr.Actions.OpenPullRequest) diff --git a/pkg/gui/controllers/working_tree_diff_actions.go b/pkg/gui/controllers/working_tree_diff_actions.go index f42fce0c3..f4c0f6334 100644 --- a/pkg/gui/controllers/working_tree_diff_actions.go +++ b/pkg/gui/controllers/working_tree_diff_actions.go @@ -59,7 +59,10 @@ func (self *WorkingTreeDiffActions) PrimaryAction(pane types.DiffPaneContext, fi self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) } - linesByFile, onStagedSide := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) + linesByFile, onStagedSide, err := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) + if err != nil { + return err + } if len(linesByFile) == 0 { return nil } @@ -79,7 +82,10 @@ func (self *WorkingTreeDiffActions) DiscardSelection(pane types.DiffPaneContext, self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) } - linesByFile, onStagedSide := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) + linesByFile, onStagedSide, err := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) + if err != nil { + return err + } if len(linesByFile) == 0 { return nil } @@ -117,7 +123,10 @@ func (self *WorkingTreeDiffActions) DiscardSelectionDisabledReason(types.DiffPan func (self *WorkingTreeDiffActions) EditHunk( pane types.DiffPaneContext, firstBufferLine int, lastBufferLine int, ) error { - linesByFile, onStagedSide := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) + linesByFile, onStagedSide, err := self.diffLineSelection(pane, firstBufferLine, lastBufferLine) + if err != nil { + return err + } if len(linesByFile) == 0 { return nil } @@ -203,21 +212,23 @@ type fileDiffLines struct { // there is nothing to act on. // // A directory's diff spans several files, and a patch is of one file, so the lines are -// grouped by the file they belong to, in the order the diff shows the files. +// grouped by the file they belong to, in the order the diff shows the files. If a line +// belongs to no file of the files panel, the diff and the panel disagree. Acting on the +// other lines would do only part of what the user asked for, so this fails instead. func (self *WorkingTreeDiffActions) diffLineSelection( pane types.DiffPaneContext, firstBufferLine int, lastBufferLine int, -) (linesByFile []fileDiffLines, onStagedSide bool) { +) (linesByFile []fileDiffLines, onStagedSide bool, err error) { infos := self.c.Helpers().DiffLine.ChangeLinesInBufferRange(pane.GetView(), firstBufferLine, lastBufferLine) infosByPath := lo.GroupBy(infos, func(info types.DiffLineInfo) string { return info.Path }) paths := lo.Uniq(lo.Map(infos, func(info types.DiffLineInfo, _ int) string { return info.Path })) for _, path := range paths { file := self.fileForDiffLinePath(path) if file == nil { - continue + return nil, false, diffLinesFileNotFoundError(self.c, path) } linesByFile = append(linesByFile, fileDiffLines{file: file, lines: infosByPath[path]}) } - return linesByFile, self.showsStagedSide(pane) + return linesByFile, self.showsStagedSide(pane), nil } // applyDiffLineSelection applies the selected change lines, a patch per file, and diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index efd84d722..522f9cbbb 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -748,6 +748,7 @@ type TranslationSet struct { CopySelectedDiffLinesToClipboard string SelectedDiffLinesCopiedToast string SelectionNotFoundInDiffToast string + DiffLinesFileNotFound string NoFilesStagedTitle string NoFilesStagedPrompt string BranchNotFoundTitle string @@ -1938,6 +1939,7 @@ func EnglishTranslationSet() *TranslationSet { CopySelectedDiffLinesToClipboard: "Copy selected diff lines to clipboard", SelectedDiffLinesCopiedToast: "Selected diff lines copied to clipboard", SelectionNotFoundInDiffToast: "Nothing in the selection could be found in the diff", + DiffLinesFileNotFound: "Can't find the file '{{.path}}' that the selected lines belong to", CommitPrefixPatternError: "Error in commitPrefix pattern", NoFilesStagedTitle: "No files staged", NoFilesStagedPrompt: "You have not staged any files. Commit all files?", diff --git a/pkg/integration/tests/main_view/report_lines_of_a_file_missing_from_the_commits_diff.go b/pkg/integration/tests/main_view/report_lines_of_a_file_missing_from_the_commits_diff.go new file mode 100644 index 000000000..68a5d3a1e --- /dev/null +++ b/pkg/integration/tests/main_view/report_lines_of_a_file_missing_from_the_commits_diff.go @@ -0,0 +1,51 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ReportLinesOfAFileMissingFromTheCommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Adding lines to a custom patch that the diff puts in a file the commit doesn't change shows an error", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // A renderer that announces the metadata protocol and passes the diff through, + // except that its +++ line names a file in another directory. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: `printf '\033]1717;1\007'; sed 's#+++ b/#+++ b/elsewhere/#'`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFileAndAdd("file1", "one\nADD1\ntwo\nthree\n") + shell.Commit("two") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("two").IsSelected(), + Contains("one"), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+ADD1"), + ). + PressPrimaryAction() + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Equals("Can't find the file 'elsewhere/file1' that the selected lines belong to")). + Confirm() + + // No patch is left behind. + t.Views().Information().Content(DoesNotContain("Building patch")) + }, +}) diff --git a/pkg/integration/tests/main_view/report_lines_of_a_file_missing_from_the_files_panel.go b/pkg/integration/tests/main_view/report_lines_of_a_file_missing_from_the_files_panel.go new file mode 100644 index 000000000..1e03ba5eb --- /dev/null +++ b/pkg/integration/tests/main_view/report_lines_of_a_file_missing_from_the_files_panel.go @@ -0,0 +1,50 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ReportLinesOfAFileMissingFromTheFilesPanel = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Staging lines that the diff puts in a file the files panel doesn't have shows an error", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // A renderer that announces the metadata protocol and passes the diff through, + // except that its +++ line names a file in another directory. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: `printf '\033]1717;1\007'; sed 's#+++ b/#+++ b/elsewhere/#'`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nADD1\ntwo\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains(" M file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+ADD1"), + ). + PressPrimaryAction() + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Equals("Can't find the file 'elsewhere/file1' that the selected lines belong to")). + Confirm() + + t.Views().Files().Lines( + Contains(" M file1"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index c7a8219bc..095a44e97 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -463,6 +463,8 @@ var tests = []*components.IntegrationTest{ main_view.RemovePartOfAddedFileFromCommit, main_view.RemovePatchFromCommit, main_view.RenderTheDiffBesideThePatchMarks, + main_view.ReportLinesOfAFileMissingFromTheCommitsDiff, + main_view.ReportLinesOfAFileMissingFromTheFilesPanel, main_view.ResetAPatchBuiltFromACommitsDiff, main_view.ResetPatchWithEscape, main_view.ResetThePatchFromThePaneShowingIt, From 56e234e94c7e6ac0e4ef6ffae5d4475c9f922990 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 7 Oct 2026 19:46:21 +0200 Subject: [PATCH 5/6] Show an error if the selected diff lines can't be found in the diff When we stage, discard or edit lines of the working tree's diff, we read the file's diff again and look for the selected lines in it. If none of them are there, nothing happens. The same goes for adding lines of a commit's diff to a custom patch, or taking lines out of it. This can happen if the file changed since its diff was shown, or if we read the line numbers out of the diff on screen wrongly. Show an error that names the file instead. As with a file that can't be found, don't act on any file if the lines of one are missing. Unlike the file check, this one happens after a new custom patch has been started. If the error leaves that patch without any lines, reset it, as we do when the last line is taken out of a patch. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/commit_diff_actions.go | 21 +++++--- pkg/gui/controllers/diff_paths.go | 7 +++ .../controllers/working_tree_diff_actions.go | 34 ++++++++----- pkg/i18n/english.go | 2 + ...eport_lines_missing_from_a_commits_diff.go | 51 +++++++++++++++++++ ...lines_missing_from_a_file_changed_since.go | 47 +++++++++++++++++ pkg/integration/tests/test_list.go | 2 + 7 files changed, 144 insertions(+), 20 deletions(-) create mode 100644 pkg/integration/tests/main_view/report_lines_missing_from_a_commits_diff.go create mode 100644 pkg/integration/tests/main_view/report_lines_missing_from_a_file_changed_since.go diff --git a/pkg/gui/controllers/commit_diff_actions.go b/pkg/gui/controllers/commit_diff_actions.go index 54b88eb84..a68a55d94 100644 --- a/pkg/gui/controllers/commit_diff_actions.go +++ b/pkg/gui/controllers/commit_diff_actions.go @@ -125,14 +125,16 @@ func (self *CommitDiffActions) PrimaryAction(pane types.DiffPaneContext, firstBu patchBuilder.Start(from, target.to, reverse, target.canRebase) } - if err := self.togglePatchLines(selection); err != nil { - return err - } + err := self.togglePatchLines(selection) // Taking the last line back out ends the patch rather than leaving an empty // one, so that the pane previewing it and the marks over the diff go with it. + // The same goes for a patch just started that no line could be put into. if patchBuilder.IsEmpty() { patchBuilder.Reset() } + if err != nil { + return err + } // The diff on screen is the one the marks belong to, so they can be brought up // to date at once rather than waiting for the render below. @@ -189,7 +191,7 @@ func (self *CommitDiffActions) removePatchLines( } } if len(indices) == 0 { - continue + return diffLinesNotFoundError(self.c, filename) } indicesByFilename[filename] = indices } @@ -254,6 +256,9 @@ func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firs patchBuilder.Reset() patchBuilder.Start(from, target.to, reverse, target.canRebase) if err := self.togglePatchLines(selection); err != nil { + // This patch exists only to be removed from the commit, so don't leave it + // behind. + patchBuilder.Reset() return err } if patchBuilder.IsEmpty() { @@ -385,6 +390,9 @@ func (self *CommitDiffActions) togglePatchLines(selection commitDiffSelection) e if err != nil { return err } + if len(indices) == 0 { + return diffLinesNotFoundError(self.c, path) + } indicesByPath[path] = indices // Selecting every change of a file the commit adds or deletes is selecting the @@ -397,12 +405,9 @@ func (self *CommitDiffActions) togglePatchLines(selection commitDiffSelection) e if err != nil { return err } - removing := len(indicesByPath[paths[0]]) > 0 && lo.Contains(included, indicesByPath[paths[0]][0]) + removing := lo.Contains(included, indicesByPath[paths[0]][0]) for _, path := range paths { - if len(indicesByPath[path]) == 0 { - continue - } previousPath := files.previousPath(path) var err error switch { diff --git a/pkg/gui/controllers/diff_paths.go b/pkg/gui/controllers/diff_paths.go index 017286c2d..3f18c5628 100644 --- a/pkg/gui/controllers/diff_paths.go +++ b/pkg/gui/controllers/diff_paths.go @@ -146,6 +146,13 @@ func diffLinesFileNotFoundError(c *ControllerCommon, absolutePath string) error map[string]string{"path": path})) } +// diffLinesNotFoundError reports that selected diff lines can't be found in the diff of +// the file at the given path in the repo. +func diffLinesNotFoundError(c *ControllerCommon, path string) error { + return errors.New(utils.ResolvePlaceholderString(c.Tr.DiffLinesNotFound, + map[string]string{"path": path})) +} + // repoRelativePath turns the absolute path a diff line carries into the one git speaks // of the file: relative to the worktree, with forward slashes. It is "" for a path that // is no file of this repo, which a diff renderer's own naming of a line can produce. diff --git a/pkg/gui/controllers/working_tree_diff_actions.go b/pkg/gui/controllers/working_tree_diff_actions.go index f4c0f6334..6739d80a2 100644 --- a/pkg/gui/controllers/working_tree_diff_actions.go +++ b/pkg/gui/controllers/working_tree_diff_actions.go @@ -136,7 +136,7 @@ func (self *WorkingTreeDiffActions) EditHunk( lineIndices := patch.ChangeLineIndicesForLines(parsedPatch, []patch.LineIdentity{linesByFile[0].lines[0].PatchLineIdentity()}) if len(lineIndices) == 0 { - return nil + return diffLinesNotFoundError(self.c, file.GetPath()) } hunkIdx := parsedPatch.HunkContainingLine(lineIndices[0]) @@ -240,9 +240,16 @@ func (self *WorkingTreeDiffActions) applyDiffLineSelection( pane types.DiffPaneContext, firstBufferLine int, linesByFile []fileDiffLines, onStagedSide bool, opts git_commands.ApplyPatchOpts, ) error { - patches := lo.Map(linesByFile, func(fileLines fileDiffLines, _ int) diffLinesPatch { - return self.buildDiffLinesPatch(fileLines.file, fileLines.lines, onStagedSide, opts) - }) + // If the lines of one file can't be found, no file is acted on, so every file's + // patch is built before any is applied. + patches := make([]diffLinesPatch, 0, len(linesByFile)) + for _, fileLines := range linesByFile { + p, err := self.buildDiffLinesPatch(fileLines.file, fileLines.lines, onStagedSide, opts) + if err != nil { + return err + } + patches = append(patches, p) + } self.c.LogAction(self.c.Tr.Actions.ApplyPatch) @@ -295,8 +302,7 @@ type diffLinesPatch struct { // acting on the file itself, which is staged or unstaged as a whole. wholeFile bool - // The patch of the lines, where they are not the whole file. Empty when none of - // them could be found in the file's diff. + // The patch of the lines, where they are not the whole file. patch string // Whether the diff the lines were found in holds changes they don't cover. @@ -322,15 +328,22 @@ type diffLinesPatch struct { // It records whether the diff it read holds changes the selection didn't cover. The // caller uses this to tell whether the side acted on still has anything of this file // in it once we are done. +// +// It fails if none of the lines are in the diff. The diff on screen is then not the +// one it read; either the file changed since the diff was shown, or we read the +// lines wrongly. func (self *WorkingTreeDiffActions) buildDiffLinesPatch( file *models.File, infos []types.DiffLineInfo, sourceCached bool, opts git_commands.ApplyPatchOpts, -) diffLinesPatch { +) (diffLinesPatch, error) { parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, sourceCached)) patchLineIndices := patch.ChangeLineIndicesForLines(parsedPatch, lo.Map(infos, func(info types.DiffLineInfo, _ int) patch.LineIdentity { return info.PatchLineIdentity() })) + if len(patchLineIndices) == 0 { + return diffLinesPatch{}, diffLinesNotFoundError(self.c, file.GetPath()) + } changesLeft := len(patchLineIndices) < changeLineCount(parsedPatch) @@ -341,7 +354,7 @@ func (self *WorkingTreeDiffActions) buildDiffLinesPatch( // and taking that back out leaves an empty file in the index rather than an // untracked one. if !changesLeft && opts.Cached { - return diffLinesPatch{file: file, wholeFile: true} + return diffLinesPatch{file: file, wholeFile: true}, nil } patchToApply := parsedPatch. @@ -351,7 +364,7 @@ func (self *WorkingTreeDiffActions) buildDiffLinesPatch( FileNameOverride: file.GetPath(), }). FormatPlain() - return diffLinesPatch{file: file, patch: patchToApply, changesLeft: changesLeft} + return diffLinesPatch{file: file, patch: patchToApply, changesLeft: changesLeft}, nil } // applyDiffLinesPatch applies what buildDiffLinesPatch built, as opts says. @@ -362,9 +375,6 @@ func (self *WorkingTreeDiffActions) applyDiffLinesPatch(p diffLinesPatch, opts g } return self.c.Git().WorkingTree.StageFile(p.file.GetPath()) } - if p.patch == "" { - return nil - } return self.c.Git().Patch.ApplyPatch(p.patch, opts) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 522f9cbbb..e2179d5a0 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -749,6 +749,7 @@ type TranslationSet struct { SelectedDiffLinesCopiedToast string SelectionNotFoundInDiffToast string DiffLinesFileNotFound string + DiffLinesNotFound string NoFilesStagedTitle string NoFilesStagedPrompt string BranchNotFoundTitle string @@ -1940,6 +1941,7 @@ func EnglishTranslationSet() *TranslationSet { SelectedDiffLinesCopiedToast: "Selected diff lines copied to clipboard", SelectionNotFoundInDiffToast: "Nothing in the selection could be found in the diff", DiffLinesFileNotFound: "Can't find the file '{{.path}}' that the selected lines belong to", + DiffLinesNotFound: "Can't find the selected lines in the diff of '{{.path}}'", CommitPrefixPatternError: "Error in commitPrefix pattern", NoFilesStagedTitle: "No files staged", NoFilesStagedPrompt: "You have not staged any files. Commit all files?", diff --git a/pkg/integration/tests/main_view/report_lines_missing_from_a_commits_diff.go b/pkg/integration/tests/main_view/report_lines_missing_from_a_commits_diff.go new file mode 100644 index 000000000..e61aab103 --- /dev/null +++ b/pkg/integration/tests/main_view/report_lines_missing_from_a_commits_diff.go @@ -0,0 +1,51 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ReportLinesMissingFromACommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Adding lines to a custom patch that the diff puts where the commit's diff has none shows an error", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + // A renderer that announces the metadata protocol and passes the diff through, + // except that its hunk header puts the hunk ten lines further down. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: `printf '\033]1717;1\007'; sed 's#@@ -1,3 +1,4 @@#@@ -11,3 +11,4 @@#'`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFileAndAdd("file1", "one\nADD1\ntwo\nthree\n") + shell.Commit("two") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("two").IsSelected(), + Contains("one"), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+ADD1"), + ). + PressPrimaryAction() + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Equals("Can't find the selected lines in the diff of 'file1'")). + Confirm() + + // No patch is left behind. + t.Views().Information().Content(DoesNotContain("Building patch")) + }, +}) diff --git a/pkg/integration/tests/main_view/report_lines_missing_from_a_file_changed_since.go b/pkg/integration/tests/main_view/report_lines_missing_from_a_file_changed_since.go new file mode 100644 index 000000000..b10618189 --- /dev/null +++ b/pkg/integration/tests/main_view/report_lines_missing_from_a_file_changed_since.go @@ -0,0 +1,47 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ReportLinesMissingFromAFileChangedSince = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Staging lines that are gone from the file since its diff was shown shows an error", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nADD1\ntwo\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains(" M file1").IsSelected(), + ). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("+ADD1"), + ) + + // The change is undone outside of lazygit, and nothing has refreshed the diff + // since. + t.Shell().UpdateFile("file1", "one\ntwo\nthree\n") + + t.Views().Main(). + PressPrimaryAction() + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Equals("Can't find the selected lines in the diff of 'file1'")). + Confirm() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 095a44e97..566baf0dc 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -463,6 +463,8 @@ var tests = []*components.IntegrationTest{ main_view.RemovePartOfAddedFileFromCommit, main_view.RemovePatchFromCommit, main_view.RenderTheDiffBesideThePatchMarks, + main_view.ReportLinesMissingFromACommitsDiff, + main_view.ReportLinesMissingFromAFileChangedSince, main_view.ReportLinesOfAFileMissingFromTheCommitsDiff, main_view.ReportLinesOfAFileMissingFromTheFilesPanel, main_view.ResetAPatchBuiltFromACommitsDiff, From 2a3883f5026ff263f45659838311b878db600a95 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 7 Oct 2026 14:14:21 +0200 Subject: [PATCH 6/6] Show an error if the commit to discard lines from can't be found Discarding lines from a commit's diff rewrites the commit with a rebase. For this we look up the commit among the commits of the current branch. If it isn't there, pressing the key does nothing. The disabled reason only allows discarding in a diff of the commits panel, so this shouldn't happen. If it does, the user should see it. Show an error that names the commit instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/commit_diff_actions.go | 5 ++++- pkg/i18n/english.go | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/commit_diff_actions.go b/pkg/gui/controllers/commit_diff_actions.go index a68a55d94..b5adae259 100644 --- a/pkg/gui/controllers/commit_diff_actions.go +++ b/pkg/gui/controllers/commit_diff_actions.go @@ -1,6 +1,7 @@ package controllers import ( + "errors" "fmt" "github.com/jesseduffield/generics/set" @@ -9,6 +10,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -236,7 +238,8 @@ func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firs } commitIndex := self.indexOfTargetCommit(target) if commitIndex == -1 { - return nil + return errors.New(utils.ResolvePlaceholderString(self.c.Tr.CommitNotInCommitsPanel, + map[string]string{"hash": utils.ShortHash(target.to)})) } selection, err := self.selectionByFile(lines) if err != nil { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index e2179d5a0..61f81a1f0 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -750,6 +750,7 @@ type TranslationSet struct { SelectionNotFoundInDiffToast string DiffLinesFileNotFound string DiffLinesNotFound string + CommitNotInCommitsPanel string NoFilesStagedTitle string NoFilesStagedPrompt string BranchNotFoundTitle string @@ -1942,6 +1943,7 @@ func EnglishTranslationSet() *TranslationSet { SelectionNotFoundInDiffToast: "Nothing in the selection could be found in the diff", DiffLinesFileNotFound: "Can't find the file '{{.path}}' that the selected lines belong to", DiffLinesNotFound: "Can't find the selected lines in the diff of '{{.path}}'", + CommitNotInCommitsPanel: "Can't find commit {{.hash}} in the commits panel", CommitPrefixPatternError: "Error in commitPrefix pattern", NoFilesStagedTitle: "No files staged", NoFilesStagedPrompt: "You have not staged any files. Commit all files?",