jesseduffield.lazygit/pkg/commands/oscommands/os_test.go
Stefan Haller 6b311ccb62 Fix quoting of shell commands on Windows
lazygit builds a shell command by interpolating Quote'd arguments into a
template and running the result via `cmd /c`. Several things were wrong
on Windows:

- Quote emitted bash-style `\"…\"` quoting, which cmd.exe doesn't
  understand. Making it usable at all previously required a fragile
  round-trip through str.ToArgv and re-escaping.
- The assembled command line was handed to `cmd /c` without `/s`, so
  cmd's default rules stripped the wrong quotes once the line contained
  more than two of them (e.g. a quoted editor path at a location with
  spaces, plus a quoted filename that also contains spaces).
- Shell metacharacters were escaped with `^` (`&` → `^&`, etc.), which
  neutralised command chaining, pipes, redirection and `%VAR%` expansion
  in custom commands.

Quote now emits the standard Windows convention directly, and NewShell
hands cmd.exe the fully-assembled line verbatim via SysProcAttr.CmdLine,
wrapped as `cmd /s /c "<command>"`. The /s flag strips exactly the outer
quote pair we add, leaving each argument's own quoting intact. With the
`^` escaping gone, metacharacters in a custom command reach cmd as the
author intended; this also removes the spurious `^` reported in #3092.

Fixes #5560
Fixes #2427
Fixes #4147

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:11:00 +02:00

220 lines
4.4 KiB
Go

package oscommands
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestOSCommandRun(t *testing.T) {
type scenario struct {
args []string
test func(error)
}
scenarios := []scenario{
{
[]string{"rmdir", "unexisting-folder"},
func(err error) {
assert.Regexp(t, "rmdir.*unexisting-folder.*", err.Error())
},
},
}
for _, s := range scenarios {
c := NewDummyOSCommand()
s.test(c.Cmd.New(s.args).Run())
}
}
func TestOSCommandQuote(t *testing.T) {
osCommand := NewDummyOSCommand()
osCommand.Platform.OS = "linux"
actual := osCommand.Quote("hello `test`")
expected := "\"hello \\`test\\`\""
assert.EqualValues(t, expected, actual)
}
// TestOSCommandQuoteSingleQuote tests the quote function with ' quotes explicitly for Linux
func TestOSCommandQuoteSingleQuote(t *testing.T) {
osCommand := NewDummyOSCommand()
osCommand.Platform.OS = "linux"
actual := osCommand.Quote("hello 'test'")
expected := `"hello 'test'"`
assert.EqualValues(t, expected, actual)
}
// TestOSCommandQuoteDoubleQuote tests the quote function with " quotes explicitly for Linux
func TestOSCommandQuoteDoubleQuote(t *testing.T) {
osCommand := NewDummyOSCommand()
osCommand.Platform.OS = "linux"
actual := osCommand.Quote(`hello "test"`)
expected := `"hello \"test\""`
assert.EqualValues(t, expected, actual)
}
// TestOSCommandQuoteWindows tests the quote function for Windows
func TestOSCommandQuoteWindows(t *testing.T) {
osCommand := NewDummyOSCommand()
osCommand.Platform.OS = "windows"
actual := osCommand.Quote(`hello "test" 'test2'`)
expected := `"hello \"test\" 'test2'"`
assert.EqualValues(t, expected, actual)
}
// On Windows, NewShell must hand the command to cmd.exe verbatim.
func TestNewShellWindowsPassesMetacharactersVerbatim(t *testing.T) {
osCommand := NewDummyOSCommand()
platform := &Platform{OS: "windows", Shell: "cmd", ShellArg: "/c"}
osCommand.Platform = platform
osCommand.Cmd.platform = platform
command := `echo a && echo b | sort > out.txt < in.txt %PATH%`
assert.Equal(t,
[]string{"cmd", "/s", "/c", command},
osCommand.Cmd.NewShell(command, "").Args(),
)
}
func TestOSCommandFileType(t *testing.T) {
type scenario struct {
path string
setup func()
test func(string)
}
scenarios := []scenario{
{
"testFile",
func() {
f, err := os.Create("testFile")
if err != nil {
panic(err)
}
if err := f.Close(); err != nil {
panic(err)
}
},
func(output string) {
assert.EqualValues(t, "file", output)
},
},
{
"file with spaces",
func() {
f, err := os.Create("file with spaces")
if err != nil {
panic(err)
}
if err := f.Close(); err != nil {
panic(err)
}
},
func(output string) {
assert.EqualValues(t, "file", output)
},
},
{
"testDirectory",
func() {
if err := os.Mkdir("testDirectory", 0o644); err != nil {
panic(err)
}
},
func(output string) {
assert.EqualValues(t, "directory", output)
},
},
{
"nonExistent",
func() {},
func(output string) {
assert.EqualValues(t, "other", output)
},
},
}
for _, s := range scenarios {
s.setup()
s.test(FileType(s.path))
assert.NoError(t, os.RemoveAll(s.path))
}
}
func TestOSCommandAppendLineToFile(t *testing.T) {
type scenario struct {
path string
setup func(string)
test func(string)
}
scenarios := []scenario{
{
filepath.Join(os.TempDir(), "testFile"),
func(path string) {
if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil {
panic(err)
}
},
func(output string) {
assert.EqualValues(t, "hello\nworld\n", output)
},
},
{
filepath.Join(os.TempDir(), "emptyTestFile"),
func(path string) {
if err := os.WriteFile(path, []byte(""), 0o600); err != nil {
panic(err)
}
},
func(output string) {
assert.EqualValues(t, "world\n", output)
},
},
{
filepath.Join(os.TempDir(), "testFileWithNewline"),
func(path string) {
if err := os.WriteFile(path, []byte("hello\n"), 0o600); err != nil {
panic(err)
}
},
func(output string) {
assert.EqualValues(t, "hello\nworld\n", output)
},
},
}
for _, s := range scenarios {
s.setup(s.path)
osCommand := NewDummyOSCommand()
if err := osCommand.AppendLineToFile(s.path, "world"); err != nil {
panic(err)
}
f, err := os.ReadFile(s.path)
if err != nil {
panic(err)
}
s.test(string(f))
_ = os.RemoveAll(s.path)
}
}