Skip to content
29 changes: 29 additions & 0 deletions pkg/commands/git_commands/git_command_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package git_commands

import (
"strings"

"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
)

// convenience struct for building git commands. Especially useful when
Expand Down Expand Up @@ -106,3 +108,30 @@ func (self *GitCommandBuilder) ToArgv() []string {
func (self *GitCommandBuilder) ToString() string {
return strings.Join(self.ToArgv(), " ")
}

// runGitCmdOnPaths runs `git <subcommand> -- <paths...>`, splitting into
// multiple calls if needed to stay under the OS command-line length limit.
// Windows CreateProcess has a ~32 KB limit; we use 30 KB as a safe threshold.
func runGitCmdOnPaths(subcommand string, paths []string, cmd oscommands.ICmdObjBuilder) error {
const maxArgBytes = 30_000

start := 0
for start < len(paths) {
end := start
total := 0
for end < len(paths) {
total += len(paths[end]) + 1 // +1 for the separating space
if total > maxArgBytes && end > start {
break
}
end++
}
if err := cmd.New(NewGitCmd(subcommand).Arg("--").
Arg(paths[start:end]...).
ToArgv()).Run(); err != nil {
return err
}
start = end
}
return nil
}
43 changes: 43 additions & 0 deletions pkg/commands/git_commands/git_command_builder_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package git_commands

import (
"strings"
"testing"

"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -54,3 +56,44 @@ func TestGitCommandBuilder(t *testing.T) {
assert.Equal(t, s.input, s.expected)
}
}

func TestRunGitCmdOnPaths(t *testing.T) {
// Each path is 9000 bytes. Three fit within the 30 KB limit (27001 bytes
// including spaces), four do not (36002 bytes), so a four-path slice must
// be split into two calls of three and one.
longPath := func(ch string) string { return strings.Repeat(ch, 9_000) }
p1, p2, p3, p4 := longPath("a"), longPath("b"), longPath("c"), longPath("d")

scenarios := []struct {
name string
paths []string
runner *oscommands.FakeCmdObjRunner
}{
{
name: "empty list makes no calls",
paths: []string{},
runner: oscommands.NewFakeRunner(t),
},
{
name: "paths that fit in one batch make a single call",
paths: []string{p1, p2, p3},
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(append([]string{"checkout", "--"}, p1, p2, p3), "", nil),
},
{
name: "paths that exceed the limit are split across multiple calls",
paths: []string{p1, p2, p3, p4},
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs(append([]string{"checkout", "--"}, p1, p2, p3), "", nil).
ExpectGitArgs(append([]string{"checkout", "--"}, p4), "", nil),
},
}

for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
cmd := oscommands.NewDummyCmdObjBuilder(s.runner)
assert.NoError(t, runGitCmdOnPaths("checkout", s.paths, cmd))
s.runner.CheckForMissingCalls()
})
}
}
101 changes: 82 additions & 19 deletions pkg/commands/git_commands/working_tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,43 +184,106 @@ type IFileNode interface {
GetFile() *models.File
}

func (self *WorkingTreeCommands) DiscardAllDirChanges(node IFileNode) error {
// this could be more efficient but we would need to handle all the edge cases
return node.ForEachFile(self.DiscardAllFileChanges)
}
func (self *WorkingTreeCommands) DiscardAllDirChanges(nodes []IFileNode) error {
// Collect files into buckets so we can batch git calls where possible.
var specialFiles []*models.File // renames, AA, DU — handled individually
var filesToReset []string // need `git reset` first (staged or conflicted)
var filesToCheckout []string // need `git checkout` (after optional reset)
var filesToRemove []string // added files to delete from disk

for _, node := range nodes {
_ = node.ForEachFile(func(file *models.File) error {
// Renames and certain merge-conflict statuses need per-file logic.
if file.IsRename() || file.ShortStatus == "AA" || file.ShortStatus == "DU" {
specialFiles = append(specialFiles, file)
return nil
}

if file.HasStagedChanges || file.HasMergeConflicts {
filesToReset = append(filesToReset, file.Path)
// DD and AU are done after the reset; no checkout or remove needed.
if file.ShortStatus == "DD" || file.ShortStatus == "AU" {
return nil
}
if file.Added {
filesToRemove = append(filesToRemove, file.Path)
} else {
filesToCheckout = append(filesToCheckout, file.Path)
}
return nil
}

// No staged changes below this point.
if file.ShortStatus == "DD" || file.ShortStatus == "AU" {
return nil
}

if file.Added {
filesToRemove = append(filesToRemove, file.Path)
return nil
}

filesToCheckout = append(filesToCheckout, file.Path)
return nil
})
}

func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(node IFileNode) error {
file := node.GetFile()
if file == nil {
if err := self.RemoveUntrackedDirFiles(node); err != nil {
for _, file := range specialFiles {
if err := self.DiscardAllFileChanges(file); err != nil {
return err
}
}

if err := runGitCmdOnPaths("reset", filesToReset, self.cmd); err != nil {
return err
}

cmdArgs := NewGitCmd("checkout").Arg("--", node.GetPath()).ToArgv()
if err := self.cmd.New(cmdArgs).Run(); err != nil {
for _, path := range filesToRemove {
if err := self.os.RemoveFile(path); err != nil {
return err
}
} else {
if file.Added && !file.HasStagedChanges {
return self.os.RemoveFile(file.Path)
}
}

return runGitCmdOnPaths("checkout", filesToCheckout, self.cmd)
}

func (self *WorkingTreeCommands) DiscardUnstagedDirChanges(nodes []IFileNode) error {
// Collect files into buckets so we can batch git calls where possible.
// Use specific file paths rather than directory paths, so that an active
// filter (e.g. from pressing `/`) only discards visible files.
var filesToRemove []string // purely untracked: remove from disk
var filesToCheckout []string // tracked or staged: restore via checkout

for _, node := range nodes {
_ = node.ForEachFile(func(file *models.File) error {
if !file.Tracked && !file.HasStagedChanges {
filesToRemove = append(filesToRemove, file.Path)
} else {
// Include staged files: a file that is staged but also has
// additional unstaged changes (AM status) needs checkout to
// discard those changes.
filesToCheckout = append(filesToCheckout, file.Path)
}
return nil
})
}

if err := self.DiscardUnstagedFileChanges(file); err != nil {
for _, path := range filesToRemove {
if err := self.os.RemoveFile(path); err != nil {
return err
}
}

return nil
return runGitCmdOnPaths("checkout", filesToCheckout, self.cmd)
}

func (self *WorkingTreeCommands) RemoveUntrackedDirFiles(node IFileNode) error {
untrackedFilePaths := node.GetFilePathsMatching(
func(file *models.File) bool { return !file.GetIsTracked() },
func(file *models.File) bool { return !file.GetIsTracked() && !file.GetHasStagedChanges() },
)

for _, path := range untrackedFilePaths {
err := os.Remove(path)
if err != nil {
if err := self.os.RemoveFile(path); err != nil {
return err
}
}
Expand Down
Loading
Loading