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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-05 11:20:31 +02:00
co-authored by Claude Opus 5
parent 1169cde157
commit 9132730229
3 changed files with 102 additions and 0 deletions
@@ -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()
})
}
@@ -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"),
)
},
})
+1
View File
@@ -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,