From 1d5ff34a71f9c766b1e148dda66e82f65999a86e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 16 Aug 2026 14:05:00 +0200 Subject: [PATCH] Act on a whole file's changes as acting on the file The diff of a deleted file is its content going away, so putting every line of it into the index leaves an empty file there (modified in the index, deleted in the working tree) rather than the deletion the user selected. The reverse case matches: the diff of an added file is its whole content, and taking all of it back out of the index leaves the file tracked and empty rather than untracked again. In the staging view you had to enter such a file deliberately to reach these cases, but stepping through a directory's diff hunk by hunk runs into them routinely. Selecting every change of a file says "this file", so stage or unstage the file itself. This applies to any file, since applying a file's whole diff amounts to the same thing everywhere else. Co-authored-by: Claude Opus 5 (1M context) --- .../controllers/working_tree_diff_actions.go | 21 +++++ .../tests/main_view/stage_deleted_file.go | 80 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 102 insertions(+) create mode 100644 pkg/integration/tests/main_view/stage_deleted_file.go diff --git a/pkg/gui/controllers/working_tree_diff_actions.go b/pkg/gui/controllers/working_tree_diff_actions.go index e75d538f2..32bcd60db 100644 --- a/pkg/gui/controllers/working_tree_diff_actions.go +++ b/pkg/gui/controllers/working_tree_diff_actions.go @@ -171,6 +171,19 @@ func (self *WorkingTreeDiffActions) applyDiffLines( } } + // Acting on every change of a file is acting on the file itself, and saying so is + // not the same as applying its diff. The diff of a deleted file is its content + // going away, and putting that into the index line by line leaves an empty file + // there rather than the deletion; the diff of an added one is its whole content, + // and taking that back out leaves an empty file in the index rather than an + // untracked one. + if opts.Cached && len(patchLineIndices) == changeLineCount(parsedPatch) { + if opts.Reverse { + return self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked) + } + return self.c.Git().WorkingTree.StageFile(file.GetPath()) + } + patchToApply := parsedPatch. Transform(patch.TransformOpts{ Reverse: opts.Reverse, @@ -184,3 +197,11 @@ func (self *WorkingTreeDiffActions) applyDiffLines( return self.c.Git().Patch.ApplyPatch(patchToApply, opts) } + +// changeLineCount returns how many of a patch's lines are changes rather than context +// or header. A selection of the whole diff covers exactly that many. +func changeLineCount(p *patch.Patch) int { + return lo.CountBy(p.Lines(), func(line *patch.PatchLine) bool { + return line.IsAddition() || line.IsDeletion() + }) +} diff --git a/pkg/integration/tests/main_view/stage_deleted_file.go b/pkg/integration/tests/main_view/stage_deleted_file.go new file mode 100644 index 000000000..9b863ae72 --- /dev/null +++ b/pkg/integration/tests/main_view/stage_deleted_file.go @@ -0,0 +1,80 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageDeletedFile = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Acting on the whole of a file's block in a directory's diff acts on the file: staging a deletion, and unstaging an addition", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("fileA", "a\n") + shell.CreateFileAndAdd("fileB", "b1\nb2\n") + shell.Commit("one") + + shell.UpdateFile("fileA", "a\nfromA\n") + shell.DeleteFile("fileB") + shell.CreateFileAndAdd("fileC", "c1\nc2\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("▼ /").IsSelected(), + Contains(" M fileA"), + Contains(" D fileB"), + Contains("A fileC"), + ). + Press(keys.Universal.FocusMainView) + + // Select everything the deleted file contributes to the diff. + t.Views().Main(). + IsFocused(). + NavigateToLine(Contains("-b1")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("-b2")). + SelectedLines( + Contains("-b1"), + Contains("-b2"), + ). + PressPrimaryAction() + + // The file is staged as deleted. Applying its lines as a patch would have left + // an empty file in the index instead, which is not what deleting a file means. + t.Views().Files().Lines( + Contains("▼ /"), + Contains(" M fileA"), + Contains("D fileB"), + Contains("A fileC"), + ) + + // The same the other way round: taking the whole of an added file back out of + // the index leaves it untracked, rather than tracked and empty. + t.Views().Main(). + IsFocused(). + Press(keys.Universal.TogglePanel) + + t.Views().Secondary(). + IsFocused(). + NavigateToLine(Contains("+c1")). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("+c2")). + SelectedLines( + Contains("+c1"), + Contains("+c2"), + ). + PressPrimaryAction() + + t.Views().Files().Lines( + Contains("▼ /"), + Contains(" M fileA"), + Contains("D fileB"), + Contains("?? fileC"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 9a1a61bd8..9d84e7d25 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -409,6 +409,7 @@ var tests = []*components.IntegrationTest{ main_view.SelectVisibleHunkOnFocusingMainView, main_view.SelectionCommandsOnlyWhereTheyApply, main_view.SelectionOverTheCustomPatch, + main_view.StageDeletedFile, main_view.StageDiffLines, main_view.StageDiffLinesOfAPathWithASpace, main_view.StageRangeSpanningFiles,