From 87d9537de9b6bc5a0d17a2121c9bced9cafa3268 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 11:22:04 +0200 Subject: [PATCH 01/97] Support a `{{diffContext}}` template variable in external diff command Useful for passing the context size to external diff commands like difftastic. --- docs-master/Custom_Pagers.md | 10 +++++----- pkg/commands/git_commands/commit.go | 2 +- pkg/commands/git_commands/diff.go | 5 +++-- pkg/commands/git_commands/stash.go | 5 +++-- pkg/commands/git_commands/working_tree.go | 4 ++-- pkg/config/pager_config.go | 9 +++++++-- pkg/gui/pty.go | 3 ++- 7 files changed, 23 insertions(+), 15 deletions(-) diff --git a/docs-master/Custom_Pagers.md b/docs-master/Custom_Pagers.md index f74005c19..8bdcf164d 100644 --- a/docs-master/Custom_Pagers.md +++ b/docs-master/Custom_Pagers.md @@ -66,17 +66,17 @@ These can be used in lazygit by using the `externalDiffCommand` config; in the c ```yaml git: pagers: - - externalDiffCommand: difft --color=always + - externalDiffCommand: difft --color=always --context={{diffContext}} ``` -The `colorArg` option is not used in this case. +The `colorArg` option is not used in this case. You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool. You can add whatever extra arguments you prefer for your difftool; for instance ```yaml git: pagers: - - externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off + - externalDiffCommand: difft --color=always --context={{diffContext}} --display=inline --syntax-highlight=off ``` This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`: @@ -84,7 +84,7 @@ This can also be used for normal git diffs with custom parameters, such as `--co ```sh #!/bin/sh -git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5" +git diff --color-words --no-index --color=always --no-ext-diff --unified=$LAZYGIT_DIFF_CONTEXT "$2" "$5" ``` And then use it in your git config like so: @@ -92,7 +92,7 @@ And then use it in your git config like so: ```yaml git: pagers: - - externalDiffCommand: ~/bin/color-words.sh + - externalDiffCommand: LAZYGIT_DIFF_CONTEXT={{diffContext}} ~/bin/color-words.sh ``` Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go index 6abf272b3..953717ec0 100644 --- a/pkg/commands/git_commands/commit.go +++ b/pkg/commands/git_commands/commit.go @@ -243,7 +243,7 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj { func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj { contextSize := self.UserConfig().Git.DiffContextSize - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() cmdArgs := NewGitCmd("show"). Config("diff.noprefix=false"). diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index 3074e653a..f4ecb5f53 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -19,7 +19,8 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands { // This is for generating diffs to be shown in the UI (e.g. rendering a range // diff to the main view). It uses a custom pager if one is configured. func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj { - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + contextSize := self.UserConfig().Git.DiffContextSize + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiff := extDiffCmd != "" useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() ignoreWhitespace := self.UserConfig().Git.IgnoreWhitespaceInDiffView @@ -32,7 +33,7 @@ func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj { Arg("--submodule"). Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())). ArgIf(ignoreWhitespace, "--ignore-all-space"). - Arg(fmt.Sprintf("--unified=%d", self.UserConfig().Git.DiffContextSize)). + Arg(fmt.Sprintf("--unified=%d", contextSize)). Arg(diffArgs...). Dir(self.repoPaths.worktreePath). ToArgv(), diff --git a/pkg/commands/git_commands/stash.go b/pkg/commands/git_commands/stash.go index 0e5eb299d..9bd960ed5 100644 --- a/pkg/commands/git_commands/stash.go +++ b/pkg/commands/git_commands/stash.go @@ -81,7 +81,8 @@ func (self *StashCommands) Hash(index int) (string, error) { } func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj { - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + contextSize := self.UserConfig().Git.DiffContextSize + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() // "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason @@ -92,7 +93,7 @@ func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj { ConfigIf(extDiffCmd != "", "diff.external="+extDiffCmd). ArgIfElse(extDiffCmd != "" || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())). - Arg(fmt.Sprintf("--unified=%d", self.UserConfig().Git.DiffContextSize)). + Arg(fmt.Sprintf("--unified=%d", contextSize)). ArgIf(self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space"). Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)). Arg(fmt.Sprintf("refs/stash@{%d}", index)). diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 8625158ab..d296858f6 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -401,7 +401,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain contextSize := self.UserConfig().Git.DiffContextSize prevPath := node.GetPreviousPath() noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile() - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiff := extDiffCmd != "" && !plain useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain @@ -450,7 +450,7 @@ func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reve colorArg = "never" } - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiff := extDiffCmd != "" && !plain useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain diff --git a/pkg/config/pager_config.go b/pkg/config/pager_config.go index d243a01b2..01f92f584 100644 --- a/pkg/config/pager_config.go +++ b/pkg/config/pager_config.go @@ -58,12 +58,17 @@ func (self *PagerConfig) GetColorArg() string { return colorArg } -func (self *PagerConfig) GetExternalDiffCommand() string { +func (self *PagerConfig) GetExternalDiffCommand(diffContext uint64) string { currentPagerConfig := self.currentPagerConfig() if currentPagerConfig == nil { return "" } - return currentPagerConfig.ExternalDiffCommand + + templateValues := map[string]string{ + "diffContext": strconv.Itoa(int(diffContext)), + } + + return utils.ResolvePlaceholderString(currentPagerConfig.ExternalDiffCommand, templateValues) } func (self *PagerConfig) GetUseExternalDiffGitConfig() bool { diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index d4f739c6d..dbf968048 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -58,6 +58,7 @@ func (p ptyCmd) GetProcess() *os.Process { return p.process } // command. func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { width := view.InnerWidth() + diffContext := gui.UserConfig().Git.DiffContextSize // LAZYGIT_COLUMNS is documented in docs/Custom_Pagers.md for pager // scripts that can't query the terminal width directly. We set it on @@ -65,7 +66,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width)) pager := gui.stateAccessor.GetPagerConfig().GetPagerCommand(width) - externalDiffCommand := gui.stateAccessor.GetPagerConfig().GetExternalDiffCommand() + externalDiffCommand := gui.stateAccessor.GetPagerConfig().GetExternalDiffCommand(diffContext) useExtDiffGitConfig := gui.stateAccessor.GetPagerConfig().GetUseExternalDiffGitConfig() if pager == "" && externalDiffCommand == "" && !useExtDiffGitConfig { From 5209294a56e752beaf9b9e175c9f795bb5df869f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:32:33 +0200 Subject: [PATCH 02/97] Extract helper for starting a command with piped output The fallback path in newPtyTask (taken when StartPty fails) needs the same start-the-command-with-a-pipe logic that newCmdTask uses, so pull it out into a helper that both can share. No behavior change. Co-Authored-By: Claude Fable 5 --- pkg/gui/tasks_adapter.go | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 27aacf58b..3eb446a90 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -7,6 +7,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/tasks" + "github.com/sirupsen/logrus" ) func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { @@ -29,19 +30,9 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error start := func() (tasks.Cmd, io.Reader) { view.SetContentWidth(contentWidth) - var err error - r, err = cmd.StdoutPipe() - if err != nil { - gui.c.Log.Error(err) - r = nil - } - cmd.Stderr = cmd.Stdout - - if err := cmd.Start(); err != nil { - gui.c.Log.Error(err) - } - - return tasks.ExecCmd{Cmd: cmd}, r + execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log) + r = pipe + return execCmd, pipe } onClose := func() { @@ -59,6 +50,24 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error return nil } +// startCmdWithPipe starts cmd with its stdout and stderr going to a single +// pipe, and returns the command along with the pipe's read end, in the shape +// that NewCmdTask expects from its start func. +func startCmdWithPipe(cmd *exec.Cmd, log *logrus.Entry) (tasks.Cmd, io.ReadCloser) { + r, err := cmd.StdoutPipe() + if err != nil { + log.Error(err) + r = nil + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + log.Error(err) + } + + return tasks.ExecCmd{Cmd: cmd}, r +} + func (gui *Gui) newStringTask(view *gocui.View, str string) error { // using str so that if rendering the exact same thing we don't reset the origin return gui.newStringTaskWithKey(view, str, str) From 400faea60ceffc183d0fe43fb91fd2fd2d65eceb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:38:13 +0200 Subject: [PATCH 03/97] Add test showing startCmdWithPipe returns a nil reader on pipe failure NewCmdTask feeds the reader returned by its start func straight into a bufio.Scanner, whose Scan panics on a nil reader with a nil pointer dereference. startCmdWithPipe returns exactly that when the pipe cannot be created. Co-Authored-By: Claude Fable 5 --- pkg/gui/tasks_adapter_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 pkg/gui/tasks_adapter_test.go diff --git a/pkg/gui/tasks_adapter_test.go b/pkg/gui/tasks_adapter_test.go new file mode 100644 index 000000000..7821f835a --- /dev/null +++ b/pkg/gui/tasks_adapter_test.go @@ -0,0 +1,27 @@ +package gui + +import ( + "bytes" + "os/exec" + "testing" + + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func TestStartCmdWithPipeWhenPipeCannotBeCreated(t *testing.T) { + cmd := exec.Command("non-existent-command") + // Assigning stdout up front makes cmd.StdoutPipe fail. This happens in + // practice on the Unix pty fallback path: a failed pty start can leave + // the tty assigned to the command's stdout. + cmd.Stdout = &bytes.Buffer{} + + _, r := startCmdWithPipe(cmd, utils.NewDummyLog()) + + // NewCmdTask's scanner panics on a nil reader, so startCmdWithPipe must + // not return one even when it can't create the pipe. + /* EXPECTED: + assert.NotNil(t, r) + ACTUAL: */ + assert.Nil(t, r) +} From f000ce9f1ce6a55cb23d8e377775a4a2ded9b852 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:50:00 +0200 Subject: [PATCH 04/97] Never hand NewCmdTask a nil reader when a command fails to start NewCmdTask feeds the reader returned by its start func into a bufio.Scanner, and Scanner.Scan panics with a nil pointer dereference when that reader is nil. Two start funcs could produce one: - newPtyTask's fallback for a failed StartPty returned a literal nil reader, alongside an ExecCmd that was never started, so the intended "fall back to a plain cmd task" never worked. This crashed lazygit on Windows when using a custom pager with the main view zero-sized, e.g. after pressing + twice to enter full-screen mode with a side panel focused: ConPTY rejects zero dimensions, making StartPty fail. - startCmdWithPipe returned nil when the pipe couldn't be created, which the Unix pty fallback path can trigger, since a failed pty start can leave the tty assigned to the command's stdout. Make startCmdWithPipe never return a nil reader: when the pipe can't be created, don't start the command at all and return an empty reader so the task shuts down cleanly with the error in the log. Then route newPtyTask's fallback through it, so a StartPty failure degrades to running the command without a pty: the pager is lost, but the command's output still renders. Co-Authored-By: Claude Fable 5 --- pkg/gui/pty.go | 11 ++++++++++- pkg/gui/tasks_adapter.go | 7 +++++-- pkg/gui/tasks_adapter_test.go | 3 --- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index dbf968048..e10ea8ec7 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -100,6 +100,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error cols, rows := gui.desiredPtySize(view) var p oscommands.Pty + var fallbackPipe io.ReadCloser start := func() (tasks.Cmd, io.Reader) { // The pty (and pager) wrap to this width; apply it here, on the // task's goroutine once the previous task has stopped, so it doesn't @@ -109,7 +110,11 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error sp, err := oscommands.StartPty(cmd, cols, rows) if err != nil { gui.c.Log.Error(err) - return tasks.ExecCmd{Cmd: cmd}, nil + // Fall back to running the command without a pty: the pager is + // lost, but the command's output still renders. + execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log) + fallbackPipe = pipe + return execCmd, pipe } p = sp.Pty @@ -125,6 +130,10 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error if p != nil { p.Close() } + if fallbackPipe != nil { + fallbackPipe.Close() + fallbackPipe = nil + } delete(gui.viewPtmxMap, view.Name()) gui.Mutexes.PtyMutex.Unlock() } diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 3eb446a90..3dce93874 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -52,12 +52,15 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error // startCmdWithPipe starts cmd with its stdout and stderr going to a single // pipe, and returns the command along with the pipe's read end, in the shape -// that NewCmdTask expects from its start func. +// that NewCmdTask expects from its start func. It never returns a nil reader, +// because NewCmdTask's scanner panics on one: when the pipe can't be created +// the command isn't started at all, and an empty reader is returned so that +// the task shuts down cleanly with the error in the log. func startCmdWithPipe(cmd *exec.Cmd, log *logrus.Entry) (tasks.Cmd, io.ReadCloser) { r, err := cmd.StdoutPipe() if err != nil { log.Error(err) - r = nil + return tasks.ExecCmd{Cmd: cmd}, io.NopCloser(strings.NewReader("")) } cmd.Stderr = cmd.Stdout diff --git a/pkg/gui/tasks_adapter_test.go b/pkg/gui/tasks_adapter_test.go index 7821f835a..48c1bb45f 100644 --- a/pkg/gui/tasks_adapter_test.go +++ b/pkg/gui/tasks_adapter_test.go @@ -20,8 +20,5 @@ func TestStartCmdWithPipeWhenPipeCannotBeCreated(t *testing.T) { // NewCmdTask's scanner panics on a nil reader, so startCmdWithPipe must // not return one even when it can't create the pipe. - /* EXPECTED: assert.NotNil(t, r) - ACTUAL: */ - assert.Nil(t, r) } From c217084c90b78ff9127ec5ff2c6e6f8c7bf7545a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:59:35 +0200 Subject: [PATCH 05/97] Add test showing StartPty fails on Windows when given a zero size CreatePseudoConsole rejects zero dimensions with E_INVALIDARG, so starting a pty sized after a hidden (and thus zero-sized) view fails. Co-Authored-By: Claude Fable 5 --- pkg/commands/oscommands/pty_windows_test.go | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 pkg/commands/oscommands/pty_windows_test.go diff --git a/pkg/commands/oscommands/pty_windows_test.go b/pkg/commands/oscommands/pty_windows_test.go new file mode 100644 index 000000000..d16d3b295 --- /dev/null +++ b/pkg/commands/oscommands/pty_windows_test.go @@ -0,0 +1,28 @@ +package oscommands + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +// The requested size can legitimately be zero: the pty inherits the main +// view's dimensions, and that view is zero-sized while hidden, e.g. in +// full-screen mode with a side panel focused. +func TestStartPtyWithZeroSize(t *testing.T) { + // The command deliberately produces no output: go test runs with + // redirected std handles, which CreateProcess duplicates into the child + // in place of handles to the attached pseudoconsole, so command output + // would bypass the pty and pollute the test log. + sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 0, 0) + /* EXPECTED: + assert.NoError(t, err) + ACTUAL: */ + assert.Error(t, err) + + if err == nil { + _ = sp.Wait() + _ = sp.Pty.Close() + } +} From 02c8ba3073200f652d0152c2bdfb7da5a8af97f0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 15:22:30 +0200 Subject: [PATCH 06/97] Clamp ConPTY sizes to the 1x1 minimum that Windows accepts CreatePseudoConsole and ResizePseudoConsole reject zero dimensions with E_INVALIDARG, but we legitimately request them: the pty is sized after the main view, and that view is zero-sized while hidden, e.g. in full-screen mode with a side panel focused. Entering that mode while a custom pager is configured therefore made StartPty fail (degrading to unpaged output now that the fallback works), and resizing a live pty from onResize would fail layout. The Unix pty accepts zero sizes, so the clamp lives in the Windows implementation only. Co-Authored-By: Claude Fable 5 --- pkg/commands/oscommands/pty_windows.go | 13 +++++++++++-- pkg/commands/oscommands/pty_windows_test.go | 3 --- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index 72ade5110..eaa762ed6 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -36,7 +36,16 @@ func (p *winPty) Resize(cols, rows uint16) error { // there is nothing left to resize. return nil } - return windows.ResizePseudoConsole(p.hpc, windows.Coord{X: int16(cols), Y: int16(rows)}) + return windows.ResizePseudoConsole(p.hpc, clampPtySize(cols, rows)) +} + +// clampPtySize clamps a requested pty size to the minimum that ConPTY +// accepts: CreatePseudoConsole and ResizePseudoConsole reject zero +// dimensions with E_INVALIDARG, but callers legitimately request them — the +// pty is sized after the main view, which is zero-sized while hidden, e.g. +// in full-screen mode with a side panel focused. +func clampPtySize(cols, rows uint16) windows.Coord { + return windows.Coord{X: int16(max(cols, 1)), Y: int16(max(rows, 1))} } // closeHpc closes the pseudoconsole exactly once. Safe to call from multiple @@ -140,7 +149,7 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { // CreatePseudoConsole dupes the handles it needs internally; we release // our references to the child-side ends immediately after. var hpc windows.Handle - size := windows.Coord{X: int16(cols), Y: int16(rows)} + size := clampPtySize(cols, rows) if err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc); err != nil { _ = windows.CloseHandle(inRead) _ = windows.CloseHandle(outWrite) diff --git a/pkg/commands/oscommands/pty_windows_test.go b/pkg/commands/oscommands/pty_windows_test.go index d16d3b295..0b4173561 100644 --- a/pkg/commands/oscommands/pty_windows_test.go +++ b/pkg/commands/oscommands/pty_windows_test.go @@ -16,10 +16,7 @@ func TestStartPtyWithZeroSize(t *testing.T) { // in place of handles to the attached pseudoconsole, so command output // would bypass the pty and pollute the test log. sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 0, 0) - /* EXPECTED: assert.NoError(t, err) - ACTUAL: */ - assert.Error(t, err) if err == nil { _ = sp.Wait() From 5aa003612c9c1dba1f73173a175194b44e4dc938 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:45:45 +0200 Subject: [PATCH 07/97] Demonstrate stale focus refresh overwriting a click When clicking in the commits view of lazygit running in an unfocused VS Code window, VS Code first sends us the focus-in event and then the mouse-click. The focus-in refresh captures the selection when it starts, then we handle the mouse click and you briefly see the clicked row getting selected, but then the selection flashes back to the original row as the refresh restores it when done. --- pkg/gui/gui_driver.go | 19 ++++++++++++ pkg/integration/components/test_driver.go | 6 ++++ pkg/integration/components/test_test.go | 4 +++ pkg/integration/components/view_driver.go | 8 +++++ ..._clicked_commit_selected_after_focus_in.go | 29 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + pkg/integration/types/types.go | 3 ++ 7 files changed, 70 insertions(+) create mode 100644 pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 74a8109a7..9e06f483b 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -77,6 +77,25 @@ func (self *GuiDriver) FocusIn() { self.waitTillIdle() } +func (self *GuiDriver) FocusInAndClick(x, y int) { + self.CheckAllToastsAcknowledged() + + self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( + tcell.NewEventFocus(true), + 0, + )) + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( + tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), + 0, + )) + self.waitTillIdle() + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( + tcell.NewEventMouse(x, y, tcell.ButtonNone, 0), + 0, + )) + self.waitTillIdle() +} + func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() { self.gui.onUIThread(func() error { self.gui.State.SetMergeOrRebaseStartedInLazygit(true) diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 42ce8ac35..19219707a 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -73,6 +73,12 @@ func (self *TestDriver) FocusIn() { self.Wait(self.inputDelay) } +func (self *TestDriver) focusInAndClick(x, y int) { + self.SetCaption(fmt.Sprintf("Focusing window and clicking %d, %d", x, y)) + self.gui.FocusInAndClick(x, y) + self.Wait(self.inputDelay) +} + func (self *TestDriver) typeContent(content string) { for _, char := range content { self.pressFast(string(char)) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 8fd4417ea..7196779eb 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -41,6 +41,10 @@ func (self *fakeGuiDriver) Click(x, y int) { func (self *fakeGuiDriver) FocusIn() { } +func (self *fakeGuiDriver) FocusInAndClick(x, y int) { + self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) +} + func (self *fakeGuiDriver) Keys() config.KeybindingConfig { return config.KeybindingConfig{} } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 920c610be..2cfaba338 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -475,6 +475,14 @@ func (self *ViewDriver) Click(x, y int) *ViewDriver { return self } +func (self *ViewDriver) FocusInAndClick(x, y int) *ViewDriver { + offsetX, offsetY, _, _ := self.getView().Dimensions() + + self.t.focusInAndClick(offsetX+1+x, offsetY+1+y) + + return self +} + // i.e. pressing down arrow func (self *ViewDriver) SelectNextItem() *ViewDriver { return self.PressFast(self.t.keys.Universal.NextItem) diff --git a/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go new file mode 100644 index 000000000..c951ea125 --- /dev/null +++ b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go @@ -0,0 +1,29 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepClickedCommitSelectedAfterFocusIn = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep a clicked commit selected when focus-in immediately precedes the click", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("commit-02").IsSelected(), + Contains("commit-01"), + ). + FocusInAndClick(1, 1). + /* EXPECTED: + SelectedLine(Contains("commit-01")) + ACTUAL: */ + SelectedLine(Contains("commit-02")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 1bb06741f..07a12e2be 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -140,6 +140,7 @@ var tests = []*components.IntegrationTest{ commit.Highlight, commit.History, commit.HistoryComplex, + commit.KeepClickedCommitSelectedAfterFocusIn, commit.KeepSelectedCommitAfterExternalCommit, commit.NewBranch, commit.PasteCommitMessage, diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 12009315a..ea76c45be 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -31,6 +31,9 @@ type GuiDriver interface { // Simulate the terminal window regaining focus (which triggers a reload of // changed config files) FocusIn() + // Simulate a terminal dispatching focus-in immediately followed by a click, + // without waiting for the focus refresh to finish in between. + FocusInAndClick(int, int) Keys() config.KeybindingConfig CurrentContext() types.Context ContextForView(viewName string) types.Context From 3d9318e2a777cadf1c1e7718682ace1e8f69a606 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:48:06 +0200 Subject: [PATCH 08/97] Preserve commit clicks during focus refreshes This fixes the problem described in the previous commit; we no longer capture the selection at the start of the refresh. There's no reason to do that (we don't do it for branches either). It is enough to capture the selection in the final bounce, before we assign the new model slice. --- pkg/gui/controllers/helpers/refresh_helper.go | 26 +++++++++---------- ..._clicked_commit_selected_after_focus_in.go | 3 --- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index cf097b67e..29784b5ff 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -322,7 +322,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var capturedReflog capturedReflogState var capturedBranches capturedBranchState self.captureOnUIThread(calledFromWorker, env.background, func() { - capturedCommits = self.captureCommitsState(options.CommitSelection) + capturedCommits = self.captureCommitsState() capturedReflog = self.captureReflogState() capturedBranches = self.captureBranchState() }) @@ -704,7 +704,6 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo // worker computes from an immutable snapshot rather than reading state the UI // thread concurrently mutates. type capturedCommitState struct { - selectionRange *localCommitSelectionRange limitCommits bool showWholeGitGraph bool filterPath string @@ -716,17 +715,12 @@ type capturedCommitState struct { // captureCommitsState reads the commits refresh's model/context/mode inputs // into an immutable snapshot. It must run on the UI thread. -func (self *RefreshHelper) captureCommitsState(commitSelection types.CommitSelectionBehavior) capturedCommitState { - var selectionRange *localCommitSelectionRange - if commitSelection == types.KeepCommitSelectionByHash { - selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() - selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) - } - +// The selection is captured later, when applying the refresh, so user input +// received while the git work is in flight is not overwritten. +func (self *RefreshHelper) captureCommitsState() capturedCommitState { parentCtx := self.c.Contexts().CommitFiles.GetParentContext() return capturedCommitState{ - selectionRange: selectionRange, limitCommits: self.c.Contexts().LocalCommits.GetLimitCommits(), showWholeGitGraph: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), filterPath: self.c.Modes().Filtering.GetPath(), @@ -815,6 +809,12 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, workingTreeState := env.git.Status.WorkingTreeState() self.onUIThreadUnlessRepoChanged(env, func() { + var selectionRange *localCommitSelectionRange + if commitSelection == types.KeepCommitSelectionByHash { + selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() + selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) + } + self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits self.RefreshAuthors(commits) @@ -833,10 +833,10 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, scrollSelectionIntoView = true } case types.KeepCommitSelectionByHash: - if captured.selectionRange != nil { - selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, captured.selectionRange) + if selectionRange != nil { + selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) if found { - self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, captured.selectionRange.mode) + self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) scrollSelectionIntoView = didMove } } diff --git a/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go index c951ea125..cfd4e23c5 100644 --- a/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go +++ b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go @@ -21,9 +21,6 @@ var KeepClickedCommitSelectedAfterFocusIn = NewIntegrationTest(NewIntegrationTes Contains("commit-01"), ). FocusInAndClick(1, 1). - /* EXPECTED: SelectedLine(Contains("commit-01")) - ACTUAL: */ - SelectedLine(Contains("commit-02")) }, }) From 9d7ca51ee7ccf70cec0a91704996b566e3259f24 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 25 Jul 2026 18:36:28 +0200 Subject: [PATCH 09/97] Small addition to CONTRIBUTING.md --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49a9a625c..04095ba77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,5 +30,6 @@ There are other forms of contributions to a project besides source code that are - File feature requests for new functionality that you want to see in lazygit. I have a lot of ideas for future improvement myself, but I have also implemented a lot of feature ideas that weren't mine, and I'm grateful for those ideas. (Of course, there are also lots of feature requests that I don't implement, so don't be disappointed if I don't jump on yours.) - Help make other people's bug reports reproducible. Sometimes people report bugs that they have only seen once, and in such a case it can be helpful to come up with reproducible scenarios. - Help complete or improve the translation into other languages; join https://crowdin.com/project/lazygit for that. +- Run a master build! This is probably the most valuable way to help me. Test the latest master not just by occasionally trying it, but by actually using it for your daily work; report any issues that you find. This will help prevent having to release hotfix updates for regressions that are only noticed by users updating to a new release. Importantly, if you file issues (whether bug reports or feature requests), stay around to answer questions and discuss your issue. There are few things that I find more annoying than spending time on responding to someone's issue (sometimes even making a PR that addresses it), and to then never hear from the OP again. So please set up your Github notifications so that you see when there's activity on your issue, and continue to participate. From 6a022241d2aecc852af7be4faa640dee5e9472f9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 28 Jul 2026 17:57:24 +0200 Subject: [PATCH 10/97] Add test to demonstrate a problem with custom patches and directories sharing a prefix We had the same bug in the files panel, and fixed it in a5eec48b4b8, but forgot to make the equivalent change to the commit files panel. --- .../select_direcories_sharing_prefix.go | 59 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 60 insertions(+) create mode 100644 pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go diff --git a/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go b/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go new file mode 100644 index 000000000..2c0059dd0 --- /dev/null +++ b/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go @@ -0,0 +1,59 @@ +package patch_building + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectDirecoriesSharingPrefix = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Select directories sharing a prefix in the commit files view and add them to a custom patch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("foo/file", "file1 content") + shell.CreateFileAndAdd("foobar/file", "file2 content") + shell.Commit("first commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("first commit").IsSelected(), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" ▼ foo"), + Equals(" A file"), + Equals(" ▼ foobar"), + Equals(" A file"), + ). + SelectNextItem(). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("foobar")). + PressPrimaryAction(). + Lines( + Equals("▼ /"), + Equals(" ▼ foo").IsSelected(), + Equals(" ● file").IsSelected(), + Equals(" ▼ foobar").IsSelected(), + /* EXPECTED: + Equals(" ● file"), + ACTUAL: */ + Equals(" A file"), + ) + + t.Views().Information().Content(Contains("Building patch")) + + t.Views().Secondary().Content( + /* EXPECTED: + Contains("foo/file").Contains("foobar/file"), + ACTUAL: */ + Contains("foo/file").DoesNotContain("foobar/file"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 07a12e2be..77e54e265 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -382,6 +382,7 @@ var tests = []*components.IntegrationTest{ patch_building.RenamedFileWhole, patch_building.ResetWithEscape, patch_building.SelectAllFiles, + patch_building.SelectDirecoriesSharingPrefix, patch_building.SpecificSelection, patch_building.StartNewPatch, patch_building.ToggleDirectory, From 8fefe2b9335cb0879b24327eff4296543b474ac8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 28 Jul 2026 17:37:30 +0200 Subject: [PATCH 11/97] Cleanup: move variable assignment out of the loop It never changes inside this function, so there's no need to recompute it with every loop iteration. Equivalent to the change that was made to isDescendentOfSelectedNodes in files_controller.go in d0c6e27fee9b4. --- pkg/gui/controllers/commits_files_controller.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index d129b3f90..5d4377fe1 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -642,9 +642,10 @@ func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) } func isDescendentOfSelectedCommitFileNodes(node *filetree.CommitFileNode, selectedNodes []*filetree.CommitFileNode) bool { + nodePath := node.GetPath() + for _, selectedNode := range selectedNodes { selectedNodePath := selectedNode.GetPath() - nodePath := node.GetPath() if strings.HasPrefix(nodePath, selectedNodePath) && nodePath != selectedNodePath { return true From c7acf383990273b03cea012e4d14ab940c73333e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 28 Jul 2026 17:40:03 +0200 Subject: [PATCH 12/97] Make isDescendentOfSelectedCommitFileNodes work for the root item The root item's path is ".", and the path of a file at top level is "./file". When using GetPath, this gives us "." and "file", respectively, and isDescendentOfSelectedCommitFileNodes would return false for these. Working with the internal paths (i.e. without stripping the leading "./") fixes this. There is no known breakage that is caused by this, that's why I'm not adding an integration test that demonstrates a bug. Equivalent to the change that was made to isDescendentOfSelectedNodes in files_controller.go in 302b621b681. --- pkg/gui/controllers/commits_files_controller.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 5d4377fe1..da24b155c 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -642,10 +642,10 @@ func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) } func isDescendentOfSelectedCommitFileNodes(node *filetree.CommitFileNode, selectedNodes []*filetree.CommitFileNode) bool { - nodePath := node.GetPath() + nodePath := node.GetInternalPath() for _, selectedNode := range selectedNodes { - selectedNodePath := selectedNode.GetPath() + selectedNodePath := selectedNode.GetInternalPath() if strings.HasPrefix(nodePath, selectedNodePath) && nodePath != selectedNodePath { return true From 1d107721f2b0c063913fc80b3f1bedd00fa01e50 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 28 Jul 2026 17:45:40 +0200 Subject: [PATCH 13/97] Fix multi-selection of files with common prefix not working in commit files panel Equivalent to the change that was made to isDescendentOfSelectedNodes in files_controller.go in a5eec48b4b8. --- pkg/gui/controllers/commits_files_controller.go | 6 +++++- pkg/gui/controllers/files_controller.go | 2 ++ .../patch_building/select_direcories_sharing_prefix.go | 6 ------ 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index da24b155c..6c8f49e1f 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -645,9 +645,13 @@ func isDescendentOfSelectedCommitFileNodes(node *filetree.CommitFileNode, select nodePath := node.GetInternalPath() for _, selectedNode := range selectedNodes { + if selectedNode.IsFile() { + continue + } + selectedNodePath := selectedNode.GetInternalPath() - if strings.HasPrefix(nodePath, selectedNodePath) && nodePath != selectedNodePath { + if strings.HasPrefix(nodePath, selectedNodePath+"/") { return true } } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 656da1bf5..567b0b6e5 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1552,6 +1552,8 @@ func normalisedSelectedNodes(selectedNodes []*filetree.FileNode) []*filetree.Fil }) } +// NOTE: there's a duplicate of this function in commits_files_controller.go; if you make +// changes here, make them there, too. (We should unify them using generics.) func isDescendentOfSelectedNodes(node *filetree.FileNode, selectedNodes []*filetree.FileNode) bool { nodePath := node.GetInternalPath() diff --git a/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go b/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go index 2c0059dd0..bd20e4ff5 100644 --- a/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go +++ b/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go @@ -41,19 +41,13 @@ var SelectDirecoriesSharingPrefix = NewIntegrationTest(NewIntegrationTestArgs{ Equals(" ▼ foo").IsSelected(), Equals(" ● file").IsSelected(), Equals(" ▼ foobar").IsSelected(), - /* EXPECTED: Equals(" ● file"), - ACTUAL: */ - Equals(" A file"), ) t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content( - /* EXPECTED: Contains("foo/file").Contains("foobar/file"), - ACTUAL: */ - Contains("foo/file").DoesNotContain("foobar/file"), ) }, }) From f15f713c689cb54faf7dc722bbfc00455884a4ab Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 30 Jul 2026 17:31:56 +0200 Subject: [PATCH 14/97] Support absolute paths when detecting edit preset from EDITOR env var --- pkg/commands/git_commands/file.go | 3 ++- pkg/commands/git_commands/file_test.go | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/commands/git_commands/file.go b/pkg/commands/git_commands/file.go index 1b5f5b2dd..00f46e821 100644 --- a/pkg/commands/git_commands/file.go +++ b/pkg/commands/git_commands/file.go @@ -2,6 +2,7 @@ package git_commands import ( "os" + "path/filepath" "strconv" "strings" @@ -93,7 +94,7 @@ func (self *FileCommands) guessDefaultEditor() string { // At this point, it might be more than just the name of the editor; // e.g. it might be "code -w" or "vim -u myvim.rc". So assume that // everything up to the first space is the editor name. - editor = strings.Split(editor, " ")[0] + editor = filepath.Base(strings.Split(editor, " ")[0]) } return editor diff --git a/pkg/commands/git_commands/file_test.go b/pkg/commands/git_commands/file_test.go index 5dd3d133c..8f91f797c 100644 --- a/pkg/commands/git_commands/file_test.go +++ b/pkg/commands/git_commands/file_test.go @@ -203,6 +203,17 @@ func TestGuessDefaultEditor(t *testing.T) { }, expectedResult: "bbedit", }, + { + gitConfigMockResponses: nil, + getenv: func(env string) string { + if env == "EDITOR" { + return "/usr/bin/nvim" + } + + return "" + }, + expectedResult: "nvim", + }, } for _, s := range scenarios { From 67c81c5b148d6501b1dac31f04c02f3b7043ae23 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 13:12:59 +0200 Subject: [PATCH 15/97] Change default spinner frames and rate I find that this looks a little nicer, and it reduces CPU load by not spinning so fast. --- docs-master/Config.md | 10 +++++----- pkg/config/user_config.go | 4 ++-- pkg/gui/presentation/branches_test.go | 10 +++++----- schema-master/config.json | 10 +++++----- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 1d101be18..b5bf09088 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -336,13 +336,13 @@ gui: spinner: # The frames of the spinner animation. frames: - - '|' - - / - - '-' - - \ + - ●∙∙ + - ∙●∙ + - ∙∙● + - ∙●∙ # The "speed" of the spinner in milliseconds. - rate: 50 + rate: 180 # Status panel view. # One of 'dashboard' (default) | 'allBranchesLog' diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 30ce0377d..11c7bcf5f 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -922,8 +922,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { PortraitModeAutoMinHeight: 46, FilterMode: "substring", Spinner: SpinnerConfig{ - Frames: []string{"|", "/", "-", "\\"}, - Rate: 50, + Frames: []string{"●∙∙", "∙●∙", "∙∙●", "∙●∙"}, + Rate: 180, }, StatusPanelView: "dashboard", SwitchToFilesAfterStashPop: true, diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go index b2c19a9ea..339f57cc3 100644 --- a/pkg/gui/presentation/branches_test.go +++ b/pkg/gui/presentation/branches_test.go @@ -162,7 +162,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", - expected: []string{"1m", "", "branch_name Pushing |"}, + expected: []string{"1m", "", "branch_name Pushing ●∙∙"}, }, { branch: &models.Branch{ @@ -282,7 +282,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", - expected: []string{"1m", "", "branc… Pushing |"}, + expected: []string{"1m", "", "bra… Pushing ●∙∙"}, }, { branch: &models.Branch{Name: "abc", Recency: "1m"}, @@ -292,7 +292,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", - expected: []string{"1m", "", "abc Pushing |"}, + expected: []string{"1m", "", "abc Pushing ●∙∙"}, }, { branch: &models.Branch{Name: "ab", Recency: "1m"}, @@ -302,7 +302,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", - expected: []string{"1m", "", "ab Pushing |"}, + expected: []string{"1m", "", "ab Pushing ●∙∙"}, }, { branch: &models.Branch{Name: "a", Recency: "1m"}, @@ -312,7 +312,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", - expected: []string{"1m", "", "a Pushing |"}, + expected: []string{"1m", "", "a Pushing ●∙∙"}, }, { branch: &models.Branch{ diff --git a/schema-master/config.json b/schema-master/config.json index 82dbebb0b..6ff13b5d0 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -3631,17 +3631,17 @@ "type": "array", "description": "The frames of the spinner animation.", "default": [ - "|", - "/", - "-", - "\\" + "●∙∙", + "∙●∙", + "∙∙●", + "∙●∙" ] }, "rate": { "type": "integer", "minimum": 1, "description": "The \"speed\" of the spinner in milliseconds.", - "default": 50 + "default": 180 } }, "additionalProperties": false, From 22db808a017f782a8620f5665437e12791da8b29 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 25 Jul 2026 08:50:00 +0200 Subject: [PATCH 16/97] Add a helper function for formatting list section headers We want to change their design, and we want to do it only in one place. --- pkg/gui/context/list_renderer.go | 5 +++++ pkg/gui/context/local_commits_context.go | 7 +++---- pkg/gui/context/menu_context.go | 2 +- pkg/gui/context/sub_commits_context.go | 5 ++--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/pkg/gui/context/list_renderer.go b/pkg/gui/context/list_renderer.go index b8d036778..35e0b26ef 100644 --- a/pkg/gui/context/list_renderer.go +++ b/pkg/gui/context/list_renderer.go @@ -1,6 +1,7 @@ package context import ( + "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -9,6 +10,10 @@ import ( "golang.org/x/exp/slices" ) +func formatListSectionHeader(label string) string { + return fmt.Sprintf("--- %s ---", label) +} + type NonModelItem struct { // Where in the model this should be inserted Index int diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index a66c720c9..034b2434e 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -1,7 +1,6 @@ package context import ( - "fmt" "log" "strings" "sync/atomic" @@ -72,7 +71,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { if c.Model().WorkingTreeStateAtLastCommitRefresh.Rebasing { result = append(result, &NonModelItem{ Index: 0, - Content: fmt.Sprintf("--- %s ---", c.Tr.PendingRebaseTodosSectionHeader), + Content: formatListSectionHeader(c.Tr.PendingRebaseTodosSectionHeader), }) } @@ -91,7 +90,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { c.Tr.PendingRevertsSectionHeader) result = append(result, &NonModelItem{ Index: firstCherryPickOrRevertTodo, - Content: fmt.Sprintf("--- %s ---", label), + Content: formatListSectionHeader(label), }) } @@ -104,7 +103,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { } result = append(result, &NonModelItem{ Index: firstRealCommit, - Content: fmt.Sprintf("--- %s ---", c.Tr.CommitsSectionHeader), + Content: formatListSectionHeader(c.Tr.CommitsSectionHeader), }) } diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 9feef1e4c..8129aa420 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -198,7 +198,7 @@ func (self *MenuViewModel) GetNonModelItems() []*NonModelItem { result = append(result, &NonModelItem{ Index: i, Column: 1, - Content: style.FgGreen.SetBold().Sprintf("--- %s ---", menuItem.Section.Title), + Content: style.FgGreen.SetBold().Sprint(formatListSectionHeader(menuItem.Section.Title)), }) prevSection = menuItem.Section } diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index 4e05c9594..b0bcee30a 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -1,7 +1,6 @@ package context import ( - "fmt" "time" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -90,7 +89,7 @@ func NewSubCommitsContext( } result = append(result, &NonModelItem{ Index: upstreamIdx, - Content: fmt.Sprintf("--- %s ---", c.Tr.DivergenceSectionHeaderRemote), + Content: formatListSectionHeader(c.Tr.DivergenceSectionHeaderRemote), }) _, localIdx, found := lo.FindIndexOf( @@ -100,7 +99,7 @@ func NewSubCommitsContext( } result = append(result, &NonModelItem{ Index: localIdx, - Content: fmt.Sprintf("--- %s ---", c.Tr.DivergenceSectionHeaderLocal), + Content: formatListSectionHeader(c.Tr.DivergenceSectionHeaderLocal), }) } From 31cbabe48f4a10fd795c8edfafa6bedb33d0d9cc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 13:43:53 +0200 Subject: [PATCH 17/97] Change list section headers to a slightly more elegant design I'm not a skilled UI designer, so I suspect there may be even better options, but it's definitely already better than the raw ASCII "---" we had before. Put the line only at the beginning because it looks bad if the line after the label is misaligned when labels don't have the same width (e.g. "Remote" vs. "Local" in the divergence view). --- pkg/gui/context/list_renderer.go | 2 +- .../tests/branch/rebase_and_drop.go | 8 +-- .../show_divergence_from_base_branch.go | 4 +- .../branch/show_divergence_from_upstream.go | 4 +- ..._divergence_from_upstream_no_divergence.go | 4 +- .../cherry_pick/cherry_pick_during_rebase.go | 8 +-- ...mend_when_there_are_conflicts_and_amend.go | 4 +- ...end_when_there_are_conflicts_and_cancel.go | 4 +- .../revert_with_conflict_multiple_commits.go | 4 +- .../revert_with_conflict_single_commit.go | 4 +- pkg/integration/tests/commit/shared.go | 4 +- .../tests/filter_and_search/filter_menu.go | 2 +- .../filter_menu_by_keybinding.go | 2 +- .../filter_menu_cancel_filter_with_escape.go | 2 +- .../filter_menu_with_no_keybindings.go | 2 +- .../advanced_interactive_rebase.go | 12 ++--- .../amend_commit_with_conflict.go | 8 +-- .../amend_head_commit_during_rebase.go | 8 +-- .../amend_non_head_commit_during_rebase.go | 4 +- .../delete_update_ref_todo.go | 8 +-- .../dont_show_branch_heads_for_todo_items.go | 4 +- .../drop_todo_commit_with_update_ref.go | 4 +- .../interactive_rebase/edit_and_auto_amend.go | 4 +- .../interactive_rebase/edit_first_commit.go | 4 +- .../edit_last_commit_of_stacked_branch.go | 4 +- .../edit_non_todo_commit_during_rebase.go | 4 +- ...nge_select_down_to_merge_outside_rebase.go | 4 +- .../edit_range_select_outside_rebase.go | 4 +- .../edit_the_confl_commit.go | 4 +- .../fixup_keep_message_rebase.go | 12 ++--- .../interactive_rebase_of_copied_branch.go | 4 +- ...e_rebase_with_conflict_for_edit_command.go | 4 +- .../mid_rebase_range_select.go | 52 +++++++++---------- .../interactive_rebase/move_in_rebase.go | 32 ++++++------ .../move_todo_down_with_rapid_keypresses.go | 4 +- .../move_update_ref_todo.go | 8 +-- .../interactive_rebase/pick_rescheduled.go | 8 +-- .../tests/interactive_rebase/quick_start.go | 8 +-- .../quick_start_keep_selection.go | 4 +- .../quick_start_keep_selection_range.go | 4 +- .../tests/interactive_rebase/rebase.go | 24 ++++----- ...vert_during_rebase_when_stopped_on_edit.go | 8 +-- ..._multiple_commits_in_interactive_rebase.go | 10 ++-- ...ert_single_commit_in_interactive_rebase.go | 10 ++-- .../reword_commit_with_editor_and_fail.go | 4 +- .../reword_you_are_here_commit.go | 8 +-- .../reword_you_are_here_commit_with_editor.go | 8 +-- .../tests/interactive_rebase/shared.go | 4 +- .../interactive_rebase/show_exec_todos.go | 8 +-- .../swap_in_rebase_with_conflict.go | 8 +-- .../swap_in_rebase_with_conflict_and_edit.go | 8 +-- .../view_files_of_todo_entries.go | 4 +- .../sync/pull_rebase_interactive_conflict.go | 4 +- .../pull_rebase_interactive_conflict_drop.go | 8 +-- 54 files changed, 195 insertions(+), 195 deletions(-) diff --git a/pkg/gui/context/list_renderer.go b/pkg/gui/context/list_renderer.go index 35e0b26ef..59a128457 100644 --- a/pkg/gui/context/list_renderer.go +++ b/pkg/gui/context/list_renderer.go @@ -11,7 +11,7 @@ import ( ) func formatListSectionHeader(label string) string { - return fmt.Sprintf("--- %s ---", label) + return fmt.Sprintf("─── %s", label) } type NonModelItem struct { diff --git a/pkg/integration/tests/branch/rebase_and_drop.go b/pkg/integration/tests/branch/rebase_and_drop.go index ff17d6417..2e8ef5c39 100644 --- a/pkg/integration/tests/branch/rebase_and_drop.go +++ b/pkg/integration/tests/branch/rebase_and_drop.go @@ -53,11 +53,11 @@ var RebaseAndDrop = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp(`pick.*to keep`).IsSelected(), MatchesRegexp(`pick.*to remove`), MatchesRegexp(`pick.*CONFLICT.*first change`), - Contains("--- Commits ---"), + Contains("─── Commits"), MatchesRegexp("second-change-branch unrelated change"), MatchesRegexp("second change"), MatchesRegexp("original"), @@ -65,11 +65,11 @@ var RebaseAndDrop = NewIntegrationTest(NewIntegrationTestArgs{ SelectNextItem(). Press(keys.Universal.Remove). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp(`pick.*to keep`), MatchesRegexp(`drop.*to remove`).IsSelected(), MatchesRegexp(`pick.*CONFLICT.*first change`), - Contains("--- Commits ---"), + Contains("─── Commits"), MatchesRegexp("second-change-branch unrelated change"), MatchesRegexp("second change"), MatchesRegexp("original"), diff --git a/pkg/integration/tests/branch/show_divergence_from_base_branch.go b/pkg/integration/tests/branch/show_divergence_from_base_branch.go index 2903b7837..1ff9edca0 100644 --- a/pkg/integration/tests/branch/show_divergence_from_base_branch.go +++ b/pkg/integration/tests/branch/show_divergence_from_base_branch.go @@ -37,9 +37,9 @@ var ShowDivergenceFromBaseBranch = NewIntegrationTest(NewIntegrationTestArgs{ IsFocused(). Title(Contains("Commits (feature <-> master)")). Lines( - DoesNotContainAnyOf("↓", "↑").Contains("--- Remote ---"), + DoesNotContainAnyOf("↓", "↑").Contains("─── Remote"), Contains("↓").Contains("master 3"), - DoesNotContainAnyOf("↓", "↑").Contains("--- Local ---"), + DoesNotContainAnyOf("↓", "↑").Contains("─── Local"), Contains("↑").Contains("feature 2"), Contains("↑").Contains("feature 1"), ) diff --git a/pkg/integration/tests/branch/show_divergence_from_upstream.go b/pkg/integration/tests/branch/show_divergence_from_upstream.go index 8aff21ca9..91e068cef 100644 --- a/pkg/integration/tests/branch/show_divergence_from_upstream.go +++ b/pkg/integration/tests/branch/show_divergence_from_upstream.go @@ -44,10 +44,10 @@ var ShowDivergenceFromUpstream = NewIntegrationTest(NewIntegrationTestArgs{ IsFocused(). Title(Contains("Commits (master <-> origin/master)")). Lines( - DoesNotContainAnyOf("↓", "↑").Contains("--- Remote ---"), + DoesNotContainAnyOf("↓", "↑").Contains("─── Remote"), Contains("↓").Contains("three"), Contains("↓").Contains("two"), - DoesNotContainAnyOf("↓", "↑").Contains("--- Local ---"), + DoesNotContainAnyOf("↓", "↑").Contains("─── Local"), Contains("↑").Contains("four"), ) }, diff --git a/pkg/integration/tests/branch/show_divergence_from_upstream_no_divergence.go b/pkg/integration/tests/branch/show_divergence_from_upstream_no_divergence.go index 5b446fcb8..ced13ed20 100644 --- a/pkg/integration/tests/branch/show_divergence_from_upstream_no_divergence.go +++ b/pkg/integration/tests/branch/show_divergence_from_upstream_no_divergence.go @@ -27,8 +27,8 @@ var ShowDivergenceFromUpstreamNoDivergence = NewIntegrationTest(NewIntegrationTe IsFocused(). Title(Contains("Commits (master <-> origin/master)")). Lines( - Contains("--- Remote ---"), - Contains("--- Local ---"), + Contains("─── Remote"), + Contains("─── Local"), ) }, }) diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go b/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go index a14dbe7c9..e46626360 100644 --- a/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go +++ b/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go @@ -60,9 +60,9 @@ var CherryPickDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ SelectNextItem(). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick CI two"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(" CI one").IsSelected(), Contains(" CI base"), ). @@ -77,9 +77,9 @@ var CherryPickDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Information().Content(DoesNotContain("commit copied")) }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick CI two"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(" CI three"), Contains(" CI one").IsSelected(), Contains(" CI base"), diff --git a/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_amend.go b/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_amend.go index acc2f389c..7b8bf1ca1 100644 --- a/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_amend.go +++ b/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_amend.go @@ -29,10 +29,10 @@ var AmendWhenThereAreConflictsAndAmend = NewIntegrationTest(NewIntegrationTestAr t.Views().Commits(). Focus(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit three"), Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit two"), Contains("file1 changed in master"), Contains("base commit"), diff --git a/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_cancel.go b/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_cancel.go index f7f5ec2e1..91eff7d59 100644 --- a/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_cancel.go +++ b/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_cancel.go @@ -33,10 +33,10 @@ var AmendWhenThereAreConflictsAndCancel = NewIntegrationTest(NewIntegrationTestA t.Views().Commits(). Focus(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit three"), Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit two"), Contains("file1 changed in master"), Contains("base commit"), diff --git a/pkg/integration/tests/commit/revert_with_conflict_multiple_commits.go b/pkg/integration/tests/commit/revert_with_conflict_multiple_commits.go index cbbd8accd..6e25a8496 100644 --- a/pkg/integration/tests/commit/revert_with_conflict_multiple_commits.go +++ b/pkg/integration/tests/commit/revert_with_conflict_multiple_commits.go @@ -44,10 +44,10 @@ var RevertWithConflictMultipleCommits = NewIntegrationTest(NewIntegrationTestArg Confirm() }). Lines( - Contains("--- Pending reverts ---"), + Contains("─── Pending reverts"), Contains("revert").Contains("CI unrelated change"), Contains("revert").Contains("CI <-- CONFLICT --- add first line"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ add second line"), Contains("CI ○ add first line"), Contains("CI ○ unrelated change"), diff --git a/pkg/integration/tests/commit/revert_with_conflict_single_commit.go b/pkg/integration/tests/commit/revert_with_conflict_single_commit.go index 1a0669b10..57679160d 100644 --- a/pkg/integration/tests/commit/revert_with_conflict_single_commit.go +++ b/pkg/integration/tests/commit/revert_with_conflict_single_commit.go @@ -39,9 +39,9 @@ var RevertWithConflictSingleCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending reverts ---"), + Contains("─── Pending reverts"), Contains("revert").Contains("CI <-- CONFLICT --- add first line"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ add second line"), Contains("CI ○ add first line"), Contains("CI ○ add empty file"), diff --git a/pkg/integration/tests/commit/shared.go b/pkg/integration/tests/commit/shared.go index 918197aaf..e143eb7a0 100644 --- a/pkg/integration/tests/commit/shared.go +++ b/pkg/integration/tests/commit/shared.go @@ -43,10 +43,10 @@ func doTheRebaseForAmendTests(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit three"), Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit two"), Contains("file1 changed in master"), Contains("base commit"), diff --git a/pkg/integration/tests/filter_and_search/filter_menu.go b/pkg/integration/tests/filter_and_search/filter_menu.go index e5b6b216e..9b6eed8a1 100644 --- a/pkg/integration/tests/filter_and_search/filter_menu.go +++ b/pkg/integration/tests/filter_and_search/filter_menu.go @@ -26,7 +26,7 @@ var FilterMenu = NewIntegrationTest(NewIntegrationTestArgs{ Filter("Ignore"). Lines( // menu has filtered down to the one item that matches the filter - Contains(`--- Local ---`), + Contains(`─── Local`), Contains(`Ignore`).IsSelected(), ). Confirm() diff --git a/pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go b/pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go index 957ca5c6a..54105b77e 100644 --- a/pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go +++ b/pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go @@ -21,7 +21,7 @@ var FilterMenuByKeybinding = NewIntegrationTest(NewIntegrationTestArgs{ Filter("@_"). Lines( // menu has filtered down to the one item that matches the filter - Contains("--- Global ---"), + Contains("─── Global"), Contains("_ Prev screen mode").IsSelected(), ). Confirm() diff --git a/pkg/integration/tests/filter_and_search/filter_menu_cancel_filter_with_escape.go b/pkg/integration/tests/filter_and_search/filter_menu_cancel_filter_with_escape.go index daf55fd0d..2ed81926e 100644 --- a/pkg/integration/tests/filter_and_search/filter_menu_cancel_filter_with_escape.go +++ b/pkg/integration/tests/filter_and_search/filter_menu_cancel_filter_with_escape.go @@ -20,7 +20,7 @@ var FilterMenuCancelFilterWithEscape = NewIntegrationTest(NewIntegrationTestArgs Filter("Ignore"). Lines( // menu has filtered down to the one item that matches the filter - Contains(`--- Local ---`), + Contains(`─── Local`), Contains(`Ignore`).IsSelected(), ) diff --git a/pkg/integration/tests/filter_and_search/filter_menu_with_no_keybindings.go b/pkg/integration/tests/filter_and_search/filter_menu_with_no_keybindings.go index 522ee7689..66718e822 100644 --- a/pkg/integration/tests/filter_and_search/filter_menu_with_no_keybindings.go +++ b/pkg/integration/tests/filter_and_search/filter_menu_with_no_keybindings.go @@ -25,7 +25,7 @@ var FilterMenuWithNoKeybindings = NewIntegrationTest(NewIntegrationTestArgs{ Lines( // menu has filtered down to the one item that matches the // filter, and it doesn't have a keybinding - Equals("--- Global ---"), + Equals("─── Global"), Equals("Toggle whitespace").IsSelected(), ) }, diff --git a/pkg/integration/tests/interactive_rebase/advanced_interactive_rebase.go b/pkg/integration/tests/interactive_rebase/advanced_interactive_rebase.go index 1162a20c7..4936fc38e 100644 --- a/pkg/integration/tests/interactive_rebase/advanced_interactive_rebase.go +++ b/pkg/integration/tests/interactive_rebase/advanced_interactive_rebase.go @@ -45,25 +45,25 @@ var AdvancedInteractiveRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains(TOP_COMMIT), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(BASE_COMMIT), ). NavigateToLine(Contains(TOP_COMMIT)). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains(TOP_COMMIT).Contains("edit"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(BASE_COMMIT), ). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("--- Pending rebase todos ---"), - Contains("--- Commits ---"), + Contains("─── Pending rebase todos"), + Contains("─── Commits"), Contains(TOP_COMMIT), Contains(BASE_COMMIT), ) diff --git a/pkg/integration/tests/interactive_rebase/amend_commit_with_conflict.go b/pkg/integration/tests/interactive_rebase/amend_commit_with_conflict.go index ef01739dc..497db50d7 100644 --- a/pkg/integration/tests/interactive_rebase/amend_commit_with_conflict.go +++ b/pkg/integration/tests/interactive_rebase/amend_commit_with_conflict.go @@ -34,10 +34,10 @@ var AmendCommitWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Common().AcknowledgeConflicts() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("three"), Contains("fixup").Contains("<-- CONFLICT --- fixup! two"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("two"), Contains("one"), ) @@ -68,9 +68,9 @@ var AmendCommitWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("<-- CONFLICT --- three"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("two"), Contains("one"), ) diff --git a/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go b/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go index 0ca3ffaaa..9663a23e5 100644 --- a/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go +++ b/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go @@ -24,9 +24,9 @@ var AmendHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ) @@ -50,9 +50,9 @@ var AmendHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go b/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go index 3e4df1404..740407dd5 100644 --- a/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go +++ b/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go @@ -24,9 +24,9 @@ var AmendNonHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02"), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go b/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go index 86b2ed950..1ae68666d 100644 --- a/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go +++ b/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go @@ -26,14 +26,14 @@ var DeleteUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-06"), Contains("pick").Contains("CI commit-05"), Contains("pick").Contains("CI commit-04"), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit-03"), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ commit-01"), ). NavigateToLine(Contains("update-ref")). @@ -45,13 +45,13 @@ var DeleteUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-06"), Contains("pick").Contains("CI commit-05"), Contains("pick").Contains("CI commit-04"), Contains("pick").Contains("CI commit-03").IsSelected(), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ commit-01"), ). NavigateToLine(Contains("commit-02")). diff --git a/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go b/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go index 1b8674593..da6f9dd85 100644 --- a/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go +++ b/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go @@ -41,14 +41,14 @@ var DontShowBranchHeadsForTodoItems = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-04")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-09"), Contains("pick").Contains("CI commit-08"), Contains("pick").Contains("CI commit-07"), Contains("update-ref").Contains("branch2"), Contains("pick").Contains("CI commit-06"), // no star on this entry, even though branch2 points to it Contains("pick").Contains("CI commit-05"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-04"), Contains("CI commit-03"), Contains("CI * commit-02"), // this star is fine though diff --git a/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go b/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go index 9fb450afe..90208c95d 100644 --- a/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go +++ b/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go @@ -39,14 +39,14 @@ var DropTodoCommitWithUpdateRef = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-07"), Contains("pick").Contains("CI commit-06"), Contains("pick").Contains("CI commit-05"), Contains("update-ref").Contains("branch1").DoesNotContain("*"), Contains("pick").Contains("CI commit-04"), Contains("pick").Contains("CI commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-02").IsSelected(), Contains("CI commit-01"), ). diff --git a/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go b/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go index 3045a5088..c340c83f3 100644 --- a/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go +++ b/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go @@ -25,9 +25,9 @@ var EditAndAutoAmend = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/edit_first_commit.go b/pkg/integration/tests/interactive_rebase/edit_first_commit.go index 2ba657370..8eca4e772 100644 --- a/pkg/integration/tests/interactive_rebase/edit_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/edit_first_commit.go @@ -24,9 +24,9 @@ var EditFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01").IsSelected(), ). Tap(func() { diff --git a/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go b/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go index 7db9bb262..87cdf4be1 100644 --- a/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go +++ b/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go @@ -37,11 +37,11 @@ var EditLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-03")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-05"), Contains("pick").Contains("CI commit-04"), Contains("update-ref").Contains("branch1"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI * commit-03").IsSelected(), Contains("CI commit-02"), Contains("CI commit-01"), diff --git a/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go b/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go index 00f77594e..bedad3ec9 100644 --- a/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go +++ b/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go @@ -23,8 +23,8 @@ var EditNonTodoCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), - Contains("--- Commits ---"), + Contains("─── Pending rebase todos"), + Contains("─── Commits"), Contains("commit-02"), Contains("commit-01"), ). diff --git a/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go b/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go index a57ab8acd..e5de3ab38 100644 --- a/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go +++ b/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go @@ -27,10 +27,10 @@ var EditRangeSelectDownToMergeOutsideRebase = NewIntegrationTest(NewIntegrationT Press(keys.Universal.RangeSelectDown). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("edit CI commit-02").IsSelected(), Contains("edit CI commit-01").IsSelected(), - Contains("--- Commits ---").IsSelected(), + Contains("─── Commits").IsSelected(), Contains(" CI ◎─╮ Merge branch 'second-change-branch' into first-change-branch").IsSelected(), Contains(" CI │ ○ * second-change-branch unrelated change"), Contains(" CI │ ○ second change"), diff --git a/pkg/integration/tests/interactive_rebase/edit_range_select_outside_rebase.go b/pkg/integration/tests/interactive_rebase/edit_range_select_outside_rebase.go index f39ab638c..f38703fc2 100644 --- a/pkg/integration/tests/interactive_rebase/edit_range_select_outside_rebase.go +++ b/pkg/integration/tests/interactive_rebase/edit_range_select_outside_rebase.go @@ -37,13 +37,13 @@ var EditRangeSelectOutsideRebase = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("merge CI Merge branch 'second-change-branch' into first-change-branch").IsSelected(), Contains("edit CI first change").IsSelected(), Contains("edit CI * second-change-branch unrelated change").IsSelected(), Contains("edit CI second change").IsSelected(), Contains("edit CI * original").IsSelected(), - Contains("--- Commits ---").IsSelected(), + Contains("─── Commits").IsSelected(), Contains(" CI ○ three").IsSelected(), Contains(" CI ○ two"), Contains(" CI ○ one"), diff --git a/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go b/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go index 5e03acdd5..81ddf685a 100644 --- a/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go +++ b/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go @@ -32,10 +32,10 @@ var EditTheConflCommit = NewIntegrationTest(NewIntegrationTestArgs{ }). Focus(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit two"), Contains("pick").Contains("<-- CONFLICT --- commit three"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit one"), ). NavigateToLine(Contains("<-- CONFLICT --- commit three")). diff --git a/pkg/integration/tests/interactive_rebase/fixup_keep_message_rebase.go b/pkg/integration/tests/interactive_rebase/fixup_keep_message_rebase.go index 595968c17..e040905fe 100644 --- a/pkg/integration/tests/interactive_rebase/fixup_keep_message_rebase.go +++ b/pkg/integration/tests/interactive_rebase/fixup_keep_message_rebase.go @@ -28,20 +28,20 @@ var FixupKeepMessageRebase = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("First Commit")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick CI Third Commit"), Contains("pick CI Second Commit"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("First Commit").IsSelected(), ). // Mark second commit as fixup NavigateToLine(Contains("Second Commit")). Press(keys.Commits.MarkCommitAsFixup). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick CI Third Commit"), Contains("fixup CI Second Commit").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("First Commit"), ). // Now set the -C flag using the SetFixupMessage keybinding @@ -53,10 +53,10 @@ var FixupKeepMessageRebase = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick CI Third Commit"), Contains("fixup -C CI Second Commit").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("First Commit"), ). // Continue the rebase diff --git a/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go b/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go index de0bb28d0..d0053a5c7 100644 --- a/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go +++ b/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go @@ -32,11 +32,11 @@ var InteractiveRebaseOfCopiedBranch = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), // No update-ref todo for branch1 here, even though command-line git would have added it Contains("pick").Contains("CI commit-03"), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-01"), ) }, diff --git a/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go b/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go index 5b341e61c..6771080d2 100644 --- a/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go +++ b/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go @@ -52,9 +52,9 @@ var InteractiveRebaseWithConflictForEditCommand = NewIntegrationTest(NewIntegrat t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("edit").Contains("<-- CONFLICT --- this will conflict").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-03"), Contains("commit-02"), Contains("commit-01"), diff --git a/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go b/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go index 95dab3056..e5ce2387e 100644 --- a/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go +++ b/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go @@ -24,13 +24,13 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ // Start a rebase Press(keys.Universal.Edit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("pick").Contains("commit-07"), Contains("pick").Contains("commit-06"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05").IsSelected(), Contains("commit-04"), ). @@ -38,73 +38,73 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ // perform various actions on a range of commits Press(keys.Universal.RangeSelectUp). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("pick").Contains("commit-07").IsSelected(), Contains("pick").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.MarkCommitAsFixup). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("fixup").Contains("commit-07").IsSelected(), Contains("fixup").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.PickCommit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("pick").Contains("commit-07").IsSelected(), Contains("pick").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Universal.Edit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("edit").Contains("commit-07").IsSelected(), Contains("edit").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.SquashDown). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.MoveDownCommit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). @@ -113,37 +113,37 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ }). Press(keys.Commits.MoveUpCommit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), Contains("pick").Contains("commit-08"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.MoveUpCommit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.MoveUpCommit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). @@ -152,13 +152,13 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: Cannot move any further")) }). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). @@ -167,13 +167,13 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-08")). Press(keys.Universal.RangeSelectDown). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("squash").Contains("commit-07"), Contains("squash").Contains("commit-06"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08").IsSelected(), - Contains("--- Commits ---").IsSelected(), + Contains("─── Commits").IsSelected(), Contains("commit-05").IsSelected(), Contains("commit-04"), ). @@ -182,13 +182,13 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: When rebasing, this action only works on a selection of TODO commits.")) }). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("squash").Contains("commit-07"), Contains("squash").Contains("commit-06"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08").IsSelected(), - Contains("--- Commits ---").IsSelected(), + Contains("─── Commits").IsSelected(), Contains("commit-05").IsSelected(), Contains("commit-04"), ). diff --git a/pkg/integration/tests/interactive_rebase/move_in_rebase.go b/pkg/integration/tests/interactive_rebase/move_in_rebase.go index 9138839b6..5e518c1a2 100644 --- a/pkg/integration/tests/interactive_rebase/move_in_rebase.go +++ b/pkg/integration/tests/interactive_rebase/move_in_rebase.go @@ -25,30 +25,30 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-03"), Contains("commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01").IsSelected(), ). SelectPreviousItem(). Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-02").IsSelected(), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-02").IsSelected(), Contains("commit-04"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). // assert we can't move past the top @@ -57,29 +57,29 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-02").IsSelected(), Contains("commit-04"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-02").IsSelected(), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-03"), Contains("commit-02").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). // assert we can't move past the bottom @@ -88,21 +88,21 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-03"), Contains("commit-02").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). // move it back up one so that we land in a different order than we started with Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-02").IsSelected(), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). Tap(func() { diff --git a/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go index 16e58e18e..1c1eead5d 100644 --- a/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go +++ b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go @@ -38,11 +38,11 @@ var MoveTodoDownWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-03"), Contains("commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01").IsSelected(), ). NavigateToLine(Contains("commit-04")). diff --git a/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go b/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go index c730fd995..cd715fa24 100644 --- a/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go +++ b/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go @@ -26,28 +26,28 @@ var MoveUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-06"), Contains("pick").Contains("CI commit-05"), Contains("pick").Contains("CI commit-04"), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit-03"), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ commit-01"), ). NavigateToLine(Contains("update-ref")). Press(keys.Commits.MoveUpCommit). Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-06"), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit-05"), Contains("pick").Contains("CI commit-04"), Contains("pick").Contains("CI commit-03"), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ commit-01"), ). Tap(func() { diff --git a/pkg/integration/tests/interactive_rebase/pick_rescheduled.go b/pkg/integration/tests/interactive_rebase/pick_rescheduled.go index af948b7cd..0822eee14 100644 --- a/pkg/integration/tests/interactive_rebase/pick_rescheduled.go +++ b/pkg/integration/tests/interactive_rebase/pick_rescheduled.go @@ -26,10 +26,10 @@ var PickRescheduled = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("one")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("three"), Contains("pick").Contains("two"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("one").IsSelected(), ). Tap(func() { @@ -41,9 +41,9 @@ var PickRescheduled = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("three"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("two"), Contains("one"), ) diff --git a/pkg/integration/tests/interactive_rebase/quick_start.go b/pkg/integration/tests/interactive_rebase/quick_start.go index 07baa0616..da7020b20 100644 --- a/pkg/integration/tests/interactive_rebase/quick_start.go +++ b/pkg/integration/tests/interactive_rebase/quick_start.go @@ -73,10 +73,10 @@ var QuickStart = NewIntegrationTest(NewIntegrationTestArgs{ // Verify quick start picks the last commit on the main branch Press(keys.Commits.StartInteractiveRebase). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("feature-branch two").IsSelected(), Contains("feature-branch one"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("last main commit"), Contains("initial commit"), ). @@ -106,9 +106,9 @@ var QuickStart = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Commits.StartInteractiveRebase). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("branch-with-merge three").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("Merge branch 'branch-to-merge'"), Contains("branch-to-merge two"), Contains("branch-to-merge one"), diff --git a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go index 4d045c8fc..7189e0d37 100644 --- a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go +++ b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go @@ -39,7 +39,7 @@ var QuickStartKeepSelection = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Commits.StartInteractiveRebase). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-07"), Contains("pick").Contains("CI commit-06"), Contains("pick").Contains("CI commit-05"), @@ -47,7 +47,7 @@ var QuickStartKeepSelection = NewIntegrationTest(NewIntegrationTestArgs{ Contains("pick").Contains("CI commit-04"), Contains("pick").Contains("CI commit-03"), Contains("CI commit-02").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-01"), ) }, diff --git a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go index 4d25a883c..1bc7758f8 100644 --- a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go +++ b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go @@ -43,7 +43,7 @@ var QuickStartKeepSelectionRange = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Commits.StartInteractiveRebase). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("CI commit-07"), Contains("CI commit-06"), Contains("update-ref").Contains("branch2"), @@ -52,7 +52,7 @@ var QuickStartKeepSelectionRange = NewIntegrationTest(NewIntegrationTestArgs{ Contains("update-ref").Contains("branch1").IsSelected(), Contains("CI commit-03").IsSelected(), Contains("CI commit-02").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-01"), ) }, diff --git a/pkg/integration/tests/interactive_rebase/rebase.go b/pkg/integration/tests/interactive_rebase/rebase.go index e1940ff7a..d829abf9a 100644 --- a/pkg/integration/tests/interactive_rebase/rebase.go +++ b/pkg/integration/tests/interactive_rebase/rebase.go @@ -34,60 +34,60 @@ var Rebase = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("first commit to edit")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("pick.*commit to drop"), MatchesRegexp("pick.*second commit to edit"), MatchesRegexp("pick.*commit to squash"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("first commit to edit").IsSelected(), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Commits.SquashDown). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("pick.*commit to drop"), MatchesRegexp("pick.*second commit to edit"), MatchesRegexp("squash.*commit to squash").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("first commit to edit"), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("pick.*commit to drop"), MatchesRegexp("edit.*second commit to edit").IsSelected(), MatchesRegexp("squash.*commit to squash"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("first commit to edit"), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Universal.Remove). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("drop.*commit to drop").IsSelected(), MatchesRegexp("edit.*second commit to edit"), MatchesRegexp("squash.*commit to squash"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("first commit to edit"), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Commits.MarkCommitAsFixup). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("fixup.*commit to fixup").IsSelected(), MatchesRegexp("drop.*commit to drop"), MatchesRegexp("edit.*second commit to edit"), MatchesRegexp("squash.*commit to squash"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("first commit to edit"), Contains("initial commit"), ). @@ -95,10 +95,10 @@ var Rebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Common().ContinueRebase() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("fixup.*commit to fixup").IsSelected(), MatchesRegexp("drop.*commit to drop"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("second commit to edit"), MatchesRegexp("first commit to edit"), Contains("initial commit"), diff --git a/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go b/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go index 44a39200b..3024e39da 100644 --- a/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go +++ b/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go @@ -30,9 +30,9 @@ var RevertDuringRebaseWhenStoppedOnEdit = NewIntegrationTest(NewIntegrationTestA NavigateToLine(Contains("commit-03")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-04"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-03").IsSelected(), Contains("commit-02"), Contains("commit-01"), @@ -49,9 +49,9 @@ var RevertDuringRebaseWhenStoppedOnEdit = NewIntegrationTest(NewIntegrationTestA Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-04"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(`Revert "commit-01"`), Contains(`Revert "commit-02"`), Contains("commit-03"), diff --git a/pkg/integration/tests/interactive_rebase/revert_multiple_commits_in_interactive_rebase.go b/pkg/integration/tests/interactive_rebase/revert_multiple_commits_in_interactive_rebase.go index 2cb0c05b7..da3285f04 100644 --- a/pkg/integration/tests/interactive_rebase/revert_multiple_commits_in_interactive_rebase.go +++ b/pkg/integration/tests/interactive_rebase/revert_multiple_commits_in_interactive_rebase.go @@ -50,13 +50,13 @@ var RevertMultipleCommitsInInteractiveRebase = NewIntegrationTest(NewIntegration Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("CI unrelated change 3"), Contains("CI unrelated change 2"), - Contains("--- Pending reverts ---"), + Contains("─── Pending reverts"), Contains("revert").Contains("CI unrelated change 1"), Contains("revert").Contains("CI <-- CONFLICT --- add first line"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ add second line"), Contains("CI ○ add first line"), Contains("CI ○ unrelated change 1"), @@ -83,10 +83,10 @@ var RevertMultipleCommitsInInteractiveRebase = NewIntegrationTest(NewIntegration t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI unrelated change 3"), Contains("pick").Contains("CI unrelated change 2"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(`CI ○ Revert "unrelated change 1"`), Contains(`CI ○ Revert "add first line"`), Contains("CI ○ add second line"), diff --git a/pkg/integration/tests/interactive_rebase/revert_single_commit_in_interactive_rebase.go b/pkg/integration/tests/interactive_rebase/revert_single_commit_in_interactive_rebase.go index 2b0ee24b2..722fa95f2 100644 --- a/pkg/integration/tests/interactive_rebase/revert_single_commit_in_interactive_rebase.go +++ b/pkg/integration/tests/interactive_rebase/revert_single_commit_in_interactive_rebase.go @@ -45,12 +45,12 @@ var RevertSingleCommitInInteractiveRebase = NewIntegrationTest(NewIntegrationTes Cancel() // stay in commits panel }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("CI unrelated change 2"), Contains("CI unrelated change 1"), - Contains("--- Pending reverts ---"), + Contains("─── Pending reverts"), Contains("revert").Contains("CI <-- CONFLICT --- add first line"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ add second line"), Contains("CI ○ add first line").IsSelected(), Contains("CI ○ add empty file"), @@ -84,10 +84,10 @@ var RevertSingleCommitInInteractiveRebase = NewIntegrationTest(NewIntegrationTes t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI unrelated change 2"), Contains("pick").Contains("CI unrelated change 1"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(`CI ○ Revert "add first line"`), Contains("CI ○ add second line"), Contains("CI ○ add first line"), diff --git a/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go b/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go index b8cf20ae8..8a58be2f8 100644 --- a/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go +++ b/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go @@ -33,9 +33,9 @@ var RewordCommitWithEditorAndFail = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go index bd58ab083..ef34b1844 100644 --- a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go +++ b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go @@ -25,9 +25,9 @@ var RewordYouAreHereCommit = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ). @@ -41,9 +41,9 @@ var RewordYouAreHereCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("renamed 02").IsSelected(), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go index 5406ed02b..a4a19f42f 100644 --- a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go +++ b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go @@ -27,9 +27,9 @@ var RewordYouAreHereCommitWithEditor = NewIntegrationTest(NewIntegrationTestArgs NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ). @@ -41,9 +41,9 @@ var RewordYouAreHereCommitWithEditor = NewIntegrationTest(NewIntegrationTestArgs Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("renamed 02").IsSelected(), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/shared.go b/pkg/integration/tests/interactive_rebase/shared.go index ea6626fd6..d1d80fafb 100644 --- a/pkg/integration/tests/interactive_rebase/shared.go +++ b/pkg/integration/tests/interactive_rebase/shared.go @@ -9,10 +9,10 @@ func handleConflictsFromSwap(t *TestDriver, expectedCommand string) { t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit two"), Contains(expectedCommand).Contains("<-- CONFLICT --- commit three"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit one"), ) diff --git a/pkg/integration/tests/interactive_rebase/show_exec_todos.go b/pkg/integration/tests/interactive_rebase/show_exec_todos.go index fad0e44e8..3873f3ab4 100644 --- a/pkg/integration/tests/interactive_rebase/show_exec_todos.go +++ b/pkg/integration/tests/interactive_rebase/show_exec_todos.go @@ -31,10 +31,10 @@ var ShowExecTodos = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectPopup().Alert().Title(Equals("Error")).Content(Contains("Rebasing (2/4)Executing: false")).Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("exec").Contains("false"), Contains("pick").Contains("CI commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ commit-02"), Contains("CI ○ commit-01"), ). @@ -43,8 +43,8 @@ var ShowExecTodos = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectPopup().Alert().Title(Equals("Error")).Content(Contains("exit status 1")).Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), - Contains("--- Commits ---"), + Contains("─── Pending rebase todos"), + Contains("─── Commits"), Contains("CI ○ commit-03"), Contains("CI ○ commit-02"), Contains("CI ○ commit-01"), diff --git a/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict.go b/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict.go index f6653f9b0..20b77c03f 100644 --- a/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict.go +++ b/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict.go @@ -29,19 +29,19 @@ var SwapInRebaseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit one")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit three"), Contains("commit two"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit one").IsSelected(), ). SelectPreviousItem(). Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit two").IsSelected(), Contains("commit three"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit one"), ). Tap(func() { diff --git a/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict_and_edit.go b/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict_and_edit.go index f5beb4374..1e9ff4934 100644 --- a/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict_and_edit.go +++ b/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict_and_edit.go @@ -29,19 +29,19 @@ var SwapInRebaseWithConflictAndEdit = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit one")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit three"), Contains("commit two"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit one").IsSelected(), ). NavigateToLine(Contains("commit two")). Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit two").IsSelected(), Contains("commit three"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit one"), ). NavigateToLine(Contains("commit three")). diff --git a/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go b/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go index 3746633c7..607f94eed 100644 --- a/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go +++ b/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go @@ -28,11 +28,11 @@ var ViewFilesOfTodoEntries = NewIntegrationTest(NewIntegrationTestArgs{ Focus(). Press(keys.Commits.StartInteractiveRebase). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-03").IsSelected(), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-01"), ). Press(keys.Universal.GoInto) diff --git a/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go b/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go index 2bb39e14f..050ae2e2f 100644 --- a/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go +++ b/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go @@ -48,10 +48,10 @@ var PullRebaseInteractiveConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("five"), Contains("pick").Contains("CONFLICT").Contains("four"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("three"), Contains("two"), Contains("one"), diff --git a/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go b/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go index ad7a4806f..707564a8d 100644 --- a/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go +++ b/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go @@ -49,20 +49,20 @@ var PullRebaseInteractiveConflictDrop = NewIntegrationTest(NewIntegrationTestArg t.Views().Commits(). Focus(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("five").IsSelected(), Contains("pick").Contains("CONFLICT").Contains("four"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("three"), Contains("two"), Contains("one"), ). Press(keys.Universal.Remove). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("drop").Contains("five").IsSelected(), Contains("pick").Contains("CONFLICT").Contains("four"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("three"), Contains("two"), Contains("one"), From c5fe27dfa55ff9b06f8054344339f0b8e2f3345d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 22 Jul 2026 11:22:27 +0200 Subject: [PATCH 18/97] Additions to AGENTS.md --- AGENTS.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2cafd9d50..96c5ba758 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,6 +213,16 @@ that changes the relevant test(s) or adds new ones to demonstrate the bug, then fix the bug in a follow-up commit. This gives reviewers (and `git bisect`) a clear before/after and proves the test actually exercises the broken code path. +This applies only to defects that existed before the entire branch or branch +stack. Never use the bug-demonstration pattern for a regression introduced by +an earlier commit in the current stack. Fix or rewrite the commit that +introduced the regression so that no commit in the final history contains it. +Put the regression test in a preparatory commit before the introducing commit, +so it guards that commit in the final history. If the test cannot pass before +the feature exists, restructure the implementation or test seam until it can; +if that would require a design tradeoff, stop and discuss it rather than adding +a later demonstration/fix pair. + Use the `EXPECTED` / `ACTUAL` pattern in the bug-demonstrating commit. The test asserts the current (wrong) behavior so it passes on the broken code, with the correct expectation preserved inline as a comment. The fix commit then swaps @@ -255,7 +265,11 @@ If you find yourself reaching for a local variable so that both forms can be expressed against the same receiver, the structure isn't right yet — go back and fix it instead of papering over it with a binding. -Use this pattern only where it makes sense; don't apply it by default. +Use this pattern only where it makes sense; don't apply it by default. Only +ever use it for bugs, never for added features or behavior changes that aren't +bugfixes; it is useful to demonstrate how a bug existed before fixing it, but +it is never useful to demonstrate how a feature didn't exist before implementing +it. ## Unify duplicated logic before you change it From ff53a3ed8cf08f523bf26453518a1ccd417d3e08 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 22 Jul 2026 12:20:34 +0200 Subject: [PATCH 19/97] Preserve the first mouse movement of a drag When the left button is pressed and the pointer then moves, the event that made the MAYBE_DRAGGING -> DRAGGING transition fell through the switch without being assigned a key or modifier, so the first cell of every drag arrived at handlers as a MouseRelease event without the motion modifier and was effectively lost. Give it the same MouseLeft/ModMotion identity as all subsequent drag events. Held-button motion events that stay within the pressed cell carry no information at all; swallow them instead of letting them through as further release-shaped events (which used to clobber the double-click state when the pointer jittered within a cell between two clicks). --- pkg/gocui/tcell_driver.go | 20 ++++++++++++++++++-- pkg/gocui/tcell_driver_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 pkg/gocui/tcell_driver_test.go diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index 885bcbabb..e70fc3440 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -366,7 +366,9 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { // process button events (not wheel events) button &= tcell.ButtonMask(0xff) + newButtonPress := false if button != tcell.ButtonNone && lastMouseKey == tcell.ButtonNone { + newButtonPress = true lastMouseKey = button lastMouseMod = tev.Modifiers() switch button { @@ -410,9 +412,23 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { } // if we haven't released the left mouse button and we've moved the cursor then we're dragging case MAYBE_DRAGGING: - if x != lastX || y != lastY { - dragState = DRAGGING + if x == lastX && y == lastY { + // Deliver the button press itself, but swallow held-button + // motion events within the same cell: they carry no new + // information, and if they fell through they would be + // delivered with the default MouseRelease key. + if !newButtonPress { + return GocuiEvent{Type: eventNone} + } + break } + // The first movement is already part of the drag; give it the + // same key and modifier as the DRAGGING events below so it + // reaches drag bindings instead of being delivered with the + // default MouseRelease key. + dragState = DRAGGING + mouseMod = ModMotion + mouseKey = MouseLeft case DRAGGING: mouseMod = ModMotion mouseKey = MouseLeft diff --git a/pkg/gocui/tcell_driver_test.go b/pkg/gocui/tcell_driver_test.go new file mode 100644 index 000000000..be36efe11 --- /dev/null +++ b/pkg/gocui/tcell_driver_test.go @@ -0,0 +1,33 @@ +package gocui + +import ( + "testing" + + "github.com/gdamore/tcell/v3" + "github.com/stretchr/testify/assert" +) + +func TestFirstMouseMovementAfterPressIsDragEvent(t *testing.T) { + t.Cleanup(resetMouseState) + resetMouseState() + + pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone)) + unchangedHeldEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone)) + dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModNone)) + + assert.Equal(t, eventMouse, pressEvent.Type) + assert.Equal(t, MouseLeft, pressEvent.Key.KeyName()) + assert.Equal(t, ModNone, pressEvent.Key.Mod()) + assert.Equal(t, eventNone, unchangedHeldEvent.Type) + assert.Equal(t, eventMouse, dragEvent.Type) + assert.Equal(t, MouseLeft, dragEvent.Key.KeyName()) + assert.Equal(t, ModMotion, dragEvent.Key.Mod()) +} + +func resetMouseState() { + lastMouseKey = tcell.ButtonNone + lastMouseMod = tcell.ModNone + dragState = NOT_DRAGGING + lastX = 0 + lastY = 0 +} From a965db2a7d58fd41558376ef324074edea20ca50 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 10:03:56 +0200 Subject: [PATCH 20/97] Demonstrate that drag release becomes hover --- pkg/gocui/tcell_driver_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pkg/gocui/tcell_driver_test.go b/pkg/gocui/tcell_driver_test.go index be36efe11..a5bed98d0 100644 --- a/pkg/gocui/tcell_driver_test.go +++ b/pkg/gocui/tcell_driver_test.go @@ -24,6 +24,21 @@ func TestFirstMouseMovementAfterPressIsDragEvent(t *testing.T) { assert.Equal(t, ModMotion, dragEvent.Key.Mod()) } +func TestMouseReleaseAfterDragIsMouseEvent(t *testing.T) { + t.Cleanup(resetMouseState) + resetMouseState() + + gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone)) + gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModNone)) + releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModNone)) + + /* EXPECTED: + assert.Equal(t, eventMouse, releaseEvent.Type) + assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName()) + ACTUAL: */ + assert.Equal(t, eventMouseMove, releaseEvent.Type) +} + func resetMouseState() { lastMouseKey = tcell.ButtonNone lastMouseMod = tcell.ModNone From 38d2293a10c54309e5b462b2b5609a3ee3a023b9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:09:30 +0200 Subject: [PATCH 21/97] Add test for double-click detection Add a test pinning down that a press/release/press sequence at the same position is detected as a double click. An upcoming commit starts delivering the release as a real mouse event to the click-recording code, which must not mistake it for a click of its own. --- pkg/gocui/double_click_test.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 pkg/gocui/double_click_test.go diff --git a/pkg/gocui/double_click_test.go b/pkg/gocui/double_click_test.go new file mode 100644 index 000000000..b8d9f5f9c --- /dev/null +++ b/pkg/gocui/double_click_test.go @@ -0,0 +1,34 @@ +package gocui + +import ( + "testing" + + "github.com/gdamore/tcell/v3" + "github.com/stretchr/testify/assert" +) + +func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) { + t.Cleanup(resetMouseState) + resetMouseState() + g := newTestGui(t) + view, _ := g.SetView("list", 0, 0, 20, 10, 0) + doubleClicks := []bool{} + assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{ + ViewName: "list", + Key: MouseLeft, + Handler: func(opts ViewMouseBindingOpts) error { + doubleClicks = append(doubleClicks, opts.IsDoubleClick) + return nil + }, + })) + + for _, event := range []GocuiEvent{ + gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)), + gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonNone, tcell.ModNone)), + gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)), + } { + assert.NoError(t, g.onKey(&event)) + } + + assert.Equal(t, []bool{false, true}, doubleClicks) +} From 44a2bbeb7c1cd7552a251f3a841688a3b3c8e0d7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:13:03 +0200 Subject: [PATCH 22/97] Deliver mouse release after a drag Releasing a mouse button was delivered as a plain mouse-move (hover) event: the release processing resets dragState to NOT_DRAGGING, after which the event fell into the NOT_DRAGGING branch. Views therefore had no way of telling that a drag gesture ended, which the upcoming drag-based features (range selection, commit reordering) need. Deliver the release as a real mouse event with the MouseRelease key and normalize its modifiers to ModNone, so release bindings also match modified drags. Make recordClickInfo ignore it: a release is the end of a click, not a click of its own, and must not break double-click detection. --- pkg/gocui/gui.go | 6 ++++++ pkg/gocui/tcell_driver.go | 9 ++++----- pkg/gocui/tcell_driver_test.go | 17 +++++++++++++---- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 57818960c..e5526a109 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1773,6 +1773,12 @@ func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) bool { g.lastClick = nil return false } + // A release ends a gesture but is not a click of its own; it must leave + // the click info of the press that started it alone, or no double click + // could ever be detected. + if key == MouseRelease { + return false + } clickInfo := &clickInfo{ x: x, diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index e70fc3440..745725993 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -202,7 +202,6 @@ const ( var ( lastMouseKey tcell.ButtonMask = tcell.ButtonNone - lastMouseMod tcell.ModMask = tcell.ModNone dragState = NOT_DRAGGING lastX = 0 lastY = 0 @@ -367,10 +366,10 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { // process button events (not wheel events) button &= tcell.ButtonMask(0xff) newButtonPress := false + buttonReleased := false if button != tcell.ButtonNone && lastMouseKey == tcell.ButtonNone { newButtonPress = true lastMouseKey = button - lastMouseMod = tev.Modifiers() switch button { case tcell.ButtonPrimary: mouseKey = MouseLeft @@ -388,6 +387,7 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { switch tev.Buttons() { case tcell.ButtonNone: if lastMouseKey != tcell.ButtonNone { + buttonReleased = true switch lastMouseKey { case tcell.ButtonPrimary: dragState = NOT_DRAGGING @@ -395,14 +395,13 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { case tcell.ButtonMiddle: default: } - mouseMod = Modifier(lastMouseMod) - lastMouseMod = tcell.ModNone + mouseMod = ModNone lastMouseKey = tcell.ButtonNone } default: } - if !wheeling { + if !wheeling && !buttonReleased { switch dragState { case NOT_DRAGGING: return GocuiEvent{ diff --git a/pkg/gocui/tcell_driver_test.go b/pkg/gocui/tcell_driver_test.go index a5bed98d0..9038e73ce 100644 --- a/pkg/gocui/tcell_driver_test.go +++ b/pkg/gocui/tcell_driver_test.go @@ -32,16 +32,25 @@ func TestMouseReleaseAfterDragIsMouseEvent(t *testing.T) { gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModNone)) releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModNone)) - /* EXPECTED: assert.Equal(t, eventMouse, releaseEvent.Type) assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName()) - ACTUAL: */ - assert.Equal(t, eventMouseMove, releaseEvent.Type) +} + +func TestMouseReleaseDoesNotKeepPressModifiers(t *testing.T) { + t.Cleanup(resetMouseState) + resetMouseState() + + gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt)) + gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt)) + releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt)) + + assert.Equal(t, eventMouse, releaseEvent.Type) + assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName()) + assert.Equal(t, ModNone, releaseEvent.Key.Mod()) } func resetMouseState() { lastMouseKey = tcell.ButtonNone - lastMouseMod = tcell.ModNone dragState = NOT_DRAGGING lastX = 0 lastY = 0 From 460998502927ed11344fbb87cb04d20aeaf41fc2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 09:23:08 +0200 Subject: [PATCH 23/97] Route mouse events to their originating view during a drag gesture Route all mouse events to the view that was under the pointer when the left button was pressed, until the button is released. Previously each event went to whatever view was under the pointer at the time, so a drag that left the view's bounds started acting on neighboring views. Since events can now carry positions outside the view, clamp the view cursor to the view's bounds in that case (handlers still receive the unclamped position), and require an actual click for tab activation so that a captured drag crossing the tab row doesn't switch tabs. --- pkg/gocui/gui.go | 70 +++++++++-- pkg/gocui/mouse_capture_test.go | 216 ++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 7 deletions(-) create mode 100644 pkg/gocui/mouse_capture_test.go diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index e5526a109..be52c8584 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -208,7 +208,9 @@ type Gui struct { // busy?" doesn't count itself. currentTask Task - lastHoverView *View + lastHoverView *View + mouseCapture *View + mouseGestureCanceled bool // uiThreadID is the goroutine id of the main event loop, recorded when // MainLoop starts. IsUIThread compares against it. Written once, read from @@ -597,6 +599,12 @@ func (g *Gui) DeleteView(name string) error { for i, v := range g.views { if v.name == name { + if g.mouseCapture == v { + g.CancelMouseCapture() + } + if g.lastHoverView == v { + g.lastHoverView = nil + } g.views = append(g.views[:i], g.views[i+1:]...) return nil } @@ -666,6 +674,24 @@ func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) error { return nil } +// captureMouse routes subsequent mouse events to view until the mouse button is +// released or CancelMouseCapture is called. +func (g *Gui) captureMouse(view *View) { + g.mouseCapture = view + g.mouseGestureCanceled = false +} + +func (g *Gui) releaseMouseCapture() { + g.mouseCapture = nil +} + +// CancelMouseCapture releases capture and ignores the rest of the physical +// gesture until the mouse button is released. +func (g *Gui) CancelMouseCapture() { + g.releaseMouseCapture() + g.mouseGestureCanceled = true +} + func (g *Gui) SetFocusHandler(handler func(bool) error) { g.focusHandler = handler } @@ -1658,9 +1684,26 @@ func (g *Gui) onKey(ev *GocuiEvent) error { case eventMouse: mx, my := ev.MouseX, ev.MouseY - v, err := g.VisibleViewByPosition(mx, my) - if err != nil { - break + if g.mouseGestureCanceled { + if ev.Key.KeyName() == MouseRelease { + g.mouseGestureCanceled = false + } + return nil + } + // While the mouse is captured, all mouse events go to the view that + // was under the pointer when the button was pressed, even if the + // pointer has since left it; this is what lets drag gestures keep + // acting on the view they started in. + v := g.mouseCapture + if v == nil { + var err error + v, err = g.VisibleViewByPosition(mx, my) + if err != nil { + break + } + } + if ev.Key.KeyName() == MouseRelease { + g.releaseMouseCapture() } // newCx and newCy are relative to the view port, i.e. to the visible area of the view @@ -1704,9 +1747,20 @@ func (g *Gui) onKey(ev *GocuiEvent) error { break } } + if ev.Key.KeyName() == MouseLeft && ev.Key.Mod()&ModMotion == 0 { + g.captureMouse(v) + } - if !IsMouseScrollKey(ev.Key.KeyName()) { - v.SetCursor(newCx, newCy) + if !IsMouseScrollKey(ev.Key.KeyName()) && ev.Key.KeyName() != MouseRelease { + cursorX, cursorY := newCx, newCy + // A captured drag can report positions outside the view; keep the + // view cursor inside its bounds in that case. Handlers still get + // the unclamped position through the binding opts. + if g.mouseCapture != nil { + cursorX = max(0, min(cursorX, v.InnerWidth()-1)) + cursorY = max(0, min(cursorY, v.InnerHeight()-1)) + } + v.SetCursor(cursorX, cursorY) if v.Editable { v.TextArea.SetCursor2D(newX, newY) @@ -1718,7 +1772,9 @@ func (g *Gui) onKey(ev *GocuiEvent) error { } } - if v.Frame && my == v.y0 { + // Only an actual click may activate tabs; a captured drag that + // crosses the tab row must not switch tabs. + if ev.Key.KeyName() == MouseLeft && ev.Key.Mod()&ModMotion == 0 && v.Frame && my == v.y0 { if len(v.Tabs) > 0 { tabIndex := v.GetClickedTabIndex(mx - v.x0) diff --git a/pkg/gocui/mouse_capture_test.go b/pkg/gocui/mouse_capture_test.go new file mode 100644 index 000000000..eea1e3f9f --- /dev/null +++ b/pkg/gocui/mouse_capture_test.go @@ -0,0 +1,216 @@ +package gocui + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMouseCaptureRoutesMotionAndReleaseOutsideView(t *testing.T) { + g := newTestGui(t) + view, err := g.SetView("captured", 10, 5, 30, 15, 0) + if err != nil && !errors.Is(err, ErrUnknownView) { + assert.NoError(t, err) + return + } + + received := []ViewMouseBindingOpts{} + for _, binding := range []*ViewMouseBinding{ + { + ViewName: "captured", + Key: MouseLeft, + Modifier: ModMotion, + Handler: func(opts ViewMouseBindingOpts) error { + received = append(received, opts) + return nil + }, + }, + { + ViewName: "captured", + Key: MouseRelease, + Handler: func(opts ViewMouseBindingOpts) error { + assert.Nil(t, g.mouseCapture) + received = append(received, opts) + return nil + }, + }, + } { + assert.NoError(t, g.SetViewClickBinding(binding)) + } + + g.captureMouse(view) + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: 0, + MouseY: 0, + Key: NewKey(MouseLeft, "", ModMotion), + })) + assert.Equal(t, ViewMouseBindingOpts{X: -11, Y: -6, Key: MouseLeft}, received[0]) + assert.Equal(t, 0, view.CursorX()) + assert.Equal(t, 0, view.CursorY()) + + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: 79, + MouseY: 23, + Key: NewKeyName(MouseRelease), + })) + assert.Equal(t, ViewMouseBindingOpts{X: 68, Y: 17, Key: MouseRelease}, received[1]) + assert.Equal(t, 0, view.CursorX()) + assert.Equal(t, 0, view.CursorY()) + assert.Nil(t, g.mouseCapture) +} + +func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) { + g := newTestGui(t) + left, _ := g.SetView("left", 0, 0, 20, 10, 0) + _, _ = g.SetView("right", 21, 0, 41, 10, 0) + + receivedBy := "" + for _, viewName := range []string{"left", "right"} { + assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{ + ViewName: viewName, + Key: MouseLeft, + Modifier: ModMotion, + Handler: func(ViewMouseBindingOpts) error { + receivedBy = viewName + return nil + }, + })) + } + + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: left.x0 + 1, + MouseY: left.y0 + 1, + Key: NewKeyName(MouseLeft), + })) + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: 22, + MouseY: 1, + Key: NewKey(MouseLeft, "", ModMotion), + })) + + assert.Equal(t, "left", receivedBy) +} + +func TestPrimaryMouseDragDoesNotActivateTabs(t *testing.T) { + g := newTestGui(t) + view, _ := g.SetView("tabs", 0, 0, 40, 10, 0) + view.Tabs = []string{"first", "second"} + + clickedTabs := []int{} + assert.NoError(t, g.SetTabClickBinding("tabs", func(tabIndex int) error { + clickedTabs = append(clickedTabs, tabIndex) + return nil + })) + + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: view.x0 + 1, + MouseY: view.y0 + 1, + Key: NewKeyName(MouseLeft), + })) + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: view.x0 + 3, + MouseY: view.y0, + Key: NewKey(MouseLeft, "", ModMotion), + })) + + assert.Empty(t, clickedTabs) + + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: view.x0 + 3, + MouseY: view.y0, + Key: NewKeyName(MouseRelease), + })) + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: view.x0 + 3, + MouseY: view.y0, + Key: NewKeyName(MouseLeft), + })) + assert.Equal(t, []int{0}, clickedTabs) +} + +func TestRejectedMouseReleaseClearsCapture(t *testing.T) { + g := newTestGui(t) + view, _ := g.SetView("captured", 0, 0, 20, 10, 0) + g.captureMouse(view) + g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return false } + + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: view.x0 + 1, + MouseY: view.y0 + 1, + Key: NewKeyName(MouseRelease), + })) + + assert.Nil(t, g.mouseCapture) +} + +func TestDeleteViewClearsMouseState(t *testing.T) { + g := newTestGui(t) + view, _ := g.SetView("temporary", 0, 0, 20, 10, 0) + g.captureMouse(view) + g.lastHoverView = view + + assert.NoError(t, g.DeleteView("temporary")) + + assert.Nil(t, g.mouseCapture) + assert.True(t, g.mouseGestureCanceled) + assert.Nil(t, g.lastHoverView) +} + +func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) { + g := newTestGui(t) + left, _ := g.SetView("left", 0, 0, 20, 10, 0) + _, _ = g.SetView("right", 21, 0, 41, 10, 0) + receivedBy := "" + for _, viewName := range []string{"left", "right"} { + assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{ + ViewName: viewName, + Key: MouseLeft, + Modifier: ModMotion, + Handler: func(ViewMouseBindingOpts) error { + receivedBy = viewName + return nil + }, + })) + } + + g.captureMouse(left) + g.CancelMouseCapture() + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: 22, + MouseY: 1, + Key: NewKey(MouseLeft, "", ModMotion), + })) + assert.Empty(t, receivedBy) + + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: 22, + MouseY: 1, + Key: NewKeyName(MouseRelease), + })) + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: 22, + MouseY: 1, + Key: NewKeyName(MouseLeft), + })) + assert.NoError(t, g.onKey(&GocuiEvent{ + Type: eventMouse, + MouseX: 23, + MouseY: 1, + Key: NewKey(MouseLeft, "", ModMotion), + })) + + assert.Equal(t, "right", receivedBy) +} From 98613957aeff8dab233e2a76732e96542a48a867 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 22 Jul 2026 11:29:32 +0200 Subject: [PATCH 24/97] Refactor: extract replayMouseEvent helper method --- pkg/gui/gui_driver.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 9e06f483b..2b035a855 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -53,13 +53,13 @@ func (self *GuiDriver) PressKeysRapidly(keyStrs ...string) { func (self *GuiDriver) Click(x, y int) { self.CheckAllToastsAcknowledged() + self.replayMouseEvent(x, y, tcell.ButtonPrimary) + self.replayMouseEvent(x, y, tcell.ButtonNone) +} + +func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) { self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( - tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), - 0, - )) - self.waitTillIdle() - self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( - tcell.NewEventMouse(x, y, tcell.ButtonNone, 0), + tcell.NewEventMouse(x, y, buttons, 0), 0, )) self.waitTillIdle() From ba024ed6c9e70e4bd49942052353479d47731ec6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 22 Jul 2026 09:12:49 +0200 Subject: [PATCH 25/97] Add ViewDriver methods for mouse drag gestures in integration tests Add press/move/release primitives next to the existing Click. The test driver remembers the last reported position so a release doesn't have to repeat the coordinates, and RepeatMouseMove lets a test verify that a held-button motion event within the same cell has no effect. --- pkg/gui/gui_driver.go | 16 ++++++++++++ pkg/integration/components/test_driver.go | 26 ++++++++++++++++++ pkg/integration/components/test_test.go | 28 +++++++++++++++++--- pkg/integration/components/view_driver.go | 32 +++++++++++++++++++++++ pkg/integration/types/types.go | 3 +++ 5 files changed, 102 insertions(+), 3 deletions(-) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 2b035a855..66fb3c659 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -57,6 +57,22 @@ func (self *GuiDriver) Click(x, y int) { self.replayMouseEvent(x, y, tcell.ButtonNone) } +func (self *GuiDriver) ClickAndHold(x, y int) { + self.CheckAllToastsAcknowledged() + self.replayMouseEvent(x, y, tcell.ButtonPrimary) +} + +// MouseMove reports the mouse at a new position with the left button still +// held down, i.e. a drag movement. (No test needs pointer motion without a +// button held, so that variant doesn't exist.) +func (self *GuiDriver) MouseMove(x, y int) { + self.replayMouseEvent(x, y, tcell.ButtonPrimary) +} + +func (self *GuiDriver) MouseRelease(x, y int) { + self.replayMouseEvent(x, y, tcell.ButtonNone) +} + func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) { self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, buttons, 0), diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 19219707a..d65caee5d 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -13,6 +13,8 @@ type TestDriver struct { gui integrationTypes.GuiDriver keys config.KeybindingConfig inputDelay int + mouseX int + mouseY int *assertionHelper shell *Shell } @@ -58,6 +60,30 @@ func (self *TestDriver) click(x, y int) { self.Wait(self.inputDelay) } +func (self *TestDriver) clickAndHold(x, y int) { + self.SetCaption(fmt.Sprintf("Clicking and holding %d, %d", x, y)) + self.mouseX, self.mouseY = x, y + self.gui.ClickAndHold(x, y) + self.Wait(self.inputDelay) +} + +func (self *TestDriver) mouseMove(x, y int) { + self.SetCaption(fmt.Sprintf("Moving mouse to %d, %d", x, y)) + self.mouseX, self.mouseY = x, y + self.gui.MouseMove(x, y) + self.Wait(self.inputDelay) +} + +func (self *TestDriver) repeatMouseMove() { + self.mouseMove(self.mouseX, self.mouseY) +} + +func (self *TestDriver) mouseRelease() { + self.SetCaption(fmt.Sprintf("Releasing mouse at %d, %d", self.mouseX, self.mouseY)) + self.gui.MouseRelease(self.mouseX, self.mouseY) + self.Wait(self.inputDelay) +} + // Should only be used in specific cases where you're doing something weird! // E.g. invoking a global keybinding from within a popup. // You probably shouldn't use this function, and should instead go through a view like t.Views().Commit().Focus().Press(...) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 7196779eb..624c71c6a 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -19,9 +19,12 @@ type coordinate struct { } type fakeGuiDriver struct { - failureMessage string - pressedKeys []string - clickedCoordinates []coordinate + failureMessage string + pressedKeys []string + clickedCoordinates []coordinate + heldCoordinates []coordinate + movedCoordinates []coordinate + releasedCoordinates []coordinate } var _ integrationTypes.GuiDriver = &fakeGuiDriver{} @@ -38,6 +41,18 @@ func (self *fakeGuiDriver) Click(x, y int) { self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) } +func (self *fakeGuiDriver) ClickAndHold(x, y int) { + self.heldCoordinates = append(self.heldCoordinates, coordinate{x: x, y: y}) +} + +func (self *fakeGuiDriver) MouseMove(x, y int) { + self.movedCoordinates = append(self.movedCoordinates, coordinate{x: x, y: y}) +} + +func (self *fakeGuiDriver) MouseRelease(x, y int) { + self.releasedCoordinates = append(self.releasedCoordinates, coordinate{x: x, y: y}) +} + func (self *fakeGuiDriver) FocusIn() { } @@ -123,12 +138,19 @@ func TestSuccess(t *testing.T) { t.press("b") t.click(0, 1) t.click(2, 3) + t.clickAndHold(0, 1) + t.mouseMove(2, 3) + t.repeatMouseMove() + t.mouseRelease() }, }) driver := &fakeGuiDriver{} test.Run(driver) assert.EqualValues(t, []string{"a", "b"}, driver.pressedKeys) assert.EqualValues(t, []coordinate{{0, 1}, {2, 3}}, driver.clickedCoordinates) + assert.EqualValues(t, []coordinate{{0, 1}}, driver.heldCoordinates) + assert.EqualValues(t, []coordinate{{2, 3}, {2, 3}}, driver.movedCoordinates) + assert.EqualValues(t, []coordinate{{2, 3}}, driver.releasedCoordinates) assert.Equal(t, "", driver.failureMessage) } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 2cfaba338..656a93fe6 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -483,6 +483,38 @@ func (self *ViewDriver) FocusInAndClick(x, y int) *ViewDriver { return self } +func (self *ViewDriver) MouseMoveToView(target *ViewDriver, x, y int) *ViewDriver { + offsetX, offsetY, _, _ := target.getView().Dimensions() + self.t.mouseMove(offsetX+1+x, offsetY+1+y) + return self +} + +func (self *ViewDriver) Drag(fromX, fromY, toX, toY int) *ViewDriver { + return self.ClickAndHold(fromX, fromY).MouseMove(toX, toY).MouseRelease() +} + +func (self *ViewDriver) ClickAndHold(x, y int) *ViewDriver { + offsetX, offsetY, _, _ := self.getView().Dimensions() + self.t.clickAndHold(offsetX+1+x, offsetY+1+y) + return self +} + +func (self *ViewDriver) MouseMove(x, y int) *ViewDriver { + offsetX, offsetY, _, _ := self.getView().Dimensions() + self.t.mouseMove(offsetX+1+x, offsetY+1+y) + return self +} + +func (self *ViewDriver) RepeatMouseMove() *ViewDriver { + self.t.repeatMouseMove() + return self +} + +func (self *ViewDriver) MouseRelease() *ViewDriver { + self.t.mouseRelease() + return self +} + // i.e. pressing down arrow func (self *ViewDriver) SelectNextItem() *ViewDriver { return self.PressFast(self.t.keys.Universal.NextItem) diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index ea76c45be..bc92c6982 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -28,6 +28,9 @@ type GuiDriver interface { // user typing faster than lazygit processes the input. PressKeysRapidly(...string) Click(int, int) + ClickAndHold(int, int) + MouseMove(int, int) + MouseRelease(int, int) // Simulate the terminal window regaining focus (which triggers a reload of // changed config files) FocusIn() From f5a069342b9748f294df2bd9d7ba3c398996377e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:17:19 +0200 Subject: [PATCH 26/97] Add DragAutoScroller helper It can be used to auto-scroll a view during drag gestures when the cursor reaches the edge of the view. --- .../controllers/helpers/drag_autoscroller.go | 154 ++++++++++++++++++ .../helpers/drag_autoscroller_test.go | 62 +++++++ 2 files changed, 216 insertions(+) create mode 100644 pkg/gui/controllers/helpers/drag_autoscroller.go create mode 100644 pkg/gui/controllers/helpers/drag_autoscroller_test.go diff --git a/pkg/gui/controllers/helpers/drag_autoscroller.go b/pkg/gui/controllers/helpers/drag_autoscroller.go new file mode 100644 index 000000000..7ec6a9c76 --- /dev/null +++ b/pkg/gui/controllers/helpers/drag_autoscroller.go @@ -0,0 +1,154 @@ +package helpers + +import ( + "time" + + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +const ( + dragAutoscrollInitialDelay = 300 * time.Millisecond + dragAutoscrollSlowInterval = 250 * time.Millisecond + dragAutoscrollFastInterval = 100 * time.Millisecond + dragAutoscrollVeryFastInterval = 50 * time.Millisecond +) + +// All state is UI-thread-owned. Timer goroutines only enqueue tick back onto +// the UI thread, where generation changes and scroll callbacks are serialized +// with mouse handlers and focus changes. +type DragAutoscroller struct { + c *HelperCommon + context types.Context + + canScroll func(direction int) bool + onScroll func(viewIndex int) bool + + // Incremented whenever the scroll direction changes or the autoscroller + // is canceled. A scheduled tick carries the generation it was created + // for, so stale ticks can be told apart from the one that is current. + generation uint64 + direction int + interval time.Duration + // Last known pointer position relative to the viewport; used by ticks to + // compute which line ends up under the pointer after scrolling. + pointerViewportY int +} + +func NewDragAutoscroller( + c *HelperCommon, + context types.Context, + canScroll func(direction int) bool, + onScroll func(viewIndex int) bool, +) *DragAutoscroller { + return &DragAutoscroller{ + c: c, + context: context, + canScroll: canScroll, + onScroll: onScroll, + } +} + +// Update is called with the pointer position of every drag event. Entering a +// scroll zone arms a timer (with an initial delay, so that merely passing +// through the zone doesn't scroll); once armed, scrolling continues on its +// own until the pointer leaves the zone, the drag ends, or a callback stops +// it. +func (self *DragAutoscroller) Update(pointerViewportY int) { + _, viewportHeight := self.context.GetViewTrait().ViewPortYBounds() + direction, interval := dragAutoscrollZone(viewportHeight, pointerViewportY) + if direction != 0 && self.canScroll != nil && !self.canScroll(direction) { + direction = 0 + interval = 0 + } + + self.pointerViewportY = pointerViewportY + generation, schedule := self.updateState(direction, interval) + if schedule { + self.schedule(generation, dragAutoscrollInitialDelay) + } +} + +func (self *DragAutoscroller) Direction() int { + return self.direction +} + +func (self *DragAutoscroller) updateState(direction int, interval time.Duration) (uint64, bool) { + if direction == self.direction { + self.interval = interval + return self.generation, false + } + + self.generation++ + self.direction = direction + self.interval = interval + return self.generation, direction != 0 +} + +func (self *DragAutoscroller) Cancel() { + self.generation++ + self.direction = 0 + self.interval = 0 +} + +func (self *DragAutoscroller) schedule(generation uint64, delay time.Duration) { + time.AfterFunc(delay, func() { + self.c.OnUIThreadBackground(func() error { + self.tick(generation) + return nil + }) + }) +} + +func (self *DragAutoscroller) tick(generation uint64) { + if generation != self.generation { + return + } + if self.direction == 0 || + self.canScroll != nil && !self.canScroll(self.direction) { + self.Cancel() + return + } + + view := self.context.GetViewTrait() + oldOriginY, _ := view.ViewPortYBounds() + if self.direction < 0 { + view.ScrollUp(1) + } else { + view.ScrollDown(1) + } + newOriginY, _ := view.ViewPortYBounds() + if newOriginY == oldOriginY { + self.Cancel() + return + } + + if !self.onScroll(newOriginY + self.pointerViewportY) { + self.Cancel() + return + } + + self.schedule(generation, self.interval) +} + +// dragAutoscrollZone returns the scroll direction and tick interval for a +// pointer position: anything beyond the view scrolls very fast, the outermost +// viewport row scrolls fast, the row just inside it scrolls slowly, and anything +// further inside doesn't scroll at all. +func dragAutoscrollZone(viewportHeight int, pointerViewportY int) (int, time.Duration) { + switch { + case pointerViewportY < 0: + return -1, dragAutoscrollVeryFastInterval + case pointerViewportY == 0: + return -1, dragAutoscrollFastInterval + case pointerViewportY == 1: + return -1, dragAutoscrollSlowInterval + case pointerViewportY > viewportHeight-1: + return 1, dragAutoscrollVeryFastInterval + case pointerViewportY == viewportHeight-1: + return 1, dragAutoscrollFastInterval + case pointerViewportY == viewportHeight-2: + return 1, dragAutoscrollSlowInterval + default: + return 0, 0 + } +} diff --git a/pkg/gui/controllers/helpers/drag_autoscroller_test.go b/pkg/gui/controllers/helpers/drag_autoscroller_test.go new file mode 100644 index 000000000..40faa789e --- /dev/null +++ b/pkg/gui/controllers/helpers/drag_autoscroller_test.go @@ -0,0 +1,62 @@ +package helpers + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDragAutoscrollZone(t *testing.T) { + testCases := []struct { + name string + pointerViewportY int + expectedDirection int + expectedInterval time.Duration + }{ + {name: "above view", pointerViewportY: -1, expectedDirection: -1, expectedInterval: dragAutoscrollVeryFastInterval}, + {name: "top outer row", pointerViewportY: 0, expectedDirection: -1, expectedInterval: dragAutoscrollFastInterval}, + {name: "top inner row", pointerViewportY: 1, expectedDirection: -1, expectedInterval: dragAutoscrollSlowInterval}, + {name: "middle", pointerViewportY: 5}, + {name: "bottom inner row", pointerViewportY: 8, expectedDirection: 1, expectedInterval: dragAutoscrollSlowInterval}, + {name: "bottom outer row", pointerViewportY: 9, expectedDirection: 1, expectedInterval: dragAutoscrollFastInterval}, + {name: "below view", pointerViewportY: 10, expectedDirection: 1, expectedInterval: dragAutoscrollVeryFastInterval}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + direction, interval := dragAutoscrollZone(10, testCase.pointerViewportY) + + assert.Equal(t, testCase.expectedDirection, direction) + assert.Equal(t, testCase.expectedInterval, interval) + }) + } +} + +func TestDragAutoscrollerDoesNotRestartWhenMovingToOuterEdge(t *testing.T) { + self := &DragAutoscroller{ + generation: 1, + direction: 1, + interval: dragAutoscrollSlowInterval, + } + + generation, schedule := self.updateState(1, dragAutoscrollFastInterval) + + assert.Equal(t, uint64(1), generation) + assert.False(t, schedule) + assert.Equal(t, dragAutoscrollFastInterval, self.interval) +} + +func TestStaleDragAutoscrollTickDoesNotCancelCurrentGeneration(t *testing.T) { + self := &DragAutoscroller{ + generation: 4, + direction: 1, + interval: dragAutoscrollFastInterval, + } + + self.tick(2) + + assert.Equal(t, uint64(4), self.generation) + assert.Equal(t, 1, self.direction) + assert.Equal(t, dragAutoscrollFastInterval, self.interval) +} From b682fb7635df0912d47c015834bf6c330ce09aef Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:52:07 +0200 Subject: [PATCH 27/97] Auto-scroll when dragging to create range selection in staging view When the pointer reaches the edge of the view during a drag (or leaves the view entirely, which mouse capture makes possible), keep scrolling and extending the selection: slowly on the innermost edge row, faster on the outermost row, and very fast beyond. Scrolling starts after a short delay so that a drag merely passing near the edge doesn't scroll. When the view loses focus mid-drag (e.g. because a popup appeared), cancel the autoscroll and the mouse capture. --- .../controllers/patch_explorer_controller.go | 90 ++++++++++++++++--- pkg/gui/gui_driver.go | 4 + .../components/assertion_helper.go | 19 ++++ pkg/integration/components/test_test.go | 4 + pkg/integration/components/view_driver.go | 28 ++++++ pkg/integration/tests/test_list.go | 1 + .../tests/ui/range_select_with_autoscroll.go | 38 ++++++++ pkg/integration/types/types.go | 3 + 8 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 pkg/integration/tests/ui/range_select_with_autoscroll.go diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index aa5fd54bb..70dfb8f4e 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) @@ -19,18 +20,27 @@ func NewPatchExplorerControllerFactory(c *ControllerCommon) *PatchExplorerContro } func (self *PatchExplorerControllerFactory) Create(context types.IPatchExplorerContext) *PatchExplorerController { - return &PatchExplorerController{ + controller := &PatchExplorerController{ baseController: baseController{}, c: self.c, context: context, } + controller.dragAutoscroller = helpers.NewDragAutoscroller( + self.c.HelperCommon, + context, + controller.canDragAutoscroll, + controller.handleDragAutoscroll, + ) + return controller } type PatchExplorerController struct { baseController c *ControllerCommon - context types.IPatchExplorerContext + context types.IPatchExplorerContext + dragAutoscroller *helpers.DragAutoscroller + draggingWithMouse bool } func (self *PatchExplorerController) Context() types.Context { @@ -153,10 +163,74 @@ func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsO ViewName: self.context.GetViewName(), Key: gocui.MouseLeft, Modifier: gocui.ModMotion, - Handler: func(gocui.ViewMouseBindingOpts) error { - return self.withRenderAndFocus(self.HandleMouseDrag)() - }, + Handler: self.handleMouseDrag, }, + { + ViewName: self.context.GetViewName(), + Key: gocui.MouseRelease, + Handler: func(gocui.ViewMouseBindingOpts) error { return self.handleDragRelease() }, + }, + } +} + +func (self *PatchExplorerController) handleMouseDrag(opts gocui.ViewMouseBindingOpts) error { + if err := self.withLock(func() error { + self.context.GetState().DragSelectLine(opts.Y) + self.renderDragSelection() + return nil + })(); err != nil { + return err + } + + self.draggingWithMouse = true + originY, _ := self.context.GetViewTrait().ViewPortYBounds() + self.dragAutoscroller.Update(opts.Y - originY) + return nil +} + +func (self *PatchExplorerController) canDragAutoscroll(int) bool { + state := self.context.GetState() + return state != nil && state.SelectingRange() +} + +func (self *PatchExplorerController) handleDragAutoscroll(viewIndex int) bool { + if !self.canDragAutoscroll(0) { + return false + } + + if err := self.withLock(func() error { + self.context.GetState().DragSelectLine(viewIndex) + self.renderDragSelection() + return nil + })(); err != nil { + return false + } + return true +} + +func (self *PatchExplorerController) renderDragSelection() { + view := self.context.GetView() + state := self.context.GetState() + originY := view.OriginY() + startIndex, _ := state.SelectedViewRange() + view.SetRangeSelectStart(startIndex) + view.SetCursorY(state.GetSelectedViewLineIdx() - originY) + self.context.Render() +} + +func (self *PatchExplorerController) handleDragRelease() error { + self.draggingWithMouse = false + self.dragAutoscroller.Cancel() + return nil +} + +func (self *PatchExplorerController) GetOnFocusLost() func(types.OnFocusLostOpts) { + return func(types.OnFocusLostOpts) { + self.dragAutoscroller.Cancel() + if self.draggingWithMouse { + self.draggingWithMouse = false + self.c.GocuiGui().CancelMouseCapture() + } } } @@ -266,12 +340,6 @@ func (self *PatchExplorerController) HandleMouseDown() error { return nil } -func (self *PatchExplorerController) HandleMouseDrag() error { - self.context.GetState().DragSelectLine(self.context.GetViewTrait().SelectedLineIdx()) - - return nil -} - func (self *PatchExplorerController) CopySelectedToClipboard() error { selected := self.context.GetState().PlainRenderSelected() diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 66fb3c659..23ca3bab4 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -73,6 +73,10 @@ func (self *GuiDriver) MouseRelease(x, y int) { self.replayMouseEvent(x, y, tcell.ButtonNone) } +func (self *GuiDriver) OnUIThreadAndWait(f func()) { + _ = self.gui.g.OnUIThreadAndWait(func() error { f(); return nil }) +} + func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) { self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, buttons, 0), diff --git a/pkg/integration/components/assertion_helper.go b/pkg/integration/components/assertion_helper.go index 0529e8bec..0a1d97b8c 100644 --- a/pkg/integration/components/assertion_helper.go +++ b/pkg/integration/components/assertion_helper.go @@ -1,9 +1,13 @@ package components import ( + "time" + integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" ) +const eventuallyTimeout = 2 * time.Second + type assertionHelper struct { gui integrationTypes.GuiDriver } @@ -24,6 +28,21 @@ func (self *assertionHelper) assertWithRetries(test func() (bool, string)) { } } +func (self *assertionHelper) assertEventually(test func() (bool, string)) { + deadline := time.Now().Add(eventuallyTimeout) + for { + ok, message := test() + if ok { + return + } + if time.Now().After(deadline) { + self.fail(message) + return + } + time.Sleep(10 * time.Millisecond) + } +} + func (self *assertionHelper) fail(message string) { self.gui.Fail(message) } diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 624c71c6a..3a145bf43 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -53,6 +53,10 @@ func (self *fakeGuiDriver) MouseRelease(x, y int) { self.releasedCoordinates = append(self.releasedCoordinates, coordinate{x: x, y: y}) } +func (self *fakeGuiDriver) OnUIThreadAndWait(f func()) { + f() +} + func (self *fakeGuiDriver) FocusIn() { } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 656a93fe6..937c19b7b 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -343,6 +343,30 @@ func (self *ViewDriver) SelectedLineIdx(expected int) *ViewDriver { return self } +func (self *ViewDriver) SelectedLineIdxAtLeast(expected int) *ViewDriver { + self.t.assertEventually(func() (bool, string) { + var actual int + self.t.gui.OnUIThreadAndWait(func() { + actual = self.getView().SelectedLineIdx() + }) + return actual >= expected, fmt.Sprintf("%s: Expected selected line index to be at least %d, got %d", self.context, expected, actual) + }) + + return self +} + +func (self *ViewDriver) OriginYAtLeast(expected int) *ViewDriver { + self.t.assertEventually(func() (bool, string) { + var actual int + self.t.gui.OnUIThreadAndWait(func() { + actual = self.getView().OriginY() + }) + return actual >= expected, fmt.Sprintf("%s: Expected origin Y to be at least %d, got %d", self.context, expected, actual) + }) + + return self +} + // focus the view (assumes the view is a side-view) func (self *ViewDriver) Focus() *ViewDriver { viewName := self.getView().Name() @@ -505,6 +529,10 @@ func (self *ViewDriver) MouseMove(x, y int) *ViewDriver { return self } +func (self *ViewDriver) MouseMoveToBottom(x int) *ViewDriver { + return self.MouseMove(x, self.getView().InnerHeight()-1) +} + func (self *ViewDriver) RepeatMouseMove() *ViewDriver { self.t.repeatMouseMove() return self diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 77e54e265..0fa259234 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -499,6 +499,7 @@ var tests = []*components.IntegrationTest{ ui.OpenLinkFailure, ui.PromoteTabToSidePanel, ui.RangeSelect, + ui.RangeSelectWithAutoscroll, ui.ReloadSidePanels, ui.ReorderSidePanels, ui.SwitchTabFromMenu, diff --git a/pkg/integration/tests/ui/range_select_with_autoscroll.go b/pkg/integration/tests/ui/range_select_with_autoscroll.go new file mode 100644 index 000000000..83d2bcba3 --- /dev/null +++ b/pkg/integration/tests/ui/range_select_with_autoscroll.go @@ -0,0 +1,38 @@ +package ui + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RangeSelectWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep scrolling while creating a range selection at the panel edge", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInStagingView = false + }, + SetupRepo: func(shell *Shell) { + fileContent := "base\n" + shell.CreateFileAndAdd("file1", fileContent) + for i := 1; i <= 40; i++ { + fileContent += fmt.Sprintf("line %d\n", i) + } + shell.UpdateFile("file1", fileContent) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + PressEnter() + t.Views().Staging(). + ClickAndHold(1, 6). + MouseMoveToBottom(1). + OriginYAtLeast(3). + SelectedLineIdxAtLeast(9). + MouseRelease() + }, +}) diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index bc92c6982..4d2da7602 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -31,6 +31,9 @@ type GuiDriver interface { ClickAndHold(int, int) MouseMove(int, int) MouseRelease(int, int) + // Can be used to avoid data races with the UI thread in the uncommon cases that + // the test driver needs to assert state while the gui is not idle. + OnUIThreadAndWait(func()) // Simulate the terminal window regaining focus (which triggers a reload of // changed config files) FocusIn() From e7998356be50aecb2ef252035b0c02999ca7905f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 22 Jul 2026 09:49:50 +0200 Subject: [PATCH 28/97] Create a range selection by dragging with the mouse in list views Dragging with the left button held now extends the selection from the pressed line, exactly like moving with shift+up/down does. We use the non-sticky flavor so that the range collapses on the next plain cursor movement, again matching the keyboard behavior. The binding is only registered for contexts that support range selection in the first place; dragging in other lists continues to do nothing. --- pkg/gui/controllers/list_controller.go | 22 +++++++++++- pkg/integration/tests/ui/range_select.go | 43 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index b2d45679b..828a9d7db 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -257,6 +257,15 @@ func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error { return nil } +func (self *ListController) HandleDrag(opts gocui.ViewMouseBindingOpts) error { + list := self.context.GetList() + newSelectedLineIdx := self.context.ViewIndexToModelIndex(opts.Y) + list.ExpandNonStickyRange(newSelectedLineIdx - list.GetSelectedLineIdx()) + + self.context.HandleFocus(types.OnFocusOpts{}) + return nil +} + func (self *ListController) pushContextIfNotFocused() error { if !self.isFocused() { self.c.Context().Push(self.context, types.OnFocusOpts{}) @@ -295,7 +304,7 @@ func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types. } func (self *ListController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { - return []*gocui.ViewMouseBinding{ + bindings := []*gocui.ViewMouseBinding{ { ViewName: self.context.GetViewName(), Key: gocui.MouseWheelUp, @@ -312,4 +321,15 @@ func (self *ListController) GetMouseKeybindings(opts types.KeybindingsOpts) []*g Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollDown() }, }, } + + if self.context.RangeSelectEnabled() { + bindings = append(bindings, &gocui.ViewMouseBinding{ + ViewName: self.context.GetViewName(), + Key: gocui.MouseLeft, + Modifier: gocui.ModMotion, + Handler: self.HandleDrag, + }) + } + + return bindings } diff --git a/pkg/integration/tests/ui/range_select.go b/pkg/integration/tests/ui/range_select.go index b021ea65d..4c5d8420a 100644 --- a/pkg/integration/tests/ui/range_select.go +++ b/pkg/integration/tests/ui/range_select.go @@ -33,6 +33,7 @@ var RangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Gui.UseHunkModeInStagingView = false + config.GetUserConfig().Gui.ExpandFocusedSidePanel = true }, SetupRepo: func(shell *Shell) { // We're testing the commits view as our representative list context, @@ -51,6 +52,7 @@ var RangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ } shell.CreateFileAndAdd("file1", "staged\n") shell.UpdateFile("file1", fileContent) + shell.NewBranch("branch1").NewBranch("branch2") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { assertRangeSelectBehaviour := func(v *ViewDriver, focusOtherView func(), lineIdxOfFirstItem int) { @@ -179,5 +181,46 @@ var RangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ PressEnter() assertRangeSelectBehaviour(t.Views().Staging().IsFocused(), func() { t.Views().Staging().PressTab() }, 6) + + t.Views().Branches().Focus() + t.Views().Branches(). + SelectedLines( + Contains("branch2"), + ) + t.Views().Commits(). + ClickAndHold(1, 3). + MouseMoveToView(t.Views().Branches(), 1, 2). + SelectedLines( + Contains("line 1"), + Contains("line 2"), + Contains("line 3"), + Contains("line 4"), + ). + Tap(func() { + t.Views().Branches().SelectedLines( + Contains("branch2"), + ) + }). + MouseRelease() + + t.Views().Branches().Focus() + t.Views().Commits(). + ClickAndHold(1, 0). + SelectedLines( + Contains("line 1"), + ). + RepeatMouseMove(). + SelectedLines( + Contains("line 1"), + ). + MouseMove(1, 3). + SelectedLines( + Contains("line 1"), + Contains("line 2"), + Contains("line 3"), + Contains("line 4"), + ). + MouseRelease(). + Click(1, 0) }, }) From 14434015c739c843b5257116bc63d36531092bd2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:18:30 +0200 Subject: [PATCH 29/97] Keep range drags moving at panel edges Give the list views the same edge autoscroll during drag selection that the staging view already has; the new mouse-release binding stops the autoscroll when the drag ends. --- pkg/gui/controllers/list_controller.go | 68 ++++++++++++++++--- .../tests/ui/range_select_with_autoscroll.go | 9 +++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index 828a9d7db..8136c6aa9 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -2,6 +2,7 @@ package controllers import ( "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -16,18 +17,27 @@ func NewListControllerFactory(c *ControllerCommon) *ListControllerFactory { } func (self *ListControllerFactory) Create(context types.IListContext) *ListController { - return &ListController{ + controller := &ListController{ baseController: baseController{}, c: self.c, context: context, } + controller.dragAutoscroller = helpers.NewDragAutoscroller( + self.c.HelperCommon, + context, + func(int) bool { return context.GetList().IsSelectingRange() }, + controller.handleDragAutoscroll, + ) + return controller } type ListController struct { baseController c *ControllerCommon - context types.IListContext + context types.IListContext + dragAutoscroller *helpers.DragAutoscroller + draggingWithMouse bool } func (self *ListController) Context() types.Context { @@ -258,14 +268,47 @@ func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error { } func (self *ListController) HandleDrag(opts gocui.ViewMouseBindingOpts) error { + self.draggingWithMouse = true + self.selectRangeThroughViewIndex(opts.Y) + originY, _ := self.context.GetViewTrait().ViewPortYBounds() + self.dragAutoscroller.Update(opts.Y - originY) + return nil +} + +func (self *ListController) selectRangeThroughViewIndex(viewIndex int) { list := self.context.GetList() - newSelectedLineIdx := self.context.ViewIndexToModelIndex(opts.Y) + newSelectedLineIdx := self.context.ViewIndexToModelIndex(viewIndex) list.ExpandNonStickyRange(newSelectedLineIdx - list.GetSelectedLineIdx()) self.context.HandleFocus(types.OnFocusOpts{}) +} + +func (self *ListController) handleDragAutoscroll(viewIndex int) bool { + if !self.context.GetList().IsSelectingRange() { + return false + } + + self.context.SetNeedRerenderVisibleLines() + self.selectRangeThroughViewIndex(viewIndex) + return true +} + +func (self *ListController) handleDragRelease() error { + self.draggingWithMouse = false + self.dragAutoscroller.Cancel() return nil } +func (self *ListController) GetOnFocusLost() func(types.OnFocusLostOpts) { + return func(types.OnFocusLostOpts) { + self.dragAutoscroller.Cancel() + if self.draggingWithMouse { + self.draggingWithMouse = false + self.c.GocuiGui().CancelMouseCapture() + } + } +} + func (self *ListController) pushContextIfNotFocused() error { if !self.isFocused() { self.c.Context().Push(self.context, types.OnFocusOpts{}) @@ -323,12 +366,19 @@ func (self *ListController) GetMouseKeybindings(opts types.KeybindingsOpts) []*g } if self.context.RangeSelectEnabled() { - bindings = append(bindings, &gocui.ViewMouseBinding{ - ViewName: self.context.GetViewName(), - Key: gocui.MouseLeft, - Modifier: gocui.ModMotion, - Handler: self.HandleDrag, - }) + bindings = append(bindings, + &gocui.ViewMouseBinding{ + ViewName: self.context.GetViewName(), + Key: gocui.MouseLeft, + Modifier: gocui.ModMotion, + Handler: self.HandleDrag, + }, + &gocui.ViewMouseBinding{ + ViewName: self.context.GetViewName(), + Key: gocui.MouseRelease, + Handler: func(gocui.ViewMouseBindingOpts) error { return self.handleDragRelease() }, + }, + ) } return bindings diff --git a/pkg/integration/tests/ui/range_select_with_autoscroll.go b/pkg/integration/tests/ui/range_select_with_autoscroll.go index 83d2bcba3..94a4fb4a5 100644 --- a/pkg/integration/tests/ui/range_select_with_autoscroll.go +++ b/pkg/integration/tests/ui/range_select_with_autoscroll.go @@ -17,6 +17,7 @@ var RangeSelectWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{ config.GetUserConfig().Gui.UseHunkModeInStagingView = false }, SetupRepo: func(shell *Shell) { + shell.CreateNCommits(40) fileContent := "base\n" shell.CreateFileAndAdd("file1", fileContent) for i := 1; i <= 40; i++ { @@ -25,6 +26,14 @@ var RangeSelectWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{ shell.UpdateFile("file1", fileContent) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches().Focus() + t.Views().Commits(). + ClickAndHold(1, 0). + MouseMoveToBottom(1). + OriginYAtLeast(3). + SelectedLineIdxAtLeast(3). + MouseRelease() + t.Views().Files(). Focus(). PressEnter() From b85483ecc09cb4811e5c80c0b7f57cea80733bf0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 09:05:24 +0200 Subject: [PATCH 30/97] Unify commit movement before adding drag destinations Merge the up/down variants of the move commands into one direction-parameterized implementation. Dragging commits is about to need moves over arbitrary distances, which we don't want to build twice. --- .../controllers/local_commits_controller.go | 65 +++++++------------ 1 file changed, 24 insertions(+), 41 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 708f8fc28..ab16a2a21 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -734,11 +734,25 @@ func (self *LocalCommitsController) isCherryPickingOrReverting() bool { } func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { + return self.move(selectedCommits, startIdx, endIdx, 1) +} + +func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) error { + return self.move(selectedCommits, startIdx, endIdx, -1) +} + +func (self *LocalCommitsController) move(selectedCommits []*models.Commit, startIdx int, endIdx int, offset int) error { if self.isRebasing() { - if err := self.c.Git().Rebase.MoveTodosDown(selectedCommits); err != nil { + var err error + if offset > 0 { + err = self.c.Git().Rebase.MoveTodosDown(selectedCommits) + } else { + err = self.c.Git().Rebase.MoveTodosUp(selectedCommits) + } + if err != nil { return err } - self.context().MoveSelection(1) + self.context().MoveSelection(offset) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) // Block input until the refresh has landed: a quick second press must @@ -753,45 +767,14 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s commits := self.c.Model().Commits return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) - err := self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx) - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( - err, types.RefreshOptions{ - BatchUIUpdates: true, - CommitSelection: types.KeepCommitSelectionIndex, - // Move the selection to follow the moved commit, in Then so it - // lands in the same frame as the refreshed commit list. - Then: func() error { - if err == nil { - self.context().MoveSelection(1) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - } - return nil - }, - }) - }) -} - -func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) error { - if self.isRebasing() { - if err := self.c.Git().Rebase.MoveTodosUp(selectedCommits); err != nil { - return err + var err error + if offset > 0 { + self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) + err = self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx) + } else { + self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) + err = self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx) } - self.context().MoveSelection(-1) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - - // Block input for the same reason as in moveDown. - self.c.RefreshBlockingInput(types.RefreshOptions{ - Scope: []types.RefreshableView{types.REBASE_COMMITS}, - CommitSelection: types.KeepCommitSelectionIndex, - }) - return nil - } - - commits := self.c.Model().Commits - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) - err := self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{ BatchUIUpdates: true, @@ -800,7 +783,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta // lands in the same frame as the refreshed commit list. Then: func() error { if err == nil { - self.context().MoveSelection(-1) + self.context().MoveSelection(offset) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } return nil From e756511042bf65fdac330aec2917d53ed445db9e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 09:17:18 +0200 Subject: [PATCH 31/97] Move dragged commits in one rebase Let the todo-move primitives take a distance instead of hardcoding a single row, by iterating the one-row move in memory. Dropping a commit several rows away thus rewrites the todo file once and, outside of an interactive rebase, runs a single rebase rather than one per row. --- pkg/app/daemon/daemon.go | 20 +++++---- pkg/commands/git_commands/rebase.go | 40 ++++++++--------- .../controllers/local_commits_controller.go | 12 +----- pkg/utils/rebase_todo.go | 31 +++++++++---- pkg/utils/rebase_todo_test.go | 43 +++++++++++++++++++ 5 files changed, 99 insertions(+), 47 deletions(-) diff --git a/pkg/app/daemon/daemon.go b/pkg/app/daemon/daemon.go index df0e4bb49..0b33bc12b 100644 --- a/pkg/app/daemon/daemon.go +++ b/pkg/app/daemon/daemon.go @@ -263,12 +263,14 @@ func (self *MoveFixupCommitDownInstruction) run(common *common.Common) error { } type MoveTodosUpInstruction struct { - Hashes []string + Hashes []string + Distance int } -func NewMoveTodosUpInstruction(hashes []string) Instruction { +func NewMoveTodosUpInstruction(hashes []string, distance int) Instruction { return &MoveTodosUpInstruction{ - Hashes: hashes, + Hashes: hashes, + Distance: distance, } } @@ -288,17 +290,19 @@ func (self *MoveTodosUpInstruction) run(common *common.Common) error { }) return handleInteractiveRebase(common, func(path string) error { - return utils.MoveTodosUp(path, todosToMove, false, getCommentChar()) + return utils.MoveTodos(path, todosToMove, false, -self.Distance, getCommentChar()) }) } type MoveTodosDownInstruction struct { - Hashes []string + Hashes []string + Distance int } -func NewMoveTodosDownInstruction(hashes []string) Instruction { +func NewMoveTodosDownInstruction(hashes []string, distance int) Instruction { return &MoveTodosDownInstruction{ - Hashes: hashes, + Hashes: hashes, + Distance: distance, } } @@ -318,7 +322,7 @@ func (self *MoveTodosDownInstruction) run(common *common.Common) error { }) return handleInteractiveRebase(common, func(path string) error { - return utils.MoveTodosDown(path, todosToMove, false, getCommentChar()) + return utils.MoveTodos(path, todosToMove, false, self.Distance, getCommentChar()) }) } diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index 97d48a1a0..74278b18d 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -112,29 +112,30 @@ func (self *RebaseCommands) GenericAmend(commits []*models.Commit, start, end in } func (self *RebaseCommands) MoveCommitsDown(commits []*models.Commit, startIdx int, endIdx int) error { - baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+2) - - hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string { - return commit.Hash() - }) - - return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ - baseHashOrRoot: baseHashOrRoot, - instruction: daemon.NewMoveTodosDownInstruction(hashes), - overrideEditor: true, - }).Run() + return self.MoveCommits(commits, startIdx, endIdx, 1) } func (self *RebaseCommands) MoveCommitsUp(commits []*models.Commit, startIdx int, endIdx int) error { - baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+1) + return self.MoveCommits(commits, startIdx, endIdx, -1) +} + +func (self *RebaseCommands) MoveCommits(commits []*models.Commit, startIdx int, endIdx int, offset int) error { + baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+max(offset, 0)+1) hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string { return commit.Hash() }) + var instruction daemon.Instruction + if offset > 0 { + instruction = daemon.NewMoveTodosDownInstruction(hashes, offset) + } else { + instruction = daemon.NewMoveTodosUpInstruction(hashes, -offset) + } + return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseHashOrRoot: baseHashOrRoot, - instruction: daemon.NewMoveTodosUpInstruction(hashes), + instruction: instruction, overrideEditor: true, }).Run() } @@ -369,21 +370,20 @@ func (self *RebaseCommands) DeleteUpdateRefTodos(commits []*models.Commit) error } func (self *RebaseCommands) MoveTodosDown(commits []*models.Commit) error { - fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo") - todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo { - return todoFromCommit(commit) - }) - - return utils.MoveTodosDown(fileName, todosToMove, true, self.config.GetCoreCommentChar()) + return self.MoveTodos(commits, 1) } func (self *RebaseCommands) MoveTodosUp(commits []*models.Commit) error { + return self.MoveTodos(commits, -1) +} + +func (self *RebaseCommands) MoveTodos(commits []*models.Commit, offset int) error { fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo") todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo { return todoFromCommit(commit) }) - return utils.MoveTodosUp(fileName, todosToMove, true, self.config.GetCoreCommentChar()) + return utils.MoveTodos(fileName, todosToMove, true, offset, self.config.GetCoreCommentChar()) } // SquashAllAboveFixupCommits squashes all fixup! commits above the given one diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index ab16a2a21..f092fb7df 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -743,13 +743,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta func (self *LocalCommitsController) move(selectedCommits []*models.Commit, startIdx int, endIdx int, offset int) error { if self.isRebasing() { - var err error - if offset > 0 { - err = self.c.Git().Rebase.MoveTodosDown(selectedCommits) - } else { - err = self.c.Git().Rebase.MoveTodosUp(selectedCommits) - } - if err != nil { + if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil { return err } self.context().MoveSelection(offset) @@ -767,14 +761,12 @@ func (self *LocalCommitsController) move(selectedCommits []*models.Commit, start commits := self.c.Model().Commits return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { - var err error if offset > 0 { self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) - err = self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx) } else { self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) - err = self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx) } + err := self.c.Git().Rebase.MoveCommits(commits, startIdx, endIdx, offset) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{ BatchUIUpdates: true, diff --git a/pkg/utils/rebase_todo.go b/pkg/utils/rebase_todo.go index fe04cbc60..3a3577f80 100644 --- a/pkg/utils/rebase_todo.go +++ b/pkg/utils/rebase_todo.go @@ -144,27 +144,40 @@ func deleteTodos(todos []todo.Todo, todosToDelete []Todo) ([]todo.Todo, error) { } func MoveTodosDown(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error { + return MoveTodos(fileName, todosToMove, isInRebase, 1, commentChar) +} + +func MoveTodosUp(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error { + return MoveTodos(fileName, todosToMove, isInRebase, -1, commentChar) +} + +func MoveTodos(fileName string, todosToMove []Todo, isInRebase bool, offset int, commentChar byte) error { todos, err := ReadRebaseTodoFile(fileName, commentChar) if err != nil { return err } - rearrangedTodos, err := moveTodosDown(todos, todosToMove, isInRebase) + rearrangedTodos, err := moveTodos(todos, todosToMove, isInRebase, offset) if err != nil { return err } return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar) } -func MoveTodosUp(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error { - todos, err := ReadRebaseTodoFile(fileName, commentChar) - if err != nil { - return err +func moveTodos(todos []todo.Todo, todosToMove []Todo, isInRebase bool, offset int) ([]todo.Todo, error) { + moveOneRow := moveTodosUp + if offset > 0 { + moveOneRow = moveTodosDown } - rearrangedTodos, err := moveTodosUp(todos, todosToMove, isInRebase) - if err != nil { - return err + + for range max(offset, -offset) { + var err error + todos, err = moveOneRow(todos, slices.Clone(todosToMove), isInRebase) + if err != nil { + return nil, err + } } - return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar) + + return todos, nil } func moveTodoDown(todos []todo.Todo, todoToMove Todo, isInRebase bool) ([]todo.Todo, error) { diff --git a/pkg/utils/rebase_todo_test.go b/pkg/utils/rebase_todo_test.go index 9daf7db01..a9ac1bba5 100644 --- a/pkg/utils/rebase_todo_test.go +++ b/pkg/utils/rebase_todo_test.go @@ -3,12 +3,55 @@ package utils import ( "errors" "fmt" + "slices" "testing" "github.com/stefanhaller/git-todo-parser/todo" "github.com/stretchr/testify/assert" ) +func TestMoveTodos(t *testing.T) { + todos := []todo.Todo{ + {Command: todo.Pick, Commit: "a"}, + {Command: todo.Pick, Commit: "b"}, + {Command: todo.Label, Label: "hidden"}, + {Command: todo.Pick, Commit: "c"}, + {Command: todo.Pick, Commit: "d"}, + {Command: todo.Pick, Commit: "e"}, + {Command: todo.Pick, Commit: "f"}, + } + + t.Run("moves a range up multiple rendered rows", func(t *testing.T) { + actual, err := moveTodos(slices.Clone(todos), []Todo{{Hash: "d"}, {Hash: "c"}}, false, -2) + + assert.NoError(t, err) + assert.Equal(t, []todo.Todo{ + {Command: todo.Pick, Commit: "a"}, + {Command: todo.Pick, Commit: "b"}, + {Command: todo.Label, Label: "hidden"}, + {Command: todo.Pick, Commit: "e"}, + {Command: todo.Pick, Commit: "f"}, + {Command: todo.Pick, Commit: "c"}, + {Command: todo.Pick, Commit: "d"}, + }, actual) + }) + + t.Run("moves a range down multiple rendered rows", func(t *testing.T) { + actual, err := moveTodos(slices.Clone(todos), []Todo{{Hash: "e"}, {Hash: "d"}}, false, 2) + + assert.NoError(t, err) + assert.Equal(t, []todo.Todo{ + {Command: todo.Pick, Commit: "a"}, + {Command: todo.Pick, Commit: "d"}, + {Command: todo.Pick, Commit: "e"}, + {Command: todo.Pick, Commit: "b"}, + {Command: todo.Label, Label: "hidden"}, + {Command: todo.Pick, Commit: "c"}, + {Command: todo.Pick, Commit: "f"}, + }, actual) + }) +} + func TestRebaseCommands_moveTodoDown(t *testing.T) { type scenario struct { testName string From b055d28fb1e9e6f3f7d9253b368eb39d6c2958b0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 09:39:09 +0200 Subject: [PATCH 32/97] Show commit insertion points during a drag Render the insertion point of a commit drag as a non-model item in the commits list. It must be inserted at the right position relative to the section headers, because the list renderer assumes non-model items are ordered by their model index. Not used yet, we'll hook it up to the drag gesture in the next commit. --- pkg/gui/context/local_commits_context.go | 44 +++++++++++++++++++ pkg/gui/context/local_commits_context_test.go | 29 ++++++++++++ pkg/i18n/english.go | 2 + 3 files changed, 75 insertions(+) create mode 100644 pkg/gui/context/local_commits_context_test.go diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 034b2434e..5c01de973 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -2,6 +2,7 @@ package context import ( "log" + "slices" "strings" "sync/atomic" "time" @@ -9,6 +10,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/presentation" + "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) @@ -17,6 +19,12 @@ type LocalCommitsContext struct { *LocalCommitsViewModel *ListContextTrait *SearchTrait + + dropIndicator *commitDropIndicator +} + +type commitDropIndicator struct { + insertionIndex int } var ( @@ -26,6 +34,7 @@ var ( ) func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { + dropIndicator := &commitDropIndicator{insertionIndex: -1} viewModel := NewLocalCommitsViewModel( func() []*models.Commit { return c.Model().Commits }, c, @@ -94,6 +103,8 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { }) } + result = addCommitDropIndicator(result, dropIndicator, c.Tr.MoveCommitsHere) + _, firstRealCommit, found := lo.FindIndexOf( c.Model().Commits, func(c *models.Commit) bool { return !c.IsTODO() @@ -105,6 +116,8 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { Index: firstRealCommit, Content: formatListSectionHeader(c.Tr.CommitsSectionHeader), }) + } else { + result = addCommitDropIndicator(result, dropIndicator, c.Tr.MoveCommitsHere) } return result @@ -113,6 +126,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { ctx := &LocalCommitsContext{ LocalCommitsViewModel: viewModel, SearchTrait: NewSearchTrait(c), + dropIndicator: dropIndicator, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Commits, @@ -137,6 +151,36 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { return ctx } +func addCommitDropIndicator( + items []*NonModelItem, indicator *commitDropIndicator, label string, +) []*NonModelItem { + if indicator.insertionIndex < 0 { + return items + } + + insertAt := len(items) + for i, item := range items { + if item.Index > indicator.insertionIndex { + insertAt = i + break + } + } + + return slices.Insert(items, insertAt, &NonModelItem{ + Index: indicator.insertionIndex, + Content: style.FgCyan.SetBold().Sprintf("━━━━━━ %s ━━━━━━", label), + Column: 6, // align with the commit subject + }) +} + +func (self *LocalCommitsContext) SetDropInsertionIndex(index int) { + self.dropIndicator.insertionIndex = index +} + +func (self *LocalCommitsContext) ClearDropInsertionIndex() { + self.dropIndicator.insertionIndex = -1 +} + type LocalCommitsViewModel struct { *ListViewModel[*models.Commit] diff --git a/pkg/gui/context/local_commits_context_test.go b/pkg/gui/context/local_commits_context_test.go new file mode 100644 index 000000000..67597ff04 --- /dev/null +++ b/pkg/gui/context/local_commits_context_test.go @@ -0,0 +1,29 @@ +package context + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/gui/style" + "github.com/stretchr/testify/assert" +) + +func TestAddCommitDropIndicator(t *testing.T) { + pendingHeader := &NonModelItem{Index: 0, Content: "pending"} + commitsHeader := &NonModelItem{Index: 3, Content: "commits"} + indicator := &commitDropIndicator{insertionIndex: 3} + + items := addCommitDropIndicator([]*NonModelItem{pendingHeader}, indicator, "drop here") + items = append(items, commitsHeader) + + assert.Equal(t, []*NonModelItem{ + pendingHeader, + { + Index: 3, + Content: style.FgCyan.SetBold().Sprint("━━━━━━ drop here ━━━━━━"), + Column: 6, + }, + commitsHeader, + }, items) + assert.Equal(t, 6, modelIndexToViewIndex(4, items, 3)) + assert.Equal(t, 3, viewIndexToModelIndex(4, items, 4)) +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 2e83fed9b..826752b53 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -373,6 +373,7 @@ type TranslationSet struct { PendingCherryPicksSectionHeader string PendingRevertsSectionHeader string CommitsSectionHeader string + MoveCommitsHere string YouDied string RewordNotSupported string ChangingThisActionIsNotAllowed string @@ -1523,6 +1524,7 @@ func EnglishTranslationSet() *TranslationSet { PendingCherryPicksSectionHeader: "Pending cherry-picks", PendingRevertsSectionHeader: "Pending reverts", CommitsSectionHeader: "Commits", + MoveCommitsHere: "drop here", YouDied: "YOU DIED!", RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported", ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed", From eda4ad11922429a5d6d435aaafc6389d97e9d9ab Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:22:02 +0200 Subject: [PATCH 33/97] Drag selected commits to a new position Pressing the left button on the current selection now starts a drag that moves the selected commits, both in the normal commits view and for todos during an interactive rebase. A press anywhere else falls through to the usual click handling, so dragging from an unselected line still creates a range selection, and releasing without having moved collapses the selection to the pressed commit like a plain click would. While dragging, the insertion point follows the pointer: rows below the dragged block insert after the pointed-at commit, rows above it insert before it, and during a rebase the destination is limited to the contiguous block of movable todos around the selection. gocui moves the view cursor along with the pointer, so each drag event moves it back to keep the original selection highlighted. The move happens on release. The model may have been refreshed during the drag, so the dragged commits are located again by their identity (hash, subject, todo action); if they no longer form a unique contiguous block, the drop is ignored rather than guessing. --- .../controllers/local_commits_controller.go | 271 +++++++++++++++++- .../local_commits_controller_test.go | 38 +++ .../drag_keeps_selection_highlighted.go | 35 +++ .../interactive_rebase/drag_to_reorder.go | 80 ++++++ .../drag_to_reorder_in_rebase.go | 72 +++++ pkg/integration/tests/test_list.go | 3 + 6 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/interactive_rebase/drag_keeps_selection_highlighted.go create mode 100644 pkg/integration/tests/interactive_rebase/drag_to_reorder.go create mode 100644 pkg/integration/tests/interactive_rebase/drag_to_reorder_in_rebase.go diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index f092fb7df..e314c0f3f 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -8,6 +8,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -28,7 +29,46 @@ type LocalCommitsController struct { *ListControllerTrait[*models.Commit] c *ControllerCommon - pullFiles PullFilesFn + pullFiles PullFilesFn + commitDrag *commitDragState +} + +// commitDragState tracks a mouse drag that moves the selected commits. It is +// created when the left button is pressed on the current selection, and lives +// until the button is released or the drag is canceled. +type commitDragState struct { + // Model index that was pressed; releasing without having moved collapses + // the selection to this commit, like a plain click would. + pressedIndex int + // Bounds of the selection at press time. + startIndex int + endIndex int + // Identifying information of the dragged commits, so that they can be + // found again on release even if the model was refreshed during the drag. + commitIdentities []commitDragIdentity + // Cursor and range-start position relative to startIndex, for restoring + // the selection after the move. + selectedOffset int + rangeStartOffset int + rangeSelectMode traits.RangeSelectMode + // Smallest and largest allowed insertion index. During a rebase this + // restricts the drag to the contiguous block of movable todos around the + // selection. + minInsertion int + maxInsertion int + // Current insertion index, or -1 if dropping wouldn't move anything + // (pointer over the dragged block itself). + insertionIndex int + // Whether any drag motion arrived since the press; distinguishes a drag + // from a plain click on the selection. + hasMoved bool +} + +type commitDragIdentity struct { + hash string + name string + action todo.TodoCommand + actionFlag string } var _ types.IController = &LocalCommitsController{} @@ -50,6 +90,235 @@ func NewLocalCommitsController( } } +func (self *LocalCommitsController) GetMouseKeybindings(types.KeybindingsOpts) []*gocui.ViewMouseBinding { + viewName := self.context().GetViewName() + return []*gocui.ViewMouseBinding{ + { + ViewName: viewName, + FocusedView: viewName, + Key: gocui.MouseLeft, + Handler: self.handleCommitDragPress, + }, + { + ViewName: viewName, + FocusedView: viewName, + Key: gocui.MouseLeft, + Modifier: gocui.ModMotion, + Handler: self.handleCommitDrag, + }, + { + ViewName: viewName, + FocusedView: viewName, + Key: gocui.MouseRelease, + Handler: self.handleCommitDragRelease, + }, + } +} + +func (self *LocalCommitsController) handleCommitDragPress(opts gocui.ViewMouseBindingOpts) error { + context := self.context() + pressedIndex := context.ViewIndexToModelIndex(opts.Y) + startIndex, endIndex := context.GetSelectionRange() + selectedIndex, rangeStartIndex, rangeSelectMode := context.GetSelectionRangeAndMode() + selectedCommits, _, _ := context.GetSelectedItems() + // Only a single press on the current selection (of commits that may be + // moved) starts a drag; everything else falls through to the generic + // list click handling, i.e. selecting the pressed line, double-click + // actions, or dragging out a range selection. The view-index comparison + // rejects presses on section headers, which map to the model index of a + // nearby commit. + if opts.IsDoubleClick || + pressedIndex < startIndex || pressedIndex > endIndex || + context.ModelIndexToViewIndex(pressedIndex) != opts.Y || + self.midRebaseMoveCommandEnabled(selectedCommits, startIndex, endIndex) != nil { + return gocui.ErrKeybindingNotHandled + } + + minInsertion, maxInsertion := self.commitDragInsertionBounds(startIndex, endIndex) + self.commitDrag = &commitDragState{ + pressedIndex: pressedIndex, + startIndex: startIndex, + endIndex: endIndex, + commitIdentities: lo.Map(selectedCommits, func(commit *models.Commit, _ int) commitDragIdentity { + return commitDragIdentityForCommit(commit) + }), + selectedOffset: selectedIndex - startIndex, + rangeStartOffset: rangeStartIndex - startIndex, + rangeSelectMode: rangeSelectMode, + minInsertion: minInsertion, + maxInsertion: maxInsertion, + insertionIndex: -1, + } + self.restoreCommitDragHighlight() + return nil +} + +func (self *LocalCommitsController) commitDragInsertionBounds(startIndex int, endIndex int) (int, int) { + commits := self.c.Model().Commits + if !self.isRebasing() { + return 0, len(commits) + } + + minInsertion := startIndex + for minInsertion > 0 && commits[minInsertion-1].IsTODO() && commits[minInsertion-1].Status != models.StatusConflicted { + minInsertion-- + } + maxInsertion := endIndex + 1 + for maxInsertion < len(commits) && commits[maxInsertion].IsTODO() && commits[maxInsertion].Status != models.StatusConflicted { + maxInsertion++ + } + return minInsertion, maxInsertion +} + +func (self *LocalCommitsController) handleCommitDrag(opts gocui.ViewMouseBindingOpts) error { + if self.commitDrag == nil { + return gocui.ErrKeybindingNotHandled + } + + self.commitDrag.hasMoved = true + self.restoreCommitDragHighlight() + insertionIndex := self.commitDragInsertionIndex(opts.Y) + if insertionIndex >= self.commitDrag.startIndex && insertionIndex <= self.commitDrag.endIndex+1 { + insertionIndex = -1 + } + if insertionIndex == self.commitDrag.insertionIndex { + return nil + } + + self.commitDrag.insertionIndex = insertionIndex + if insertionIndex < 0 { + self.context().ClearDropInsertionIndex() + } else { + self.context().SetDropInsertionIndex(insertionIndex) + } + self.c.PostRefreshUpdate(self.context()) + return nil +} + +// gocui moves the view cursor to the pointer position before invoking our +// handlers; move it back so that the dragged commits stay highlighted for the +// whole duration of the drag. +func (self *LocalCommitsController) restoreCommitDragHighlight() { + state := self.commitDrag + context := self.context() + view := context.GetView() + selectedIndex := state.startIndex + state.selectedOffset + rangeStartIndex := state.startIndex + state.rangeStartOffset + + view.SetCursorY(context.ModelIndexToViewIndex(selectedIndex) - view.OriginY()) + view.SetRangeSelectStart(context.ModelIndexToViewIndex(rangeStartIndex)) +} + +func (self *LocalCommitsController) commitDragInsertionIndex(viewIndex int) int { + context := self.context() + if viewIndex < 0 { + return self.commitDrag.minInsertion + } + if viewIndex >= context.TotalContentHeight() { + return self.commitDrag.maxInsertion + } + + // Rows above the dragged block insert before the pointed-at commit, rows + // below it insert after it, so that in both directions the line under + // the pointer is the one that makes way. + modelIndex := context.ViewIndexToModelIndex(viewIndex) + insertionIndex := modelIndex + if modelIndex > self.commitDrag.endIndex { + insertionIndex++ + } + return max(self.commitDrag.minInsertion, min(insertionIndex, self.commitDrag.maxInsertion)) +} + +func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindingOpts) error { + if self.commitDrag == nil { + return gocui.ErrKeybindingNotHandled + } + + state := self.commitDrag + self.commitDrag = nil + self.context().ClearDropInsertionIndex() + + if !state.hasMoved { + self.context().SetSelection(state.pressedIndex) + self.c.PostRefreshUpdate(self.context()) + return nil + } + self.c.PostRefreshUpdate(self.context()) + if state.insertionIndex < 0 { + return nil + } + + offset := state.insertionIndex - state.startIndex + if state.insertionIndex > state.endIndex { + offset = state.insertionIndex - state.endIndex - 1 + } + selectedCommits, startIndex, endIndex, found := findCommitDragBlock( + self.context().GetItems(), state.commitIdentities, + ) + if !found { + return nil + } + self.context().SetSelectionRangeAndMode( + startIndex+state.selectedOffset, + startIndex+state.rangeStartOffset, + state.rangeSelectMode, + ) + return self.move(selectedCommits, startIndex, endIndex, offset) +} + +func commitDragIdentityForCommit(commit *models.Commit) commitDragIdentity { + return commitDragIdentity{ + hash: commit.Hash(), + name: commit.Name, + action: commit.Action, + actionFlag: commit.ActionFlag, + } +} + +// findCommitDragBlock locates the dragged commits in the (possibly refreshed) +// commit list by their identity rather than by the indices recorded at press +// time. If they no longer exist as a contiguous block, or more than one block +// matches, we give up rather than guess. +func findCommitDragBlock( + commits []*models.Commit, identities []commitDragIdentity, +) ([]*models.Commit, int, int, bool) { + matchStart := -1 + for startIndex := 0; startIndex+len(identities) <= len(commits); startIndex++ { + matches := true + for offset, identity := range identities { + if commitDragIdentityForCommit(commits[startIndex+offset]) != identity { + matches = false + break + } + } + if matches { + if matchStart >= 0 { + return nil, -1, -1, false + } + matchStart = startIndex + } + } + + if matchStart < 0 { + return nil, -1, -1, false + } + endIndex := matchStart + len(identities) - 1 + return commits[matchStart : endIndex+1], matchStart, endIndex, true +} + +func (self *LocalCommitsController) GetOnFocusLost() func(types.OnFocusLostOpts) { + return func(types.OnFocusLostOpts) { + if self.commitDrag == nil { + return + } + + self.commitDrag = nil + self.c.GocuiGui().CancelMouseCapture() + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) + } +} + func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { editCommitKey := opts.Config.Universal.Edit diff --git a/pkg/gui/controllers/local_commits_controller_test.go b/pkg/gui/controllers/local_commits_controller_test.go index c5c5e7a5d..0f0a4d137 100644 --- a/pkg/gui/controllers/local_commits_controller_test.go +++ b/pkg/gui/controllers/local_commits_controller_test.go @@ -4,9 +4,47 @@ import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/stretchr/testify/assert" ) +func TestFindCommitDragBlock(t *testing.T) { + commit := func(hash string) *models.Commit { + return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash}) + } + identities := []commitDragIdentity{ + commitDragIdentityForCommit(commit("b")), + commitDragIdentityForCommit(commit("c")), + } + + t.Run("finds the original block after selection changes", func(t *testing.T) { + commits := []*models.Commit{commit("a"), commit("b"), commit("c"), commit("d")} + + actual, startIndex, endIndex, found := findCommitDragBlock(commits, identities) + + assert.True(t, found) + assert.Equal(t, commits[1:3], actual) + assert.Equal(t, 1, startIndex) + assert.Equal(t, 2, endIndex) + }) + + t.Run("rejects a block that is no longer contiguous", func(t *testing.T) { + _, _, _, found := findCommitDragBlock( + []*models.Commit{commit("a"), commit("b"), commit("d"), commit("c")}, identities, + ) + + assert.False(t, found) + }) + + t.Run("rejects an ambiguous block", func(t *testing.T) { + _, _, _, found := findCommitDragBlock( + []*models.Commit{commit("b"), commit("c"), commit("b"), commit("c")}, identities, + ) + + assert.False(t, found) + }) +} + func Test_countSquashableCommitsAbove(t *testing.T) { scenarios := []struct { name string diff --git a/pkg/integration/tests/interactive_rebase/drag_keeps_selection_highlighted.go b/pkg/integration/tests/interactive_rebase/drag_keeps_selection_highlighted.go new file mode 100644 index 000000000..32ef54272 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_keeps_selection_highlighted.go @@ -0,0 +1,35 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragKeepsSelectionHighlighted = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep the original commit range highlighted while dragging sideways", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(5) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.RangeSelectDown). + ClickAndHold(1, 1). + SelectedLines( + Contains("commit-05"), + Contains("commit-04"), + Contains("commit-03"), + ). + MouseMove(10, 1). + SelectedLines( + Contains("commit-05"), + Contains("commit-04"), + Contains("commit-03"), + ). + MouseRelease() + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/drag_to_reorder.go b/pkg/integration/tests/interactive_rebase/drag_to_reorder.go new file mode 100644 index 000000000..77aab7d67 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder.go @@ -0,0 +1,80 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragToReorder = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Drag a selected commit range multiple rows in one operation", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(5) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.RangeSelectDown). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + ClickAndHold(1, 1). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + MouseMove(1, 3). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("drop here"), + Contains("commit-01"), + ). + SelectNextItem(). + SelectedLines( + Contains("commit-03"), + ). + MouseRelease(). + TopLines( + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-01"), + ). + ClickAndHold(1, 2). + MouseMove(1, 0). + TopLines( + Contains("drop here"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-01"), + ). + MouseRelease(). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + ClickAndHold(1, 1). + MouseRelease(). + SelectedLines( + Contains("commit-04"), + ) + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/drag_to_reorder_in_rebase.go b/pkg/integration/tests/interactive_rebase/drag_to_reorder_in_rebase.go new file mode 100644 index 000000000..1d27a1635 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder_in_rebase.go @@ -0,0 +1,72 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragToReorderInRebase = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Drag rebase todos without allowing real commits to move", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(5) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + NavigateToLine(Contains("commit-01")). + Press(keys.Universal.Edit). + Lines( + Contains("─── Pending rebase todos"), + Contains("commit-05"), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("─── Commits"), + Contains("commit-01").IsSelected(), + ). + NavigateToLine(Contains("commit-05")). + ClickAndHold(1, 1). + MouseMove(1, 6). + Lines( + Contains("─── Pending rebase todos"), + Contains("commit-05").IsSelected(), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("drop here"), + Contains("─── Commits"), + Contains("commit-01"), + ). + MouseRelease(). + Lines( + Contains("─── Pending rebase todos"), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-05").IsSelected(), + Contains("─── Commits"), + Contains("commit-01"), + ). + NavigateToLine(Contains("commit-01")). + ClickAndHold(1, 6). + MouseMove(1, 4). + SelectedLines( + Contains("commit-05"), + Contains("─── Commits"), + Contains("commit-01"), + ). + MouseRelease(). + Lines( + Contains("─── Pending rebase todos"), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-05").IsSelected(), + Contains("─── Commits").IsSelected(), + Contains("commit-01").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 0fa259234..fdc375dca 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -290,6 +290,9 @@ var tests = []*components.IntegrationTest{ interactive_rebase.AmendNonHeadCommitDuringRebase, interactive_rebase.DeleteUpdateRefTodo, interactive_rebase.DontShowBranchHeadsForTodoItems, + interactive_rebase.DragKeepsSelectionHighlighted, + interactive_rebase.DragToReorder, + interactive_rebase.DragToReorderInRebase, interactive_rebase.DropCommitInCopiedBranchWithUpdateRef, interactive_rebase.DropMergeCommit, interactive_rebase.DropTodoCommitWithUpdateRef, From 0738d55551439d79ef9de424e37a3a08f957164b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:24:04 +0200 Subject: [PATCH 34/97] Keep scrolling while commits are dragged at an edge Reuse the drag autoscroller for commit drags. Scrolling stops once the insertion point reaches the end of the allowed range in the scroll direction, so during a rebase the view doesn't keep scrolling once the last insertion position among the todos has been reached. --- .../controllers/local_commits_controller.go | 58 ++++++++++++++++--- .../drag_to_reorder_with_autoscroll.go | 35 +++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 pkg/integration/tests/interactive_rebase/drag_to_reorder_with_autoscroll.go diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index e314c0f3f..8522c39bf 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -29,8 +29,9 @@ type LocalCommitsController struct { *ListControllerTrait[*models.Commit] c *ControllerCommon - pullFiles PullFilesFn - commitDrag *commitDragState + pullFiles PullFilesFn + commitDrag *commitDragState + dragAutoscroller *helpers.DragAutoscroller } // commitDragState tracks a mouse drag that moves the selected commits. It is @@ -77,7 +78,7 @@ func NewLocalCommitsController( c *ControllerCommon, pullFiles PullFilesFn, ) *LocalCommitsController { - return &LocalCommitsController{ + controller := &LocalCommitsController{ baseController: baseController{}, c: c, pullFiles: pullFiles, @@ -88,6 +89,13 @@ func NewLocalCommitsController( c.Contexts().LocalCommits.GetSelectedItems, ), } + controller.dragAutoscroller = helpers.NewDragAutoscroller( + c.HelperCommon, + c.Contexts().LocalCommits, + controller.canCommitDragAutoscroll, + controller.handleCommitDragAutoscroll, + ) + return controller } func (self *LocalCommitsController) GetMouseKeybindings(types.KeybindingsOpts) []*gocui.ViewMouseBinding { @@ -176,13 +184,22 @@ func (self *LocalCommitsController) handleCommitDrag(opts gocui.ViewMouseBinding } self.commitDrag.hasMoved = true + if self.updateCommitDragInsertion(opts.Y) { + self.c.PostRefreshUpdate(self.context()) + } + originY := self.context().GetView().OriginY() + self.dragAutoscroller.Update(opts.Y - originY) self.restoreCommitDragHighlight() - insertionIndex := self.commitDragInsertionIndex(opts.Y) + return nil +} + +func (self *LocalCommitsController) updateCommitDragInsertion(viewIndex int) bool { + insertionIndex := self.commitDragInsertionIndex(viewIndex) if insertionIndex >= self.commitDrag.startIndex && insertionIndex <= self.commitDrag.endIndex+1 { insertionIndex = -1 } if insertionIndex == self.commitDrag.insertionIndex { - return nil + return false } self.commitDrag.insertionIndex = insertionIndex @@ -191,8 +208,7 @@ func (self *LocalCommitsController) handleCommitDrag(opts gocui.ViewMouseBinding } else { self.context().SetDropInsertionIndex(insertionIndex) } - self.c.PostRefreshUpdate(self.context()) - return nil + return true } // gocui moves the view cursor to the pointer position before invoking our @@ -235,6 +251,7 @@ func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindi } state := self.commitDrag + self.dragAutoscroller.Cancel() self.commitDrag = nil self.context().ClearDropInsertionIndex() @@ -312,6 +329,7 @@ func (self *LocalCommitsController) GetOnFocusLost() func(types.OnFocusLostOpts) return } + self.dragAutoscroller.Cancel() self.commitDrag = nil self.c.GocuiGui().CancelMouseCapture() self.context().ClearDropInsertionIndex() @@ -319,6 +337,32 @@ func (self *LocalCommitsController) GetOnFocusLost() func(types.OnFocusLostOpts) } } +// Stop autoscrolling once the insertion point has reached the end of the +// allowed range in the scroll direction; e.g. during a rebase there is no +// point in scrolling on into the section of real commits. +func (self *LocalCommitsController) canCommitDragAutoscroll(direction int) bool { + state := self.commitDrag + if state == nil { + return false + } + if direction < 0 { + return state.insertionIndex != state.minInsertion + } + return state.insertionIndex != state.maxInsertion +} + +func (self *LocalCommitsController) handleCommitDragAutoscroll(viewIndex int) bool { + if self.commitDrag == nil { + return false + } + + self.updateCommitDragInsertion(viewIndex) + self.context().SetNeedRerenderVisibleLines() + self.context().HandleRender() + self.restoreCommitDragHighlight() + return self.canCommitDragAutoscroll(self.dragAutoscroller.Direction()) +} + func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { editCommitKey := opts.Config.Universal.Edit diff --git a/pkg/integration/tests/interactive_rebase/drag_to_reorder_with_autoscroll.go b/pkg/integration/tests/interactive_rebase/drag_to_reorder_with_autoscroll.go new file mode 100644 index 000000000..d8086e0a3 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder_with_autoscroll.go @@ -0,0 +1,35 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragToReorderWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep scrolling commits while a dragged commit is held at the panel edge", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(40) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + TopLines( + Contains("commit-40").IsSelected(), + ). + ClickAndHold(1, 0). + MouseMoveToBottom(1). + OriginYAtLeast(3). + MouseRelease(). + SelectedLines( + Contains("commit-40"), + ). + SelectedLineIdxAtLeast(3). + GotoTop(). + TopLines( + Contains("commit-39").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index fdc375dca..fe8478b9c 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -293,6 +293,7 @@ var tests = []*components.IntegrationTest{ interactive_rebase.DragKeepsSelectionHighlighted, interactive_rebase.DragToReorder, interactive_rebase.DragToReorderInRebase, + interactive_rebase.DragToReorderWithAutoscroll, interactive_rebase.DropCommitInCopiedBranchWithUpdateRef, interactive_rebase.DropMergeCommit, interactive_rebase.DropTodoCommitWithUpdateRef, From cefec1c5c9f8da7692b390d34b0405fbcdb8d375 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 12:47:28 +0200 Subject: [PATCH 35/97] Cancel commit drags with escape While a commit drag is in progress, escape now aborts it: the drag state and the drop indicator are discarded and the mouse capture is released, so nothing happens when the button is eventually released. Otherwise escape keeps its normal meaning. --- .../controllers/local_commits_controller.go | 27 +++++++++++++++---- .../interactive_rebase/drag_to_reorder.go | 27 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 8522c39bf..184fa7791 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -329,14 +329,27 @@ func (self *LocalCommitsController) GetOnFocusLost() func(types.OnFocusLostOpts) return } - self.dragAutoscroller.Cancel() - self.commitDrag = nil - self.c.GocuiGui().CancelMouseCapture() - self.context().ClearDropInsertionIndex() - self.c.PostRefreshUpdate(self.context()) + self.cancelCommitDrag() } } +func (self *LocalCommitsController) cancelCommitDrag() { + self.dragAutoscroller.Cancel() + self.commitDrag = nil + self.c.GocuiGui().CancelMouseCapture() + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) +} + +func (self *LocalCommitsController) handleCommitDragCancel() error { + if self.commitDrag == nil { + return gocui.ErrKeybindingNotHandled + } + + self.cancelCommitDrag() + return nil +} + // Stop autoscrolling once the insertion point has reached the end of the // allowed range in the scroll direction; e.g. during a rebase there is no // point in scrolling on into the section of real commits. @@ -367,6 +380,10 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ editCommitKey := opts.Config.Universal.Edit bindings := []*types.Binding{ + { + Keys: opts.GetKeys(opts.Config.Universal.Return), + Handler: self.handleCommitDragCancel, + }, { Keys: opts.GetKeys(opts.Config.Commits.SquashDown), Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.squashDown)), diff --git a/pkg/integration/tests/interactive_rebase/drag_to_reorder.go b/pkg/integration/tests/interactive_rebase/drag_to_reorder.go index 77aab7d67..4d2f883a0 100644 --- a/pkg/integration/tests/interactive_rebase/drag_to_reorder.go +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder.go @@ -25,6 +25,33 @@ var DragToReorder = NewIntegrationTest(NewIntegrationTestArgs{ Contains("commit-01"), ). ClickAndHold(1, 1). + MouseMove(1, 3). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("drop here"), + Contains("commit-01"), + ). + PressEscape(). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + MouseMove(1, 4). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + MouseRelease(). + ClickAndHold(1, 1). TopLines( Contains("commit-05").IsSelected(), Contains("commit-04").IsSelected(), From 104fdf34a9bcd50a1e7260d75d4fdc1749de3a0c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 14:55:32 +0200 Subject: [PATCH 36/97] Keep the destination visible while commits move Moving commits runs a rebase, which can take a while. Instead of letting the drop indicator vanish the moment the button is released, keep it in place and turn it into a "moving commits here" spinner once the move takes longer than a short grace period, so that quick moves stay free of flicker. The indicator is cleared when the post-move refresh lands. --- pkg/gui/context/local_commits_context.go | 39 +++++++- pkg/gui/context/local_commits_context_test.go | 26 ++++- .../controllers/local_commits_controller.go | 95 +++++++++++++++++-- pkg/gui/gui_driver.go | 14 ++- pkg/i18n/english.go | 2 + 5 files changed, 162 insertions(+), 14 deletions(-) diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 5c01de973..7f34ea7dc 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -1,6 +1,7 @@ package context import ( + "fmt" "log" "slices" "strings" @@ -8,6 +9,7 @@ import ( "time" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/style" @@ -25,6 +27,7 @@ type LocalCommitsContext struct { type commitDropIndicator struct { insertionIndex int + moving bool } var ( @@ -103,7 +106,14 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { }) } - result = addCommitDropIndicator(result, dropIndicator, c.Tr.MoveCommitsHere) + result = addCommitDropIndicator( + result, + dropIndicator, + c.Tr.MoveCommitsHere, + c.Tr.MovingCommitsHere, + c.UserConfig().Gui.Spinner, + time.Now(), + ) _, firstRealCommit, found := lo.FindIndexOf( c.Model().Commits, func(c *models.Commit) bool { @@ -117,7 +127,14 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { Content: formatListSectionHeader(c.Tr.CommitsSectionHeader), }) } else { - result = addCommitDropIndicator(result, dropIndicator, c.Tr.MoveCommitsHere) + result = addCommitDropIndicator( + result, + dropIndicator, + c.Tr.MoveCommitsHere, + c.Tr.MovingCommitsHere, + c.UserConfig().Gui.Spinner, + time.Now(), + ) } return result @@ -152,11 +169,20 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { } func addCommitDropIndicator( - items []*NonModelItem, indicator *commitDropIndicator, label string, + items []*NonModelItem, + indicator *commitDropIndicator, + dropLabel string, + movingLabel string, + spinnerConfig config.SpinnerConfig, + now time.Time, ) []*NonModelItem { if indicator.insertionIndex < 0 { return items } + label := dropLabel + if indicator.moving { + label = fmt.Sprintf("%s %s", movingLabel, presentation.Loader(now, spinnerConfig)) + } insertAt := len(items) for i, item := range items { @@ -175,10 +201,17 @@ func addCommitDropIndicator( func (self *LocalCommitsContext) SetDropInsertionIndex(index int) { self.dropIndicator.insertionIndex = index + self.dropIndicator.moving = false +} + +func (self *LocalCommitsContext) SetMovingCommitsInsertionIndex(index int) { + self.dropIndicator.insertionIndex = index + self.dropIndicator.moving = true } func (self *LocalCommitsContext) ClearDropInsertionIndex() { self.dropIndicator.insertionIndex = -1 + self.dropIndicator.moving = false } type LocalCommitsViewModel struct { diff --git a/pkg/gui/context/local_commits_context_test.go b/pkg/gui/context/local_commits_context_test.go index 67597ff04..f93af3a72 100644 --- a/pkg/gui/context/local_commits_context_test.go +++ b/pkg/gui/context/local_commits_context_test.go @@ -2,7 +2,9 @@ package context import ( "testing" + "time" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/stretchr/testify/assert" ) @@ -11,8 +13,11 @@ func TestAddCommitDropIndicator(t *testing.T) { pendingHeader := &NonModelItem{Index: 0, Content: "pending"} commitsHeader := &NonModelItem{Index: 3, Content: "commits"} indicator := &commitDropIndicator{insertionIndex: 3} + spinnerConfig := config.SpinnerConfig{Frames: []string{"one", "two"}, Rate: 100} - items := addCommitDropIndicator([]*NonModelItem{pendingHeader}, indicator, "drop here") + items := addCommitDropIndicator( + []*NonModelItem{pendingHeader}, indicator, "drop here", "moving commits here", spinnerConfig, time.UnixMilli(0), + ) items = append(items, commitsHeader) assert.Equal(t, []*NonModelItem{ @@ -27,3 +32,22 @@ func TestAddCommitDropIndicator(t *testing.T) { assert.Equal(t, 6, modelIndexToViewIndex(4, items, 3)) assert.Equal(t, 3, viewIndexToModelIndex(4, items, 4)) } + +func TestAddMovingCommitsIndicator(t *testing.T) { + items := addCommitDropIndicator( + nil, + &commitDropIndicator{insertionIndex: 2, moving: true}, + "drop here", + "moving commits here", + config.SpinnerConfig{Frames: []string{"one", "two"}, Rate: 100}, + time.UnixMilli(100), + ) + + assert.Equal(t, []*NonModelItem{ + { + Index: 2, + Content: style.FgCyan.SetBold().Sprint("━━━━━━ moving commits here two ━━━━━━"), + Column: 6, + }, + }, items) +} diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 184fa7791..17c1dcf30 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -2,6 +2,7 @@ package controllers import ( "strings" + "time" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -20,6 +21,10 @@ import ( // after selecting the 200th commit, we'll load in all the rest const COMMIT_THRESHOLD = 200 +// How long a commit move may take before the drop indicator switches to a +// "moving commits here" spinner; quick moves stay free of flicker. +const commitDragMovingIndicatorDelay = 200 * time.Millisecond + type ( PullFilesFn func() error ) @@ -29,9 +34,10 @@ type LocalCommitsController struct { *ListControllerTrait[*models.Commit] c *ControllerCommon - pullFiles PullFilesFn - commitDrag *commitDragState - dragAutoscroller *helpers.DragAutoscroller + pullFiles PullFilesFn + commitDrag *commitDragState + dragAutoscroller *helpers.DragAutoscroller + movingCommitsIndicatorStop chan struct{} } // commitDragState tracks a mouse drag that moves the selected commits. It is @@ -253,15 +259,16 @@ func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindi state := self.commitDrag self.dragAutoscroller.Cancel() self.commitDrag = nil - self.context().ClearDropInsertionIndex() if !state.hasMoved { + self.context().ClearDropInsertionIndex() self.context().SetSelection(state.pressedIndex) self.c.PostRefreshUpdate(self.context()) return nil } - self.c.PostRefreshUpdate(self.context()) if state.insertionIndex < 0 { + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) return nil } @@ -273,6 +280,8 @@ func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindi self.context().GetItems(), state.commitIdentities, ) if !found { + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) return nil } self.context().SetSelectionRangeAndMode( @@ -280,7 +289,69 @@ func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindi startIndex+state.rangeStartOffset, state.rangeSelectMode, ) - return self.move(selectedCommits, startIndex, endIndex, offset) + self.startMovingCommitsIndicator(state.insertionIndex) + if err := self.move(selectedCommits, startIndex, endIndex, offset, + func() error { self.stopMovingCommitsIndicator(); return nil }); err != nil { + self.stopMovingCommitsIndicator() + return err + } + return nil +} + +// startMovingCommitsIndicator keeps the drop indicator visible while the move +// is running, turning it into a spinner once the grace period elapses. The +// ticker goroutine only ever touches state from the UI thread, where the +// comparison against the current stop channel makes late callbacks harmless. +func (self *LocalCommitsController) startMovingCommitsIndicator(insertionIndex int) { + self.stopMovingCommitsIndicatorTicker() + stop := make(chan struct{}) + self.movingCommitsIndicatorStop = stop + go utils.Safe(func() { + graceTimer := time.NewTimer(commitDragMovingIndicatorDelay) + defer graceTimer.Stop() + select { + case <-graceTimer.C: + self.c.OnUIThreadContentOnlyBackground(func() error { + if self.movingCommitsIndicatorStop == stop { + self.context().SetMovingCommitsInsertionIndex(insertionIndex) + self.context().HandleRender() + } + return nil + }) + case <-stop: + return + } + + rate := time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate) + ticker := time.NewTicker(rate) + defer ticker.Stop() + for { + select { + case <-ticker.C: + self.c.OnUIThreadContentOnlyBackground(func() error { + if self.movingCommitsIndicatorStop == stop { + self.context().HandleRender() + } + return nil + }) + case <-stop: + return + } + } + }) +} + +func (self *LocalCommitsController) stopMovingCommitsIndicator() { + self.stopMovingCommitsIndicatorTicker() + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) +} + +func (self *LocalCommitsController) stopMovingCommitsIndicatorTicker() { + if self.movingCommitsIndicatorStop != nil { + close(self.movingCommitsIndicatorStop) + self.movingCommitsIndicatorStop = nil + } } func commitDragIdentityForCommit(commit *models.Commit) commitDragIdentity { @@ -1064,14 +1135,16 @@ func (self *LocalCommitsController) isCherryPickingOrReverting() bool { } func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { - return self.move(selectedCommits, startIdx, endIdx, 1) + return self.move(selectedCommits, startIdx, endIdx, 1, nil) } func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) error { - return self.move(selectedCommits, startIdx, endIdx, -1) + return self.move(selectedCommits, startIdx, endIdx, -1, nil) } -func (self *LocalCommitsController) move(selectedCommits []*models.Commit, startIdx int, endIdx int, offset int) error { +func (self *LocalCommitsController) move( + selectedCommits []*models.Commit, startIdx int, endIdx int, offset int, onComplete func() error, +) error { if self.isRebasing() { if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil { return err @@ -1085,6 +1158,7 @@ func (self *LocalCommitsController) move(selectedCommits []*models.Commit, start self.c.RefreshBlockingInput(types.RefreshOptions{ Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, + Then: onComplete, }) return nil } @@ -1108,6 +1182,9 @@ func (self *LocalCommitsController) move(selectedCommits []*models.Commit, start self.context().MoveSelection(offset) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } + if onComplete != nil { + return onComplete() + } return nil }, }) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 23ca3bab4..25bfcf2d5 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -73,16 +73,28 @@ func (self *GuiDriver) MouseRelease(x, y int) { self.replayMouseEvent(x, y, tcell.ButtonNone) } +func (self *GuiDriver) MouseReleaseWithoutWaiting(x, y int) { + self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone) +} + +func (self *GuiDriver) WaitUntilIdle() { + self.waitTillIdle() +} + func (self *GuiDriver) OnUIThreadAndWait(f func()) { _ = self.gui.g.OnUIThreadAndWait(func() error { f(); return nil }) } func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) { + self.replayMouseEventWithoutWaiting(x, y, buttons) + self.waitTillIdle() +} + +func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.ButtonMask) { self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, buttons, 0), 0, )) - self.waitTillIdle() } // FocusIn simulates the terminal window regaining focus, which is how lazygit diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 826752b53..ac423b6f0 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -374,6 +374,7 @@ type TranslationSet struct { PendingRevertsSectionHeader string CommitsSectionHeader string MoveCommitsHere string + MovingCommitsHere string YouDied string RewordNotSupported string ChangingThisActionIsNotAllowed string @@ -1525,6 +1526,7 @@ func EnglishTranslationSet() *TranslationSet { PendingRevertsSectionHeader: "Pending reverts", CommitsSectionHeader: "Commits", MoveCommitsHere: "drop here", + MovingCommitsHere: "moving commits here", YouDied: "YOU DIED!", RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported", ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed", From 4479d34e379abf8fc4cede250f89d85264cc951f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 29 Jul 2026 11:27:01 +0200 Subject: [PATCH 37/97] Remove BenchmarkMigrationOnLargeConfiguration It has served its purpose when config migration was initially implemented, but nobody runs this benchmark nowadays, and the example config has run out of date with reality. Some PRs have still updated it when they made changes to the config, but others didn't, and it's unclear what the rules are; so let's just remove it. --- pkg/config/app_config_test.go | 623 ---------------------------------- 1 file changed, 623 deletions(-) diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index be97c2acb..2c707d965 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -470,629 +470,6 @@ func TestCustomCommandsOutputMigration(t *testing.T) { } } -var largeConfiguration = []byte(` -# Config relating to the Lazygit UI -gui: - # The number of lines you scroll by when scrolling the main window - scrollHeight: 2 - - # If true, allow scrolling past the bottom of the content in the main window - scrollPastBottom: true - - # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#scroll-off-margin - scrollOffMargin: 2 - - # One of: 'margin' (default) | 'jump' - scrollOffBehavior: margin - - # The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs. - # Note that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command. - tabWidth: 4 - - # If true, capture mouse events. - # When mouse events are captured, it's a little harder to select text: e.g. requiring you to hold the option key when on macOS. - mouseEvents: true - - # If true, do not show a warning when amending a commit. - skipAmendWarning: false - - # If true, do not show a warning when discarding changes in the staging view. - skipDiscardChangeWarning: false - - # If true, do not show warning when applying/popping the stash - skipStashWarning: false - - # If true, do not show a warning when attempting to commit without any staged files; instead stage all unstaged files. - skipNoStagedFilesWarning: false - - # If true, do not show a warning when rewording a commit via an external editor - skipRewordInEditorWarning: false - - # Fraction of the total screen width to use for the left side section. You may want to pick a small number (e.g. 0.2) if you're using a narrow screen, so that you can see more of the main section. - # Number from 0 to 1.0. - sidePanelWidth: 0.3333 - - # If true, increase the height of the focused side window; creating an accordion effect. - expandFocusedSidePanel: false - - # The weight of the expanded side panel, relative to the other panels. 2 means - # twice as tall as the other panels. Only relevant if expandFocusedSidePanel is true. - expandedSidePanelWeight: 2 - - # Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split. - # Options are: - # - 'horizontal': split the window horizontally - # - 'vertical': split the window vertically - # - 'flexible': (default) split the window horizontally if the window is wide enough, otherwise split vertically - mainPanelSplitMode: flexible - - # How the window is split when in half screen mode (i.e. after hitting '+' once). - # Possible values: - # - 'left': split the window horizontally (side panel on the left, main view on the right) - # - 'top': split the window vertically (side panel on top, main view below) - enlargedSideViewLocation: left - - # If true, wrap lines in the staging view to the width of the view. This - # makes it much easier to work with diffs that have long lines, e.g. - # paragraphs of markdown text. - wrapLinesInStagingView: true - - # One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru' - language: auto - - # Format used when displaying time e.g. commit time. - # Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format - timeFormat: 02 Jan 06 - - # Format used when displaying time if the time is less than 24 hours ago. - # Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format - shortTimeFormat: 3:04PM - - # Config relating to colors and styles. - # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes - theme: - # Border color of focused window - activeBorderColor: - - green - - bold - - # Border color of non-focused windows - inactiveBorderColor: - - default - - # Border color of focused window when searching in that window - searchingActiveBorderColor: - - cyan - - bold - - # Color of keybindings help text in the bottom line - optionsTextColor: - - blue - - # Background color of selected line. - # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line - selectedLineBgColor: - - blue - - # Background color of selected line when view doesn't have focus. - inactiveViewSelectedLineBgColor: - - bold - - # Foreground color of copied commit - cherryPickedCommitFgColor: - - blue - - # Background color of copied commit - cherryPickedCommitBgColor: - - cyan - - # Foreground color of marked base commit (for rebase) - markedBaseCommitFgColor: - - blue - - # Background color of marked base commit (for rebase) - markedBaseCommitBgColor: - - yellow - - # Color for file with unstaged changes - unstagedChangesColor: - - red - - # Default text color - defaultFgColor: - - default - - # Config relating to the commit length indicator - commitLength: - # If true, show an indicator of commit message length - show: true - - # If true, show the '5 of 20' footer at the bottom of list views - showListFooter: true - - # If true, display the files in the file views as a tree. If false, display the files as a flat list. - # This can be toggled from within Lazygit with the '' key, but that will not change the default. - showFileTree: true - - # If true, show the number of lines changed per file in the Files view - showNumstatInFilesView: false - - # If true, show a random tip in the command log when Lazygit starts - showRandomTip: true - - # If true, show the command log - showCommandLog: true - - # If true, show the bottom line that contains keybinding info and useful buttons. If false, this line will be hidden except to display a loader for an in-progress action. - showBottomLine: true - - # If true, show jump-to-window keybindings in window titles. - showPanelJumps: true - - # Deprecated: use nerdFontsVersion instead - showIcons: false - - # Nerd fonts version to use. - # One of: '2' | '3' | empty string (default) - # If empty, do not show icons. - nerdFontsVersion: "" - - # If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty. - showFileIcons: true - - # Length of author name in (non-expanded) commits view. 2 means show initials only. - commitAuthorShortLength: 2 - - # Length of author name in expanded commits view. 2 means show initials only. - commitAuthorLongLength: 17 - - # Length of commit hash in commits view. 0 shows '*' if NF icons aren't on. - commitHashLength: 8 - - # If true, show commit hashes alongside branch names in the branches view. - showBranchCommitHash: false - - # Whether to show the divergence from the base branch in the branches view. - # One of: 'none' | 'onlyArrow' | 'arrowAndNumber' - showDivergenceFromBaseBranch: none - - # Height of the command log view - commandLogSize: 8 - - # Whether to split the main window when viewing file changes. - # One of: 'auto' | 'always' - # If 'auto', only split the main window when a file has both staged and unstaged changes - splitDiff: auto - - # Default size for focused window. Can be changed from within Lazygit with '+' and '_' (but this won't change the default). - # One of: 'normal' (default) | 'half' | 'full' - screenMode: normal - - # Window border style. - # One of 'rounded' (default) | 'single' | 'double' | 'hidden' | 'bold' - border: rounded - - # If true, show a seriously epic explosion animation when nuking the working tree. - animateExplosion: true - - # Whether to stack UI components on top of each other. - # One of 'auto' (default) | 'always' | 'never' - portraitMode: auto - - # How things are filtered when typing '/'. - # One of 'substring' (default) | 'fuzzy' - filterMode: substring - - # Config relating to the spinner. - spinner: - # The frames of the spinner animation. - frames: - - '|' - - / - - '-' - - \ - - # The "speed" of the spinner in milliseconds. - rate: 50 - - # Status panel view. - # One of 'dashboard' (default) | 'allBranchesLog' - statusPanelView: dashboard - - # If true, jump to the Files panel after popping a stash - switchToFilesAfterStashPop: true - - # If true, jump to the Files panel after applying a stash - switchToFilesAfterStashApply: true - - # If true, when using the panel jump keys (default 1 through 5) and target panel is already active, go to next tab instead - switchTabsWithPanelJumpKeys: false - -# Config relating to git -git: - # See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md - paging: - # Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never' - colorArg: always - - # e.g. - # diff-so-fancy - # delta --dark --paging=never - # ydiff -p cat -s --wrap --width={{columnWidth}} - pager: "" - - useConfig: false - - # e.g. 'difft --color=always' - externalDiffCommand: "" - - # Config relating to committing - commit: - # If true, pass '--signoff' flag when committing - signOff: false - - # Automatic WYSIWYG wrapping of the commit message as you type - autoWrapCommitMessage: true - - # If autoWrapCommitMessage is true, the width to wrap to - autoWrapWidth: 72 - - # Config relating to merging - merging: - # If true, run merges in a subprocess so that if a commit message is required, Lazygit will not hang - # Only applicable to unix users. - manualCommit: false - - # Extra args passed to , e.g. --no-ff - args: "" - - # The commit message to use for a squash merge commit. Can contain "{{selectedRef}}" and "{{currentBranch}}" placeholders. - squashMergeMessage: Squash merge {{selectedRef}} into {{currentBranch}} - - # list of branches that are considered 'main' branches, used when displaying commits - mainBranches: - - master - - main - - # Prefix to use when skipping hooks. E.g. if set to 'WIP', then pre-commit hooks will be skipped when the commit message starts with 'WIP' - skipHookPrefix: WIP - - # If true, periodically fetch from remote - autoFetch: true - - # If true, periodically refresh files and submodules - autoRefresh: true - - # If true, poll the repo periodically for external ref changes (commits, - # branch updates, checkouts made outside lazygit) and refresh when one - # is detected. Independent of autoRefresh, which only governs the files - # panel. - autoDetectExternalChanges: true - - # If true, pass the --all arg to git fetch - fetchAll: true - - # If true, lazygit will automatically stage files that used to have merge - # conflicts but no longer do; and it will also ask you if you want to - # continue a merge or rebase if you've resolved all conflicts. If false, it - # won't do either of these things. - autoStageResolvedConflicts: true - - # Command used when displaying the current branch git log in the main window - branchLogCmd: git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} -- - - # Command used to display git log of all branches in the main window. - # Deprecated: Use allBranchesLogCmds instead. - allBranchesLogCmd: git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium - - # If true, do not spawn a separate process when using GPG - overrideGpg: false - - # If true, do not allow force pushes - disableForcePushing: false - - # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-branch-name-prefix - branchPrefix: "" - - # If true, parse emoji strings in commit messages e.g. render :rocket: as 🚀 - # (This should really be under 'gui', not 'git') - parseEmoji: false - - # Config for showing the log in the commits view - log: - # One of: 'date-order' | 'author-date-order' | 'topo-order' | 'default' - # 'topo-order' makes it easier to read the git log graph, but commits may not - # appear chronologically. See https://git-scm.com/docs/ - # - # Deprecated: Configure this with Log menu -> Commit sort order ( in the commits window by default). - order: topo-order - - # This determines whether the git graph is rendered in the commits panel - # One of 'always' | 'never' | 'when-maximised' - # - # Deprecated: Configure this with Log menu -> Show git graph ( in the commits window by default). - showGraph: always - - # displays the whole git graph by default in the commits view (equivalent to passing the --all argument to git log) - showWholeGraph: false - - # When copying commit hashes to the clipboard, truncate them to this - # length. Set to 40 to disable truncation. - truncateCopiedCommitHashesTo: 12 - -# Periodic update checks -update: - # One of: 'prompt' (default) | 'background' | 'never' - method: prompt - - # Period in days between update checks - days: 14 - -# Background refreshes -refresher: - # File/submodule refresh interval in seconds. - # Auto-refresh can be disabled via option 'git.autoRefresh'. - refreshInterval: 10 - - # Re-fetch interval in seconds. - # Auto-fetch can be disabled via option 'git.autoFetch'. - fetchInterval: 60 - - # Interval in seconds at which lazygit polls for external ref changes - # (commits, branch updates, checkouts made outside lazygit). - # Detection can be disabled via option 'git.autoDetectExternalChanges'. - externalChangeCheckInterval: 2 - -# If true, show a confirmation popup before quitting Lazygit -confirmOnQuit: false - -# If true, exit Lazygit when the user presses escape in a context where there is nothing to cancel/close -quitOnTopLevelReturn: false - -# Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc -os: - # Command for editing a file. Should contain "{{filename}}". - edit: "" - - # Command for editing a file at a given line number. Should contain - # "{{filename}}", and may optionally contain "{{line}}". - editAtLine: "" - - # Same as EditAtLine, except that the command needs to wait until the - # window is closed. - editAtLineAndWait: "" - - # Whether lazygit suspends until an edit process returns - editInTerminal: false - - # For opening a directory in an editor - openDirInEditor: "" - - # A built-in preset that sets all of the above settings. Supported presets - # are defined in the getPreset function in editor_presets.go. - editPreset: "" - - # Command for opening a file, as if the file is double-clicked. Should - # contain "{{filename}}", but doesn't support "{{line}}". - open: "" - - # Command for opening a link. Should contain "{{link}}". - openLink: "" - - # EditCommand is the command for editing a file. - # Deprecated: use Edit instead. Note that semantics are different: - # EditCommand is just the command itself, whereas Edit contains a - # "{{filename}}" variable. - editCommand: "" - - # EditCommandTemplate is the command template for editing a file - # Deprecated: use EditAtLine instead. - editCommandTemplate: "" - - # OpenCommand is the command for opening a file - # Deprecated: use Open instead. - openCommand: "" - - # OpenLinkCommand is the command for opening a link - # Deprecated: use OpenLink instead. - openLinkCommand: "" - - # CopyToClipboardCmd is the command for copying to clipboard. - # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard - copyToClipboardCmd: "" - - # ReadFromClipboardCmd is the command for reading the clipboard. - # See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard - readFromClipboardCmd: "" - -# If true, don't display introductory popups upon opening Lazygit. -disableStartupPopups: false - -# What to do when opening Lazygit outside of a git repo. -# - 'prompt': (default) ask whether to initialize a new repo or open in the most recent repo -# - 'create': initialize a new repo -# - 'skip': open most recent repo -# - 'quit': exit Lazygit -notARepository: prompt - -# If true, display a confirmation when subprocess terminates. This allows you to view the output of the subprocess before returning to Lazygit. -promptToReturnFromSubprocess: true - -# Keybindings -keybinding: - universal: - quit: q - quit-alt1: - return: - quitWithoutChangingDirectory: Q - togglePanel: - prevItem: - nextItem: - prevItem-alt: k - nextItem-alt: j - prevPage: ',' - nextPage: . - scrollLeft: H - scrollRight: L - gotoTop: < - gotoBottom: '>' - toggleRangeSelect: v - rangeSelectDown: - rangeSelectUp: - prevBlock: - nextBlock: - prevBlock-alt: h - nextBlock-alt: l - nextBlock-alt2: - prevBlock-alt2: - jumpToBlock: - - "1" - - "2" - - "3" - - "4" - - "5" - nextMatch: "n" - prevMatch: "N" - startSearch: / - optionMenu: - optionMenu-alt1: '?' - select: - goInto: - confirm: - confirmInEditor: - remove: d - new: "n" - edit: e - openFile: o - scrollUpMain: - scrollDownMain: - scrollUpMain-alt1: K - scrollDownMain-alt1: J - scrollUpMain-alt2: - scrollDownMain-alt2: - executeShellCommand: ':' - createRebaseOptionsMenu: m - - # 'Files' appended for legacy reasons - pushFiles: P - - # 'Files' appended for legacy reasons - pullFiles: p - refresh: R - createPatchOptionsMenu: - nextTab: ']' - prevTab: '[' - nextScreenMode: + - prevScreenMode: _ - undo: z - redo: Z - filteringMenu: - diffingMenu: W - diffingMenu-alt: - copyToClipboard: - openRecentRepos: - submitEditorText: - extrasMenu: '@' - toggleWhitespaceInDiffView: - increaseContextInDiffView: '}' - decreaseContextInDiffView: '{' - increaseRenameSimilarityThreshold: ) - decreaseRenameSimilarityThreshold: ( - openDiffTool: - status: - checkForUpdate: u - recentRepos: - allBranchesLogGraph: a - files: - commitChanges: c - commitChangesWithoutHook: w - amendLastCommit: A - commitChangesWithEditor: C - findBaseCommitForFixup: - confirmDiscard: x - ignoreFile: i - refreshFiles: r - stashAllChanges: s - viewStashOptions: S - toggleStagedAll: a - viewResetOptions: D - fetch: f - openMergeOptions: M - openStatusFilter: - copyFileInfoToClipboard: "y" - collapseAll: '-' - expandAll: = - branches: - createPullRequest: o - viewPullRequestOptions: O - copyPullRequestURL: - checkoutBranchByName: c - forceCheckoutBranch: F - rebaseBranch: r - renameBranch: R - mergeIntoCurrentBranch: M - viewGitFlowOptions: i - fastForward: f - createTag: T - pushTag: P - setUpstream: u - fetchRemote: f - sortOrder: s - worktrees: - viewWorktreeOptions: w - commits: - squashDown: s - renameCommit: r - renameCommitWithEditor: R - viewResetOptions: g - markCommitAsFixup: f - createFixupCommit: F - squashAboveCommits: S - moveDownCommit: - moveUpCommit: - amendToCommit: A - resetCommitAuthor: a - pickCommit: p - revertCommit: t - cherryPickCopy: C - pasteCommits: V - markCommitAsBaseForRebase: B - tagCommit: T - checkoutCommit: - resetCherryPick: - copyCommitAttributeToClipboard: "y" - openLogMenu: - openInBrowser: o - viewBisectOptions: b - startInteractiveRebase: i - amendAttribute: - resetAuthor: a - setAuthor: A - addCoAuthor: c - stash: - popStash: g - renameStash: r - commitFiles: - checkoutCommitFile: c - main: - toggleSelectHunk: a - pickBothHunks: b - editSelectHunk: E - submodules: - init: i - update: u - bulkMenu: b - commitMessage: - commitMenu: -`) - -func BenchmarkMigrationOnLargeConfiguration(b *testing.B) { - for b.Loop() { - changes := NewChangesSet() - _, _, _ = computeMigratedConfig("path doesn't matter", largeConfiguration, changes) - } -} - func TestAllBranchesLogCmdMigrations(t *testing.T) { scenarios := []struct { name string From 1ebb65b775e5d491b4cc59f0d323ba5a7b92d966 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 29 Jul 2026 10:24:52 +0200 Subject: [PATCH 38/97] Cleanup: add blank lines for canonical markdown My markdown formatter in VS Code adds these when I press Command-S. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 53c02478e..2fdf688fd 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,7 @@ nix-shell -p lazygit # or with flakes enabled nix run nixpkgs#lazygit ``` + Or you can add lazygit to your `configuration.nix` using the `environment.systemPackages` option. More details can be found via NixOS search [page](https://search.nixos.org/). @@ -431,6 +432,7 @@ More details can be found via NixOS search [page](https://search.nixos.org/). This repository includes a nix flake that provides the latest development version and additional development tools: **Run lazygit directly from the repository:** + ```sh nix run github:jesseduffield/lazygit # or from a local clone @@ -438,6 +440,7 @@ nix run . ``` **Build lazygit from source:** + ```sh nix build github:jesseduffield/lazygit # or from a local clone @@ -446,6 +449,7 @@ nix build . **Development environment:** For contributors, the flake provides a development shell with Go toolchain, development tools, and dependencies: + ```sh nix develop github:jesseduffield/lazygit # or from a local clone @@ -453,12 +457,14 @@ nix develop ``` The development shell includes: + - Go toolchain - git and make - Proper environment variables for development **Using in other flakes:** The flake also provides an overlay for easy integration into other flake-based projects: + ```nix { inputs.lazygit.url = "github:jesseduffield/lazygit"; From 2694aa774a744c85bb5a6527f0091afa50deeae4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 27 Jul 2026 10:59:08 +0200 Subject: [PATCH 39/97] Extract GitCommandBuilder method AddCommonDiffArgs We were setting the same arguments in five places; extract this to one. --- pkg/commands/git_commands/commit.go | 12 ++-------- pkg/commands/git_commands/commit_test.go | 14 ++++++------ pkg/commands/git_commands/diff.go | 11 +--------- .../git_commands/git_command_builder.go | 16 ++++++++++++++ pkg/commands/git_commands/stash.go | 10 +-------- pkg/commands/git_commands/stash_test.go | 14 ++++++------ pkg/commands/git_commands/working_tree.go | 22 ++----------------- .../git_commands/working_tree_test.go | 22 +++++++++---------- 8 files changed, 47 insertions(+), 74 deletions(-) diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go index 953717ec0..17aca59d2 100644 --- a/pkg/commands/git_commands/commit.go +++ b/pkg/commands/git_commands/commit.go @@ -241,23 +241,15 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj { } func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj { - contextSize := self.UserConfig().Git.DiffContextSize - - extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) - useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() cmdArgs := NewGitCmd("show"). Config("diff.noprefix=false"). - ConfigIf(extDiffCmd != "", "diff.external="+extDiffCmd). - ArgIfElse(extDiffCmd != "" || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). + AddCommonDiffArgs(self.pagerConfig, self.UserConfig(), true). Arg("--submodule"). - Arg("--color="+self.pagerConfig.GetColorArg()). - Arg(fmt.Sprintf("--unified=%d", contextSize)). + Arg("--color=" + self.pagerConfig.GetColorArg()). Arg("--stat"). Arg("--decorate"). Arg("-p"). Arg(hash). - ArgIf(self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space"). - Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)). Arg("--"). Arg(filterPaths...). Dir(self.repoPaths.worktreePath). diff --git a/pkg/commands/git_commands/commit_test.go b/pkg/commands/git_commands/commit_test.go index 25966c06f..72d2ed1b2 100644 --- a/pkg/commands/git_commands/commit_test.go +++ b/pkg/commands/git_commands/commit_test.go @@ -267,7 +267,7 @@ func TestCommitShowCmdObj(t *testing.T) { similarityThreshold: 50, ignoreWhitespace: false, pagerConfig: nil, - expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"}, + expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, { testName: "Default case with filter path", @@ -276,7 +276,7 @@ func TestCommitShowCmdObj(t *testing.T) { similarityThreshold: 50, ignoreWhitespace: false, pagerConfig: nil, - expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--", "file.txt"}, + expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--", "file.txt"}, }, { testName: "Show diff with custom context size", @@ -285,7 +285,7 @@ func TestCommitShowCmdObj(t *testing.T) { similarityThreshold: 50, ignoreWhitespace: false, pagerConfig: nil, - expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=77", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"}, + expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=77", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, { testName: "Show diff with custom similarity threshold", @@ -294,7 +294,7 @@ func TestCommitShowCmdObj(t *testing.T) { similarityThreshold: 33, ignoreWhitespace: false, pagerConfig: nil, - expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=33%", "--"}, + expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=33%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, { testName: "Show diff, ignoring whitespace", @@ -303,7 +303,7 @@ func TestCommitShowCmdObj(t *testing.T) { similarityThreshold: 50, ignoreWhitespace: true, pagerConfig: nil, - expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=77", "--stat", "--decorate", "-p", "1234567890", "--ignore-all-space", "--find-renames=50%", "--"}, + expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=77", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, { testName: "Show diff with external diff command", @@ -312,7 +312,7 @@ func TestCommitShowCmdObj(t *testing.T) { similarityThreshold: 50, ignoreWhitespace: false, pagerConfig: &config.PagingConfig{ExternalDiffCommand: "difft --color=always"}, - expected: []string{"-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "-c", "diff.noprefix=false", "show", "--ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"}, + expected: []string{"-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, { testName: "Show diff using git's external diff config", @@ -321,7 +321,7 @@ func TestCommitShowCmdObj(t *testing.T) { similarityThreshold: 50, ignoreWhitespace: false, pagerConfig: &config.PagingConfig{UseExternalDiffGitConfig: true}, - expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"}, + expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, } diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index f4ecb5f53..5e0cae109 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -19,21 +19,12 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands { // This is for generating diffs to be shown in the UI (e.g. rendering a range // diff to the main view). It uses a custom pager if one is configured. func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj { - contextSize := self.UserConfig().Git.DiffContextSize - extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) - useExtDiff := extDiffCmd != "" - useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() - ignoreWhitespace := self.UserConfig().Git.IgnoreWhitespaceInDiffView - return self.cmd.New( NewGitCmd("diff"). Config("diff.noprefix=false"). - ConfigIf(useExtDiff, "diff.external="+extDiffCmd). - ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). + AddCommonDiffArgs(self.pagerConfig, self.UserConfig(), true). Arg("--submodule"). Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())). - ArgIf(ignoreWhitespace, "--ignore-all-space"). - Arg(fmt.Sprintf("--unified=%d", contextSize)). Arg(diffArgs...). Dir(self.repoPaths.worktreePath). ToArgv(), diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index f37681223..ee1723c32 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -1,9 +1,11 @@ package git_commands import ( + "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/config" ) // OptionalLocksEnvVar is the name of the environment variable that tells git @@ -111,6 +113,20 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand return self } +func (self *GitCommandBuilder) AddCommonDiffArgs(pagerConfig *config.PagerConfig, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder { + contextSize := userConfig.Git.DiffContextSize + extDiffCmd := pagerConfig.GetExternalDiffCommand(contextSize) + useExtDiff := forUI && extDiffCmd != "" + useExtDiffGitConfig := forUI && pagerConfig.GetUseExternalDiffGitConfig() + + return self. + ConfigIf(useExtDiff, "diff.external="+extDiffCmd). + ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). + Arg(fmt.Sprintf("--unified=%d", contextSize)). + ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space"). + Arg(fmt.Sprintf("--find-renames=%d%%", userConfig.Git.RenameSimilarityThreshold)) +} + func (self *GitCommandBuilder) ToArgv() []string { return append([]string{"git"}, self.args...) } diff --git a/pkg/commands/git_commands/stash.go b/pkg/commands/git_commands/stash.go index 9bd960ed5..08a944bc3 100644 --- a/pkg/commands/git_commands/stash.go +++ b/pkg/commands/git_commands/stash.go @@ -81,21 +81,13 @@ func (self *StashCommands) Hash(index int) (string, error) { } func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj { - contextSize := self.UserConfig().Git.DiffContextSize - extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) - useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() - // "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason cmdArgs := NewGitCmd("stash").Arg("show"). + AddCommonDiffArgs(self.pagerConfig, self.UserConfig(), true). Arg("-p"). Arg("--stat"). Arg("-u"). - ConfigIf(extDiffCmd != "", "diff.external="+extDiffCmd). - ArgIfElse(extDiffCmd != "" || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())). - Arg(fmt.Sprintf("--unified=%d", contextSize)). - ArgIf(self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space"). - Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)). Arg(fmt.Sprintf("refs/stash@{%d}", index)). Dir(self.repoPaths.worktreePath). ToArgv() diff --git a/pkg/commands/git_commands/stash_test.go b/pkg/commands/git_commands/stash_test.go index a942a4e98..1d318ffc6 100644 --- a/pkg/commands/git_commands/stash_test.go +++ b/pkg/commands/git_commands/stash_test.go @@ -114,7 +114,7 @@ func TestStashStashEntryCmdObj(t *testing.T) { contextSize: 3, similarityThreshold: 50, ignoreWhitespace: false, - expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=3", "--find-renames=50%", "refs/stash@{5}"}, + expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"}, }, { testName: "Show diff with custom context size", @@ -122,7 +122,7 @@ func TestStashStashEntryCmdObj(t *testing.T) { contextSize: 77, similarityThreshold: 50, ignoreWhitespace: false, - expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=77", "--find-renames=50%", "refs/stash@{5}"}, + expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=77", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"}, }, { testName: "Show diff with custom similarity threshold", @@ -130,7 +130,7 @@ func TestStashStashEntryCmdObj(t *testing.T) { contextSize: 3, similarityThreshold: 33, ignoreWhitespace: false, - expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=3", "--find-renames=33%", "refs/stash@{5}"}, + expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=3", "--find-renames=33%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"}, }, { testName: "Show diff with external diff command", @@ -139,7 +139,7 @@ func TestStashStashEntryCmdObj(t *testing.T) { similarityThreshold: 50, ignoreWhitespace: false, pagerConfig: &config.PagingConfig{ExternalDiffCommand: "difft --color=always"}, - expected: []string{"git", "-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "stash", "show", "-p", "--stat", "-u", "--ext-diff", "--color=always", "--unified=3", "--find-renames=50%", "refs/stash@{5}"}, + expected: []string{"git", "-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"}, }, { testName: "Show diff using git's external diff config", @@ -148,15 +148,15 @@ func TestStashStashEntryCmdObj(t *testing.T) { similarityThreshold: 50, ignoreWhitespace: false, pagerConfig: &config.PagingConfig{UseExternalDiffGitConfig: true}, - expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--ext-diff", "--color=always", "--unified=3", "--find-renames=50%", "refs/stash@{5}"}, + expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"}, }, { - testName: "Default case", + testName: "Ignore whitespace", index: 5, contextSize: 3, similarityThreshold: 50, ignoreWhitespace: true, - expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=3", "--ignore-all-space", "--find-renames=50%", "refs/stash@{5}"}, + expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=3", "--ignore-all-space", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"}, }, } diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index d296858f6..f0a0c1b83 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -398,12 +398,8 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain colorArg = "never" } - contextSize := self.UserConfig().Git.DiffContextSize prevPath := node.GetPreviousPath() noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile() - extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) - useExtDiff := extDiffCmd != "" && !plain - useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain paths := pathOverrides if len(paths) == 0 { @@ -411,13 +407,9 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain } cmdArgs := NewGitCmd("diff"). - ConfigIf(useExtDiff, "diff.external="+extDiffCmd). - ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). + AddCommonDiffArgs(self.pagerConfig, self.UserConfig(), !plain). Arg("--submodule"). - Arg(fmt.Sprintf("--unified=%d", contextSize)). Arg(fmt.Sprintf("--color=%s", colorArg)). - ArgIf(!plain && self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space"). - Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)). ArgIf(cached, "--cached"). ArgIf(noIndex, "--no-index"). Arg("--"). @@ -443,29 +435,19 @@ func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bo } func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj { - contextSize := self.UserConfig().Git.DiffContextSize - colorArg := self.pagerConfig.GetColorArg() if plain { colorArg = "never" } - extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) - useExtDiff := extDiffCmd != "" && !plain - useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain - cmdArgs := NewGitCmd("diff"). Config("diff.noprefix=false"). - ConfigIf(useExtDiff, "diff.external="+extDiffCmd). - ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). + AddCommonDiffArgs(self.pagerConfig, self.UserConfig(), !plain). Arg("--submodule"). - Arg(fmt.Sprintf("--unified=%d", contextSize)). - Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)). Arg(fmt.Sprintf("--color=%s", colorArg)). Arg(from). Arg(to). ArgIf(reverse, "-R"). - ArgIf(!plain && self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space"). Arg("--"). Arg(fileNames...). Dir(self.repoPaths.worktreePath). diff --git a/pkg/commands/git_commands/working_tree_test.go b/pkg/commands/git_commands/working_tree_test.go index 8af2b707d..5b87a1320 100644 --- a/pkg/commands/git_commands/working_tree_test.go +++ b/pkg/commands/git_commands/working_tree_test.go @@ -221,7 +221,7 @@ func TestWorkingTreeDiff(t *testing.T) { contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil), + ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil), }, { testName: "cached", @@ -236,7 +236,7 @@ func TestWorkingTreeDiff(t *testing.T) { contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--cached", "--", "test.txt"}, expectedResult, nil), + ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--cached", "--", "test.txt"}, expectedResult, nil), }, { testName: "plain", @@ -251,7 +251,7 @@ func TestWorkingTreeDiff(t *testing.T) { contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=never", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil), + ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=never", "--", "test.txt"}, expectedResult, nil), }, { testName: "File not tracked and file has no staged changes", @@ -266,7 +266,7 @@ func TestWorkingTreeDiff(t *testing.T) { contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--no-index", "--", "/dev/null", "test.txt"}, expectedResult, nil), + ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--no-index", "--", "/dev/null", "test.txt"}, expectedResult, nil), }, { testName: "Default case (ignore whitespace)", @@ -281,7 +281,7 @@ func TestWorkingTreeDiff(t *testing.T) { contextSize: 3, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--ignore-all-space", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil), + ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil), }, { testName: "Show diff with custom context size", @@ -296,7 +296,7 @@ func TestWorkingTreeDiff(t *testing.T) { contextSize: 17, similarityThreshold: 50, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=17", "--color=always", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil), + ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=17", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil), }, { testName: "Show diff with custom similarity threshold", @@ -311,7 +311,7 @@ func TestWorkingTreeDiff(t *testing.T) { contextSize: 3, similarityThreshold: 33, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=33%", "--", "test.txt"}, expectedResult, nil), + ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=33%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil), }, } @@ -360,7 +360,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { ignoreWhitespace: false, contextSize: 3, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), + ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), }, { testName: "Show diff with custom context size", @@ -372,7 +372,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { ignoreWhitespace: false, contextSize: 123, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=123", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), + ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=123", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), }, { testName: "Default case (ignore whitespace)", @@ -384,7 +384,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { ignoreWhitespace: true, contextSize: 3, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--ignore-all-space", "--", "test.txt"}, expectedResult, nil), + ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), }, { testName: "Renamed file passes both paths so the rename is detected", @@ -397,7 +397,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { ignoreWhitespace: false, contextSize: 3, runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "new.txt", "old.txt"}, expectedResult, nil), + ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "new.txt", "old.txt"}, expectedResult, nil), }, } From 1cf632002eda454763274296939211f43d29d840 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 27 Jul 2026 17:45:59 +0200 Subject: [PATCH 40/97] Make CurrentPagerName always return a name We don't want callers to need any additional logic, so pass in the translation set so that the function can decide what static text to return. This allows us to get rid of the CurrentPagerUsesGitConfigDiff method which is in the way for the refactoring we're about to do. --- pkg/config/pager_config.go | 29 ++++++++++++------------ pkg/config/pager_config_test.go | 17 ++++++++++---- pkg/gui/controllers/global_controller.go | 9 +------- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/pkg/config/pager_config.go b/pkg/config/pager_config.go index 01f92f584..b6514f671 100644 --- a/pkg/config/pager_config.go +++ b/pkg/config/pager_config.go @@ -4,6 +4,7 @@ import ( "strconv" "strings" + "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" ) @@ -93,23 +94,21 @@ func (self *PagerConfig) CurrentPagerIndex() (int, int) { } // CurrentPagerName returns a name for the current pager, suitable for showing -// to the user. It returns an empty string if no name can be derived; callers -// should substitute a localized fallback in that case. -func (self *PagerConfig) CurrentPagerName() string { +// to the user. +func (self *PagerConfig) CurrentPagerName(tr *i18n.TranslationSet) string { + name := "" currentPagerConfig := self.currentPagerConfig() - if currentPagerConfig == nil { - return "" + if currentPagerConfig != nil { + name = currentPagerConfig.displayName() } - return currentPagerConfig.displayName() -} - -// CurrentPagerUsesGitConfigDiff reports whether the current pager defers to -// git's own external diff config. Such an entry has no name we can derive (the -// actual command may even vary per file via .gitattributes), so callers show a -// generic label rather than treating it like the default no-pager entry. -func (self *PagerConfig) CurrentPagerUsesGitConfigDiff() bool { - currentPagerConfig := self.currentPagerConfig() - return currentPagerConfig != nil && currentPagerConfig.UseExternalDiffGitConfig + if name == "" { + if currentPagerConfig != nil && currentPagerConfig.UseExternalDiffGitConfig { + name = tr.ExternalDiffPagerName + } else { + name = tr.DefaultPagerName + } + } + return name } func (self *PagingConfig) displayName() string { diff --git a/pkg/config/pager_config_test.go b/pkg/config/pager_config_test.go index 7267b9228..117044afc 100644 --- a/pkg/config/pager_config_test.go +++ b/pkg/config/pager_config_test.go @@ -3,10 +3,13 @@ package config import ( "testing" + "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/stretchr/testify/assert" ) func TestCurrentPagerName(t *testing.T) { + tr := i18n.EnglishTranslationSet() + scenarios := []struct { name string pager PagingConfig @@ -33,9 +36,14 @@ func TestCurrentPagerName(t *testing.T) { expected: "difft", }, { - name: "no name can be derived", + name: "no name can be derived for external diff", pager: PagingConfig{UseExternalDiffGitConfig: true}, - expected: "", + expected: tr.ExternalDiffPagerName, + }, + { + name: "no name can be derived for raw diff", + pager: PagingConfig{}, + expected: tr.DefaultPagerName, }, } @@ -45,7 +53,7 @@ func TestCurrentPagerName(t *testing.T) { userConfig.Git.Pagers = []PagingConfig{s.pager} config := NewPagerConfig(func() *UserConfig { return userConfig }) - assert.Equal(t, s.expected, config.CurrentPagerName()) + assert.Equal(t, s.expected, config.CurrentPagerName(tr)) }) } } @@ -53,7 +61,8 @@ func TestCurrentPagerName(t *testing.T) { func TestCurrentPagerNameWithoutPagers(t *testing.T) { config := NewPagerConfig(func() *UserConfig { return &UserConfig{} }) - assert.Equal(t, "", config.CurrentPagerName()) + tr := i18n.EnglishTranslationSet() + assert.Equal(t, tr.DefaultPagerName, config.CurrentPagerName(tr)) } func TestCyclePagers(t *testing.T) { diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index 77ef29070..3d3f77cd5 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -195,14 +195,7 @@ func (self *GlobalController) onPagerChanged() { pagerConfig := self.c.State().GetPagerConfig() current, total := pagerConfig.CurrentPagerIndex() - name := pagerConfig.CurrentPagerName() - if name == "" { - if pagerConfig.CurrentPagerUsesGitConfigDiff() { - name = self.c.Tr.ExternalDiffPagerName - } else { - name = self.c.Tr.DefaultPagerName - } - } + name := pagerConfig.CurrentPagerName(self.c.Tr) self.c.Toast(utils.ResolvePlaceholderString(self.c.Tr.SelectedPager, map[string]string{ "name": name, "current": strconv.Itoa(current + 1), From ac4a1f575cbad933b02b8e85e49f34cd888c6264 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 29 Jul 2026 13:43:27 +0200 Subject: [PATCH 41/97] Fix tests to use the right field name It doesn't matter for what this test used to test (that nothing changes if the 'pagers' array exists), but it will be relevant once we further migrate the 'pagers' array from there. --- pkg/config/app_config_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index 2c707d965..f79141e73 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -618,13 +618,13 @@ func TestPagerMigration(t *testing.T) { paging: pager: delta --dark --paging=never pagers: - - diff: diff-so-fancy + - pager: diff-so-fancy `, expected: `git: paging: pager: delta --dark --paging=never pagers: - - diff: diff-so-fancy + - pager: diff-so-fancy `, expectedDidChange: false, expectedChanges: []string{}, From 8731d8a51bf9b50364ada0ad2fb8e7d57296dd51 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 27 Jul 2026 18:36:09 +0200 Subject: [PATCH 42/97] Rework the custom pager config (rename to diff renderer) For a long time lazygit has used the term "custom pager" to refer to what's really a "diff renderer". A pager is a program that allows you to view output page by page (hence the name), e.g. less; lazygit's custom diff renderers are not pagers. It used the term only because the feature is implemented using git's GIT_PAGER env var, but that's an implementation detail. Rename the 'git.pagers' config to 'git.diffRenderers', and restructure its elements while we're at it to make things clearer: - Add a 'type' field to explicitly specify which type of diff renderer it is (the two fundamentally different ones are 'stdinFilter' and 'extDiff'). - Add a third type, 'rawGit', which has an 'args' field that makes it easy to use 'git --color-words' as a custom renderer - Unify the old 'pager' and 'externalDiffCommand' fields to a single 'command' field for both types Existing config files are migrated automatically. --- docs-master/Config.md | 49 ++-- docs-master/keybindings/Keybindings_en.md | 4 +- docs-master/keybindings/Keybindings_ja.md | 4 +- docs-master/keybindings/Keybindings_ko.md | 4 +- docs-master/keybindings/Keybindings_nl.md | 4 +- docs-master/keybindings/Keybindings_pl.md | 4 +- docs-master/keybindings/Keybindings_pt.md | 4 +- docs-master/keybindings/Keybindings_ru.md | 4 +- docs-master/keybindings/Keybindings_zh-CN.md | 4 +- docs-master/keybindings/Keybindings_zh-TW.md | 4 +- pkg/commands/git.go | 8 +- pkg/commands/git_commands/commit.go | 4 +- pkg/commands/git_commands/commit_test.go | 20 +- pkg/commands/git_commands/common.go | 28 +- pkg/commands/git_commands/deps_test.go | 2 +- pkg/commands/git_commands/diff.go | 10 +- .../git_commands/git_command_builder.go | 14 +- pkg/commands/git_commands/stash.go | 4 +- pkg/commands/git_commands/stash_test.go | 10 +- pkg/commands/git_commands/working_tree.go | 8 +- pkg/commands/oscommands/pty_windows.go | 10 +- pkg/config/app_config.go | 107 ++++++- pkg/config/app_config_test.go | 268 +++++++++++++----- pkg/config/diff_renderer_config_manager.go | 159 +++++++++++ .../diff_renderer_config_manager_test.go | 96 +++++++ pkg/config/pager_config.go | 130 --------- pkg/config/pager_config_test.go | 91 ------ pkg/config/user_config.go | 109 +++---- pkg/config/user_config_validation.go | 41 +-- pkg/config/user_config_validation_test.go | 30 +- pkg/gui/controllers/global_controller.go | 52 ++-- pkg/gui/controllers/screen_mode_actions.go | 7 +- pkg/gui/gui.go | 10 +- pkg/gui/patch_exploring/state.go | 2 +- pkg/gui/pty.go | 26 +- pkg/gui/types/common.go | 2 +- pkg/i18n/english.go | 34 +-- .../tests/diff/cycle_diff_renderers.go | 50 ++++ pkg/integration/tests/diff/cycle_pagers.go | 45 --- pkg/integration/tests/test_list.go | 2 +- pkg/utils/yaml_utils/yaml_utils.go | 17 ++ schema-master/config.json | 93 +++--- 42 files changed, 940 insertions(+), 634 deletions(-) create mode 100644 pkg/config/diff_renderer_config_manager.go create mode 100644 pkg/config/diff_renderer_config_manager_test.go delete mode 100644 pkg/config/pager_config.go delete mode 100644 pkg/config/pager_config_test.go create mode 100644 pkg/integration/tests/diff/cycle_diff_renderers.go delete mode 100644 pkg/integration/tests/diff/cycle_pagers.go diff --git a/docs-master/Config.md b/docs-master/Config.md index b5bf09088..3dede3bc9 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -66,8 +66,8 @@ gui: # The number of spaces per tab; used for everything that's shown in the main # view, but probably mostly relevant for diffs. - # Note that when using a pager, the pager has its own tab width setting, so you - # need to pass it separately in the pager command. + # Note that when using a diff renderer, the renderer has its own tab width + # setting, so you need to pass it separately in the renderer command. tabWidth: 4 # If true, capture mouse events. @@ -360,38 +360,39 @@ gui: # Config relating to git git: - # Array of pagers. Each entry has the following format: + # Array of diff renderers. Each entry has the following format: # - # # A name for the pager, shown in the notification when cycling pagers. - # # If not set, the name is derived from the first word of the pager - # # command (or of the external diff command). + # # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff' + # # | 'rawGit' + # type: "stdinFilter" + # + # # A name for the diff renderer, shown in the notification when cycling + # # renderers. If not set, the name is derived from the first word of the + # # renderer command. # name: "" # - # # Value of the --color arg in the git diff command. Some pagers want - # # this to be set to 'always' and some want it set to 'never' + # # Value of the --color arg in the git diff command. Only used for type + # # 'stdinFilter'. Some renderers want this to be set to 'always' and some + # # want it set to 'never'. # colorArg: "always" # + # # The command to use for rendering diffs. This is either a stdinFilter or + # # an external diff command, depending on the type field; not applicable if + # # the type is 'rawGit'. # # e.g. # # diff-so-fancy # # delta --dark --paging=never - # # ydiff -p cat -s --wrap --width={{columnWidth}} - # pager: "" + # # ydiff -p cat + # # difft --color=always + # command: "" # - # # e.g. 'difft --color=always' - # externalDiffCommand: "" - # - # # If true, Lazygit will use git's `diff.external` config for paging. - # # The advantage over `externalDiffCommand` is that this can be - # # configured per file type in .gitattributes; see - # # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver. - # useExternalDiffGitConfig: false - # - # 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually - # exclusive; set at most one per entry. + # # Extra arguments (array of strings) passed to the git command. Only + # # applicable if the type is 'rawGit'. + # args: [] # # See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md # for more information. - pagers: [] + diffRenderers: [] # Config relating to committing commit: @@ -714,8 +715,8 @@ keybinding: prevTab: '[' nextScreenMode: + prevScreenMode: _ - cyclePagers: '|' - cyclePagersReverse: \ + cycleDiffRenderers: '|' + cycleDiffRenderersReverse: \ undo: z redo: Z filteringMenu: diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 4aa202740..3ec731bf2 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | Refresh | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | Next screen mode (normal/half/fullscreen) | | | `` _ `` | Prev screen mode | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | Cancel | | | `` ? `` | Open keybindings menu | | | `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 5b9c798a1..6a3d9b5c1 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | 更新 | Gitの状態を更新します(`git status`、`git branch`などをバックグラウンドで実行してパネルの内容を更新します)。これは`git fetch`を実行しません。 | | `` + `` | 次の画面モード(通常/半分/全画面) | | | `` _ `` | 前の画面モード | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | キャンセル | | | `` ? `` | キーバインディングメニューを開く | | | `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index d1eb3afb1..a0e5d84dc 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | 새로고침 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | 다음 스크린 모드 (normal/half/fullscreen) | | | `` _ `` | 이전 스크린 모드 | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | 취소 | | | `` ? `` | 매뉴 열기 | | | `` `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 0eb61729b..cf9934b40 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | Verversen | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | Volgende scherm modus (normaal/half/groot) | | | `` _ `` | Vorige scherm modus | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | Annuleren | | | `` ? `` | Open menu | | | `` `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 719a7e6c4..ba46f7c46 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | Odśwież | Odśwież stan git (tj. uruchom `git status`, `git branch`, itp. w tle, aby zaktualizować zawartość paneli). To nie uruchamia `git fetch`. | | `` + `` | Następny tryb ekranu (normalny/półpełny/pełnoekranowy) | | | `` _ `` | Poprzedni tryb ekranu | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | Anuluj | | | `` ? `` | Otwórz menu przypisań klawiszy | | | `` `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 5fcba2688..3071613be 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | Atualizar | Atualize o estado do git (ou seja, execute `git status`, `git branch`, etc em segundo plano para atualizar o conteúdo de painéis). Isso não executa `git fetch`. | | `` + `` | Modo de tela seguinte (normal/metade/tela cheia) | | | `` _ `` | Modo de tela anterior | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | Cancelar | | | `` ? `` | Abrir o menu de atalhos do teclado | | | `` `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 683135448..3d03f9ca6 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | Обновить | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | Следующий режим экрана (нормальный/полуэкранный/полноэкранный) | | | `` _ `` | Предыдущий режим экрана | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | Отменить | | | `` ? `` | Открыть меню | | | `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index b4f1134ec..9819cb982 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | 刷新 | 刷新Git状态(即在后台运行`git status`、`git branch`等命令以更新面板内容)。此操作不会执行`git fetch`。 | | `` + `` | 下一屏模式(正常/半屏/全屏) | | | `` _ `` | 上一屏模式 | | -| `` \| `` | 切换分页器 | 从已配置的分页器列表中选择下一个分页器 | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | 取消 | | | `` ? `` | 打开菜单 | | | `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index b50e25d35..1706a627e 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | 重新整理 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | | | `` _ `` | 上一個螢幕模式 | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | 取消 | | | `` ? `` | 開啟選單 | | | `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/pkg/commands/git.go b/pkg/commands/git.go index ba6e5a033..6a08c1d2b 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -60,7 +60,7 @@ func NewGitCommand( version *git_commands.GitVersion, osCommand *oscommands.OSCommand, gitConfig git_config.IGitConfig, - pagerConfig *config.PagerConfig, + diffRendererConfigManager *config.DiffRendererConfigManager, ) (*GitCommand, error) { repoPaths, err := git_commands.GetRepoPaths(osCommand.Cmd, version) if err != nil { @@ -82,7 +82,7 @@ func NewGitCommand( osCommand, gitConfig, repoPaths, - pagerConfig, + diffRendererConfigManager, ), nil } @@ -92,7 +92,7 @@ func NewGitCommandAux( osCommand *oscommands.OSCommand, gitConfig git_config.IGitConfig, repoPaths *git_commands.RepoPaths, - pagerConfig *config.PagerConfig, + diffRendererConfigManager *config.DiffRendererConfigManager, ) *GitCommand { cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath()) @@ -103,7 +103,7 @@ func NewGitCommandAux( // common ones are: cmn, osCommand, dotGitDir, configCommands configCommands := git_commands.NewConfigCommands(cmn, gitConfig) - gitCommon := git_commands.NewGitCommon(cmn, version, cmd, osCommand, repoPaths, configCommands, pagerConfig) + gitCommon := git_commands.NewGitCommon(cmn, version, cmd, osCommand, repoPaths, configCommands, diffRendererConfigManager) fileLoader := git_commands.NewFileLoader(gitCommon, cmd, configCommands) statusCommands := git_commands.NewStatusCommands(gitCommon) diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go index 17aca59d2..d067e9831 100644 --- a/pkg/commands/git_commands/commit.go +++ b/pkg/commands/git_commands/commit.go @@ -243,9 +243,9 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj { func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj { cmdArgs := NewGitCmd("show"). Config("diff.noprefix=false"). - AddCommonDiffArgs(self.pagerConfig, self.UserConfig(), true). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true). Arg("--submodule"). - Arg("--color=" + self.pagerConfig.GetColorArg()). + Arg("--color=" + self.diffRendererConfigManager.GetColorArg()). Arg("--stat"). Arg("--decorate"). Arg("-p"). diff --git a/pkg/commands/git_commands/commit_test.go b/pkg/commands/git_commands/commit_test.go index 72d2ed1b2..9b2ddecfb 100644 --- a/pkg/commands/git_commands/commit_test.go +++ b/pkg/commands/git_commands/commit_test.go @@ -255,7 +255,7 @@ func TestCommitShowCmdObj(t *testing.T) { contextSize uint64 similarityThreshold int ignoreWhitespace bool - pagerConfig *config.PagingConfig + diffRendererConfig *config.DiffRendererConfig expected []string } @@ -266,7 +266,7 @@ func TestCommitShowCmdObj(t *testing.T) { contextSize: 3, similarityThreshold: 50, ignoreWhitespace: false, - pagerConfig: nil, + diffRendererConfig: nil, expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, { @@ -275,7 +275,7 @@ func TestCommitShowCmdObj(t *testing.T) { contextSize: 3, similarityThreshold: 50, ignoreWhitespace: false, - pagerConfig: nil, + diffRendererConfig: nil, expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--", "file.txt"}, }, { @@ -284,7 +284,7 @@ func TestCommitShowCmdObj(t *testing.T) { contextSize: 77, similarityThreshold: 50, ignoreWhitespace: false, - pagerConfig: nil, + diffRendererConfig: nil, expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=77", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, { @@ -293,7 +293,7 @@ func TestCommitShowCmdObj(t *testing.T) { contextSize: 3, similarityThreshold: 33, ignoreWhitespace: false, - pagerConfig: nil, + diffRendererConfig: nil, expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=33%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, { @@ -302,7 +302,7 @@ func TestCommitShowCmdObj(t *testing.T) { contextSize: 77, similarityThreshold: 50, ignoreWhitespace: true, - pagerConfig: nil, + diffRendererConfig: nil, expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=77", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, { @@ -311,7 +311,7 @@ func TestCommitShowCmdObj(t *testing.T) { contextSize: 3, similarityThreshold: 50, ignoreWhitespace: false, - pagerConfig: &config.PagingConfig{ExternalDiffCommand: "difft --color=always"}, + diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"}, expected: []string{"-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, { @@ -320,7 +320,7 @@ func TestCommitShowCmdObj(t *testing.T) { contextSize: 3, similarityThreshold: 50, ignoreWhitespace: false, - pagerConfig: &config.PagingConfig{UseExternalDiffGitConfig: true}, + diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff"}, expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"}, }, } @@ -328,8 +328,8 @@ func TestCommitShowCmdObj(t *testing.T) { for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { userConfig := config.GetDefaultConfig() - if s.pagerConfig != nil { - userConfig.Git.Pagers = []config.PagingConfig{*s.pagerConfig} + if s.diffRendererConfig != nil { + userConfig.Git.DiffRenderers = []config.DiffRendererConfig{*s.diffRendererConfig} } userConfig.Git.IgnoreWhitespaceInDiffView = s.ignoreWhitespace userConfig.Git.DiffContextSize = s.contextSize diff --git a/pkg/commands/git_commands/common.go b/pkg/commands/git_commands/common.go index ba3b64d7f..4a8c85213 100644 --- a/pkg/commands/git_commands/common.go +++ b/pkg/commands/git_commands/common.go @@ -8,12 +8,12 @@ import ( type GitCommon struct { *common.Common - version *GitVersion - cmd oscommands.ICmdObjBuilder - os *oscommands.OSCommand - repoPaths *RepoPaths - config *ConfigCommands - pagerConfig *config.PagerConfig + version *GitVersion + cmd oscommands.ICmdObjBuilder + os *oscommands.OSCommand + repoPaths *RepoPaths + config *ConfigCommands + diffRendererConfigManager *config.DiffRendererConfigManager } func NewGitCommon( @@ -23,15 +23,15 @@ func NewGitCommon( osCommand *oscommands.OSCommand, repoPaths *RepoPaths, config *ConfigCommands, - pagerConfig *config.PagerConfig, + diffRendererConfigManager *config.DiffRendererConfigManager, ) *GitCommon { return &GitCommon{ - Common: cmn, - version: version, - cmd: cmd, - os: osCommand, - repoPaths: repoPaths, - config: config, - pagerConfig: pagerConfig, + Common: cmn, + version: version, + cmd: cmd, + os: osCommand, + repoPaths: repoPaths, + config: config, + diffRendererConfigManager: diffRendererConfigManager, } } diff --git a/pkg/commands/git_commands/deps_test.go b/pkg/commands/git_commands/deps_test.go index 235f21716..91332cff6 100644 --- a/pkg/commands/git_commands/deps_test.go +++ b/pkg/commands/git_commands/deps_test.go @@ -62,7 +62,7 @@ func buildGitCommon(deps commonDeps) *GitCommon { gitCommon.Common.SetUserConfig(config.GetDefaultConfig()) } - gitCommon.pagerConfig = config.NewPagerConfig(func() *config.UserConfig { + gitCommon.diffRendererConfigManager = config.NewDiffRendererConfigManager(func() *config.UserConfig { return gitCommon.Common.UserConfig() }) diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index 5e0cae109..d532f1bbb 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -17,14 +17,14 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands { } // This is for generating diffs to be shown in the UI (e.g. rendering a range -// diff to the main view). It uses a custom pager if one is configured. +// diff to the main view). It uses a custom diff renderer if one is configured. func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj { return self.cmd.New( NewGitCmd("diff"). Config("diff.noprefix=false"). - AddCommonDiffArgs(self.pagerConfig, self.UserConfig(), true). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true). Arg("--submodule"). - Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())). + Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())). Arg(diffArgs...). Dir(self.repoPaths.worktreePath). ToArgv(), @@ -32,8 +32,8 @@ func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj { } // This is a basic generic diff command that can be used for any diff operation -// (e.g. copying a diff to the clipboard). It will not use a custom pager, and -// does not use user configs such as ignore whitespace. +// (e.g. copying a diff to the clipboard). It will not use a custom diff renderer, +// and does not use user configs such as ignore whitespace. // If you want to diff specific refs (one or two), you need to add them yourself // in additionalArgs; it is recommended to also pass `--` after that. If you // want to restrict the diff to specific paths, pass them in additionalArgs diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index ee1723c32..49e27ec1f 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -113,18 +113,18 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand return self } -func (self *GitCommandBuilder) AddCommonDiffArgs(pagerConfig *config.PagerConfig, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder { +func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder { contextSize := userConfig.Git.DiffContextSize - extDiffCmd := pagerConfig.GetExternalDiffCommand(contextSize) - useExtDiff := forUI && extDiffCmd != "" - useExtDiffGitConfig := forUI && pagerConfig.GetUseExternalDiffGitConfig() + extDiffCmd := diffRendererConfigManager.GetExternalDiffCommand(contextSize) + useExtDiff := forUI && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff return self. - ConfigIf(useExtDiff, "diff.external="+extDiffCmd). - ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). + ConfigIf(forUI && extDiffCmd != "", "diff.external="+extDiffCmd). + ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff"). Arg(fmt.Sprintf("--unified=%d", contextSize)). ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space"). - Arg(fmt.Sprintf("--find-renames=%d%%", userConfig.Git.RenameSimilarityThreshold)) + Arg(fmt.Sprintf("--find-renames=%d%%", userConfig.Git.RenameSimilarityThreshold)). + ArgIf(forUI, diffRendererConfigManager.GetRawGitArgs()...) } func (self *GitCommandBuilder) ToArgv() []string { diff --git a/pkg/commands/git_commands/stash.go b/pkg/commands/git_commands/stash.go index 08a944bc3..ea23c5141 100644 --- a/pkg/commands/git_commands/stash.go +++ b/pkg/commands/git_commands/stash.go @@ -83,11 +83,11 @@ func (self *StashCommands) Hash(index int) (string, error) { func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj { // "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason cmdArgs := NewGitCmd("stash").Arg("show"). - AddCommonDiffArgs(self.pagerConfig, self.UserConfig(), true). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true). Arg("-p"). Arg("--stat"). Arg("-u"). - Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())). + Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())). Arg(fmt.Sprintf("refs/stash@{%d}", index)). Dir(self.repoPaths.worktreePath). ToArgv() diff --git a/pkg/commands/git_commands/stash_test.go b/pkg/commands/git_commands/stash_test.go index 1d318ffc6..8f5629c98 100644 --- a/pkg/commands/git_commands/stash_test.go +++ b/pkg/commands/git_commands/stash_test.go @@ -103,7 +103,7 @@ func TestStashStashEntryCmdObj(t *testing.T) { contextSize uint64 similarityThreshold int ignoreWhitespace bool - pagerConfig *config.PagingConfig + diffRendererConfig *config.DiffRendererConfig expected []string } @@ -138,7 +138,7 @@ func TestStashStashEntryCmdObj(t *testing.T) { contextSize: 3, similarityThreshold: 50, ignoreWhitespace: false, - pagerConfig: &config.PagingConfig{ExternalDiffCommand: "difft --color=always"}, + diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"}, expected: []string{"git", "-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"}, }, { @@ -147,7 +147,7 @@ func TestStashStashEntryCmdObj(t *testing.T) { contextSize: 3, similarityThreshold: 50, ignoreWhitespace: false, - pagerConfig: &config.PagingConfig{UseExternalDiffGitConfig: true}, + diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff"}, expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"}, }, { @@ -166,8 +166,8 @@ func TestStashStashEntryCmdObj(t *testing.T) { userConfig.Git.IgnoreWhitespaceInDiffView = s.ignoreWhitespace userConfig.Git.DiffContextSize = s.contextSize userConfig.Git.RenameSimilarityThreshold = s.similarityThreshold - if s.pagerConfig != nil { - userConfig.Git.Pagers = []config.PagingConfig{*s.pagerConfig} + if s.diffRendererConfig != nil { + userConfig.Git.DiffRenderers = []config.DiffRendererConfig{*s.diffRendererConfig} } repoPaths := RepoPaths{ worktreePath: "/path/to/worktree", diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index f0a0c1b83..1f9d9653b 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -393,7 +393,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, // in the working tree. When pathOverrides is non-empty, those paths are used instead of // the node's path (used to diff only filtered/visible files within a directory). func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj { - colorArg := self.pagerConfig.GetColorArg() + colorArg := self.diffRendererConfigManager.GetColorArg() if plain { colorArg = "never" } @@ -407,7 +407,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain } cmdArgs := NewGitCmd("diff"). - AddCommonDiffArgs(self.pagerConfig, self.UserConfig(), !plain). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain). Arg("--submodule"). Arg(fmt.Sprintf("--color=%s", colorArg)). ArgIf(cached, "--cached"). @@ -435,14 +435,14 @@ func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bo } func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj { - colorArg := self.pagerConfig.GetColorArg() + colorArg := self.diffRendererConfigManager.GetColorArg() if plain { colorArg = "never" } cmdArgs := NewGitCmd("diff"). Config("diff.noprefix=false"). - AddCommonDiffArgs(self.pagerConfig, self.UserConfig(), !plain). + AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain). Arg("--submodule"). Arg(fmt.Sprintf("--color=%s", colorArg)). Arg(from). diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index eaa762ed6..1bfb29ab5 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -71,11 +71,11 @@ func (p *winPty) closeHpc() { // Windows 11 24H2 it waits for the console host to exit, and since closing // only delivers CTRL_CLOSE_EVENT to the attached client without terminating // it, a client that keeps running (git still computing an expensive diff, a -// pager waiting for input) keeps the host — and with it ClosePseudoConsole — -// alive arbitrarily long. Close is called while holding the global PtyMutex -// and while the task's onDone once is executing, where blocking wedges every -// subsequent task for the view (and with it the UI), so none of this may -// happen on the caller's thread. +// diff renderer waiting for input) keeps the host — and with it +// ClosePseudoConsole — alive arbitrarily long. Close is called while holding +// the global PtyMutex and while the task's onDone once is executing, where +// blocking wedges every subsequent task for the view (and with it the UI), so +// none of this may happen on the caller's thread. // // Within the teardown, the pipe ends must be closed before the // pseudoconsole, and without holding p.mu: closing the pseudoconsole flushes diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 9b9db2c12..d81a1402c 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -288,6 +288,8 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([] }{ {[]string{"gui", "skipUnstageLineWarning"}, "skipDiscardChangeWarning"}, {[]string{"keybinding", "universal", "executeCustomCommand"}, "executeShellCommand"}, + {[]string{"keybinding", "universal", "cyclePagers"}, "cycleDiffRenderers"}, + {[]string{"keybinding", "universal", "cyclePagersReverse"}, "cycleDiffRenderersReverse"}, {[]string{"gui", "windowSize"}, "screenMode"}, {[]string{"keybinding", "files", "openMergeTool"}, "openMergeOptions"}, } @@ -347,7 +349,12 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([] return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err) } - err = migratePagers(&rootNode, changes) + err = migratePaging(&rootNode, changes) + if err != nil { + return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err) + } + + err = migratePagersToDiffRenderers(&rootNode, changes) if err != nil { return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err) } @@ -512,7 +519,9 @@ func migrateAllBranchesLogCmd(rootNode *yaml.Node, changes *ChangesSet) error { }) } -func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error { +// Migrate the single 'paging' node to an array of 'pagers'. This is not the final structure, we +// migrate it to diffRenderers from there in a separate step below. +func migratePaging(rootNode *yaml.Node, changes *ChangesSet) error { return yaml_utils.TransformNode(rootNode, []string{"git"}, func(gitNode *yaml.Node) error { pagingKeyNode, pagingValueNode := yaml_utils.LookupKey(gitNode, "paging") if pagingKeyNode == nil || pagingValueNode.Kind != yaml.MappingNode { @@ -521,10 +530,11 @@ func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error { } pagersKeyNode, _ := yaml_utils.LookupKey(gitNode, "pagers") - if pagersKeyNode != nil { - // Conversely, if there *is* already a "pagers" array, we also have nothing to do. - // This covers the case where the user keeps both the "paging" section and the "pagers" - // array for the sake of easier testing of old versions. + diffRenderersKeyNode, _ := yaml_utils.LookupKey(gitNode, "diffRenderers") + if pagersKeyNode != nil || diffRenderersKeyNode != nil { + // Conversely, if there is already a newer array config, we also have nothing to do. + // This covers the case where the user keeps both formats for the sake of easier testing + // of old versions. return nil } @@ -532,6 +542,7 @@ func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error { pagingContentCopy := pagingValueNode.Content pagingValueNode.Kind = yaml.SequenceNode pagingValueNode.Tag = "!!seq" + pagingValueNode.Style &^= yaml.FlowStyle pagingValueNode.Content = []*yaml.Node{{ Kind: yaml.MappingNode, Content: pagingContentCopy, @@ -543,6 +554,90 @@ func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error { }) } +func migratePagersToDiffRenderers(rootNode *yaml.Node, changes *ChangesSet) error { + return yaml_utils.TransformNode(rootNode, []string{"git"}, func(gitNode *yaml.Node) error { + pagersKeyNode, pagersValueNode := yaml_utils.LookupKey(gitNode, "pagers") + if pagersKeyNode == nil || pagersValueNode.Kind != yaml.SequenceNode { + // If there's no "pagers" section (or it's not a sequence), there's nothing to do + return nil + } + + diffRenderersKeyNode, _ := yaml_utils.LookupKey(gitNode, "diffRenderers") + if diffRenderersKeyNode != nil { + // Conversely, if there *is* already a "diffRenderers" array, we also have nothing to do. + // This covers the case where the user keeps both the "pagers" and the "diffRenderers" + // arrays for the sake of easier testing of old versions. + return nil + } + + pagersKeyNode.Value = "diffRenderers" + changes.Add("Renamed git.pagers to git.diffRenderers") + + for _, diffRendererNode := range pagersValueNode.Content { + if diffRendererNode.Kind != yaml.MappingNode { + continue + } + + pagerKeyNode, pagerValueNode := yaml_utils.LookupKey(diffRendererNode, "pager") + externalDiffCommandKeyNode, externalDiffCommandValueNode := yaml_utils.LookupKey(diffRendererNode, "externalDiffCommand") + useExternalDiffGitConfigKeyNode, useExternalDiffGitConfigValueNode := yaml_utils.LookupKey(diffRendererNode, "useExternalDiffGitConfig") + + hasPager := hasNonNullScalarValue(pagerValueNode) + hasExternalDiffCommand := hasNonNullScalarValue(externalDiffCommandValueNode) + useExternalDiffGitConfig := yamlBoolValue(useExternalDiffGitConfigValueNode) + + if hasPager { + pagerKeyNode.Value = "command" + changes.Add("Renamed 'pager' to 'command' in git pager") + } else if hasExternalDiffCommand { + externalDiffCommandKeyNode.Value = "command" + yaml_utils.AddStringKey(diffRendererNode, "type", "extDiff") + changes.Add("Changed 'externalDiffCommand' to 'command' with 'type: extDiff' in git pager") + } else if useExternalDiffGitConfig { + yaml_utils.RemoveKey(diffRendererNode, "useExternalDiffGitConfig") + yaml_utils.AddStringKey(diffRendererNode, "type", "extDiff") + changes.Add("Changed 'useExternalDiffGitConfig: true' to 'type: extDiff' in git pager") + } else { + yaml_utils.AddStringKey(diffRendererNode, "type", "rawGit") + diffRendererNode.Style &^= yaml.FlowStyle + changes.Add("Changed git pager without a command to 'type: rawGit'") + } + + if pagerKeyNode != nil && !hasPager { + yaml_utils.RemoveKey(diffRendererNode, "pager") + changes.Add("Removed empty 'pager' from git pager") + } + if externalDiffCommandKeyNode != nil && !hasExternalDiffCommand { + yaml_utils.RemoveKey(diffRendererNode, "externalDiffCommand") + changes.Add("Removed empty 'externalDiffCommand' from git pager") + } + if useExternalDiffGitConfigKeyNode != nil && !useExternalDiffGitConfig { + yaml_utils.RemoveKey(diffRendererNode, "useExternalDiffGitConfig") + if useExternalDiffGitConfigValueNode.Tag == "!!null" { + changes.Add("Removed empty 'useExternalDiffGitConfig' from git pager") + } else { + changes.Add("Removed 'useExternalDiffGitConfig: false' from git pager") + } + } + } + + return nil + }) +} + +func hasNonNullScalarValue(node *yaml.Node) bool { + return node != nil && node.Kind == yaml.ScalarNode && node.Tag != "!!null" && node.Value != "" +} + +func yamlBoolValue(node *yaml.Node) bool { + if node == nil { + return false + } + + var value bool + return node.Decode(&value) == nil && value +} + func (c *AppConfig) GetDebug() bool { return c.debug } diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index f79141e73..180f4b882 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -83,24 +83,28 @@ func TestMigrationOfRenamedKeys(t *testing.T) { }, { name: "Rename several", - input: `gui: - windowSize: half - skipUnstageLineWarning: true -keybinding: - universal: - executeCustomCommand: a -`, - expected: `gui: - screenMode: half - skipDiscardChangeWarning: true -keybinding: - universal: - executeShellCommand: a -`, + input: "gui:\n" + + " windowSize: half\n" + + " skipUnstageLineWarning: true\n" + + "keybinding:\n" + + " universal:\n" + + " executeCustomCommand: a\n" + + " cyclePagers: b\n" + + " cyclePagersReverse: c\n", + expected: "gui:\n" + + " screenMode: half\n" + + " skipDiscardChangeWarning: true\n" + + "keybinding:\n" + + " universal:\n" + + " executeShellCommand: a\n" + + " cycleDiffRenderers: b\n" + + " cycleDiffRenderersReverse: c\n", expectedDidChange: true, expectedChanges: []string{ "Renamed 'gui.skipUnstageLineWarning' to 'skipDiscardChangeWarning'", "Renamed 'keybinding.universal.executeCustomCommand' to 'executeShellCommand'", + "Renamed 'keybinding.universal.cyclePagers' to 'cycleDiffRenderers'", + "Renamed 'keybinding.universal.cyclePagersReverse' to 'cycleDiffRenderersReverse'", "Renamed 'gui.windowSize' to 'screenMode'", }, }, @@ -595,6 +599,7 @@ func TestPagerMigration(t *testing.T) { expectedDidChange bool expectedChanges []string }{ + // Migrate 'paging' to 'pagers' array { name: "Incomplete Configuration Passes uneventfully", input: "git:", @@ -603,68 +608,203 @@ func TestPagerMigration(t *testing.T) { }, { name: "No paging section", - input: `git: - autoFetch: true -`, - expected: `git: - autoFetch: true -`, - expectedDidChange: false, - expectedChanges: []string{}, - }, - { - name: "Both paging and pagers exist", - input: `git: - paging: - pager: delta --dark --paging=never - pagers: - - pager: diff-so-fancy -`, - expected: `git: - paging: - pager: delta --dark --paging=never - pagers: - - pager: diff-so-fancy -`, + input: "git:\n" + + " autoFetch: true\n", + expected: "git:\n" + + " autoFetch: true\n", expectedDidChange: false, expectedChanges: []string{}, }, { name: "paging is not an object", - input: `git: - paging: 5 -`, - expected: `git: - paging: 5 -`, + input: "git:\n" + + " paging: 5\n", + expected: "git:\n" + + " paging: 5\n", expectedDidChange: false, expectedChanges: []string{}, }, { - name: "paging is moved to pagers array (keeping the order)", - input: `git: - paging: - pager: delta --dark --paging=never - autoFetch: true -`, - expected: `git: - pagers: - - pager: delta --dark --paging=never - autoFetch: true -`, - expectedDidChange: true, - expectedChanges: []string{"Moved git.paging object to git.pagers array"}, + name: "pagers is not an array", + input: "git:\n" + + " pagers: 5\n", + expected: "git:\n" + + " pagers: 5\n", + expectedDidChange: false, + expectedChanges: []string{}, }, { - name: "paging is moved to pagers array even if empty", - input: `git: - paging: {} -`, - expected: `git: - pagers: [{}] -`, + name: "paging and pagers coexist", + input: "git:\n" + + " paging:\n" + + " pager: delta --dark --paging=never\n" + + " pagers:\n" + + " - pager: diff-so-fancy\n", + expected: "git:\n" + + " paging:\n" + + " pager: delta --dark --paging=never\n" + + " diffRenderers:\n" + + " - command: diff-so-fancy\n", expectedDidChange: true, - expectedChanges: []string{"Moved git.paging object to git.pagers array"}, + expectedChanges: []string{ + "Renamed git.pagers to git.diffRenderers", + "Renamed 'pager' to 'command' in git pager", + }, + }, + { + name: "paging and diffRenderers coexist", + input: "git:\n" + + " paging:\n" + + " pager: delta --dark --paging=never\n" + + " diffRenderers:\n" + + " - command: diff-so-fancy\n", + expected: "git:\n" + + " paging:\n" + + " pager: delta --dark --paging=never\n" + + " diffRenderers:\n" + + " - command: diff-so-fancy\n", + expectedDidChange: false, + expectedChanges: []string{}, + }, + { + name: "pagers and diffRenderers coexist", + input: "git:\n" + + " pagers:\n" + + " - pager: delta --dark --paging=never\n" + + " diffRenderers:\n" + + " - command: diff-so-fancy\n", + expected: "git:\n" + + " pagers:\n" + + " - pager: delta --dark --paging=never\n" + + " diffRenderers:\n" + + " - command: diff-so-fancy\n", + expectedDidChange: false, + expectedChanges: []string{}, + }, + { + name: "paging is moved to diffRenderers array preserving fields and order", + input: "git:\n" + + " paging:\n" + + " name: delta\n" + + " colorArg: never\n" + + " pager: delta --dark --paging=never\n" + + " autoFetch: true\n", + expected: "git:\n" + + " diffRenderers:\n" + + " - name: delta\n" + + " colorArg: never\n" + + " command: delta --dark --paging=never\n" + + " autoFetch: true\n", + expectedDidChange: true, + expectedChanges: []string{ + "Moved git.paging object to git.pagers array", + "Renamed git.pagers to git.diffRenderers", + "Renamed 'pager' to 'command' in git pager", + }, + }, + { + name: "paging is moved to diffRenderers array even if empty", + input: "git:\n" + + " paging: {}\n", + expected: "git:\n" + + " diffRenderers:\n" + + " - type: rawGit\n", + expectedDidChange: true, + expectedChanges: []string{ + "Moved git.paging object to git.pagers array", + "Renamed git.pagers to git.diffRenderers", + "Changed git pager without a command to 'type: rawGit'", + }, + }, + + // Migrate 'pagers' array to 'diffRenderers' array + { + name: "empty pagers array is renamed", + input: "git:\n" + + " pagers: []\n", + expected: "git:\n" + + " diffRenderers: []\n", + expectedDidChange: true, + expectedChanges: []string{"Renamed git.pagers to git.diffRenderers"}, + }, + { + name: "pagers array entries are adapted", + input: "git:\n" + + " pagers:\n" + + " - name: delta\n" + + " colorArg: never\n" + + " pager: delta --dark --paging=never\n" + + " - name: difft\n" + + " colorArg: never\n" + + " externalDiffCommand: difft --color=always\n" + + " - name: git-config\n" + + " colorArg: never\n" + + " useExternalDiffGitConfig: TRUE\n" + + " - name: git\n" + + " colorArg: never\n" + + " autoFetch: true\n", + expected: "git:\n" + + " diffRenderers:\n" + + " - name: delta\n" + + " colorArg: never\n" + + " command: delta --dark --paging=never\n" + + " - name: difft\n" + + " colorArg: never\n" + + " command: difft --color=always\n" + + " type: extDiff\n" + + " - name: git-config\n" + + " colorArg: never\n" + + " type: extDiff\n" + + " - name: git\n" + + " colorArg: never\n" + + " type: rawGit\n" + + " autoFetch: true\n", + expectedDidChange: true, + expectedChanges: []string{ + "Renamed git.pagers to git.diffRenderers", + "Renamed 'pager' to 'command' in git pager", + "Changed 'externalDiffCommand' to 'command' with 'type: extDiff' in git pager", + "Changed 'useExternalDiffGitConfig: true' to 'type: extDiff' in git pager", + "Changed git pager without a command to 'type: rawGit'", + }, + }, + { + name: "zero-valued mechanism fields do not take precedence and are removed", + input: "git:\n" + + " pagers:\n" + + " - pager: delta --dark --paging=never\n" + + " externalDiffCommand: null\n" + + " useExternalDiffGitConfig: false\n" + + " - pager: \"\"\n" + + " externalDiffCommand: difft --color=always\n" + + " useExternalDiffGitConfig: false\n" + + " - pager: null\n" + + " externalDiffCommand: \"\"\n" + + " useExternalDiffGitConfig: YES\n" + + " - pager: \"\"\n" + + " externalDiffCommand:\n" + + " useExternalDiffGitConfig: false\n" + + " - useExternalDiffGitConfig: null\n", + expected: "git:\n" + + " diffRenderers:\n" + + " - command: delta --dark --paging=never\n" + + " - command: difft --color=always\n" + + " type: extDiff\n" + + " - type: extDiff\n" + + " - type: rawGit\n" + + " - type: rawGit\n", + expectedDidChange: true, + expectedChanges: []string{ + "Renamed git.pagers to git.diffRenderers", + "Renamed 'pager' to 'command' in git pager", + "Removed empty 'externalDiffCommand' from git pager", + "Removed 'useExternalDiffGitConfig: false' from git pager", + "Changed 'externalDiffCommand' to 'command' with 'type: extDiff' in git pager", + "Removed empty 'pager' from git pager", + "Changed 'useExternalDiffGitConfig: true' to 'type: extDiff' in git pager", + "Changed git pager without a command to 'type: rawGit'", + "Removed empty 'useExternalDiffGitConfig' from git pager", + }, }, } diff --git a/pkg/config/diff_renderer_config_manager.go b/pkg/config/diff_renderer_config_manager.go new file mode 100644 index 000000000..b23933962 --- /dev/null +++ b/pkg/config/diff_renderer_config_manager.go @@ -0,0 +1,159 @@ +package config + +import ( + "strconv" + "strings" + + "github.com/jesseduffield/lazygit/pkg/i18n" + "github.com/jesseduffield/lazygit/pkg/utils" +) + +type DiffRendererConfigManager struct { + getUserConfig func() *UserConfig + diffRendererIndex int +} + +type DiffRendererType int + +const ( + DiffRendererType_StdinFilter DiffRendererType = iota + DiffRendererType_ExtDiff + DiffRendererType_RawGit +) + +func NewDiffRendererConfigManager(getUserConfig func() *UserConfig) *DiffRendererConfigManager { + return &DiffRendererConfigManager{getUserConfig: getUserConfig} +} + +func (self *DiffRendererConfigManager) currentDiffRendererConfig() *DiffRendererConfig { + diffRenderers := self.getUserConfig().Git.DiffRenderers + if len(diffRenderers) == 0 { + return nil + } + + // Guard against the diff renderer index being out of range, which can happen if the user + // has removed diff renderers from their config file while lazygit is running. + if self.diffRendererIndex >= len(diffRenderers) { + self.diffRendererIndex = 0 + } + + return &diffRenderers[self.diffRendererIndex] +} + +func (self *DiffRendererConfig) getType() DiffRendererType { + switch self.Type { + case "stdinFilter", "": + return DiffRendererType_StdinFilter + case "extDiff": + return DiffRendererType_ExtDiff + case "rawGit": + return DiffRendererType_RawGit + } + panic("invalid diff renderer type: " + self.Type) +} + +func (self *DiffRendererConfigManager) GetDiffRendererType() DiffRendererType { + currentDiffRendererConfig := self.currentDiffRendererConfig() + if currentDiffRendererConfig == nil { + return DiffRendererType_RawGit + } + return currentDiffRendererConfig.getType() +} + +func (self *DiffRendererConfigManager) GetStdinFilterCommand(width int) string { + currentDiffRendererConfig := self.currentDiffRendererConfig() + if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter { + return "" + } + + templateValues := map[string]string{ + "columnWidth": strconv.Itoa(width/2 - 6), + } + + commandTemplate := string(currentDiffRendererConfig.Command) + return utils.ResolvePlaceholderString(commandTemplate, templateValues) +} + +func (self *DiffRendererConfigManager) GetColorArg() string { + currentDiffRendererConfig := self.currentDiffRendererConfig() + if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter { + return "always" + } + + colorArg := currentDiffRendererConfig.ColorArg + if colorArg == "" { + return "always" + } + return colorArg +} + +func (self *DiffRendererConfigManager) GetExternalDiffCommand(diffContext uint64) string { + currentDiffRendererConfig := self.currentDiffRendererConfig() + if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff { + return "" + } + + templateValues := map[string]string{ + "diffContext": strconv.Itoa(int(diffContext)), + } + + return utils.ResolvePlaceholderString(string(currentDiffRendererConfig.Command), templateValues) +} + +func (self *DiffRendererConfigManager) GetRawGitArgs() []string { + currentDiffRendererConfig := self.currentDiffRendererConfig() + if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_RawGit { + return nil + } + return currentDiffRendererConfig.Args +} + +func (self *DiffRendererConfigManager) CycleDiffRenderers() { + self.diffRendererIndex = (self.diffRendererIndex + 1) % len(self.getUserConfig().Git.DiffRenderers) +} + +func (self *DiffRendererConfigManager) CycleDiffRenderersBackward() { + n := len(self.getUserConfig().Git.DiffRenderers) + self.diffRendererIndex = (self.diffRendererIndex - 1 + n) % n +} + +func (self *DiffRendererConfigManager) CurrentDiffRendererIndex() (int, int) { + return self.diffRendererIndex, len(self.getUserConfig().Git.DiffRenderers) +} + +// CurrentDiffRendererName returns a name for the current diff renderer, suitable for showing +// to the user. +func (self *DiffRendererConfigManager) CurrentDiffRendererName(tr *i18n.TranslationSet) string { + currentDiffRendererConfig := self.currentDiffRendererConfig() + if currentDiffRendererConfig == nil { + return tr.DefaultDiffRendererName + } + + if name := currentDiffRendererConfig.displayName(); name != "" { + return name + } + + if currentDiffRendererConfig.getType() == DiffRendererType_ExtDiff && currentDiffRendererConfig.Command == "" { + return tr.ExternalDiffDiffRendererName + } + + return tr.DefaultDiffRendererName +} + +func (self *DiffRendererConfig) displayName() string { + if self.Name != "" { + return self.Name + } + if self.getType() == DiffRendererType_RawGit && len(self.Args) > 0 { + return self.Args[0] + } + return firstWord(string(self.Command)) +} + +func firstWord(command string) string { + fields := strings.Fields(command) + if len(fields) == 0 { + return "" + } + return fields[0] +} diff --git a/pkg/config/diff_renderer_config_manager_test.go b/pkg/config/diff_renderer_config_manager_test.go new file mode 100644 index 000000000..98fd8f304 --- /dev/null +++ b/pkg/config/diff_renderer_config_manager_test.go @@ -0,0 +1,96 @@ +package config + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/i18n" + "github.com/stretchr/testify/assert" +) + +func TestCurrentDiffRendererName(t *testing.T) { + tr := i18n.EnglishTranslationSet() + + scenarios := []struct { + name string + diffRendererConfig DiffRendererConfig + expected string + }{ + { + name: "explicit name takes precedence over the command", + diffRendererConfig: DiffRendererConfig{Name: "delta side-by-side", Command: "delta --side-by-side"}, + expected: "delta side-by-side", + }, + { + name: "derived from the first word of the stdinFilter command", + diffRendererConfig: DiffRendererConfig{Command: "delta --side-by-side"}, + expected: "delta", + }, + { + name: "surrounding whitespace in the command is ignored", + diffRendererConfig: DiffRendererConfig{Command: " diff-so-fancy "}, + expected: "diff-so-fancy", + }, + { + name: "derived from the first word of the extDiff command", + diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"}, + expected: "difft", + }, + { + name: "no name can be derived for external diff", + diffRendererConfig: DiffRendererConfig{Type: "extDiff"}, + expected: tr.ExternalDiffDiffRendererName, + }, + { + name: "derived from first argument of rawGit args", + diffRendererConfig: DiffRendererConfig{Type: "rawGit", Args: []string{"--color-words"}}, + expected: "--color-words", + }, + { + name: "no name can be derived for raw diff", + diffRendererConfig: DiffRendererConfig{Type: "rawGit"}, + expected: tr.DefaultDiffRendererName, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + userConfig := &UserConfig{} + userConfig.Git.DiffRenderers = []DiffRendererConfig{s.diffRendererConfig} + config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig }) + + assert.Equal(t, s.expected, config.CurrentDiffRendererName(tr)) + }) + } +} + +func TestCurrentDiffRendererNameWithoutDiffRenderers(t *testing.T) { + config := NewDiffRendererConfigManager(func() *UserConfig { return &UserConfig{} }) + + tr := i18n.EnglishTranslationSet() + assert.Equal(t, tr.DefaultDiffRendererName, config.CurrentDiffRendererName(tr)) +} + +func TestCycleDiffRenderers(t *testing.T) { + userConfig := &UserConfig{} + userConfig.Git.DiffRenderers = []DiffRendererConfig{{Name: "a"}, {Name: "b"}, {Name: "c"}} + config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig }) + + currentIndex := func() int { + index, _ := config.CurrentDiffRendererIndex() + return index + } + + assert.Equal(t, 0, currentIndex()) + + config.CycleDiffRenderers() + assert.Equal(t, 1, currentIndex()) + config.CycleDiffRenderers() + assert.Equal(t, 2, currentIndex()) + config.CycleDiffRenderers() + assert.Equal(t, 0, currentIndex(), "cycling forward past the last diff renderer wraps to the first") + + config.CycleDiffRenderersBackward() + assert.Equal(t, 2, currentIndex(), "cycling backward past the first diff renderer wraps to the last") + config.CycleDiffRenderersBackward() + assert.Equal(t, 1, currentIndex()) +} diff --git a/pkg/config/pager_config.go b/pkg/config/pager_config.go deleted file mode 100644 index b6514f671..000000000 --- a/pkg/config/pager_config.go +++ /dev/null @@ -1,130 +0,0 @@ -package config - -import ( - "strconv" - "strings" - - "github.com/jesseduffield/lazygit/pkg/i18n" - "github.com/jesseduffield/lazygit/pkg/utils" -) - -type PagerConfig struct { - getUserConfig func() *UserConfig - pagerIndex int -} - -func NewPagerConfig(getUserConfig func() *UserConfig) *PagerConfig { - return &PagerConfig{getUserConfig: getUserConfig} -} - -func (self *PagerConfig) currentPagerConfig() *PagingConfig { - pagers := self.getUserConfig().Git.Pagers - if len(pagers) == 0 { - return nil - } - - // Guard against the pager index being out of range, which can happen if the user - // has removed pagers from their config file while lazygit is running. - if self.pagerIndex >= len(pagers) { - self.pagerIndex = 0 - } - - return &pagers[self.pagerIndex] -} - -func (self *PagerConfig) GetPagerCommand(width int) string { - currentPagerConfig := self.currentPagerConfig() - if currentPagerConfig == nil { - return "" - } - - templateValues := map[string]string{ - "columnWidth": strconv.Itoa(width/2 - 6), - } - - pagerTemplate := string(currentPagerConfig.Pager) - return utils.ResolvePlaceholderString(pagerTemplate, templateValues) -} - -func (self *PagerConfig) GetColorArg() string { - currentPagerConfig := self.currentPagerConfig() - if currentPagerConfig == nil { - return "always" - } - - colorArg := currentPagerConfig.ColorArg - if colorArg == "" { - return "always" - } - return colorArg -} - -func (self *PagerConfig) GetExternalDiffCommand(diffContext uint64) string { - currentPagerConfig := self.currentPagerConfig() - if currentPagerConfig == nil { - return "" - } - - templateValues := map[string]string{ - "diffContext": strconv.Itoa(int(diffContext)), - } - - return utils.ResolvePlaceholderString(currentPagerConfig.ExternalDiffCommand, templateValues) -} - -func (self *PagerConfig) GetUseExternalDiffGitConfig() bool { - currentPagerConfig := self.currentPagerConfig() - if currentPagerConfig == nil { - return false - } - return currentPagerConfig.UseExternalDiffGitConfig -} - -func (self *PagerConfig) CyclePagers() { - self.pagerIndex = (self.pagerIndex + 1) % len(self.getUserConfig().Git.Pagers) -} - -func (self *PagerConfig) CyclePagersBackward() { - n := len(self.getUserConfig().Git.Pagers) - self.pagerIndex = (self.pagerIndex - 1 + n) % n -} - -func (self *PagerConfig) CurrentPagerIndex() (int, int) { - return self.pagerIndex, len(self.getUserConfig().Git.Pagers) -} - -// CurrentPagerName returns a name for the current pager, suitable for showing -// to the user. -func (self *PagerConfig) CurrentPagerName(tr *i18n.TranslationSet) string { - name := "" - currentPagerConfig := self.currentPagerConfig() - if currentPagerConfig != nil { - name = currentPagerConfig.displayName() - } - if name == "" { - if currentPagerConfig != nil && currentPagerConfig.UseExternalDiffGitConfig { - name = tr.ExternalDiffPagerName - } else { - name = tr.DefaultPagerName - } - } - return name -} - -func (self *PagingConfig) displayName() string { - if self.Name != "" { - return self.Name - } - if word := firstWord(string(self.Pager)); word != "" { - return word - } - return firstWord(self.ExternalDiffCommand) -} - -func firstWord(command string) string { - fields := strings.Fields(command) - if len(fields) == 0 { - return "" - } - return fields[0] -} diff --git a/pkg/config/pager_config_test.go b/pkg/config/pager_config_test.go deleted file mode 100644 index 117044afc..000000000 --- a/pkg/config/pager_config_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package config - -import ( - "testing" - - "github.com/jesseduffield/lazygit/pkg/i18n" - "github.com/stretchr/testify/assert" -) - -func TestCurrentPagerName(t *testing.T) { - tr := i18n.EnglishTranslationSet() - - scenarios := []struct { - name string - pager PagingConfig - expected string - }{ - { - name: "explicit name takes precedence over the command", - pager: PagingConfig{Name: "delta side-by-side", Pager: "delta --side-by-side"}, - expected: "delta side-by-side", - }, - { - name: "derived from the first word of the pager command", - pager: PagingConfig{Pager: "delta --side-by-side"}, - expected: "delta", - }, - { - name: "surrounding whitespace in the command is ignored", - pager: PagingConfig{Pager: " diff-so-fancy "}, - expected: "diff-so-fancy", - }, - { - name: "falls back to the external diff command when there is no pager", - pager: PagingConfig{ExternalDiffCommand: "difft --color=always"}, - expected: "difft", - }, - { - name: "no name can be derived for external diff", - pager: PagingConfig{UseExternalDiffGitConfig: true}, - expected: tr.ExternalDiffPagerName, - }, - { - name: "no name can be derived for raw diff", - pager: PagingConfig{}, - expected: tr.DefaultPagerName, - }, - } - - for _, s := range scenarios { - t.Run(s.name, func(t *testing.T) { - userConfig := &UserConfig{} - userConfig.Git.Pagers = []PagingConfig{s.pager} - config := NewPagerConfig(func() *UserConfig { return userConfig }) - - assert.Equal(t, s.expected, config.CurrentPagerName(tr)) - }) - } -} - -func TestCurrentPagerNameWithoutPagers(t *testing.T) { - config := NewPagerConfig(func() *UserConfig { return &UserConfig{} }) - - tr := i18n.EnglishTranslationSet() - assert.Equal(t, tr.DefaultPagerName, config.CurrentPagerName(tr)) -} - -func TestCyclePagers(t *testing.T) { - userConfig := &UserConfig{} - userConfig.Git.Pagers = []PagingConfig{{Name: "a"}, {Name: "b"}, {Name: "c"}} - config := NewPagerConfig(func() *UserConfig { return userConfig }) - - currentIndex := func() int { - index, _ := config.CurrentPagerIndex() - return index - } - - assert.Equal(t, 0, currentIndex()) - - config.CyclePagers() - assert.Equal(t, 1, currentIndex()) - config.CyclePagers() - assert.Equal(t, 2, currentIndex()) - config.CyclePagers() - assert.Equal(t, 0, currentIndex(), "cycling forward past the last pager wraps to the first") - - config.CyclePagersBackward() - assert.Equal(t, 2, currentIndex(), "cycling backward past the first pager wraps to the last") - config.CyclePagersBackward() - assert.Equal(t, 1, currentIndex()) -} diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 11c7bcf5f..a8148f022 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -87,7 +87,7 @@ type GuiConfig struct { // One of: 'margin' (default) | 'jump' ScrollOffBehavior string `yaml:"scrollOffBehavior"` // The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs. - // Note that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command. + // Note that when using a diff renderer, the renderer has its own tab width setting, so you need to pass it separately in the renderer command. TabWidth int `yaml:"tabWidth" jsonschema:"minimum=1"` // If true, capture mouse events. // When mouse events are captured, it's a little harder to select text: e.g. requiring you to hold the option key when on macOS. @@ -269,37 +269,39 @@ type SpinnerConfig struct { } type GitConfig struct { - // Array of pagers. Each entry has the following format: - // [dev] The following documentation is duplicated from the PagingConfig struct below. + // Array of diff renderers. Each entry has the following format: + // [dev] The following documentation is duplicated from the DiffRendererConfig struct below. // - // # A name for the pager, shown in the notification when cycling pagers. - // # If not set, the name is derived from the first word of the pager - // # command (or of the external diff command). + // # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff' + // # | 'rawGit' + // type: "stdinFilter" + // + // # A name for the diff renderer, shown in the notification when cycling + // # renderers. If not set, the name is derived from the first word of the + // # renderer command. // name: "" // - // # Value of the --color arg in the git diff command. Some pagers want - // # this to be set to 'always' and some want it set to 'never' + // # Value of the --color arg in the git diff command. Only used for type + // # 'stdinFilter'. Some renderers want this to be set to 'always' and some + // # want it set to 'never'. // colorArg: "always" // + // # The command to use for rendering diffs. This is either a stdinFilter or + // # an external diff command, depending on the type field; not applicable if + // # the type is 'rawGit'. // # e.g. // # diff-so-fancy // # delta --dark --paging=never - // # ydiff -p cat -s --wrap --width={{columnWidth}} - // pager: "" + // # ydiff -p cat + // # difft --color=always + // command: "" // - // # e.g. 'difft --color=always' - // externalDiffCommand: "" - // - // # If true, Lazygit will use git's `diff.external` config for paging. - // # The advantage over `externalDiffCommand` is that this can be - // # configured per file type in .gitattributes; see - // # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver. - // useExternalDiffGitConfig: false - // - // 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually exclusive; set at most one per entry. + // # Extra arguments (array of strings) passed to the git command. Only + // # applicable if the type is 'rawGit'. + // args: [] // // See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information. - Pagers []PagingConfig `yaml:"pagers"` + DiffRenderers []DiffRendererConfig `yaml:"diffRenderers"` // Config relating to committing Commit CommitConfig `yaml:"commit"` // Config relating to merging @@ -358,31 +360,34 @@ type GitConfig struct { TruncateCopiedCommitHashesTo int `yaml:"truncateCopiedCommitHashesTo"` } -type PagerType string +type DiffRendererCommandType string -func (PagerType) JSONSchemaExtend(schema *jsonschema.Schema) { +func (DiffRendererCommandType) JSONSchemaExtend(schema *jsonschema.Schema) { schema.Examples = []any{ "delta --dark --paging=never", "diff-so-fancy", - "ydiff -p cat -s --wrap --width={{columnWidth}}", + "ydiff -p cat", + "difft --color=always", } } // [dev] This documentation is duplicated in the GitConfig struct. If you make changes here, make them there too. -type PagingConfig struct { - // A name for the pager, shown in the notification when cycling pagers. If not set, the name is derived from the first word of the pager command (or of the external diff command). +type DiffRendererConfig struct { + // The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff' | 'rawGit' + Type string `yaml:"type" jsonschema:"enum=stdinFilter,enum=extDiff,enum=rawGit"` + // A name for the diff renderer, shown in the notification when cycling renderers. If not set, the name is derived from the first word of the renderer command. Name string `yaml:"name"` - // Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never' + // Value of the --color arg in the git diff command. Only used for type 'stdinFilter'. Some renderers want this to be set to 'always' and some want it set to 'never'. ColorArg string `yaml:"colorArg" jsonschema:"enum=always,enum=never"` + // The command to use for rendering diffs. This is either a stdinFilter or an external diff command, depending on the type field; not applicable if the type is 'rawGit'. // e.g. // diff-so-fancy // delta --dark --paging=never - // ydiff -p cat -s --wrap --width={{columnWidth}} - Pager PagerType `yaml:"pager"` - // e.g. 'difft --color=always' - ExternalDiffCommand string `yaml:"externalDiffCommand"` - // If true, Lazygit will use git's `diff.external` config for paging. The advantage over `externalDiffCommand` is that this can be configured per file type in .gitattributes; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver. - UseExternalDiffGitConfig bool `yaml:"useExternalDiffGitConfig"` + // ydiff -p cat + // difft --color=always + Command DiffRendererCommandType `yaml:"command"` + // Extra arguments (array of strings) passed to the git command. Only applicable if the type is 'rawGit'. + Args []string `yaml:"args"` } type CommitConfig struct { @@ -524,23 +529,23 @@ type KeybindingUniversalConfig struct { // Deprecated: add the key to `scrollUpMain` instead. ScrollUpMainAlt2 Keybinding `yaml:"scrollUpMain-alt2"` // Deprecated: add the key to `scrollDownMain` instead. - ScrollDownMainAlt2 Keybinding `yaml:"scrollDownMain-alt2"` - ExecuteShellCommand Keybinding `yaml:"executeShellCommand"` - CreateRebaseOptionsMenu Keybinding `yaml:"createRebaseOptionsMenu"` - Push Keybinding `yaml:"pushFiles"` // 'Files' appended for legacy reasons - Pull Keybinding `yaml:"pullFiles"` // 'Files' appended for legacy reasons - Refresh Keybinding `yaml:"refresh"` - CreatePatchOptionsMenu Keybinding `yaml:"createPatchOptionsMenu"` - NextTab Keybinding `yaml:"nextTab"` - PrevTab Keybinding `yaml:"prevTab"` - NextScreenMode Keybinding `yaml:"nextScreenMode"` - PrevScreenMode Keybinding `yaml:"prevScreenMode"` - CyclePagers Keybinding `yaml:"cyclePagers"` - CyclePagersReverse Keybinding `yaml:"cyclePagersReverse"` - Undo Keybinding `yaml:"undo"` - Redo Keybinding `yaml:"redo"` - FilteringMenu Keybinding `yaml:"filteringMenu"` - DiffingMenu Keybinding `yaml:"diffingMenu"` + ScrollDownMainAlt2 Keybinding `yaml:"scrollDownMain-alt2"` + ExecuteShellCommand Keybinding `yaml:"executeShellCommand"` + CreateRebaseOptionsMenu Keybinding `yaml:"createRebaseOptionsMenu"` + Push Keybinding `yaml:"pushFiles"` // 'Files' appended for legacy reasons + Pull Keybinding `yaml:"pullFiles"` // 'Files' appended for legacy reasons + Refresh Keybinding `yaml:"refresh"` + CreatePatchOptionsMenu Keybinding `yaml:"createPatchOptionsMenu"` + NextTab Keybinding `yaml:"nextTab"` + PrevTab Keybinding `yaml:"prevTab"` + NextScreenMode Keybinding `yaml:"nextScreenMode"` + PrevScreenMode Keybinding `yaml:"prevScreenMode"` + CycleDiffRenderers Keybinding `yaml:"cycleDiffRenderers"` + CycleDiffRenderersReverse Keybinding `yaml:"cycleDiffRenderersReverse"` + Undo Keybinding `yaml:"undo"` + Redo Keybinding `yaml:"redo"` + FilteringMenu Keybinding `yaml:"filteringMenu"` + DiffingMenu Keybinding `yaml:"diffingMenu"` // Deprecated: add the key to `diffingMenu` instead. DiffingMenuAlt Keybinding `yaml:"diffingMenu-alt"` CopyToClipboard Keybinding `yaml:"copyToClipboard"` @@ -1055,8 +1060,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { PrevTab: Keybinding{"["}, NextScreenMode: Keybinding{"+"}, PrevScreenMode: Keybinding{"_"}, - CyclePagers: Keybinding{"|"}, - CyclePagersReverse: Keybinding{"\\"}, + CycleDiffRenderers: Keybinding{"|"}, + CycleDiffRenderersReverse: Keybinding{"\\"}, Undo: Keybinding{"z"}, Redo: Keybinding{"Z"}, FilteringMenu: Keybinding{""}, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 9550e9160..81d2792ff 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -46,7 +46,7 @@ func (config *UserConfig) Validate() error { []string{"always", "never", "when-maximised"}); err != nil { return err } - if err := validatePagers(config.Git.Pagers); err != nil { + if err := validateDiffRenderers(config.Git.DiffRenderers); err != nil { return err } if err := validateKeybindings(config.Keybinding); err != nil { @@ -110,25 +110,26 @@ func validateSpinner(spinner SpinnerConfig) error { return nil } -// validatePagers rejects pager entries that combine more than one diff -// mechanism. A pager (GIT_PAGER) formats the diff that git produces, whereas -// externalDiffCommand and useExternalDiffGitConfig change how git produces the -// diff in the first place; piping one through the other almost always yields -// garbled output, so we treat the three as mutually exclusive. -func validatePagers(pagers []PagingConfig) error { - for i, pager := range pagers { - count := 0 - if pager.Pager != "" { - count++ - } - if pager.ExternalDiffCommand != "" { - count++ - } - if pager.UseExternalDiffGitConfig { - count++ - } - if count > 1 { - return fmt.Errorf("git.pagers[%d]: at most one of 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' may be set; they are mutually exclusive", i) +func validateDiffRenderers(diffRenderers []DiffRendererConfig) error { + for _, diffRenderer := range diffRenderers { + switch diffRenderer.Type { + case "stdinFilter", "": + if diffRenderer.Command == "" { + return errors.New("git.diffRenderers: 'command' must be specified for diff renderer type 'stdinFilter'.") + } + if len(diffRenderer.Args) > 0 { + return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'stdinFilter'.") + } + case "extDiff": + if len(diffRenderer.Args) > 0 { + return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'extDiff'.") + } + case "rawGit": + if diffRenderer.Command != "" { + return errors.New("git.diffRenderers: 'command' cannot be used with diff renderer type 'rawGit'.") + } + default: + return fmt.Errorf("git.diffRenderers: unknown type '%s'. Allowed values: stdinFilter, extDiff, rawGit", diffRenderer.Type) } } return nil diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index a0c17636d..370818718 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -361,26 +361,30 @@ func TestUserConfigValidate_sidePanels(t *testing.T) { } } -func TestUserConfigValidate_pagers(t *testing.T) { +func TestUserConfigValidate_diffRenderers(t *testing.T) { scenarios := []struct { - name string - pager PagingConfig - valid bool + name string + diffRenderer DiffRendererConfig + valid bool }{ - {name: "empty", pager: PagingConfig{}, valid: true}, - {name: "pager only", pager: PagingConfig{Pager: "delta"}, valid: true}, - {name: "external diff command only", pager: PagingConfig{ExternalDiffCommand: "difft"}, valid: true}, - {name: "git config external diff only", pager: PagingConfig{UseExternalDiffGitConfig: true}, valid: true}, - {name: "pager and external diff command", pager: PagingConfig{Pager: "delta", ExternalDiffCommand: "difft"}, valid: false}, - {name: "pager and git config external diff", pager: PagingConfig{Pager: "delta", UseExternalDiffGitConfig: true}, valid: false}, - {name: "both external diff mechanisms", pager: PagingConfig{ExternalDiffCommand: "difft", UseExternalDiffGitConfig: true}, valid: false}, - {name: "all three", pager: PagingConfig{Pager: "delta", ExternalDiffCommand: "difft", UseExternalDiffGitConfig: true}, valid: false}, + {name: "stdinFilter with type default", diffRenderer: DiffRendererConfig{Command: "delta"}, valid: true}, + {name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta"}, valid: true}, + {name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter"}, valid: false}, + {name: "stdinFilter with type default without command", diffRenderer: DiffRendererConfig{}, valid: false}, + {name: "stdinFilter with args", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta", Args: []string{"-x"}}, valid: false}, + {name: "external diff", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft"}, valid: true}, + {name: "external diff without command", diffRenderer: DiffRendererConfig{Type: "extDiff"}, valid: true}, + {name: "external diff with args", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft", Args: []string{"-x"}}, valid: false}, + {name: "raw git", diffRenderer: DiffRendererConfig{Type: "rawGit"}, valid: true}, + {name: "raw git with args", diffRenderer: DiffRendererConfig{Type: "rawGit", Args: []string{"-x"}}, valid: true}, + {name: "raw git with command", diffRenderer: DiffRendererConfig{Type: "rawGit", Command: "delta"}, valid: false}, + {name: "unknown type", diffRenderer: DiffRendererConfig{Type: "unknown"}, valid: false}, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { config := GetDefaultConfig() - config.Git.Pagers = []PagingConfig{s.pager} + config.Git.DiffRenderers = []DiffRendererConfig{s.diffRenderer} err := config.Validate() if s.valid { diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index 3d3f77cd5..b50ec6d1d 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -62,18 +62,18 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type Description: self.c.Tr.PrevScreenMode, }, { - Keys: opts.GetKeys(opts.Config.Universal.CyclePagers), - Handler: opts.Guards.NoPopupPanel(self.cyclePagers), - GetDisabledReason: self.canCyclePagers, - Description: self.c.Tr.CyclePagers, - Tooltip: self.c.Tr.CyclePagersTooltip, + Keys: opts.GetKeys(opts.Config.Universal.CycleDiffRenderers), + Handler: opts.Guards.NoPopupPanel(self.cycleDiffRenderers), + GetDisabledReason: self.canCycleDiffRenderers, + Description: self.c.Tr.CycleDiffRenderers, + Tooltip: self.c.Tr.CycleDiffRenderersTooltip, }, { - Keys: opts.GetKeys(opts.Config.Universal.CyclePagersReverse), - Handler: opts.Guards.NoPopupPanel(self.cyclePagersBackward), - GetDisabledReason: self.canCyclePagers, - Description: self.c.Tr.CyclePagersReverse, - Tooltip: self.c.Tr.CyclePagersReverseTooltip, + Keys: opts.GetKeys(opts.Config.Universal.CycleDiffRenderersReverse), + Handler: opts.Guards.NoPopupPanel(self.cycleDiffRenderersBackward), + GetDisabledReason: self.canCycleDiffRenderers, + Description: self.c.Tr.CycleDiffRenderersReverse, + Tooltip: self.c.Tr.CycleDiffRenderersReverseTooltip, }, { Keys: opts.GetKeys(opts.Config.Universal.Return), @@ -170,21 +170,21 @@ func (self *GlobalController) prevScreenMode() error { return (&ScreenModeActions{c: self.c}).Prev() } -func (self *GlobalController) cyclePagers() error { - self.c.State().GetPagerConfig().CyclePagers() - self.onPagerChanged() +func (self *GlobalController) cycleDiffRenderers() error { + self.c.State().GetDiffRendererConfigManager().CycleDiffRenderers() + self.onDiffRenderersChanged() return nil } -func (self *GlobalController) cyclePagersBackward() error { - self.c.State().GetPagerConfig().CyclePagersBackward() - self.onPagerChanged() +func (self *GlobalController) cycleDiffRenderersBackward() error { + self.c.State().GetDiffRendererConfigManager().CycleDiffRenderersBackward() + self.onDiffRenderersChanged() return nil } -// onPagerChanged re-renders the main view so the newly selected pager takes -// effect, and shows a toast naming it. -func (self *GlobalController) onPagerChanged() { +// onDiffRenderersChanged re-renders the main view so the newly selected diff renderer +// takes effect, and shows a toast naming it. +func (self *GlobalController) onDiffRenderersChanged() { currentSide := self.c.Context().CurrentSide() currentKey := self.c.Context().Current().GetKey() if currentSide.GetKey() == currentKey || @@ -193,21 +193,21 @@ func (self *GlobalController) onPagerChanged() { currentSide.HandleRenderToMain() } - pagerConfig := self.c.State().GetPagerConfig() - current, total := pagerConfig.CurrentPagerIndex() - name := pagerConfig.CurrentPagerName(self.c.Tr) - self.c.Toast(utils.ResolvePlaceholderString(self.c.Tr.SelectedPager, map[string]string{ + diffRendererConfigManager := self.c.State().GetDiffRendererConfigManager() + current, total := diffRendererConfigManager.CurrentDiffRendererIndex() + name := diffRendererConfigManager.CurrentDiffRendererName(self.c.Tr) + self.c.Toast(utils.ResolvePlaceholderString(self.c.Tr.SelectedDiffRenderers, map[string]string{ "name": name, "current": strconv.Itoa(current + 1), "total": strconv.Itoa(total), })) } -func (self *GlobalController) canCyclePagers() *types.DisabledReason { - _, total := self.c.State().GetPagerConfig().CurrentPagerIndex() +func (self *GlobalController) canCycleDiffRenderers() *types.DisabledReason { + _, total := self.c.State().GetDiffRendererConfigManager().CurrentDiffRendererIndex() if total <= 1 { return &types.DisabledReason{ - Text: self.c.Tr.CyclePagersDisabledReason, + Text: self.c.Tr.CycleDiffRenderersDisabledReason, } } return nil diff --git a/pkg/gui/controllers/screen_mode_actions.go b/pkg/gui/controllers/screen_mode_actions.go index 887f34603..cb1b0616e 100644 --- a/pkg/gui/controllers/screen_mode_actions.go +++ b/pkg/gui/controllers/screen_mode_actions.go @@ -42,9 +42,10 @@ func (self *ScreenModeActions) rerenderViewsWithScreenModeDependentContent() { } } - // Rerender the main view; for views that display a diff this is necessary in case a custom - // pager depends on the width of the view. For other views it isn't needed, but we don't bother - // making a distinction here, as rerendering the main view unnecessarily is not a big deal. + // Rerender the main view; for views that display a diff this is necessary in case a custom diff + // renderer depends on the width of the view. For other views it isn't needed, but we don't + // bother making a distinction here, as rerendering the main view unnecessarily is not a big + // deal. self.c.Context().CurrentSide().HandleRenderToMain() } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 912d46567..e1d387370 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -69,7 +69,7 @@ type Gui struct { // this is the state of the GUI for the current repo State *GuiRepoState - pagerConfig *config.PagerConfig + diffRendererConfig *config.DiffRendererConfigManager CustomCommandsClient *custom_commands.Client @@ -178,8 +178,8 @@ func (self *StateAccessor) GetRepoGeneration() int { return int(self.gui.repoGeneration.Load()) } -func (self *StateAccessor) GetPagerConfig() *config.PagerConfig { - return self.gui.pagerConfig +func (self *StateAccessor) GetDiffRendererConfigManager() *config.DiffRendererConfigManager { + return self.gui.diffRendererConfig } func (self *StateAccessor) GetShowExtrasWindow() bool { @@ -346,7 +346,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context gui.gitVersion, gui.os, git_config.NewStdCachedGitConfig(gui.Log), - gui.pagerConfig, + gui.diffRendererConfig, ) if err != nil { return err @@ -859,7 +859,7 @@ func NewGui( gui.BackgroundRoutineMgr = &BackgroundRoutineMgr{gui: gui} gui.stateAccessor = &StateAccessor{gui: gui} - gui.pagerConfig = config.NewPagerConfig(func() *config.UserConfig { return gui.UserConfig() }) + gui.diffRendererConfig = config.NewDiffRendererConfigManager(func() *config.UserConfig { return gui.UserConfig() }) return gui, nil } diff --git a/pkg/gui/patch_exploring/state.go b/pkg/gui/patch_exploring/state.go index 5f1a29e61..7222fef3b 100644 --- a/pkg/gui/patch_exploring/state.go +++ b/pkg/gui/patch_exploring/state.go @@ -79,7 +79,7 @@ func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *Stat // if we have clicked from the outside to focus the main view we'll pass in a non-negative line index so that we can instantly select that line if selectedLineIdx >= 0 { // Clamp to the number of wrapped view lines; index might be out of - // bounds if a custom pager is being used which produces more lines + // bounds if a custom diff renderer is being used which produces more lines selectedLineIdx = min(selectedLineIdx, len(viewLineIndices)-1) selectMode = RANGE diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index e10ea8ec7..8273c131a 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/utils" @@ -51,39 +52,32 @@ func (p ptyCmd) String() string { return p.cmd.String() } func (p ptyCmd) GetProcess() *os.Process { return p.process } // Some commands need to output for a terminal to active certain behaviour. -// For example, git won't invoke the GIT_PAGER env var unless it thinks it's +// For example, git won't invoke the GIT_PAGER env var unless it thinks it's // talking to a terminal. We typically write cmd outputs straight to a view, // which is just an io.Reader. the pty package lets us wrap a command in a // pseudo-terminal meaning we'll get the behaviour we want from the underlying // command. func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { width := view.InnerWidth() - diffContext := gui.UserConfig().Git.DiffContextSize - // LAZYGIT_COLUMNS is documented in docs/Custom_Pagers.md for pager - // scripts that can't query the terminal width directly. We set it on - // every platform so those scripts remain portable. + // Set LAZYGIT_COLUMNS for diff renderer scripts that can't query the terminal width directly. cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width)) - pager := gui.stateAccessor.GetPagerConfig().GetPagerCommand(width) - externalDiffCommand := gui.stateAccessor.GetPagerConfig().GetExternalDiffCommand(diffContext) - useExtDiffGitConfig := gui.stateAccessor.GetPagerConfig().GetUseExternalDiffGitConfig() - - if pager == "" && externalDiffCommand == "" && !useExtDiffGitConfig { - // If we're not using a custom pager nor external diff command, then we don't need to use a pty + if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit { + // If we're not using a custom diff renderer, then we don't need to use a pty return gui.newCmdTask(view, cmd, prefix) } // Run the pty after layout so that it gets the correct size gui.afterLayout(func() error { - // Need to get the width and the pager again because the layout might have + // Need to get the width and the pager command again because the layout might have // changed the size of the view width = view.InnerWidth() - pager := gui.stateAccessor.GetPagerConfig().GetPagerCommand(width) + pager := gui.stateAccessor.GetDiffRendererConfigManager().GetStdinFilterCommand(width) cmdStr := strings.Join(cmd.Args, " ") - // This communicates to pagers that we're in a very simple + // This communicates to diff renderers that we're in a very simple // terminal that they should not expect to have much capabilities. // Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities. // Context: https://github.com/jesseduffield/lazygit/issues/3419 @@ -102,7 +96,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error var p oscommands.Pty var fallbackPipe io.ReadCloser start := func() (tasks.Cmd, io.Reader) { - // The pty (and pager) wrap to this width; apply it here, on the + // The pty (and diff renderer) wrap to this width; apply it here, on the // task's goroutine once the previous task has stopped, so it doesn't // race that task's writes (see View.SetContentWidth). view.SetContentWidth(width) @@ -110,7 +104,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error sp, err := oscommands.StartPty(cmd, cols, rows) if err != nil { gui.c.Log.Error(err) - // Fall back to running the command without a pty: the pager is + // Fall back to running the command without a pty: the diff renderer is // lost, but the command's output still renders. execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log) fallbackPipe = pipe diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 87bd9ef50..7091d46f7 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -391,7 +391,7 @@ type HasUrn interface { type IStateAccessor interface { GetRepoPathStack() *utils.StringStack GetRepoState() IRepoStateAccessor - GetPagerConfig() *config.PagerConfig + GetDiffRendererConfigManager() *config.DiffRendererConfigManager // tells us whether we're currently updating lazygit GetUpdating() bool SetUpdating(bool) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index ac423b6f0..812c30fc5 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -612,14 +612,14 @@ type TranslationSet struct { ViewResetToUpstreamOptions string NextScreenMode string PrevScreenMode string - CyclePagers string - CyclePagersTooltip string - CyclePagersReverse string - CyclePagersReverseTooltip string - CyclePagersDisabledReason string - SelectedPager string - DefaultPagerName string - ExternalDiffPagerName string + CycleDiffRenderers string + CycleDiffRenderersTooltip string + CycleDiffRenderersReverse string + CycleDiffRenderersReverseTooltip string + CycleDiffRenderersDisabledReason string + SelectedDiffRenderers string + DefaultDiffRendererName string + ExternalDiffDiffRendererName string StartSearch string StartFilter string SelectRemoteRepository string @@ -1767,14 +1767,14 @@ func EnglishTranslationSet() *TranslationSet { ViewResetToUpstreamOptions: "View upstream reset options", NextScreenMode: "Next screen mode (normal/half/fullscreen)", PrevScreenMode: "Prev screen mode", - CyclePagers: "Cycle pagers", - CyclePagersTooltip: "Choose the next pager in the list of configured pagers.", - CyclePagersReverse: "Cycle pagers (reverse)", - CyclePagersReverseTooltip: "Choose the previous pager in the list of configured pagers.", - CyclePagersDisabledReason: "No other pagers configured", - SelectedPager: "Pager: {{.name}} ({{.current}} of {{.total}})", - DefaultPagerName: "(default)", - ExternalDiffPagerName: "(external diff)", + CycleDiffRenderers: "Cycle diff renderers", + CycleDiffRenderersTooltip: "Choose the next renderer in the list of configured diff renderers.", + CycleDiffRenderersReverse: "Cycle diff renderers (reverse)", + CycleDiffRenderersReverseTooltip: "Choose the previous renderer in the list of configured diff renderers.", + CycleDiffRenderersDisabledReason: "No other diff renderers configured", + SelectedDiffRenderers: "Diff renderer: {{.name}} ({{.current}} of {{.total}})", + DefaultDiffRendererName: "(default)", + ExternalDiffDiffRendererName: "(external diff)", StartSearch: "Search the current view by text", StartFilter: "Filter the current view by text", SelectRemoteRepository: "Select base repository for pull requests", @@ -2319,7 +2319,7 @@ keybinding: suspendApp: redo: -- The 'git.paging.useConfig' option has been removed. If you were relying on it to configure your pager, you'll have to explicitly set the pager again using the 'git.paging.pager' option. +- The 'git.paging.useConfig' option has been removed. If you were relying on it to configure your pager, you'll have to explicitly set the command again using the 'git.diffRenderers.*.command' option. `, "0.62.0": `- The default keybinding for submitting a commit from the commit description editor has changed from alt-enter to command-enter on Mac, or ctrl-enter on Linux and Windows; these are the same bindings that are used in many multi-line edit field situations, e.g. in GitHub comments. Unfortunately these are not supported by all terminals; see https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md#terminal-compatibility for more on that. If you want to revert this change, you can do so by adding the following to your config: diff --git a/pkg/integration/tests/diff/cycle_diff_renderers.go b/pkg/integration/tests/diff/cycle_diff_renderers.go new file mode 100644 index 000000000..4cf4ebfb5 --- /dev/null +++ b/pkg/integration/tests/diff/cycle_diff_renderers.go @@ -0,0 +1,50 @@ +package diff + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CycleDiffRenderers = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Cycle forwards and backwards through configured diff renderers", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + // an explicit name overrides the derived one + {Name: "custom name", Command: "cat"}, + // no name, so it's derived from the first word of the command + {Command: "cat -n"}, + // rawGit derives it from the first argument if any + {Type: "rawGit", Args: []string{"--color-words"}}, + // neither name nor command, so it falls back to the default label + {Type: "rawGit"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(1) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.CycleDiffRenderers) + t.ExpectToast(Equals("Diff renderer: cat (2 of 4)")) + + t.Views().Commits().Press(keys.Universal.CycleDiffRenderers) + t.ExpectToast(Equals("Diff renderer: --color-words (3 of 4)")) + + t.Views().Commits().Press(keys.Universal.CycleDiffRenderers) + t.ExpectToast(Equals("Diff renderer: (default) (4 of 4)")) + + // cycling forward past the last diff renderer wraps around to the first + t.Views().Commits().Press(keys.Universal.CycleDiffRenderers) + t.ExpectToast(Equals("Diff renderer: custom name (1 of 4)")) + + // cycling backward past the first diff renderer wraps around to the last + t.Views().Commits().Press(keys.Universal.CycleDiffRenderersReverse) + t.ExpectToast(Equals("Diff renderer: (default) (4 of 4)")) + + t.Views().Commits().Press(keys.Universal.CycleDiffRenderersReverse) + t.ExpectToast(Equals("Diff renderer: --color-words (3 of 4)")) + }, +}) diff --git a/pkg/integration/tests/diff/cycle_pagers.go b/pkg/integration/tests/diff/cycle_pagers.go deleted file mode 100644 index 2f2da9a5b..000000000 --- a/pkg/integration/tests/diff/cycle_pagers.go +++ /dev/null @@ -1,45 +0,0 @@ -package diff - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var CyclePagers = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Cycle forwards and backwards through configured pagers", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(cfg *config.AppConfig) { - cfg.GetUserConfig().Git.Pagers = []config.PagingConfig{ - // an explicit name overrides the derived one - {Name: "custom name", Pager: "cat"}, - // no name, so it's derived from the first word of the command - {Pager: "cat -n"}, - // neither name nor command, so it falls back to the default label - {}, - } - }, - SetupRepo: func(shell *Shell) { - shell.CreateNCommits(1) - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Commits(). - Focus(). - Press(keys.Universal.CyclePagers) - t.ExpectToast(Equals("Pager: cat (2 of 3)")) - - t.Views().Commits().Press(keys.Universal.CyclePagers) - t.ExpectToast(Equals("Pager: (default) (3 of 3)")) - - // cycling forward past the last pager wraps around to the first - t.Views().Commits().Press(keys.Universal.CyclePagers) - t.ExpectToast(Equals("Pager: custom name (1 of 3)")) - - // cycling backward past the first pager wraps around to the last - t.Views().Commits().Press(keys.Universal.CyclePagersReverse) - t.ExpectToast(Equals("Pager: (default) (3 of 3)")) - - t.Views().Commits().Press(keys.Universal.CyclePagersReverse) - t.ExpectToast(Equals("Pager: cat (2 of 3)")) - }, -}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index fe8478b9c..a6b5aeafd 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -218,7 +218,7 @@ var tests = []*components.IntegrationTest{ demo.Undo, demo.WorktreeCreateFromBranches, diff.CopyToClipboard, - diff.CyclePagers, + diff.CycleDiffRenderers, diff.Diff, diff.DiffAndApplyPatch, diff.DiffCommits, diff --git a/pkg/utils/yaml_utils/yaml_utils.go b/pkg/utils/yaml_utils/yaml_utils.go index 251d4dc01..c7a72515b 100644 --- a/pkg/utils/yaml_utils/yaml_utils.go +++ b/pkg/utils/yaml_utils/yaml_utils.go @@ -32,6 +32,23 @@ func RemoveKey(node *yaml.Node, key string) (*yaml.Node, *yaml.Node) { return nil, nil } +// Adds a string field to the given object. Caution: doesn't check for duplicate +// keys, that's the caller's responsibility +func AddStringKey(mappingNode *yaml.Node, key string, value string) { + keyNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: key, + } + valueNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: value, + } + + mappingNode.Content = append(mappingNode.Content, keyNode, valueNode) +} + // Walks a yaml document from the root node to the specified path, and then applies the transformation to that node. // If the requested path is not defined in the document, no changes are made to the document. func TransformNode(rootNode *yaml.Node, path []string, transform func(node *yaml.Node) error) error { diff --git a/schema-master/config.json b/schema-master/config.json index 6ff13b5d0..bd4b3bc8e 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -314,14 +314,58 @@ "type": "object", "description": "Custom icons for filenames and file extensions\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color" }, - "GitConfig": { + "DiffRendererConfig": { "properties": { - "pagers": { + "type": { + "type": "string", + "enum": [ + "stdinFilter", + "extDiff", + "rawGit" + ], + "description": "The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff' | 'rawGit'" + }, + "name": { + "type": "string", + "description": "A name for the diff renderer, shown in the notification when cycling renderers. If not set, the name is derived from the first word of the renderer command." + }, + "colorArg": { + "type": "string", + "enum": [ + "always", + "never" + ], + "description": "Value of the --color arg in the git diff command. Only used for type 'stdinFilter'. Some renderers want this to be set to 'always' and some want it set to 'never'." + }, + "command": { + "type": "string", + "description": "The command to use for rendering diffs. This is either a stdinFilter or an external diff command, depending on the type field; not applicable if the type is 'rawGit'.\ne.g.\ndiff-so-fancy\ndelta --dark --paging=never\nydiff -p cat\ndifft --color=always", + "examples": [ + "delta --dark --paging=never", + "diff-so-fancy", + "ydiff -p cat", + "difft --color=always" + ] + }, + "args": { "items": { - "$ref": "#/$defs/PagingConfig" + "type": "string" }, "type": "array", - "description": "Array of pagers. Each entry has the following format:\n\n # A name for the pager, shown in the notification when cycling pagers.\n # If not set, the name is derived from the first word of the pager\n # command (or of the external diff command).\n name: \"\"\n\n # Value of the --color arg in the git diff command. Some pagers want\n # this to be set to 'always' and some want it set to 'never'\n colorArg: \"always\"\n\n # e.g.\n # diff-so-fancy\n # delta --dark --paging=never\n # ydiff -p cat -s --wrap --width={{columnWidth}}\n pager: \"\"\n\n # e.g. 'difft --color=always'\n externalDiffCommand: \"\"\n\n # If true, Lazygit will use git's `diff.external` config for paging.\n # The advantage over `externalDiffCommand` is that this can be\n # configured per file type in .gitattributes; see\n # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.\n useExternalDiffGitConfig: false\n\n'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually exclusive; set at most one per entry.\n\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information." + "description": "Extra arguments (array of strings) passed to the git command. Only applicable if the type is 'rawGit'." + } + }, + "additionalProperties": false, + "type": "object" + }, + "GitConfig": { + "properties": { + "diffRenderers": { + "items": { + "$ref": "#/$defs/DiffRendererConfig" + }, + "type": "array", + "description": "Array of diff renderers. Each entry has the following format:\n\n # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'\n # | 'rawGit'\n type: \"stdinFilter\"\n\n # A name for the diff renderer, shown in the notification when cycling\n # renderers. If not set, the name is derived from the first word of the\n # renderer command.\n name: \"\"\n\n # Value of the --color arg in the git diff command. Only used for type\n # 'stdinFilter'. Some renderers want this to be set to 'always' and some\n # want it set to 'never'.\n colorArg: \"always\"\n\n # The command to use for rendering diffs. This is either a stdinFilter or\n # an external diff command, depending on the type field; not applicable if\n # the type is 'rawGit'.\n # e.g.\n # diff-so-fancy\n # delta --dark --paging=never\n # ydiff -p cat\n # difft --color=always\n command: \"\"\n\n # Extra arguments (array of strings) passed to the git command. Only\n # applicable if the type is 'rawGit'.\n args: []\n\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information." }, "commit": { "$ref": "#/$defs/CommitConfig", @@ -535,7 +579,7 @@ "tabWidth": { "type": "integer", "minimum": 1, - "description": "The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.\nNote that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command.", + "description": "The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.\nNote that when using a diff renderer, the renderer has its own tab width setting, so you need to pass it separately in the renderer command.", "default": 4 }, "mouseEvents": { @@ -3163,7 +3207,7 @@ ], "default": "_" }, - "cyclePagers": { + "cycleDiffRenderers": { "oneOf": [ { "type": "string" @@ -3177,7 +3221,7 @@ ], "default": "|" }, - "cyclePagersReverse": { + "cycleDiffRenderersReverse": { "oneOf": [ { "type": "string" @@ -3544,41 +3588,6 @@ "type": "object", "description": "Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc" }, - "PagingConfig": { - "properties": { - "name": { - "type": "string", - "description": "A name for the pager, shown in the notification when cycling pagers. If not set, the name is derived from the first word of the pager command (or of the external diff command)." - }, - "colorArg": { - "type": "string", - "enum": [ - "always", - "never" - ], - "description": "Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never'" - }, - "pager": { - "type": "string", - "description": "e.g.\ndiff-so-fancy\ndelta --dark --paging=never\nydiff -p cat -s --wrap --width={{columnWidth}}", - "examples": [ - "delta --dark --paging=never", - "diff-so-fancy", - "ydiff -p cat -s --wrap --width={{columnWidth}}" - ] - }, - "externalDiffCommand": { - "type": "string", - "description": "e.g. 'difft --color=always'" - }, - "useExternalDiffGitConfig": { - "type": "boolean", - "description": "If true, Lazygit will use git's `diff.external` config for paging. The advantage over `externalDiffCommand` is that this can be configured per file type in .gitattributes; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver." - } - }, - "additionalProperties": false, - "type": "object" - }, "RefresherConfig": { "properties": { "refreshInterval": { From 4598c7b548c142a5a80e3a3e442f0f679bbecca0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 26 Jul 2026 18:53:40 +0200 Subject: [PATCH 43/97] Adapt documentation --- README.md | 6 +- docs-master/Config.md | 2 +- docs-master/Custom_DiffRenderers.md | 84 ++++++++++++++++++++++ docs-master/Custom_Pagers.md | 108 ---------------------------- docs-master/README.md | 2 +- pkg/config/user_config.go | 2 +- schema-master/config.json | 2 +- 7 files changed, 91 insertions(+), 115 deletions(-) create mode 100644 docs-master/Custom_DiffRenderers.md delete mode 100644 docs-master/Custom_Pagers.md diff --git a/README.md b/README.md index 2fdf688fd..5d5e47e11 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ If you're a mere mortal like me and you're tired of hearing how powerful git is - [Changing Directory On Exit](#changing-directory-on-exit) - [Undo/Redo](#undoredo) - [Configuration](#configuration) - - [Custom Pagers](#custom-pagers) + - [Custom Diff Renderers](#custom-diff-renderers) - [Custom Commands](#custom-commands) - [Git flow support](#git-flow-support) - [Contributing](#contributing) @@ -590,9 +590,9 @@ See the [docs](/docs/Undoing.md) Check out the [configuration docs](docs/Config.md). -### Custom Pagers +### Custom Diff Renderers -See the [docs](docs/Custom_Pagers.md) +See the [docs](docs/Custom_DiffRenderers.md) ### Custom Commands diff --git a/docs-master/Config.md b/docs-master/Config.md index 3dede3bc9..857a4e359 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -390,7 +390,7 @@ git: # # applicable if the type is 'rawGit'. # args: [] # - # See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md + # See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md # for more information. diffRenderers: [] diff --git a/docs-master/Custom_DiffRenderers.md b/docs-master/Custom_DiffRenderers.md new file mode 100644 index 000000000..1a6b1d5ce --- /dev/null +++ b/docs-master/Custom_DiffRenderers.md @@ -0,0 +1,84 @@ +# Custom Diff Renderers + +Custom diff renderers are useful for showing a better rendering of a diff than git's builtin raw diff, and using one is strongly recommended (I personally prefer delta myself, but that's a matter of personal preference). There are three types of diff renderers that lazygit supports: + +- **stdin filters**, e.g. [delta](#delta) and [diff-so-fancy](#diff-so-fancy). They take git's raw output as stdin and produce something nicer on stdout, and they are hooked up using git's GIT_PAGER mechanism. (These used to be called "custom pagers" in earlier lazygit versions.) +- **external diff programs**, e.g. difftastic; these are called using git's `--ext-diff` flag, and they take over diff generation from git completely rather than post-processing git's output. +- **git's raw output using custom arguments**; mainly useful for `--color-words` (or `--word-diff` if you are color blind). + +Diff renderers are configured with the `diffRenderers` array in the `git` section of lazygit's config file; it is an array because you can have multiple entries that you can cycle through with the `|` key. This can be useful if you usually prefer a particular diff renderer, but want to use a different one for certain kinds of diffs. + +Fields that are shared by all renderer types: + +- **type** The type of diff renderer; choices are `stdinFilter`, `extDiff`, or `rawGit`. `stdinFilter` is the default, because it's the most common one; so you can omit this if you use delta. +- **name** A name that is shown in the status bar toast when cycling renderers; defaults to the first word of the renderer command, but can be useful e.g. to distinguish "delta" from "delta side-by-side" if you have entries for both. + +Fields only for `stdinFilter`: + +- **command** The command line to use for `GIT_PAGER`. + +- **colorArg** whether you want the `--color=always` arg in your `git diff` command. Some diff renderers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most renderers need. + +Fields only for `extDiff`: + +- **command** The command line to use for the `diff.external` git config. If left empty, it uses the global value of git's `diff.external` config; this can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver. + + You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool. + +Fields only for `rawGit`: + +- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`) + +Here's an example for a multi-renderer setup: + +```yaml +git: + diffRenderers: + - command: delta --dark --paging=never + - command: ydiff -p cat + colorArg: never + - type: extDiff + command: difft --color=always --context={{diffContext}} + - type: rawGit + args: --color-words + name: color-words + - type: rawGit # git's default diff + name: default +``` + +## Delta: + +```yaml +git: + diffRenderers: + - command: delta --dark --paging=never +``` + +![](https://i.imgur.com/QJpQkF3.png) + +A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor. + +Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons. + +## Diff-so-fancy + +```yaml +git: + diffRenderers: + - command: diff-so-fancy +``` + +![](https://i.imgur.com/rjH1TpT.png) + +## ydiff + +```yaml +gui: + sidePanelWidth: 0.2 # gives you more space to show things side-by-side +git: + diffRenderers: + - colorArg: never + command: ydiff -p cat +``` + +![](https://i.imgur.com/vaa8z0H.png) diff --git a/docs-master/Custom_Pagers.md b/docs-master/Custom_Pagers.md deleted file mode 100644 index 8bdcf164d..000000000 --- a/docs-master/Custom_Pagers.md +++ /dev/null @@ -1,108 +0,0 @@ -# Custom Pagers - -Lazygit supports custom pagers, [configured](/docs/Config.md) in the config.yml file (which can be opened by pressing `e` in the Status panel). - -Multiple pagers are supported; you can cycle through them with the `|` key. This can be useful if you usually prefer a particular pager, but want to use a different one for certain kinds of diffs. - -Pagers are configured with the `pagers` array in the git section; here's an example for a multi-pager setup (use an empty object `{}` for the default builtin diff display that doesn't use a pager): - -```yaml -git: - pagers: - - pager: delta --dark --paging=never - - pager: ydiff -p cat -s --wrap --width={{columnWidth}} - colorArg: never - - externalDiffCommand: difft --color=always - - {} # default, no pager used -``` - -The `colorArg` key is for whether you want the `--color=always` arg in your `git diff` command. Some pagers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most pagers need. - -## Delta: - -```yaml -git: - pagers: - - pager: delta --dark --paging=never -``` - -![](https://i.imgur.com/QJpQkF3.png) - -A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `pager:` config to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor. - -Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons. - -## Diff-so-fancy - -```yaml -git: - pagers: - - pager: diff-so-fancy -``` - -![](https://i.imgur.com/rjH1TpT.png) - -## ydiff - -```yaml -gui: - sidePanelWidth: 0.2 # gives you more space to show things side-by-side -git: - pagers: - - colorArg: never - pager: ydiff -p cat -s --wrap --width={{columnWidth}} -``` - -![](https://i.imgur.com/vaa8z0H.png) - -Be careful with this one, I think the homebrew and pip versions are behind master. I needed to directly download the ydiff script to get the no-pager functionality working. - -## Using external diff commands - -Some diff tools can't work as a simple pager like the ones above do, because they need access to the entire diff, so just post-processing git's diff is not enough for them. The most notable example is probably [difftastic](https://difftastic.wilfred.me.uk). - -These can be used in lazygit by using the `externalDiffCommand` config; in the case of difftastic, that could be - -```yaml -git: - pagers: - - externalDiffCommand: difft --color=always --context={{diffContext}} -``` - -The `colorArg` option is not used in this case. You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool. - -You can add whatever extra arguments you prefer for your difftool; for instance - -```yaml -git: - pagers: - - externalDiffCommand: difft --color=always --context={{diffContext}} --display=inline --syntax-highlight=off -``` - -This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`: - -```sh -#!/bin/sh - -git diff --color-words --no-index --color=always --no-ext-diff --unified=$LAZYGIT_DIFF_CONTEXT "$2" "$5" -``` - -And then use it in your git config like so: - -```yaml -git: - pagers: - - externalDiffCommand: LAZYGIT_DIFF_CONTEXT={{diffContext}} ~/bin/color-words.sh -``` - -Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using - -```yaml -git: - pagers: - - useExternalDiffGitConfig: true -``` - -This can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver. - -`pager`, `externalDiffCommand`, and `useExternalDiffGitConfig` are alternative ways of producing the diff, so a pager entry may use at most one of them. diff --git a/docs-master/README.md b/docs-master/README.md index 1bc0bb6be..c586d9699 100644 --- a/docs-master/README.md +++ b/docs-master/README.md @@ -2,7 +2,7 @@ * [Configuration](./Config.md). * [Custom Commands](./Custom_Command_Keybindings.md) -* [Custom Pagers](./Custom_Pagers.md) +* [Custom Diff Renderers](./Custom_DiffRenderers.md) * [Dev docs](./dev) * [Keybindings](./keybindings) * [Undo/Redo](./Undoing.md) diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index a8148f022..9738186d9 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -300,7 +300,7 @@ type GitConfig struct { // # applicable if the type is 'rawGit'. // args: [] // - // See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information. + // See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md for more information. DiffRenderers []DiffRendererConfig `yaml:"diffRenderers"` // Config relating to committing Commit CommitConfig `yaml:"commit"` diff --git a/schema-master/config.json b/schema-master/config.json index bd4b3bc8e..45a2b9efe 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -365,7 +365,7 @@ "$ref": "#/$defs/DiffRendererConfig" }, "type": "array", - "description": "Array of diff renderers. Each entry has the following format:\n\n # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'\n # | 'rawGit'\n type: \"stdinFilter\"\n\n # A name for the diff renderer, shown in the notification when cycling\n # renderers. If not set, the name is derived from the first word of the\n # renderer command.\n name: \"\"\n\n # Value of the --color arg in the git diff command. Only used for type\n # 'stdinFilter'. Some renderers want this to be set to 'always' and some\n # want it set to 'never'.\n colorArg: \"always\"\n\n # The command to use for rendering diffs. This is either a stdinFilter or\n # an external diff command, depending on the type field; not applicable if\n # the type is 'rawGit'.\n # e.g.\n # diff-so-fancy\n # delta --dark --paging=never\n # ydiff -p cat\n # difft --color=always\n command: \"\"\n\n # Extra arguments (array of strings) passed to the git command. Only\n # applicable if the type is 'rawGit'.\n args: []\n\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information." + "description": "Array of diff renderers. Each entry has the following format:\n\n # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'\n # | 'rawGit'\n type: \"stdinFilter\"\n\n # A name for the diff renderer, shown in the notification when cycling\n # renderers. If not set, the name is derived from the first word of the\n # renderer command.\n name: \"\"\n\n # Value of the --color arg in the git diff command. Only used for type\n # 'stdinFilter'. Some renderers want this to be set to 'always' and some\n # want it set to 'never'.\n colorArg: \"always\"\n\n # The command to use for rendering diffs. This is either a stdinFilter or\n # an external diff command, depending on the type field; not applicable if\n # the type is 'rawGit'.\n # e.g.\n # diff-so-fancy\n # delta --dark --paging=never\n # ydiff -p cat\n # difft --color=always\n command: \"\"\n\n # Extra arguments (array of strings) passed to the git command. Only\n # applicable if the type is 'rawGit'.\n args: []\n\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md for more information." }, "commit": { "$ref": "#/$defs/CommitConfig", From e296845aaed85c3888b733435f84a3235eabb650 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 26 Jul 2026 19:01:10 +0200 Subject: [PATCH 44/97] Adapt links --- pkg/constants/links.go | 32 ++++++++++++++++---------------- pkg/gui/command_log_panel.go | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pkg/constants/links.go b/pkg/constants/links.go index e9b06cba3..695b24d38 100644 --- a/pkg/constants/links.go +++ b/pkg/constants/links.go @@ -1,14 +1,14 @@ package constants type Docs struct { - CustomPagers string - CustomCommands string - CustomKeybindings string - Keybindings string - Undoing string - Config string - Tutorial string - CustomPatchDemo string + CustomDiffRenderers string + CustomCommands string + CustomKeybindings string + Keybindings string + Undoing string + Config string + Tutorial string + CustomPatchDemo string } var Links = struct { @@ -25,13 +25,13 @@ var Links = struct { Discussions: "https://github.com/jesseduffield/lazygit/discussions", Releases: "https://github.com/jesseduffield/lazygit/releases", Docs: Docs{ - CustomPagers: "https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md", - CustomKeybindings: "https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md", - CustomCommands: "https://github.com/jesseduffield/lazygit/wiki/Custom-Commands-Compendium", - Keybindings: "https://github.com/jesseduffield/lazygit/blob/%s/docs/keybindings", - Undoing: "https://github.com/jesseduffield/lazygit/blob/master/docs/Undoing.md", - Config: "https://github.com/jesseduffield/lazygit/blob/%s/docs/Config.md", - Tutorial: "https://youtu.be/VDXvbHZYeKY", - CustomPatchDemo: "https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches", + CustomDiffRenderers: "https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md", + CustomKeybindings: "https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md", + CustomCommands: "https://github.com/jesseduffield/lazygit/wiki/Custom-Commands-Compendium", + Keybindings: "https://github.com/jesseduffield/lazygit/blob/%s/docs/keybindings", + Undoing: "https://github.com/jesseduffield/lazygit/blob/master/docs/Undoing.md", + Config: "https://github.com/jesseduffield/lazygit/blob/%s/docs/Config.md", + Tutorial: "https://youtu.be/VDXvbHZYeKY", + CustomPatchDemo: "https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches", }, } diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go index 6f7976c3e..e43f69999 100644 --- a/pkg/gui/command_log_panel.go +++ b/pkg/gui/command_log_panel.go @@ -186,7 +186,7 @@ func (gui *Gui) getRandomTip() string { // links fmt.Sprintf( "If you want a git diff with syntax colouring, check out lazygit's integration with delta:\n%s", - constants.Links.Docs.CustomPagers, + constants.Links.Docs.CustomDiffRenderers, ), fmt.Sprintf( "You can build your own custom menus and commands to run from within lazygit. For examples see:\n%s", From 67ec23e08ae863167f96f87dcb553518dfadd375 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 29 Jul 2026 18:38:21 +0200 Subject: [PATCH 45/97] Isolate GitHub pull-request response parsing The fetch currently combines transport, JSON decoding, and model conversion, which makes response changes difficult to verify without exercising the network. Put the deterministic work behind a small parser so later payload changes can be covered with raw GraphQL fixtures. --- pkg/commands/git_commands/github.go | 7 ++- pkg/commands/git_commands/github_test.go | 77 ++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/pkg/commands/git_commands/github.go b/pkg/commands/git_commands/github.go index b74815301..06f9ab550 100644 --- a/pkg/commands/git_commands/github.go +++ b/pkg/commands/git_commands/github.go @@ -231,9 +231,12 @@ func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string, return nil, err } + return parsePullRequestsResponse(respBytes) +} + +func parsePullRequestsResponse(respBytes []byte) ([]*models.GithubPullRequest, error) { var result Response - err = json.Unmarshal(respBytes, &result) - if err != nil { + if err := json.Unmarshal(respBytes, &result); err != nil { return nil, err } diff --git a/pkg/commands/git_commands/github_test.go b/pkg/commands/git_commands/github_test.go index b332ba12a..664da857d 100644 --- a/pkg/commands/git_commands/github_test.go +++ b/pkg/commands/git_commands/github_test.go @@ -76,6 +76,83 @@ func TestGraphQLEndpoint(t *testing.T) { } } +func TestParsePullRequestsResponse(t *testing.T) { + t.Run("flattens aliases and normalizes drafts", func(t *testing.T) { + response := []byte(`{ + "data": { + "repository": { + "a1": { + "edges": [ + { + "node": { + "title": "Add feature", + "headRefName": "feature", + "number": 42, + "url": "https://github.com/jesseduffield/lazygit/pull/42", + "headRepositoryOwner": {"login": "contributor"}, + "state": "OPEN", + "isDraft": false + } + } + ] + }, + "a2": { + "edges": [ + { + "node": { + "title": "Draft feature", + "headRefName": "draft-feature", + "number": 43, + "url": "https://github.com/jesseduffield/lazygit/pull/43", + "headRepositoryOwner": {"login": "contributor"}, + "state": "OPEN", + "isDraft": true + } + } + ] + } + } + } +}`) + + prs, err := parsePullRequestsResponse(response) + + assert.NoError(t, err) + assert.ElementsMatch(t, []*models.GithubPullRequest{ + { + HeadRefName: "feature", + Number: 42, + Title: "Add feature", + State: "OPEN", + Url: "https://github.com/jesseduffield/lazygit/pull/42", + HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"}, + }, + { + HeadRefName: "draft-feature", + Number: 43, + Title: "Draft feature", + State: "DRAFT", + Url: "https://github.com/jesseduffield/lazygit/pull/43", + HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"}, + }, + }, prs) + }) + + t.Run("returns an empty slice for an empty result", func(t *testing.T) { + prs, err := parsePullRequestsResponse([]byte(`{"data":{"repository":{}}}`)) + + assert.NoError(t, err) + assert.Empty(t, prs) + }) + + t.Run("rejects malformed JSON", func(t *testing.T) { + prs, err := parsePullRequestsResponse([]byte(`{"data":`)) + + assert.Error(t, err) + assert.Nil(t, prs) + }) +} + func TestGenerateGithubPullRequestMap(t *testing.T) { cases := []struct { name string From ff26f61ffd1952994f317af2b58dcf5f637039d0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 29 Jul 2026 18:42:57 +0200 Subject: [PATCH 46/97] Carry aggregate check state with GitHub pull requests GitHub exposes a combined status for the head commit without requiring individual check contexts. Include that rollup in the existing request and startup cache so every consumer sees the same state without making a second network request. --- pkg/commands/git_commands/github.go | 23 ++++++++++++++++ pkg/commands/git_commands/github_test.go | 27 +++++++++++++++++-- pkg/commands/models/github.go | 1 + pkg/config/app_config.go | 1 + pkg/gui/controllers/helpers/refresh_helper.go | 1 + pkg/gui/gui.go | 1 + 6 files changed, 52 insertions(+), 2 deletions(-) diff --git a/pkg/commands/git_commands/github.go b/pkg/commands/git_commands/github.go index 06f9ab550..3a6146923 100644 --- a/pkg/commands/git_commands/github.go +++ b/pkg/commands/git_commands/github.go @@ -85,12 +85,25 @@ type PullRequestNode struct { HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"` State string `json:"state"` IsDraft bool `json:"isDraft"` + HeadRef GithubRef `json:"headRef"` } type GithubRepositoryOwner struct { Login string `json:"login"` } +type GithubRef struct { + Target GithubGitObject `json:"target"` +} + +type GithubGitObject struct { + StatusCheckRollup GithubStatusCheckRollup `json:"statusCheckRollup"` +} + +type GithubStatusCheckRollup struct { + State string `json:"state"` +} + type graphQLRequest struct { Query string `json:"query"` Variables map[string]string `json:"variables"` @@ -121,6 +134,15 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin number url isDraft + headRef { + target { + ... on Commit { + statusCheckRollup { + state + } + } + } + } headRepositoryOwner { login } @@ -249,6 +271,7 @@ func parsePullRequestsResponse(respBytes []byte) ([]*models.GithubPullRequest, e Number: node.Number, Title: node.Title, State: lo.Ternary(node.IsDraft && node.State != "CLOSED", "DRAFT", node.State), + ChecksState: node.HeadRef.Target.StatusCheckRollup.State, Url: node.Url, HeadRepositoryOwner: models.GithubRepositoryOwner{ Login: node.HeadRepositoryOwner.Login, diff --git a/pkg/commands/git_commands/github_test.go b/pkg/commands/git_commands/github_test.go index 664da857d..ce068d750 100644 --- a/pkg/commands/git_commands/github_test.go +++ b/pkg/commands/git_commands/github_test.go @@ -76,6 +76,20 @@ func TestGraphQLEndpoint(t *testing.T) { } } +func TestFetchPullRequestsQueryFetchesOnlyAggregateCheckState(t *testing.T) { + query, variables := fetchPullRequestsQuery([]string{"feature"}, "owner", "repo") + + assert.Contains(t, query, "headRef {") + assert.Contains(t, query, "... on Commit {") + assert.Contains(t, query, "statusCheckRollup {") + assert.NotContains(t, query, "contexts") + assert.Equal(t, map[string]string{ + "owner": "owner", + "repo": "repo", + "branch1": "feature", + }, variables) +} + func TestParsePullRequestsResponse(t *testing.T) { t.Run("flattens aliases and normalizes drafts", func(t *testing.T) { response := []byte(`{ @@ -91,7 +105,12 @@ func TestParsePullRequestsResponse(t *testing.T) { "url": "https://github.com/jesseduffield/lazygit/pull/42", "headRepositoryOwner": {"login": "contributor"}, "state": "OPEN", - "isDraft": false + "isDraft": false, + "headRef": { + "target": { + "statusCheckRollup": {"state": "SUCCESS"} + } + } } } ] @@ -106,7 +125,8 @@ func TestParsePullRequestsResponse(t *testing.T) { "url": "https://github.com/jesseduffield/lazygit/pull/43", "headRepositoryOwner": {"login": "contributor"}, "state": "OPEN", - "isDraft": true + "isDraft": true, + "headRef": null } } ] @@ -124,6 +144,7 @@ func TestParsePullRequestsResponse(t *testing.T) { Number: 42, Title: "Add feature", State: "OPEN", + ChecksState: "SUCCESS", Url: "https://github.com/jesseduffield/lazygit/pull/42", HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"}, }, @@ -176,6 +197,7 @@ func TestGenerateGithubPullRequestMap(t *testing.T) { Number: 42, Title: "Add feature", State: "OPEN", + ChecksState: "PENDING", Url: "https://github.com/jesseduffield/lazygit/pull/42", HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"}, }, @@ -199,6 +221,7 @@ func TestGenerateGithubPullRequestMap(t *testing.T) { Number: 42, Title: "Add feature", State: "OPEN", + ChecksState: "PENDING", Url: "https://github.com/jesseduffield/lazygit/pull/42", HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"}, }, diff --git a/pkg/commands/models/github.go b/pkg/commands/models/github.go index 6477c6ee6..da7bd79db 100644 --- a/pkg/commands/models/github.go +++ b/pkg/commands/models/github.go @@ -5,6 +5,7 @@ type GithubPullRequest struct { Number int `json:"number"` Title string `json:"title"` State string `json:"state"` // "MERGED", "OPEN", "CLOSED", "DRAFT" + ChecksState string `json:"checksState"` Url string `json:"url"` HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"` } diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index d81a1402c..a0be00329 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -850,6 +850,7 @@ type CachedPullRequest struct { Number int `yaml:"number"` Title string `yaml:"title"` State string `yaml:"state"` + ChecksState string `yaml:"checksState,omitempty"` Url string `yaml:"url"` HeadRepositoryOwner string `yaml:"headRepositoryOwner"` } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 29784b5ff..29fb66be2 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1770,6 +1770,7 @@ func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullReque Number: pr.Number, Title: pr.Title, State: pr.State, + ChecksState: pr.ChecksState, Url: pr.Url, HeadRepositoryOwner: pr.HeadRepositoryOwner.Login, } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e1d387370..f536d5b64 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -674,6 +674,7 @@ func (gui *Gui) loadCachedPullRequests() []*models.GithubPullRequest { Number: cached.Number, Title: cached.Title, State: cached.State, + ChecksState: cached.ChecksState, Url: cached.Url, HeadRepositoryOwner: models.GithubRepositoryOwner{ Login: cached.HeadRepositoryOwner, From 320d33a8ef1b7cd0eaf167d577e2b9bfcc733977 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 30 Jul 2026 12:21:47 +0200 Subject: [PATCH 47/97] Centralize pull-request header presentation The branch controller should decide which pull request to show, not how its header is styled and linked. Move the existing formatter and state badge next to the branch presentation helpers so subsequent header changes stay in one layer. --- pkg/gui/controllers/branches_controller.go | 42 +--------------------- pkg/gui/presentation/branches.go | 41 +++++++++++++++++++++ pkg/gui/presentation/branches_test.go | 20 +++++++++++ 3 files changed, 62 insertions(+), 41 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index a73ee3bc2..47f95aa91 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -5,15 +5,12 @@ import ( "fmt" "strings" - "github.com/gookit/color" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/presentation" - "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" - "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" @@ -214,13 +211,7 @@ func (self *BranchesController) GetOnRenderToMain() func() { pr, ok := self.c.Model().PullRequestsMap[branch.Name] if ok && presentation.ShouldShowPrForBranch(pr, branch.Name, self.c.UserConfig()) { - icon := lo.Ternary(icons.IsIconEnabled(), icons.IconForRemoteUrl(pr.Url)+" ", "") - ptyTask.Prefix = style.PrintHyperlink(fmt.Sprintf("%s%s %s %s\n", - icon, - coloredStateText(pr.State), - pr.Title, - style.FgCyan.Sprintf("#%d", pr.Number)), - pr.Url) + ptyTask.Prefix = presentation.FormatPullRequestHeader(pr) ptyTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n" } } @@ -236,37 +227,6 @@ func (self *BranchesController) GetOnRenderToMain() func() { } } -func stateText(state string) string { - var icon, label string - switch state { - case "OPEN": - icon, label = " ", "Open" - case "CLOSED": - icon, label = " ", "Closed" - case "MERGED": - icon, label = " ", "Merged" - case "DRAFT": - icon, label = " ", "Draft" - default: - return "" - } - if icons.IsIconEnabled() { - return icon + label - } - return label -} - -func coloredStateText(state string) string { - if icons.IsIconEnabled() { - return fmt.Sprintf("%s%s%s", - presentation.WithPrColor(state, "", false), - presentation.WithPrColor(state, color.RGB(0xFF, 0xFF, 0xFF, false).Sprint(stateText(state)), true), - presentation.WithPrColor(state, "", false)) - } - - return presentation.WithPrColor(state, stateText(state), false) -} - func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branch) error { upstream := lo.Ternary(selectedBranch.RemoteBranchStoredLocally(), selectedBranch.ShortUpstreamRefName(), diff --git a/pkg/gui/presentation/branches.go b/pkg/gui/presentation/branches.go index 2e8ab0106..b705b4b2f 100644 --- a/pkg/gui/presentation/branches.go +++ b/pkg/gui/presentation/branches.go @@ -287,6 +287,47 @@ func WithPrColor(state string, text string, isBg bool) string { } } +func FormatPullRequestHeader(pr *models.GithubPullRequest) string { + icon := lo.Ternary(icons.IsIconEnabled(), icons.IconForRemoteUrl(pr.Url)+" ", "") + return style.PrintHyperlink(fmt.Sprintf("%s%s %s %s\n", + icon, + coloredPullRequestStateText(pr.State), + pr.Title, + style.FgCyan.Sprintf("#%d", pr.Number)), + pr.Url) +} + +func pullRequestStateText(state string) string { + var icon, label string + switch state { + case "OPEN": + icon, label = " ", "Open" + case "CLOSED": + icon, label = " ", "Closed" + case "MERGED": + icon, label = " ", "Merged" + case "DRAFT": + icon, label = " ", "Draft" + default: + return "" + } + if icons.IsIconEnabled() { + return icon + label + } + return label +} + +func coloredPullRequestStateText(state string) string { + if icons.IsIconEnabled() { + return fmt.Sprintf("%s%s%s", + WithPrColor(state, "", false), + WithPrColor(state, color.RGB(0xFF, 0xFF, 0xFF, false).Sprint(pullRequestStateText(state)), true), + WithPrColor(state, "", false)) + } + + return WithPrColor(state, pullRequestStateText(state), false) +} + func ShouldShowPrForBranch(pr *models.GithubPullRequest, branchName string, userConfig *config.UserConfig) bool { if !lo.Contains(userConfig.Git.MainBranches, branchName) { return true diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go index 339f57cc3..178ddec69 100644 --- a/pkg/gui/presentation/branches_test.go +++ b/pkg/gui/presentation/branches_test.go @@ -10,6 +10,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" + "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" "github.com/stretchr/testify/assert" @@ -22,6 +23,25 @@ func makeAtomic(v int32) *atomic.Int32 { return &result } +func TestFormatPullRequestHeader(t *testing.T) { + oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone) + defer color.ForceSetColorLevel(oldColorLevel) + icons.SetNerdFontsVersion("") + + pr := &models.GithubPullRequest{ + Title: "Improve checks", + Number: 5871, + State: "OPEN", + Url: "https://github.com/jesseduffield/lazygit/pull/5871", + } + numberText := style.FgCyan.Sprint("#5871") + + actual := FormatPullRequestHeader(pr) + + expected := style.PrintHyperlink("Open Improve checks "+numberText+"\n", pr.Url) + assert.Equal(t, expected, actual) +} + func Test_getBranchDisplayStrings(t *testing.T) { scenarios := []struct { branch *models.Branch From 2669842445de8adf962eb3c280d73d85db4f5184 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 30 Jul 2026 12:25:48 +0200 Subject: [PATCH 48/97] Show a Github PR's combined checks state in branches list and main view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the branches list we show the checks icon (✓, ✗ etc) instead of the gihub icon for branches that are open and have a state. It is a little confusing, because the ✓ in front of the name means something very different than the ✓ after it, but the checks status is just too useful to see in the list. In the main view we show it as a compact status before the PR title, with a hyperlink that takes you directly to the checks tab in Github. --- pkg/gui/controllers/branches_controller.go | 2 +- pkg/gui/presentation/branches.go | 52 +++++++++++++-- pkg/gui/presentation/branches_test.go | 74 ++++++++++++++++++++-- pkg/i18n/english.go | 10 +++ 4 files changed, 123 insertions(+), 15 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 47f95aa91..cfc46b503 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -211,7 +211,7 @@ func (self *BranchesController) GetOnRenderToMain() func() { pr, ok := self.c.Model().PullRequestsMap[branch.Name] if ok && presentation.ShouldShowPrForBranch(pr, branch.Name, self.c.UserConfig()) { - ptyTask.Prefix = presentation.FormatPullRequestHeader(pr) + ptyTask.Prefix = presentation.FormatPullRequestHeader(pr, self.c.Tr) ptyTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n" } } diff --git a/pkg/gui/presentation/branches.go b/pkg/gui/presentation/branches.go index b705b4b2f..f58f34dc6 100644 --- a/pkg/gui/presentation/branches.go +++ b/pkg/gui/presentation/branches.go @@ -150,6 +150,12 @@ func getBranchDisplayStrings( prIcon = "●" } coloredPrIcon = WithPrColor(pr.State, prIcon, false) + if pr.State == "OPEN" { + icon, _, textStyle := checksStatePresentation(pr.ChecksState, tr) + if icon != "" { + coloredPrIcon = textStyle.Sprint(icon) + } + } } res = append(res, coloredPrIcon) @@ -287,14 +293,21 @@ func WithPrColor(state string, text string, isBg bool) string { } } -func FormatPullRequestHeader(pr *models.GithubPullRequest) string { +func FormatPullRequestHeader(pr *models.GithubPullRequest, tr *i18n.TranslationSet) string { icon := lo.Ternary(icons.IsIconEnabled(), icons.IconForRemoteUrl(pr.Url)+" ", "") - return style.PrintHyperlink(fmt.Sprintf("%s%s %s %s\n", - icon, - coloredPullRequestStateText(pr.State), - pr.Title, - style.FgCyan.Sprintf("#%d", pr.Number)), - pr.Url) + stateText := coloredPullRequestStateText(pr.State) + checksStateText := coloredChecksStateText(pr.ChecksState, tr) + numberText := style.FgCyan.Sprintf("#%d", pr.Number) + + // The checks status links to the checks tab, so it needs to be its own + // hyperlink separate from the rest of the header. + parts := []string{style.PrintHyperlink(icon+stateText, pr.Url)} + if checksStateText != "" { + parts = append(parts, style.PrintHyperlink(checksStateText, strings.TrimSuffix(pr.Url, "/")+"/checks")) + } + parts = append(parts, style.PrintHyperlink(fmt.Sprintf("%s %s\n", pr.Title, numberText), pr.Url)) + + return strings.Join(parts, " ") } func pullRequestStateText(state string) string { @@ -328,6 +341,31 @@ func coloredPullRequestStateText(state string) string { return WithPrColor(state, pullRequestStateText(state), false) } +func checksStatePresentation(state string, tr *i18n.TranslationSet) (string, string, style.TextStyle) { + switch state { + case "SUCCESS": + return "✓", tr.PullRequestChecksPassing, style.FgGreen + case "PENDING": + return "●", tr.PullRequestChecksPending, style.FgYellow + case "FAILURE": + return "✗", tr.PullRequestChecksFailing, style.FgRed + case "ERROR": + return "!", tr.PullRequestChecksError, style.FgRed + case "EXPECTED": + return "○", tr.PullRequestChecksExpected, style.FgDefault + default: + return "", "", style.Nothing + } +} + +func coloredChecksStateText(state string, tr *i18n.TranslationSet) string { + icon, text, textStyle := checksStatePresentation(state, tr) + if text != "" { + return textStyle.Sprintf("%s %s", icon, text) + } + return "" +} + func ShouldShowPrForBranch(pr *models.GithubPullRequest, branchName string, userConfig *config.UserConfig) bool { if !lo.Contains(userConfig.Git.MainBranches, branchName) { return true diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go index 178ddec69..3d83ca0ba 100644 --- a/pkg/gui/presentation/branches_test.go +++ b/pkg/gui/presentation/branches_test.go @@ -12,6 +12,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/xo/terminfo" @@ -29,17 +30,76 @@ func TestFormatPullRequestHeader(t *testing.T) { icons.SetNerdFontsVersion("") pr := &models.GithubPullRequest{ - Title: "Improve checks", - Number: 5871, - State: "OPEN", - Url: "https://github.com/jesseduffield/lazygit/pull/5871", + Title: "Improve checks", + Number: 5871, + State: "OPEN", + ChecksState: "SUCCESS", + Url: "https://github.com/jesseduffield/lazygit/pull/5871", } numberText := style.FgCyan.Sprint("#5871") + tr := i18n.EnglishTranslationSet() - actual := FormatPullRequestHeader(pr) + t.Run("links checks separately from the rest of the header", func(t *testing.T) { + actual := FormatPullRequestHeader(pr, tr) - expected := style.PrintHyperlink("Open Improve checks "+numberText+"\n", pr.Url) - assert.Equal(t, expected, actual) + expected := style.PrintHyperlink("Open", pr.Url) + + " " + + style.PrintHyperlink("✓ Passing", pr.Url+"/checks") + + " " + + style.PrintHyperlink("Improve checks "+numberText+"\n", pr.Url) + assert.Equal(t, expected, actual) + }) + + t.Run("leaves the separator unlinked when checks are unavailable", func(t *testing.T) { + prWithoutChecks := *pr + prWithoutChecks.ChecksState = "" + + actual := FormatPullRequestHeader(&prWithoutChecks, tr) + + expected := style.PrintHyperlink("Open", pr.Url) + + " " + + style.PrintHyperlink("Improve checks "+numberText+"\n", pr.Url) + assert.Equal(t, expected, actual) + }) + + t.Run("avoids a double slash in the checks URL", func(t *testing.T) { + prWithTrailingSlash := *pr + prWithTrailingSlash.Url += "/" + + actual := FormatPullRequestHeader(&prWithTrailingSlash, tr) + + assert.Contains(t, actual, "https://github.com/jesseduffield/lazygit/pull/5871/checks") + assert.NotContains(t, actual, "pull/5871//checks") + }) +} + +func TestChecksStatePresentation(t *testing.T) { + tr := i18n.EnglishTranslationSet() + testCases := []struct { + name string + state string + expectedIcon string + expectedText string + expectedStyle style.TextStyle + }{ + {name: "success", state: "SUCCESS", expectedIcon: "✓", expectedText: "Passing", expectedStyle: style.FgGreen}, + {name: "pending", state: "PENDING", expectedIcon: "●", expectedText: "Pending", expectedStyle: style.FgYellow}, + {name: "failure", state: "FAILURE", expectedIcon: "✗", expectedText: "Failing", expectedStyle: style.FgRed}, + {name: "error", state: "ERROR", expectedIcon: "!", expectedText: "Error", expectedStyle: style.FgRed}, + {name: "expected", state: "EXPECTED", expectedIcon: "○", expectedText: "Expected", expectedStyle: style.FgDefault}, + {name: "empty", state: "", expectedIcon: "", expectedText: "", expectedStyle: style.Nothing}, + {name: "unknown", state: "FUTURE_STATE", expectedIcon: "", expectedText: "", expectedStyle: style.Nothing}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + icon, text, textStyle := checksStatePresentation(testCase.state, tr) + + assert.Equal(t, testCase.expectedIcon, icon) + assert.Equal(t, testCase.expectedText, text) + assert.Equal(t, testCase.expectedStyle, textStyle) + }) + } } func Test_getBranchDisplayStrings(t *testing.T) { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 812c30fc5..deb55d6e9 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -367,6 +367,11 @@ type TranslationSet struct { FwdNoLocalUpstream string FwdCommitsToPush string PullRequestNoUpstream string + PullRequestChecksPassing string + PullRequestChecksPending string + PullRequestChecksFailing string + PullRequestChecksError string + PullRequestChecksExpected string ErrorOccurred string ConflictLabel string PendingRebaseTodosSectionHeader string @@ -1519,6 +1524,11 @@ func EnglishTranslationSet() *TranslationSet { FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally", FwdCommitsToPush: "Cannot fast-forward a branch with commits to push", PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream", + PullRequestChecksPassing: "Passing", + PullRequestChecksPending: "Pending", + PullRequestChecksFailing: "Failing", + PullRequestChecksError: "Error", + PullRequestChecksExpected: "Expected", ErrorOccurred: "An error occurred! Please create an issue at", ConflictLabel: "CONFLICT", PendingRebaseTodosSectionHeader: "Pending rebase todos", From 47471c501b4ae6ecd8c265bb7940c623de03b453 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 30 Jul 2026 12:33:47 +0200 Subject: [PATCH 49/97] Move Github PR cache out of state.yml into a separate file Having the cache in state.yml causes this file to be rewritten every 60s, which is annoying if you have a lazygit running in the background somewhere without even realizing it, and it keeps overwriting the foreground lazygit's newer command shell history and recent repos list with its stale data. State.yml should only contain things that change in response to user actions, not periodically. --- pkg/config/app_config.go | 83 ++++++----- pkg/config/dummies.go | 11 +- pkg/config/github_pull_request_cache.go | 124 +++++++++++++++ pkg/config/github_pull_request_cache_test.go | 141 ++++++++++++++++++ pkg/gui/controllers/helpers/refresh_helper.go | 7 +- pkg/gui/gui.go | 5 +- 6 files changed, 319 insertions(+), 52 deletions(-) create mode 100644 pkg/config/github_pull_request_cache.go create mode 100644 pkg/config/github_pull_request_cache_test.go diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index a0be00329..ade614f7d 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -21,17 +21,18 @@ import ( // AppConfig contains the base configuration fields required for lazygit. type AppConfig struct { - debug bool `long:"debug" env:"DEBUG" default:"false"` - version string `long:"version" env:"VERSION" default:"unversioned"` - buildDate string `long:"build-date" env:"BUILD_DATE"` - name string `long:"name" env:"NAME" default:"lazygit"` - buildSource string `long:"build-source" env:"BUILD_SOURCE" default:""` - userConfig *UserConfig - globalUserConfigFiles []*ConfigFile - userConfigFiles []*ConfigFile - userConfigDir string - tempDir string - appState *AppState + debug bool `long:"debug" env:"DEBUG" default:"false"` + version string `long:"version" env:"VERSION" default:"unversioned"` + buildDate string `long:"build-date" env:"BUILD_DATE"` + name string `long:"name" env:"NAME" default:"lazygit"` + buildSource string `long:"build-source" env:"BUILD_SOURCE" default:""` + userConfig *UserConfig + globalUserConfigFiles []*ConfigFile + userConfigFiles []*ConfigFile + userConfigDir string + tempDir string + appState *AppState + githubPullRequestCache *githubPullRequestCache } type AppConfigurer interface { @@ -51,6 +52,8 @@ type AppConfigurer interface { GetAppState() *AppState SaveAppState() error + GetCachedGithubPullRequests(repoPath string) ([]CachedPullRequest, error) + SaveCachedGithubPullRequests(repoPath string, pullRequests []CachedPullRequest) error } type ConfigFilePolicy int @@ -107,19 +110,21 @@ func NewAppConfig( if err != nil { return nil, err } + githubPullRequestCache := loadGithubPullRequestCache() appConfig := &AppConfig{ - name: name, - version: version, - buildDate: date, - debug: debuggingFlag, - buildSource: buildSource, - userConfig: userConfig, - globalUserConfigFiles: configFiles, - userConfigFiles: configFiles, - userConfigDir: configDir, - tempDir: tempDir, - appState: appState, + name: name, + version: version, + buildDate: date, + debug: debuggingFlag, + buildSource: buildSource, + userConfig: userConfig, + globalUserConfigFiles: configFiles, + userConfigFiles: configFiles, + userConfigDir: configDir, + tempDir: tempDir, + appState: appState, + githubPullRequestCache: githubPullRequestCache, } return appConfig, nil @@ -666,6 +671,20 @@ func (c *AppConfig) GetAppState() *AppState { return c.appState } +func (c *AppConfig) GetCachedGithubPullRequests(repoPath string) ([]CachedPullRequest, error) { + if c.githubPullRequestCache == nil { + return nil, nil + } + return c.githubPullRequestCache.get(repoPath), c.githubPullRequestCache.takeLoadError() +} + +func (c *AppConfig) SaveCachedGithubPullRequests(repoPath string, pullRequests []CachedPullRequest) error { + if c.githubPullRequestCache == nil { + return nil + } + return c.githubPullRequestCache.save(repoPath, pullRequests) +} + func (c *AppConfig) GetUserConfigPaths() []string { return lo.FilterMap(c.userConfigFiles, func(f *ConfigFile, _ int) (string, bool) { return f.Path, f.exists @@ -837,28 +856,10 @@ type AppState struct { ShellCommandsHistory []string `yaml:"customcommandshistory"` HideCommandLog bool - - // Cache of GitHub pull requests per repo path, so that PR info can be - // shown instantly on startup before the async refresh completes. - GithubPullRequests map[string][]CachedPullRequest `yaml:"githubPullRequests"` -} - -// CachedPullRequest stores the essential fields of a GitHub pull request -// for persisting in the app state cache. -type CachedPullRequest struct { - HeadRefName string `yaml:"headRefName"` - Number int `yaml:"number"` - Title string `yaml:"title"` - State string `yaml:"state"` - ChecksState string `yaml:"checksState,omitempty"` - Url string `yaml:"url"` - HeadRepositoryOwner string `yaml:"headRepositoryOwner"` } func getDefaultAppState() *AppState { - return &AppState{ - GithubPullRequests: make(map[string][]CachedPullRequest), - } + return &AppState{} } func LogPath() (string, error) { diff --git a/pkg/config/dummies.go b/pkg/config/dummies.go index 5bc349fa0..b872fac29 100644 --- a/pkg/config/dummies.go +++ b/pkg/config/dummies.go @@ -9,11 +9,12 @@ func NewDummyAppConfig() *AppConfig { userConfig := GetDefaultConfig() userConfig.Keybinding.MergeLegacyAltKeybindings() appConfig := &AppConfig{ - name: "lazygit", - version: "unversioned", - debug: false, - userConfig: userConfig, - appState: &AppState{}, + name: "lazygit", + version: "unversioned", + debug: false, + userConfig: userConfig, + appState: &AppState{}, + githubPullRequestCache: newGithubPullRequestCache(""), } _ = yaml.Unmarshal([]byte{}, appConfig.appState) return appConfig diff --git a/pkg/config/github_pull_request_cache.go b/pkg/config/github_pull_request_cache.go new file mode 100644 index 000000000..3ea8d0841 --- /dev/null +++ b/pkg/config/github_pull_request_cache.go @@ -0,0 +1,124 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +const githubPullRequestsCacheFileName = "github_pull_requests.json" + +// CachedPullRequest stores the essential fields of a GitHub pull request. +type CachedPullRequest struct { + HeadRefName string `json:"headRefName"` + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + ChecksState string `json:"checksState,omitempty"` + Url string `json:"url"` + HeadRepositoryOwner string `json:"headRepositoryOwner"` +} + +type githubPullRequestCache struct { + mutex sync.Mutex + path string + pullRequestsByRepoPath map[string][]CachedPullRequest + loadErr error +} + +func loadGithubPullRequestCache() *githubPullRequestCache { + path, err := githubPullRequestCachePath() + if err != nil { + cache := newGithubPullRequestCache("") + cache.loadErr = err + return cache + } + + cache := newGithubPullRequestCache(path) + cache.load() + return cache +} + +func githubPullRequestCachePath() (string, error) { + path, err := stateFilePath(stateFileName) + if err != nil { + return "", err + } + + return filepath.Join(filepath.Dir(path), githubPullRequestsCacheFileName), nil +} + +func newGithubPullRequestCache(path string) *githubPullRequestCache { + return &githubPullRequestCache{ + path: path, + pullRequestsByRepoPath: make(map[string][]CachedPullRequest), + } +} + +func (c *githubPullRequestCache) load() { + if c.path == "" { + return + } + + content, err := os.ReadFile(c.path) + if err != nil { + if !os.IsNotExist(err) { + c.loadErr = fmt.Errorf("reading GitHub pull request cache: %w", err) + } + return + } + if len(content) == 0 { + return + } + + if err := json.Unmarshal(content, &c.pullRequestsByRepoPath); err != nil { + c.pullRequestsByRepoPath = make(map[string][]CachedPullRequest) + c.loadErr = fmt.Errorf("parsing GitHub pull request cache: %w", err) + } else if c.pullRequestsByRepoPath == nil { + c.pullRequestsByRepoPath = make(map[string][]CachedPullRequest) + } +} + +func (c *githubPullRequestCache) get(repoPath string) []CachedPullRequest { + c.mutex.Lock() + defer c.mutex.Unlock() + + return append([]CachedPullRequest(nil), c.pullRequestsByRepoPath[repoPath]...) +} + +// takeLoadError returns the error, if any, that occurred while loading the +// cache from disk, clearing it so that it is reported only once. +func (c *githubPullRequestCache) takeLoadError() error { + c.mutex.Lock() + defer c.mutex.Unlock() + + loadErr := c.loadErr + c.loadErr = nil + return loadErr +} + +func (c *githubPullRequestCache) save(repoPath string, pullRequests []CachedPullRequest) error { + c.mutex.Lock() + defer c.mutex.Unlock() + + c.pullRequestsByRepoPath[repoPath] = append([]CachedPullRequest(nil), pullRequests...) + if c.path == "" { + return nil + } + + content, err := json.MarshalIndent(c.pullRequestsByRepoPath, "", " ") + if err != nil { + return err + } + content = append(content, '\n') + + // Apparently when people have read-only permissions they prefer us to fail + // silently, so don't propagate permission errors. + if err := os.WriteFile(c.path, content, 0o644); err != nil && !os.IsPermission(err) { + return err + } + + return nil +} diff --git a/pkg/config/github_pull_request_cache_test.go b/pkg/config/github_pull_request_cache_test.go new file mode 100644 index 000000000..aa10d1acb --- /dev/null +++ b/pkg/config/github_pull_request_cache_test.go @@ -0,0 +1,141 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGithubPullRequestCachePath(t *testing.T) { + stateDir := t.TempDir() + t.Setenv("CONFIG_DIR", stateDir) + + path, err := githubPullRequestCachePath() + + assert.NoError(t, err) + assert.Equal(t, filepath.Join(stateDir, githubPullRequestsCacheFileName), path) +} + +func TestGithubPullRequestCache(t *testing.T) { + path := filepath.Join(t.TempDir(), githubPullRequestsCacheFileName) + cache := newGithubPullRequestCache(path) + repoOnePullRequests := []CachedPullRequest{{ + HeadRefName: "first-branch", + Number: 1, + Title: "First pull request", + State: "OPEN", + Url: "https://github.com/owner/repo/pull/1", + HeadRepositoryOwner: "owner", + }} + repoTwoPullRequests := []CachedPullRequest{{ + HeadRefName: "second-branch", + Number: 2, + Title: "Second pull request", + State: "MERGED", + Url: "https://github.com/other/repo/pull/2", + HeadRepositoryOwner: "other", + }} + + assert.NoError(t, cache.save("/repo/one", repoOnePullRequests)) + assert.NoError(t, cache.save("/repo/two", repoTwoPullRequests)) + + content, err := os.ReadFile(path) + assert.NoError(t, err) + assert.Equal(t, `{ + "/repo/one": [ + { + "headRefName": "first-branch", + "number": 1, + "title": "First pull request", + "state": "OPEN", + "url": "https://github.com/owner/repo/pull/1", + "headRepositoryOwner": "owner" + } + ], + "/repo/two": [ + { + "headRefName": "second-branch", + "number": 2, + "title": "Second pull request", + "state": "MERGED", + "url": "https://github.com/other/repo/pull/2", + "headRepositoryOwner": "other" + } + ] +} +`, string(content)) + + reloadedCache := newGithubPullRequestCache(path) + reloadedCache.load() + assert.Equal(t, repoOnePullRequests, reloadedCache.get("/repo/one")) + assert.Equal(t, repoTwoPullRequests, reloadedCache.get("/repo/two")) + assert.NoError(t, reloadedCache.takeLoadError()) +} + +func TestGithubPullRequestCacheIgnoresMalformedContent(t *testing.T) { + path := filepath.Join(t.TempDir(), githubPullRequestsCacheFileName) + assert.NoError(t, os.WriteFile(path, []byte("{"), 0o644)) + + cache := newGithubPullRequestCache(path) + cache.load() + + assert.ErrorContains(t, cache.takeLoadError(), "parsing GitHub pull request cache") + assert.Empty(t, cache.get("/repo")) + assert.NoError(t, cache.save("/repo", []CachedPullRequest{{Number: 1}})) + + reloadedCache := newGithubPullRequestCache(path) + reloadedCache.load() + assert.Equal(t, []CachedPullRequest{{Number: 1}}, reloadedCache.get("/repo")) + assert.NoError(t, reloadedCache.takeLoadError()) +} + +func TestGithubPullRequestCacheDoesNotModifyAppState(t *testing.T) { + stateDir := t.TempDir() + t.Setenv("CONFIG_DIR", stateDir) + statePath := filepath.Join(stateDir, stateFileName) + stateContent := []byte("recentrepos:\n - /repo\n") + assert.NoError(t, os.WriteFile(statePath, stateContent, 0o644)) + + cache := loadGithubPullRequestCache() + assert.NoError(t, cache.save("/repo", []CachedPullRequest{{Number: 1}})) + + actualStateContent, err := os.ReadFile(statePath) + assert.NoError(t, err) + assert.Equal(t, stateContent, actualStateContent) + _, err = os.Stat(filepath.Join(stateDir, githubPullRequestsCacheFileName)) + assert.NoError(t, err) +} + +func TestGithubPullRequestCacheSerializesConcurrentSaves(t *testing.T) { + path := filepath.Join(t.TempDir(), githubPullRequestsCacheFileName) + cache := newGithubPullRequestCache(path) + const repoCount = 20 + var waitGroup sync.WaitGroup + errs := make(chan error, repoCount) + + for index := range repoCount { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + repoPath := fmt.Sprintf("/repo/%d", index) + errs <- cache.save(repoPath, []CachedPullRequest{{Number: index}}) + }() + } + waitGroup.Wait() + close(errs) + for err := range errs { + assert.NoError(t, err) + } + + reloadedCache := newGithubPullRequestCache(path) + reloadedCache.load() + for index := range repoCount { + repoPath := fmt.Sprintf("/repo/%d", index) + assert.Equal(t, []CachedPullRequest{{Number: index}}, reloadedCache.get(repoPath)) + } + assert.NoError(t, reloadedCache.takeLoadError()) +} diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 29fb66be2..3db9ab9dc 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1776,10 +1776,7 @@ func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullReque } }) - appState := self.c.GetAppState() - if appState.GithubPullRequests == nil { - appState.GithubPullRequests = make(map[string][]config.CachedPullRequest) + if err := self.c.GetConfig().SaveCachedGithubPullRequests(repoPath, cached); err != nil { + self.c.Log.Warnf("error saving GitHub pull request cache: %v", err) } - appState.GithubPullRequests[repoPath] = cached - self.c.SaveAppStateAndLogError() } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index f536d5b64..7713cbd57 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -666,7 +666,10 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { func (gui *Gui) loadCachedPullRequests() []*models.GithubPullRequest { repoPath := gui.git.RepoPaths.RepoPath() - cachedPRs := gui.c.GetAppState().GithubPullRequests[repoPath] + cachedPRs, err := gui.Config.GetCachedGithubPullRequests(repoPath) + if err != nil { + gui.Log.Warnf("error loading GitHub pull request cache: %v", err) + } return lo.Map(cachedPRs, func(cached config.CachedPullRequest, _ int) *models.GithubPullRequest { return &models.GithubPullRequest{ From b6deefacd244b818c282c460f406c28b1f1eb1fe Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 29 Jul 2026 17:33:04 +0200 Subject: [PATCH 50/97] Exclude more commit trailers from auto-wrapping We already excluded the most commonly used commit trailers from being auto-wrapped when typing or rewording a commit message, but this was limited to two hard-coded ones ("Signed-off-by:" and "Co-authored-by:"). Extend this mechanism to use a heuristic to prevent more trailers from wrapping; the heuristic kicks in for any "Key: Value" line if Key contains a dash, or the value looks like a URL (so that it also catches things like "Bug: https://my-bug-tracker/345"). To avoid mistaking a "Key: Value"-looking line in the message body for a trailer, only apply the heuristic in the last paragraph of the message, i.e. the block of lines at the end that is separated from the body by a blank line. Each line there is judged on its own, so a line that isn't recognized as a trailer still wraps without affecting the real trailers next to it. --- pkg/gocui/text_area.go | 73 ++++++++++++++++++++----------------- pkg/gocui/text_area_test.go | 59 ++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 41 deletions(-) diff --git a/pkg/gocui/text_area.go b/pkg/gocui/text_area.go index 7aeb6220a..98a5af4da 100644 --- a/pkg/gocui/text_area.go +++ b/pkg/gocui/text_area.go @@ -87,6 +87,12 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) { result = append(result, cells[startOfLine:to]...) } + // Commit message trailers ("Signed-off-by:" and the like) must not be + // auto-wrapped. They are only recognized in the last paragraph of the + // message, so that a trailer-looking line in the message body isn't treated + // as one; see startOfTrailerBlock. + trailerBlockStart := startOfTrailerBlock(content) + for currentPos, c := range cells { if c.char == "\n" { appendCellsSinceLineStart(currentPos + 1) @@ -98,7 +104,9 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) { trailerMatcher.reset() } else { currentLineWidth += c.width - if c.char == " " && !footNoteMatcher.isFootNote() && !trailerMatcher.isTrailer() { + inTrailerBlock := c.contentIndex >= trailerBlockStart + if c.char == " " && !footNoteMatcher.isFootNote() && + !(inTrailerBlock && trailerMatcher.isTrailer(content[c.contentIndex+len(c.char):])) { indexOfLastWhitespace = currentPos + 1 } else if autoWrapWidth > 0 && currentLineWidth > autoWrapWidth && indexOfLastWhitespace >= 0 { wrapAt := indexOfLastWhitespace @@ -118,7 +126,9 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) { } footNoteMatcher.addCharacter(c.char) - trailerMatcher.addCharacter(c.char) + if inTrailerBlock { + trailerMatcher.addCharacter(c.char) + } } } @@ -127,6 +137,21 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) { return result, softLineBreakIndices } +// startOfTrailerBlock returns the byte index into content at which the trailer +// block begins, i.e. the start of the last paragraph (the run of lines at the +// end of the message that is separated from the body by a blank line). Trailers +// are only looked for from this index onwards, so that a trailer-looking line in +// the middle of the message body isn't mistaken for a trailer. Trailing blank +// lines are ignored, and a message that consists of a single paragraph is +// treated as its own trailer block. +func startOfTrailerBlock(content string) int { + end := len(strings.TrimRight(content, "\n")) + if blankLine := strings.LastIndex(content[:end], "\n\n"); blankLine >= 0 { + return blankLine + len("\n\n") + } + return 0 +} + var footNoteRe = regexp.MustCompile(`^\[\d+\]:\s*$`) type footNoteMatcher struct { @@ -171,15 +196,11 @@ func (self *footNoteMatcher) reset() { self.didFailToMatch = false } -var supportedTrailers = []string{ - "Signed-off-by:", - "Co-authored-by:", -} - type trailerMatcher struct { - lineStr strings.Builder - didFailToMatch bool - didMatch bool + didFailToMatch bool + didMatch bool + keyContainsDash bool + keyEndsWithColon bool } func (self *trailerMatcher) addCharacter(chr string) { @@ -194,19 +215,11 @@ func (self *trailerMatcher) addCharacter(chr string) { return } - if self.lineStr.Len() == 0 { - // If this is the first character, see if it could possibly match any supported trailer; if - // not, we can fail early and stop tracking further characters for this line. - if !anyOf(supportedTrailers, func(trailer string) bool { return trailer[0] == chr[0] }) { - self.didFailToMatch = true - return - } - } - - self.lineStr.WriteString(chr) + self.keyContainsDash = self.keyContainsDash || chr == "-" + self.keyEndsWithColon = chr == ":" } -func (self *trailerMatcher) isTrailer() bool { +func (self *trailerMatcher) isTrailer(remainingContent string) bool { if self.didFailToMatch { return false } @@ -215,8 +228,9 @@ func (self *trailerMatcher) isTrailer() bool { return true } - line := self.lineStr.String() - if anyOf(supportedTrailers, func(trailer string) bool { return line == trailer }) { + remainingContent = strings.TrimLeft(remainingContent, WHITESPACES) + if self.keyEndsWithColon && (self.keyContainsDash || + strings.HasPrefix(remainingContent, "http://") || strings.HasPrefix(remainingContent, "https://")) { self.didMatch = true return true } @@ -226,19 +240,10 @@ func (self *trailerMatcher) isTrailer() bool { } func (self *trailerMatcher) reset() { - self.lineStr.Reset() self.didFailToMatch = false self.didMatch = false -} - -func anyOf(strings []string, predicate func(s string) bool) bool { - for _, s := range strings { - if predicate(s) { - return true - } - } - - return false + self.keyContainsDash = false + self.keyEndsWithColon = false } func (self *TextArea) updateCells() { diff --git a/pkg/gocui/text_area_test.go b/pkg/gocui/text_area_test.go index f0bc2fca8..f66876d24 100644 --- a/pkg/gocui/text_area_test.go +++ b/pkg/gocui/text_area_test.go @@ -945,18 +945,63 @@ func Test_AutoWrapContent(t *testing.T) { expectedSoftLineBreaks: []int{16, 21}, }, { - name: "don't break at space after trailer", - content: "abc\nSigned-off-by: John Doe \nCo-authored-by: Jane Smith \n", + name: "don't break at space after trailer at beginning of message", + content: "Signed-off-by: John Doe \nDepends-on: Some dependency with spaces\n", autoWrapWidth: 10, - expectedWrappedContent: "abc\nSigned-off-by: John Doe \nCo-authored-by: Jane Smith \n", + expectedWrappedContent: "Signed-off-by: John Doe \nDepends-on: Some dependency with spaces\n", expectedSoftLineBreaks: []int{}, }, { - name: "do break at space after trailer if there is no space after the colon", - content: "abc\nSigned-off-by:John Doe \n", + name: "don't break at space after trailer in a trailer block at the end of a message", + content: "abc\n\nSigned-off-by: John Doe \nDepends-on: Some dependency with spaces\n", autoWrapWidth: 10, - expectedWrappedContent: "abc\nSigned-off-by:John \nDoe \n\n", - expectedSoftLineBreaks: []int{23, 27}, + expectedWrappedContent: "abc\n\nSigned-off-by: John Doe \nDepends-on: Some dependency with spaces\n", + expectedSoftLineBreaks: []int{}, + }, + { + name: "don't break at space after trailer with URL value", + content: "abc\n\nBug: https://example.com/a/very/long/path\nIssue: http://example.com/a/very/long/path\n", + autoWrapWidth: 10, + expectedWrappedContent: "abc\n\nBug: https://example.com/a/very/long/path\nIssue: http://example.com/a/very/long/path\n", + expectedSoftLineBreaks: []int{}, + }, + { + name: "do break at space if trailer is not in a trailer block at the end", + content: "abc\n\nSigned-off-by: John Doe \n\nMore text here\n", + autoWrapWidth: 10, + expectedWrappedContent: "abc\n\nSigned-off-by: \nJohn Doe \n\n\nMore text \nhere\n", + expectedSoftLineBreaks: []int{20, 29, 55}, + }, + { + // Each line in the trailer block is judged on its own, so a line + // that isn't recognized as a trailer wraps without affecting the + // real trailers around it. + name: "keep a trailer next to a non-trailer line in the same block", + content: "abc\n\nFixes: a long description that wraps\nSigned-off-by: John Doe \n", + autoWrapWidth: 10, + expectedWrappedContent: "abc\n\nFixes: a \nlong \ndescription \nthat wraps\nSigned-off-by: John Doe \n", + expectedSoftLineBreaks: []int{14, 19, 31}, + }, + { + name: "don't break at space after trailer when the block ends in a blank line", + content: "abc\n\nSigned-off-by: John Doe \n\n", + autoWrapWidth: 10, + expectedWrappedContent: "abc\n\nSigned-off-by: John Doe \n\n", + expectedSoftLineBreaks: []int{}, + }, + { + name: "do break normal text after non-hyphenated key", + content: "However: in this commit blah blah blah\n", + autoWrapWidth: 10, + expectedWrappedContent: "However: \nin this \ncommit \nblah blah \nblah\n", + expectedSoftLineBreaks: []int{9, 17, 24, 34}, + }, + { + name: "do break at space after trailer if there is no space after the colon", + content: "abc\n\nSigned-off-by:John Doe \n", + autoWrapWidth: 10, + expectedWrappedContent: "abc\n\nSigned-off-by:John \nDoe \n\n", + expectedSoftLineBreaks: []int{24, 28}, }, { name: "hard line breaks", From 0a92529b1439fb461815ee31f9c0f3f14b4d97fd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 3 Aug 2026 18:55:38 +0200 Subject: [PATCH 51/97] Prevent stale index.lock files from diffs rendered through a pty on Windows At the end of a diff against the worktree, git re-reads and refreshes the index and writes it back if it found stale stat information (diff.autoRefreshIndex, on by default). It holds index.lock for the whole refresh; GIT_OPTIONAL_LOCKS does not cover this lock, and the window scales with the size of the repository (~150ms for a 6k-file repository with a warm stat cache). On Windows, a pty task that is stopped because the user moved on terminates its git process at an arbitrary point: tearing down the pseudoconsole delivers CTRL_CLOSE_EVENT, which git leaves to the default handler, which simply calls ExitProcess. If that lands inside the refresh, a stale index.lock is left behind and the next git command chokes on it. This is the same problem that 98801da106 fixed by no longer killing git processes; the ConPTY support added in 0.63 reintroduced it through the close event. Disable the automatic refresh for pty-rendered commands. They can afford it: the refresh only persists refreshed stat information, and lazygit's foreground git status refreshes -- which never run in a pty and are never killed -- already write that back on every user action and on terminal focus-in. The cost is that while the on-disk stat cache is stale, an external differ is invoked even for files whose stat information changed but whose content didn't, showing them as empty diffs; this heals with the next foreground refresh, which also re-renders the view. Unix keeps the refresh: a stopped pty child gets SIGTERM there, and git's signal handlers remove its lock files, so the lock window is harmless. The rawGit renderer keeps it too: its tasks don't run in a pty and are never killed on Windows -- they either run to completion or die on a broken pipe mid-output, before the refresh begins. Co-Authored-By: Claude Fable 5 --- pkg/gui/pty.go | 41 +++++++++++++++++++++++++++++++++++++++++ pkg/gui/pty_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 pkg/gui/pty_test.go diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index 8273c131a..2184621ae 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -5,6 +5,8 @@ import ( "io" "os" "os/exec" + "path/filepath" + "runtime" "strings" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -68,6 +70,8 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error return gui.newCmdTask(view, cmd, prefix) } + cmd.Args = withPtyGitConfig(cmd.Args, runtime.GOOS) + // Run the pty after layout so that it gets the correct size gui.afterLayout(func() error { // Need to get the width and the pager command again because the layout might have @@ -139,6 +143,43 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error return nil } +// withPtyGitConfig returns args with extra git configuration for commands +// that render into a pty. On Windows, such a command is terminated at an +// arbitrary point of its execution when its task stops: tearing down the +// pseudoconsole delivers CTRL_CLOSE_EVENT, which git leaves to the default +// handler, which just calls ExitProcess. git's automatic index refresh +// (diff.autoRefreshIndex, on by default) takes index.lock at the end of a +// diff against the worktree to write back refreshed stat information — +// GIT_OPTIONAL_LOCKS does not cover this lock — and a termination landing +// in that window leaves a stale index.lock behind that the next git command +// chokes on. So don't let pty-rendered commands refresh the index; +// lazygit's foreground `git status` refreshes, which never run in a pty, +// keep the stat cache fresh instead. +// +// On Unix a stopped pty child gets SIGTERM, and git's signal handlers remove +// its lock files, so the refresh can stay enabled there and keep healing +// stale stat info. +func withPtyGitConfig(args []string, goos string) []string { + if goos != "windows" { + return args + } + // Most pty commands are direct git invocations, but the user-configured + // ones can be arbitrary command lines (e.g. a branchLogCmd wrapping git + // in `sh -c`), and injecting git flags into those would corrupt them. + // Only direct git invocations get the config; that loses nothing, since + // the wrapped commands are log commands, which never take the index + // lock. (For direct invocations other than worktree diffs the config is + // simply a no-op.) + base := strings.TrimSuffix(strings.ToLower(filepath.Base(args[0])), ".exe") + if base != "git" { + return args + } + result := make([]string, 0, len(args)+2) + result = append(result, args[0]) + result = append(result, "-c", "diff.autoRefreshIndex=false") + return append(result, args[1:]...) +} + func removeExistingTermEnvVars(env []string) []string { return lo.Filter(env, func(envVar string, _ int) bool { return !isTermEnvVar(envVar) diff --git a/pkg/gui/pty_test.go b/pkg/gui/pty_test.go new file mode 100644 index 000000000..8d7f0e2ca --- /dev/null +++ b/pkg/gui/pty_test.go @@ -0,0 +1,29 @@ +package gui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWithPtyGitConfig(t *testing.T) { + args := []string{"git", "-C", "/repo", "diff", "--color=always"} + + assert.Equal(t, + []string{"git", "-c", "diff.autoRefreshIndex=false", "-C", "/repo", "diff", "--color=always"}, + withPtyGitConfig(args, "windows")) + + assert.Equal(t, args, withPtyGitConfig(args, "linux")) + assert.Equal(t, args, withPtyGitConfig(args, "darwin")) + + // A user-configured command that wraps git in a shell must not have git + // flags injected into it. + shellArgs := []string{"sh", "-c", "git log --graph {{branchName}} -- | sed -e s/x/y/"} + assert.Equal(t, shellArgs, withPtyGitConfig(shellArgs, "windows")) + + // The guard recognizes git regardless of case and extension. + exeArgs := []string{"GIT.EXE", "diff"} + assert.Equal(t, + []string{"GIT.EXE", "-c", "diff.autoRefreshIndex=false", "diff"}, + withPtyGitConfig(exeArgs, "windows")) +} From d52c0a604f830ffac5340aaac662bf575cd64690 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 2 Aug 2026 15:38:57 +0200 Subject: [PATCH 52/97] Give task commands a Terminate method instead of exposing their process The task stop path terminates the still-running command by pulling its *os.Process out of the Cmd interface and applying one global strategy (TerminateProcessGracefully) to it. That shape can't accommodate the upcoming fix for orphaned process trees on Windows: there, stopping a pty task requires terminating the entire process tree via a job object whose handle lives with the pty, not with the process. And the two Cmd implementations genuinely need different strategies anyway: a process-group kill (the likely future fix for #5675 on Unix) is only safe for pty children, which run as session leaders, while plain commands share lazygit's own process group. So let each Cmd implementation decide how to terminate itself, and drop GetProcess, which had no other callers. No change in behavior. Co-Authored-By: Claude Fable 5 --- pkg/gui/pty.go | 6 +++--- pkg/tasks/tasks.go | 17 +++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index 2184621ae..719f5c348 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -49,9 +49,9 @@ type ptyCmd struct { wait func() error } -func (p ptyCmd) Wait() error { return p.wait() } -func (p ptyCmd) String() string { return p.cmd.String() } -func (p ptyCmd) GetProcess() *os.Process { return p.process } +func (p ptyCmd) Wait() error { return p.wait() } +func (p ptyCmd) String() string { return p.cmd.String() } +func (p ptyCmd) Terminate() error { return oscommands.TerminateProcessGracefully(p.process) } // Some commands need to output for a terminal to active certain behaviour. // For example, git won't invoke the GIT_PAGER env var unless it thinks it's diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 3a964c838..3768e0c19 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -4,7 +4,6 @@ import ( "bufio" "fmt" "io" - "os" "os/exec" "sync" "sync/atomic" @@ -24,7 +23,9 @@ import ( type Cmd interface { Wait() error String() string - GetProcess() *os.Process + // Terminate makes the process stop early, as gracefully as the platform + // allows. It doesn't wait for the process to exit. + Terminate() error } // ExecCmd adapts *exec.Cmd to Cmd. @@ -32,8 +33,11 @@ type ExecCmd struct { *exec.Cmd } -func (c ExecCmd) GetProcess() *os.Process { - return c.Process +// Terminate sends SIGTERM on Unix. On Windows it does nothing, so a stopped +// command keeps running until it next writes to its (by then closed) output +// pipe. +func (c ExecCmd) Terminate() error { + return oscommands.TerminateProcessGracefully(c.Process) } // This file revolves around running commands that will be output to the main panel @@ -213,10 +217,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix // when flicking through several very long diffs when diff.algorithm = histogram is // being used, in which case multiple git processes continue to calculate expensive // diffs in the background even though they have been stopped already. - // - // Unfortunately this will do nothing on Windows, so Windows users will have to live - // with the higher CPU usage. - if err := oscommands.TerminateProcessGracefully(cmd.GetProcess()); err != nil { + if err := cmd.Terminate(); err != nil { self.Log.Errorf("error when trying to terminate cmd task: %v; Command: %v", err, cmd.String()) } From 6048fee49d152da28c07bfc867d797a205945266 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 3 Aug 2026 18:58:23 +0200 Subject: [PATCH 53/97] Kill the pty child's process tree on Windows to avoid orphans (#5879) Stopping a pty task on Windows relies on ClosePseudoConsole, which delivers CTRL_CLOSE_EVENT to the console's attached clients. But only to those attached at that moment: when the user flicks quickly through commits, a task is often stopped within the first few milliseconds of its life, before the child has attached to the pseudoconsole. Such a child misses the event and survives, running the entire diff to completion in the background (spawning one external differ per changed file) and keeping its conhost.exe alive; rapid navigation accumulates these git/difft/conhost trees, and they outlive lazygit. Grandchildren are affected too: git for Windows runs commands through a two-level git.exe wrapper, so a single task has several attach windows, and a grandchild spawned while the console is going down is orphaned even when its parent got the event and exited. Fix this by putting the child into a job object before it runs its first instruction (created suspended, assigned, then resumed), so that every descendant is in the job from the start; the teardown in Close terminates the job right after initiating the pseudoconsole close. There is no point in a grace period between the two: the close event is not a graceful signal -- git and the common diff tools leave it to the default handler, which calls ExitProcess at an arbitrary point -- so clients that received it are already dying, and the kill exists for those that missed it. Killing at an arbitrary point cannot leak a stale index.lock, because pty-rendered commands no longer take that lock (see withPtyGitConfig in pkg/gui/pty.go). The pseudoconsole close runs on its own goroutine because the kill must not wait for it: on builds where ClosePseudoConsole blocks until the console host exits (pre-24H2), the host keeps running as long as a surviving client does, and that client only goes away through the job kill; sequencing the kill after a blocking close would deadlock in exactly the case the kill exists for. KILL_ON_JOB_CLOSE doubles as a safety net: if lazygit exits without running the teardown, the OS closes the job handle and reaps the tree. In a harness that mimicked the stop path with randomized 0-120ms stop delays, 3 of 30 process trees survived as orphans before this change; none survive with it. Co-Authored-By: Claude Fable 5 --- pkg/commands/oscommands/pty_windows.go | 79 ++++++++++++++++++++- pkg/commands/oscommands/pty_windows_test.go | 30 ++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index 1bfb29ab5..dcc6ab1a3 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -12,7 +12,10 @@ import ( ) type winPty struct { - hpc windows.Handle + hpc windows.Handle + // job holds the child and every descendant it spawns; terminating it + // kills whatever is left of the process tree (see Close). + job windows.Handle inWrite *os.File outRead *os.File @@ -83,11 +86,36 @@ func (p *winPty) closeHpc() { // nobody is reading anymore, so that flush can only complete once the pipe // is broken. The background waiter's closeHpc may already be wedged in such // a flush while holding p.mu; closing the pipes is what unblocks it. +// +// Closing the pseudoconsole delivers CTRL_CLOSE_EVENT only to the clients +// attached to it at that moment. A child that is stopped right after being +// spawned is still starting up and not attached yet, so the event misses it +// and it survives, running its command to completion as an orphan — and +// keeping its console host alive with it (#5879); the same holds for +// grandchildren spawned while the console is going down, and for clients +// that ignore the event (the Windows flavor of #5675). The job kill reaps +// all of those. There is no point in delaying it: the close event is not a +// graceful signal worth waiting on — git and the common diff tools leave it +// to the default handler, which calls ExitProcess at whatever instruction +// the process happens to execute — so clients that got the event are +// already dying. Killing at an arbitrary point cannot leak a stale +// index.lock, because pty-rendered commands don't take that lock (see +// withPtyGitConfig in pkg/gui/pty.go). +// +// The pseudoconsole close gets its own goroutine because the kill must not +// wait for it: on builds where ClosePseudoConsole blocks until the console +// host exits (pre-24H2), the host keeps running as long as a surviving +// client does, and that client only goes away through the job kill — +// sequencing the kill after a blocking close would thus deadlock in +// exactly the case the kill exists for. func (p *winPty) Close() error { go utils.Safe(func() { p.inWrite.Close() p.outRead.Close() - p.closeHpc() + go utils.Safe(p.closeHpc) + + _ = windows.TerminateJobObject(p.job, 1) + _ = windows.CloseHandle(p.job) }) return nil } @@ -163,6 +191,34 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { } }() + // The child goes into a job object so that the teardown in Close can + // terminate the whole process tree. KILL_ON_JOB_CLOSE makes the OS do + // that when the last handle to the job is closed, which doubles as a + // safety net: if lazygit exits without running the teardown, the handle + // is closed for it and the tree is reaped. + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return StartedPty{}, fmt.Errorf("CreateJobObject: %w", err) + } + defer func() { + if err != nil { + // Kills the child on error paths where it was already assigned + // to the job; plain handle cleanup before that. + _ = windows.CloseHandle(job) + } + }() + limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + } + if _, err = windows.SetInformationJobObject( + job, windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits)), + ); err != nil { + return StartedPty{}, fmt.Errorf("SetInformationJobObject: %w", err) + } + // Attach the pseudoconsole to the child via a process attribute list. attrList, err := windows.NewProcThreadAttributeList(1) if err != nil { @@ -221,7 +277,7 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { nil, // process security nil, // thread security false, - windows.EXTENDED_STARTUPINFO_PRESENT|windows.CREATE_UNICODE_ENVIRONMENT, + windows.EXTENDED_STARTUPINFO_PRESENT|windows.CREATE_UNICODE_ENVIRONMENT|windows.CREATE_SUSPENDED, envPtr, dirPtr, &si.StartupInfo, @@ -230,6 +286,22 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { if err != nil { return StartedPty{}, fmt.Errorf("CreateProcess: %w", err) } + + // The child was created suspended so that it can be assigned to the job + // before it runs its first instruction; that way every descendant it + // ever spawns is in the job from the start. + if err = windows.AssignProcessToJobObject(job, pi.Process); err != nil { + // Not in the job yet, so the deferred job-handle close can't reap it. + _ = windows.TerminateProcess(pi.Process, 1) + _ = windows.CloseHandle(pi.Thread) + _ = windows.CloseHandle(pi.Process) + return StartedPty{}, fmt.Errorf("AssignProcessToJobObject: %w", err) + } + if _, err = windows.ResumeThread(pi.Thread); err != nil { + _ = windows.CloseHandle(pi.Thread) + _ = windows.CloseHandle(pi.Process) + return StartedPty{}, fmt.Errorf("ResumeThread: %w", err) + } _ = windows.CloseHandle(pi.Thread) // Re-open the process by PID to get an *os.Process to wait on. Do this @@ -245,6 +317,7 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { wp := &winPty{ hpc: hpc, + job: job, inWrite: os.NewFile(uintptr(inWrite), "conpty-in"), outRead: os.NewFile(uintptr(outRead), "conpty-out"), } diff --git a/pkg/commands/oscommands/pty_windows_test.go b/pkg/commands/oscommands/pty_windows_test.go index 0b4173561..688393843 100644 --- a/pkg/commands/oscommands/pty_windows_test.go +++ b/pkg/commands/oscommands/pty_windows_test.go @@ -3,6 +3,7 @@ package oscommands import ( "os/exec" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -23,3 +24,32 @@ func TestStartPtyWithZeroSize(t *testing.T) { _ = sp.Pty.Close() } } + +// Closing the pty must terminate the process tree it was running, even when +// it is closed so soon after starting that the child hasn't attached to the +// pseudoconsole yet: such a child misses the CTRL_CLOSE_EVENT that the close +// delivers to attached clients, and only the job-object kill reaps it. +// Without the kill, cmd and its ping child keep running for ~30 seconds and +// the Wait here times out. +func TestClosePtyTerminatesChildProcessTree(t *testing.T) { + // The output redirect is there for the reason described in + // TestStartPtyWithZeroSize. + sp, err := StartPty(exec.Command("cmd", "/c", "ping -n 30 127.0.0.1 >nul"), 80, 24) + assert.NoError(t, err) + if err != nil { + return + } + + _ = sp.Pty.Close() + + exited := make(chan struct{}) + go func() { + _ = sp.Wait() + close(exited) + }() + select { + case <-exited: + case <-time.After(5 * time.Second): + t.Fatal("child process was not terminated by closing the pty") + } +} From b4dee2af0eb247060200b64f244c95a7711a0b03 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 3 Aug 2026 14:23:08 +0200 Subject: [PATCH 54/97] Reap the pty's conhost on Windows builds where it fails to run down (#5879) A pseudoconsole's conhost.exe is spawned by CreatePseudoConsole as a child of lazygit, so it is not part of the job object that the pty teardown kills. That is normally fine: a healthy conhost runs itself down once the reference handle is closed and its clients are gone. But conhost builds before the ConPTY overhaul that shipped with Windows 11 24H2 (confirmed on 23H2, build 22631) fail to complete the rundown when a client attached after the close event was delivered and was then killed -- the fate of exactly the clients the job kill exists for -- and such a conhost lingers forever with no clients, at a rate of about one per five fast commit navigations. These builds remain widespread: all of Windows 10 (whose ESU tail runs into 2028, and whose hardware often cannot run Windows 11 at all) plus pre-24H2 Windows 11 fleets. Since Windows offers no way to obtain the conhost's pid or handle from the HPCON, identify it by diffing lazygit's direct conhost children around the CreatePseudoConsole call, serialized by a mutex so that two concurrently starting ptys can't confuse each other's diff, and open a handle immediately so that pid reuse is harmless. The teardown then gives conhost a second to exit on its own before terminating it; on healthy builds the wait succeeds and the reap never fires. If the conhost can't be identified unambiguously, we simply don't reap, which is no worse than before. Co-Authored-By: Claude Fable 5 --- pkg/commands/oscommands/pty_windows.go | 98 ++++++++++++++++++++- pkg/commands/oscommands/pty_windows_test.go | 18 ++++ 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index dcc6ab1a3..853fe1fae 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "os/exec" + "strings" "sync" + "time" "unsafe" "github.com/jesseduffield/lazygit/pkg/utils" @@ -15,7 +17,11 @@ type winPty struct { hpc windows.Handle // job holds the child and every descendant it spawns; terminating it // kills whatever is left of the process tree (see Close). - job windows.Handle + job windows.Handle + // conhost is a handle to the conhost.exe serving this pty, or 0 if it + // couldn't be identified. Held so that the teardown in Close can reap + // it on Windows builds whose conhost fails to run down on its own. + conhost windows.Handle inWrite *os.File outRead *os.File @@ -67,6 +73,10 @@ func (p *winPty) closeHpc() { windows.ClosePseudoConsole(p.hpc) } +// How long Close waits for the conhost to run itself down after its clients +// are gone, before concluding that it never will (see Close) and reaping it. +const conhostExitTimeout = time.Second + // Close tears the pty down without waiting for it: the teardown runs on a // background goroutine and Close returns immediately. // @@ -108,6 +118,15 @@ func (p *winPty) closeHpc() { // client does, and that client only goes away through the job kill — // sequencing the kill after a blocking close would thus deadlock in // exactly the case the kill exists for. +// +// After the kill, the conhost serving the pty is reaped as well if it +// doesn't exit by itself: a healthy conhost runs down once the reference +// handle is closed and its clients are gone, but conhost builds before +// Windows 11 24H2 fail to complete the rundown when a client attached +// after the close event was delivered and was then killed — the fate of +// exactly the clients the job kill is for — and such a conhost sits +// around forever, serving nothing (#5879). The reap is inert on healthy +// builds: the wait succeeds and only the handle is closed. func (p *winPty) Close() error { go utils.Safe(func() { p.inWrite.Close() @@ -116,6 +135,14 @@ func (p *winPty) Close() error { _ = windows.TerminateJobObject(p.job, 1) _ = windows.CloseHandle(p.job) + + if p.conhost != 0 { + event, err := windows.WaitForSingleObject(p.conhost, uint32(conhostExitTimeout/time.Millisecond)) + if err != nil || event != windows.WAIT_OBJECT_0 { + _ = windows.TerminateProcess(p.conhost, 1) + } + _ = windows.CloseHandle(p.conhost) + } }) return nil } @@ -151,6 +178,52 @@ func startWaiter(proc *os.Process, p *winPty) func() error { } } +// conhostScanMu serializes CreatePseudoConsole and the child-process scans +// around it, so that two concurrently starting ptys can't make each other's +// "which conhost is new" diff ambiguous. +var conhostScanMu sync.Mutex + +// conhostChildren returns the pids of all conhost.exe processes that are +// direct children of this process. Errors just yield a smaller (possibly +// empty) set; the caller treats identification as best-effort. +func conhostChildren() map[uint32]bool { + pids := map[uint32]bool{} + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return pids + } + defer func() { _ = windows.CloseHandle(snap) }() + me := uint32(os.Getpid()) + var pe windows.ProcessEntry32 + pe.Size = uint32(unsafe.Sizeof(pe)) + for err := windows.Process32First(snap, &pe); err == nil; err = windows.Process32Next(snap, &pe) { + if pe.ParentProcessID == me && strings.EqualFold(windows.UTF16ToString(pe.ExeFile[:]), "conhost.exe") { + pids[pe.ProcessID] = true + } + } + return pids +} + +// openNewConhostChild returns a handle to the single conhost child that +// appeared since the before scan, or 0 if there isn't exactly one candidate +// or it can't be opened. +func openNewConhostChild(before map[uint32]bool) windows.Handle { + var found []uint32 + for pid := range conhostChildren() { + if !before[pid] { + found = append(found, pid) + } + } + if len(found) != 1 { + return 0 + } + h, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_TERMINATE, false, found[0]) + if err != nil { + return 0 + } + return h +} + func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { // Two pipes: one for the child's stdin (we never write to it, but ConPTY // needs a handle), one for the child's stdout/stderr multiplexed through @@ -176,9 +249,24 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { // CreatePseudoConsole dupes the handles it needs internally; we release // our references to the child-side ends immediately after. - var hpc windows.Handle + // + // It also spawns the conhost.exe serving the console session, as a + // direct child of this process. The teardown in Close needs a handle to + // that conhost (see there), but Windows offers no way to obtain one + // from the HPCON, so identify it by diffing our conhost children around + // the call. Open a real handle right away so that pid reuse can't later + // misdirect the teardown's reap. If identification fails, the handle + // stays 0 and the teardown skips the reap. + var hpc, conhost windows.Handle size := clampPtySize(cols, rows) - if err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc); err != nil { + conhostScanMu.Lock() + conhostsBefore := conhostChildren() + err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc) + if err == nil { + conhost = openNewConhostChild(conhostsBefore) + } + conhostScanMu.Unlock() + if err != nil { _ = windows.CloseHandle(inRead) _ = windows.CloseHandle(outWrite) return StartedPty{}, fmt.Errorf("CreatePseudoConsole: %w", err) @@ -188,6 +276,9 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { defer func() { if err != nil { windows.ClosePseudoConsole(hpc) + if conhost != 0 { + _ = windows.CloseHandle(conhost) + } } }() @@ -318,6 +409,7 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { wp := &winPty{ hpc: hpc, job: job, + conhost: conhost, inWrite: os.NewFile(uintptr(inWrite), "conpty-in"), outRead: os.NewFile(uintptr(outRead), "conpty-out"), } diff --git a/pkg/commands/oscommands/pty_windows_test.go b/pkg/commands/oscommands/pty_windows_test.go index 688393843..46a92babc 100644 --- a/pkg/commands/oscommands/pty_windows_test.go +++ b/pkg/commands/oscommands/pty_windows_test.go @@ -25,6 +25,24 @@ func TestStartPtyWithZeroSize(t *testing.T) { } } +// StartPty must identify the conhost.exe that CreatePseudoConsole spawned to +// serve the pty: the teardown in Close reaps it on Windows builds whose +// conhost fails to run down on its own, and a failed identification silently +// degrades to not reaping. If this fails, the child-scan in +// openNewConhostChild no longer matches how Windows hosts pseudoconsoles. +func TestStartPtyIdentifiesConhost(t *testing.T) { + sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 80, 24) + assert.NoError(t, err) + if err != nil { + return + } + + assert.NotZero(t, sp.Pty.(*winPty).conhost) + + _ = sp.Wait() + _ = sp.Pty.Close() +} + // Closing the pty must terminate the process tree it was running, even when // it is closed so soon after starting that the child hasn't attached to the // pseudoconsole yet: such a child misses the CTRL_CLOSE_EVENT that the close From d251faddec3311051eafaf48c767389cd02a00c2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 3 Aug 2026 19:04:03 +0200 Subject: [PATCH 55/97] Reap pty process trees synchronously when quitting on Windows (#5879) The pty teardown in Close runs on a background goroutine that doesn't get to finish when lazygit is quitting: the process exits milliseconds after the view buffer managers are closed. The job objects still cover the clients -- KILL_ON_JOB_CLOSE reaps them when the process's handles are rundown at exit -- but nothing reaps the conhost, so on Windows builds whose conhost fails to run down on its own, quitting leaks one conhost per live pty. This is not a rare timing window: a diff longer than what has been read keeps its git process (and thus its pty and conhost) running for the entire time it is displayed, so that scrolling can read more. Quitting while looking at a long diff is therefore the common case, and with an external differ configured it leaks a conhost on affected builds on almost every quit. Fix this by having the gui's shutdown path wait synchronously for the in-flight teardowns after closing the view buffer managers. A quit signal makes the teardowns skip the conhost rundown wait -- the conhost serves nothing once its clients are dead, and the exit must not stall for its sake -- so the wait normally completes in milliseconds, keeping quit as fast as before; a 2-second cap protects the exit path even if a teardown wedges. Co-Authored-By: Claude Fable 5 --- pkg/commands/oscommands/pty_unix.go | 6 +++ pkg/commands/oscommands/pty_windows.go | 50 ++++++++++++++++++++- pkg/commands/oscommands/pty_windows_test.go | 32 +++++++++++++ pkg/gui/gui.go | 6 +++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/pkg/commands/oscommands/pty_unix.go b/pkg/commands/oscommands/pty_unix.go index 6cf63cdff..cd3962a8e 100644 --- a/pkg/commands/oscommands/pty_unix.go +++ b/pkg/commands/oscommands/pty_unix.go @@ -32,3 +32,9 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error) { Wait: cmd.Wait, }, nil } + +// TerminateLivePtys is a no-op on Unix: stopping a pty task signals the +// child (SIGTERM, plus SIGHUP to the foreground process group when the +// master closes), and the processes clean themselves up without lazygit +// having to wait for them. +func TerminateLivePtys() {} diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index 853fe1fae..e645ae627 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -77,6 +77,42 @@ func (p *winPty) closeHpc() { // are gone, before concluding that it never will (see Close) and reaping it. const conhostExitTimeout = time.Second +var ( + // ptyTeardowns counts the in-flight teardown goroutines spawned by + // Close; TerminateLivePtys waits for them when lazygit exits. + ptyTeardowns sync.WaitGroup + // ptyQuit is closed by TerminateLivePtys. In-flight teardowns skip the + // conhost rundown wait once it is closed: the conhost serves nothing + // once its clients are gone, and the exit must not stall for its sake. + ptyQuit = make(chan struct{}) + ptyQuitOnce sync.Once +) + +// TerminateLivePtys synchronously terminates the process trees and console +// hosts of all ptys whose teardown hasn't finished yet. Call it when lazygit +// is about to exit: the asynchronous teardowns in Close won't get to finish +// (the conhost rundown wait outlives the process), and while +// KILL_ON_JOB_CLOSE reaps the clients when the job handles are closed at +// process death, nothing would reap the conhosts on the Windows builds that +// need it (see Close). A long diff on screen keeps its git process running +// the whole time it is shown, so quitting with such a teardown in flight is +// the rule, not the exception. +func TerminateLivePtys() { + ptyQuitOnce.Do(func() { close(ptyQuit) }) + + done := make(chan struct{}) + go utils.Safe(func() { + ptyTeardowns.Wait() + close(done) + }) + select { + case <-done: + case <-time.After(2 * time.Second): + // Don't hold up the exit any longer; the job handles' rundown + // still covers the clients. + } +} + // Close tears the pty down without waiting for it: the teardown runs on a // background goroutine and Close returns immediately. // @@ -127,8 +163,14 @@ const conhostExitTimeout = time.Second // exactly the clients the job kill is for — and such a conhost sits // around forever, serving nothing (#5879). The reap is inert on healthy // builds: the wait succeeds and only the handle is closed. +// +// When lazygit is quitting, the conhost rundown wait is skipped; see +// TerminateLivePtys. func (p *winPty) Close() error { + ptyTeardowns.Add(1) go utils.Safe(func() { + defer ptyTeardowns.Done() + p.inWrite.Close() p.outRead.Close() go utils.Safe(p.closeHpc) @@ -137,7 +179,13 @@ func (p *winPty) Close() error { _ = windows.CloseHandle(p.job) if p.conhost != 0 { - event, err := windows.WaitForSingleObject(p.conhost, uint32(conhostExitTimeout/time.Millisecond)) + timeout := conhostExitTimeout + select { + case <-ptyQuit: + timeout = 0 + default: + } + event, err := windows.WaitForSingleObject(p.conhost, uint32(timeout/time.Millisecond)) if err != nil || event != windows.WAIT_OBJECT_0 { _ = windows.TerminateProcess(p.conhost, 1) } diff --git a/pkg/commands/oscommands/pty_windows_test.go b/pkg/commands/oscommands/pty_windows_test.go index 46a92babc..e6581a85d 100644 --- a/pkg/commands/oscommands/pty_windows_test.go +++ b/pkg/commands/oscommands/pty_windows_test.go @@ -43,6 +43,38 @@ func TestStartPtyIdentifiesConhost(t *testing.T) { _ = sp.Pty.Close() } +// TerminateLivePtys must reap a still-running pty synchronously: it runs +// when lazygit is about to exit, where the asynchronous teardown would not +// get to finish. Note that it switches the package's pty teardowns into +// quit mode for the remainder of the test binary's lifetime; that's fine +// for the other tests here, which must hold in either mode (quit mode only +// shortens the teardown's conhost rundown wait). +func TestTerminateLivePtysReapsRunningPty(t *testing.T) { + // The output redirect is there for the reason described in + // TestStartPtyWithZeroSize. + sp, err := StartPty(exec.Command("cmd", "/c", "ping -n 30 127.0.0.1 >nul"), 80, 24) + assert.NoError(t, err) + if err != nil { + return + } + + _ = sp.Pty.Close() + TerminateLivePtys() + + // The teardown has completed as part of TerminateLivePtys, so the child + // must be gone already; the timeout is generosity, not a grace period. + exited := make(chan struct{}) + go func() { + _ = sp.Wait() + close(exited) + }() + select { + case <-exited: + case <-time.After(time.Second): + t.Fatal("child process was not terminated by TerminateLivePtys") + } +} + // Closing the pty must terminate the process tree it was running, even when // it is closed so soon after starting that the child hasn't attached to the // pseudoconsole yet: such a child misses the CTRL_CLOSE_EVENT that the close diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 7713cbd57..4dcd3c209 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -1005,6 +1005,12 @@ func (gui *Gui) RunAndHandleError(startArgs appTypes.StartArgs) error { manager.Close() } + // The pty teardowns spawned by the manager closes above run on + // background goroutines that won't get to finish before the + // process exits; reap their process trees synchronously instead + // so that they don't outlive lazygit. + oscommands.TerminateLivePtys() + close(gui.stopChan) if errors.Is(err, gocui.ErrQuit) { From 1edd432d2ffd2dac0457a1e3174f78089e4bc428 Mon Sep 17 00:00:00 2001 From: Tyce Herrman Date: Fri, 31 Jul 2026 09:24:04 -0400 Subject: [PATCH 56/97] Make justfile commands available in the Nix development shell --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index b8069e9f7..8d0078d16 100644 --- a/flake.nix +++ b/flake.nix @@ -101,6 +101,7 @@ # Development tools git gnumake + just ]; # Environment variables for development From 1f231c92625a65983cb8f90be707bac594fe645c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 4 Aug 2026 09:03:58 +0200 Subject: [PATCH 57/97] Update translations from Crowdin --- docs-master/keybindings/Keybindings_nl.md | 36 +++---- pkg/i18n/translations/nl.json | 114 +++++++++++++++++++++- pkg/i18n/translations/pt.json | 1 - pkg/i18n/translations/zh-CN.json | 5 - 4 files changed, 130 insertions(+), 26 deletions(-) diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index cf9934b40..7d95e72dd 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -9,14 +9,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Wissel naar een recente repo | | | `` , K, (fn+up/shift+k) `` | Scroll naar beneden vanaf hoofdpaneel | | | `` , J, (fn+down/shift+j) `` | Scroll naar beneden vanaf hoofdpaneel | | -| `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. | +| `` @ `` | Commandolog opties weergeven | Bekijk commandolog opties, bijv. commandolog tonen/verbergen en focus. | | `` P `` | Push | Push de huidige branch naar de bijbehorende upstream-branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. | | `` p `` | Pull | Pull wijzigingen van de remote voor de huidige branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.

The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.

The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. | | `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view.

The default can be changed in the config file with the key 'git.diffContextSize'. | | `` { `` | Decrease diff context size | Decrease the amount of the context shown around changes in the diff view.

The default can be changed in the config file with the key 'git.diffContextSize'. | -| `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. | +| `` : `` | Voer shellcommando uit | Bring up a prompt where you can enter a shell command to execute. | | `` `` | Bekijk aangepaste patch opties | | | `` m `` | Bekijk merge/rebase opties | Toon abort/continue/skip opties voor huidige merge/rebase. | | `` R `` | Verversen | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | @@ -30,10 +30,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` W, `` | Open diff menu | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q, `` | Afsluiten | | | `` `` | Pauzeer de applicatie | | -| `` `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.

The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. | +| `` `` | Witruimte weergeven in-/uitschakelen | Toggle whether or not whitespace changes are shown in the diff view.

The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. | | `` `` | Verander config bestand | Open bestand in externe editor. | | `` z `` | Ongedaan maken (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | -| `` Z `` | Redo (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | +| `` Z `` | Redo (via reflog) (experimenteel) | Het reflog wordt gebruikt om te bepalen welk git commando moet worden gebruikt om het laatste git commando te herhalen. Wijzigingen aan de working tree worden niet meegenomen, alleen command's zijn kandidaten. | ## Lijstpaneel navigatie @@ -161,25 +161,25 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` s `` | Squash | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. | | `` f `` | Fixup | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. | | `` c `` | Set fixup message | Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message. | -| `` r `` | Hernoem commit | Reword the selected commit's message. | +| `` r `` | Hernoem commit | Herschrijf de commit message van de geselecteerde commit. | | `` R `` | Hernoem commit met editor | | | `` d `` | Verwijder commit | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. | -| `` e `` | Edit (start interactive rebase) | Wijzig commit | -| `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.
If you would instead like to start an interactive rebase from the selected commit, press `e`. | +| `` e `` | Bewerken (start interactieve rebase) | Wijzig commit | +| `` i `` | Start interactieve rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.
If you would instead like to start an interactive rebase from the selected commit, press `e`. | | `` p `` | Pick | Kies commit (wanneer midden in rebase) | | `` F `` | Creëer fixup commit | Creëer fixup commit | | `` S `` | Apply fixup commits | Squash bovenstaande commits | | `` , `` | Verplaats commit 1 naar beneden | | | `` , `` | Verplaats commit 1 naar boven | | | `` V `` | Plak commits (cherry-pick) | | -| `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | +| `` B `` | Markeer als basiscommit voor rebase | Selecteer een basiscommit voor de volgende rebase. Als je rebased op een branch worden alleen commits boven de basiscommit meegenomen. Hiervoor wordt het `git rebase --onto` commando gebruikt. | | `` A `` | Amend | Wijzig commit met staged veranderingen | | `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. | -| `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | -| `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | -| `` `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | +| `` t `` | Revert | Maak een revert commit voor de geselecteerde commit, die de wijzigingen in deze commit terugdraait. | +| `` T `` | Tag commit | Maak een nieuwe tag die naar de geselecteerde commit wijst. Je wordt gevraagd om een tag naam en optionele omschrijving. | +| `` `` | Log opties weergeven | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | | `` G `` | Open pull request in browser | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | @@ -241,7 +241,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , h `` | Selecteer de vorige hunk | | | `` , l `` | Selecteer de volgende hunk | | | `` v `` | Toggle drag selecteer | | -| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. | | `` `` | Copy selected text to clipboard | | | `` o `` | Open bestand | Open bestand in standaardapplicatie. | | `` e `` | Verander bestand | Open bestand in externe editor. | @@ -255,7 +255,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | @@ -316,7 +316,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , h `` | Selecteer de vorige hunk | | | `` , l `` | Selecteer de volgende hunk | | | `` v `` | Toggle drag selecteer | | -| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. | | `` `` | Copy selected text to clipboard | | | `` `` | Toggle staged | Toggle lijnen staged / unstaged | | `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | @@ -361,7 +361,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | @@ -396,10 +396,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | Uitchecken | Geselecteerde tag uitchecken als detached HEAD. | -| `` n `` | Creëer tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | +| `` n `` | Creëer tag | Maak een nieuwe tag die naar de huidige commit wijst. Je wordt gevraagd om een tag naam en optionele beschrijving. | | `` w `` | New worktree | | | `` d `` | Verwijderen | View delete options for local/remote tag. | -| `` P `` | Push tag | Push the selected tag to a remote. You'll be prompted to select a remote. | +| `` P `` | Tag pushen | Push de geselecteerde tag naar een remote. Je krijgt de optie een remote te selecteren. | | `` g `` | Resetten | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open externe diff applicatie (git difftool) | | | `` 0 `` | Focus main view | | diff --git a/pkg/i18n/translations/nl.json b/pkg/i18n/translations/nl.json index e6369d85a..4ebdcde84 100644 --- a/pkg/i18n/translations/nl.json +++ b/pkg/i18n/translations/nl.json @@ -69,6 +69,10 @@ "FilterLabelUntrackedFiles": "(alleen niet-getrackt)", "FilterLabelConflictingFiles": "(alleen conflicten)", "MergeConflictsTitle": "Merge conflicten", + "MergeConflictDescription_DD": "Conflict: deze file is verplaatst of hernoemd in zowel de current changes als de incoming changes, maar naar verschillende plekken. Ik weet niet welke, maar ze zouden allebei ook weergegeven moeten worden als conflicten (gemarkeerd met respectievelijk 'AU' en 'UA'). De meest waarschijnlijke oplossing is om deze file zelf te verwijderen, één van de twee nieuwe locaties te houden, en de andere ook te verwijderen.", + "MergeConflictDescription_AU": "Conflict: een file is verplaatst of hernoemd naar deze plek in de current changes, maar dezelfde file is naar een andere plek verplaatst of hernoemd in de incoming changes. Die andere locatie zou ook zichtbaar moeten zijn als een conflict (gemarkeerd met 'UA'), alsmede de originele locatie van de file (gemarkeerd met 'DD').", + "MergeConflictDescription_UA": "Conflict: een file is verplaatst of hernoemd naar deze plek in de incoming changes, maar dezelfde file is naar een andere plek verplaatst of hernoemd in de current changes. Die andere locatie zou ook zichtbaar moeten zijn als een conflict (gemarkeerd met 'AU'), alsmede de originele locatie van de file (gemarkeerd met 'DD').", + "MergeConflictDescription_DU": "Conflict: deze file is verwijderd in de current changes, en gewijzigd in de incoming changes.\n\nDe meest waarschijnlijke oplossing is om dit bestand te verwijderen nadat de incoming changes wijzigingen handmatig zijn toegepast op een andere plaats in de code.", "MergeConflictDescription_UD": "Conflict: dit bestand is gewijzigd in de current changes en verwijderd in incoming changes.\n\nDe meest waarschijnlijke oplossing is om dit bestand te verwijderen nadat de current changes wijzigingen handmatig zijn toegepast op een andere plaats in de code.", "MergeConflictIncomingDiff": "Inkomende wijziging:", "MergeConflictCurrentDiff": "Huidige wijzigingen:", @@ -118,18 +122,28 @@ "CloseCancel": "Sluiten", "Confirm": "Bevestig", "Quit": "Afsluiten", + "CannotSquashOrFixupFirstCommit": "Er is geen commit hieronder om in te squashen", "Fixup": "Fixup", "SureSquashThisCommit": "Weet je zeker dat je deze commit wil samenvoegen met de commit hieronder?", "Squash": "Squash", "PickCommitTooltip": "Kies commit (wanneer midden in rebase)", + "Pick": "Pick", + "Edit": "Edit", + "Revert": "Revert", + "RevertCommitTooltip": "Maak een revert commit voor de geselecteerde commit, die de wijzigingen in deze commit terugdraait.", "Reword": "Hernoem commit", + "CommitRewordTooltip": "Herschrijf de commit message van de geselecteerde commit.", "DropCommit": "Verwijder commit", "MoveDownCommit": "Verplaats commit 1 naar beneden", "MoveUpCommit": "Verplaats commit 1 naar boven", "CannotMoveAnyFurther": "Kan niet verder verplaatsen", "CannotMoveMergeCommit": "Kan een merge commit niet verplaatsen", + "EditCommit": "Bewerken (start interactieve rebase)", "EditCommitTooltip": "Wijzig commit", "AmendCommitTooltip": "Wijzig commit met staged veranderingen", + "ResetAuthor": "Reset auteur", + "ResetAuthorTooltip": "Wijzig de commit auteur naar de huidige gebruiker. Dit vernieuwt ook de auteur timestamp", + "SetAuthor": "Auteur instellen", "AddCoAuthor": "Voeg co-auteur toe", "RewordCommitEditor": "Hernoem commit met editor", "NoCommitsThisBranch": "Geen commits in deze branch", @@ -138,6 +152,7 @@ "Undo": "Ongedaan maken", "UndoReflog": "Ongedaan maken (via reflog) (experimenteel)", "RedoReflog": "Redo (via reflog) (experimenteel)", + "RedoTooltip": "Het reflog wordt gebruikt om te bepalen welk git commando moet worden gebruikt om het laatste git commando te herhalen. Wijzigingen aan de working tree worden niet meegenomen, alleen command's zijn kandidaten.", "DiscardAllTooltip": "Verwijder zowel gestagede als niet-gestagede wijzigingen in '{{.path}}'.", "DiscardUnstagedTooltip": "Verwijder niet-gestagede wijzigingen in '{{.path}}'.", "DiscardUnstagedDisabled": "De geselecteerde items hebben geen mix van gestagede en niet-gestagede wijzigingen.", @@ -162,12 +177,12 @@ "CheckForUpdate": "Check voor updates", "CheckingForUpdates": "Zoeken naar updates...", "UpdateAvailableTitle": "Update beschikbaar!", + "UpdateAvailable": "Download en installeer versie {{.newVersion}}?", "FailedToRetrieveLatestVersionErr": "Ophalen versie-informatie mislukt", "OnLatestVersionErr": "Je hebt al de laatste versie", "MajorVersionErr": "Nieuwe versie ({{.newVersion}}) is niet backwards compatibele vergeleken met de huidige versie ({{.currentVersion}})", "CouldNotFindBinaryErr": "Kon geen binary vinden op {{.url}}", "ConfirmQuitDuringUpdate": "Er is een update bezig. Weet je zeker dat je wilt afsluiten?", - "IntroPopupMessage": "\nBedankt voor het gebruik van lazygit! Je bent een kanjer. Deze vier dingen wil ik met je delen:\n\n 1) Als je meer wilt weten over lazygit's features, kijk dan deze video:\n https://youtu.be/CPLdltN7wgE\n\n 2) Lees de laatste release notes hier:\n https://github.com/jesseduffield/lazygit/releases\n\n 3) Als je git gebruikt, dan ben je een programmeur! Met jouw hulp kunnen we\n lazygit beter maken, dus overweeg mee te helpen met coden op\n https://github.com/jesseduffield/lazygit\n Of geef het repo een ster om te laten zien dat je het leuk vindt!\n\n 4) Als lazygit je leven makkelijker heeft gemaakt kan je \"dank je wel\" zeggen door\n op de donatie knop rechtsonder te drukken. Doneren geeft geen recht op voorrang bij ondersteuning\n maar wordt wel zeer gewaardeerd.\n\nDruk op {{confirmationKey}} om te beginnen.\n", "GitconfigParseErr": "Gogit kon je gitconfig bestand niet goed parsen door de aanwezigheid van losstaande '\\' tekens. Het weghalen van deze tekens zou het probleem moeten oplossen. ", "EditFile": "Verander bestand", "EditFileTooltip": "Open bestand in externe editor.", @@ -183,11 +198,16 @@ "UnsupportedGitService": "Niet-ondersteunde git-service", "CopyPullRequestURL": "Kopieer de URL van het pull-verzoek naar het klembord", "NoBranchOnRemote": "Deze branch bestaat niet op de remote. U moet het eerst naar de remote pushen.", + "Fetch": "Fetch", "ExpandAll": "Vouw alle bestanden uit", "ExpandAllTooltip": "Vouw alle mappen in de bestandsstructuur uit", "FileEnter": "Stage individuele hunks/lijnen", "StageSelectionTooltip": "Toggle lijnen staged / unstaged", "DiscardSelection": "Verwijdert change (git reset)", + "ToggleSelectHunk": "Wissel tussen hunk selectie aan of uit", + "SelectHunk": "Selecteer hunks", + "SelectLineByLine": "Selecteer regel-voor-regel", + "ToggleSelectHunkTooltip": "Wissel tussen regel-voor-regel of hunk selectie modus.", "ToggleSelectionForPatch": "Voeg toe/verwijder lijn(en) in patch", "ToggleStagingView": "Ga naar een ander paneel", "ReturnToFilesPanel": "Ga terug naar het bestanden paneel", @@ -254,6 +274,7 @@ "ScrollUpMainWindow": "Scroll naar beneden vanaf hoofdpaneel", "ScrollDownMainWindow": "Scroll naar beneden vanaf hoofdpaneel", "SuspendApp": "Pauzeer de applicatie", + "CannotSuspendApp": "Applicatie pauzeren wordt niet ondersteund op Windows", "AmendCommitTitle": "Commit wijzigen", "AmendCommitPrompt": "Weet je zeker dat je deze commit wil wijzigen met de vorige staged bestanden?", "AmendCommitWithConflictsContinue": "Nee, doorgaan met rebase", @@ -279,6 +300,7 @@ "DiscardOldFileChangeTooltip": "Uitsluit deze commit zijn veranderingen aan dit bestand", "DiscardFileChangesTitle": "Uitsluit bestand zijn veranderingen", "CreateRepo": "Niet in een git repository. Maak een nieuwe git repository? (y/N): ", + "AutoStashTitle": "Autostash?", "AutoStashPrompt": "Je moet je veranderingen stashen en poppen om ze over te brengen. Dit automatisch doen? (enter/esc)", "Discard": "Bekijk 'veranderingen ongedaan maken' opties", "Cancel": "Annuleren", @@ -294,6 +316,7 @@ "CreateFixupCommit": "Creëer fixup commit", "CreateFixupCommitTooltip": "Creëer fixup commit", "SquashAboveCommitsTooltip": "Squash bovenstaande commits", + "ExecuteShellCommand": "Voer shellcommando uit", "CommitChangesWithoutHook": "Commit veranderingen zonder pre-commit hook", "ResetTo": "Reset naar", "PressEnterToReturn": "Press om terug te gaan naar lazygit", @@ -329,10 +352,30 @@ "DivergenceSectionHeaderRemote": "Remote", "SetUpstreamTitle": "Stel in als upstream branch", "EditRemoteTooltip": "Wijzig remote", + "TagCommitTooltip": "Maak een nieuwe tag die naar de geselecteerde commit wijst. Je wordt gevraagd om een tag naam en optionele omschrijving.", "TagNameTitle": "Tag naam:", + "TagMessageTitle": "Tag omschrijving", + "LightweightTag": "Lichtgewicht tag", + "AnnotatedTag": "Geannoteerde tag", + "DeleteTagTitle": "Verwijder tag '{{.tagName}}'?", + "DeleteLocalTag": "Verwijder lokale tag", + "DeleteRemoteTag": "Verwijder remote tag", + "DeleteLocalAndRemoteTag": "Verwijder locale en remote tag", + "SelectRemoteTagUpstream": "Remote van waar de tag '{{.tagName}}' verwijderd moet worden:", + "DeleteRemoteTagPrompt": "Weet je zeker dat je de remote tag '{{.tagName}}' wilt verwijderen uit '{{.upstream}}'?", + "DeleteLocalAndRemoteTagPrompt": "Weet je zeker dat je {{.tagName}} zowel lokaal als in {{.upstream}}' wilt verwijderen?", + "RemoteTagDeletedMessage": "Remote tag verwijderd", "PushTagTitle": "Remote om tag '{{.tagName}}' te pushen naar:", + "PushTag": "Tag pushen", + "PushTagTooltip": "Push de geselecteerde tag naar een remote. Je krijgt de optie een remote te selecteren.", "NewTag": "Creëer tag", + "NewTagTooltip": "Maak een nieuwe tag die naar de huidige commit wijst. Je wordt gevraagd om een tag naam en optionele beschrijving.", + "CreatingTag": "Tag wordt gemaakt", + "ForceTag": "Forceer Tag", + "ForceTagPrompt": "De tag '{{.tagName}}' bestaat al. Druk op {{.cancelKey}} om te annuleren, of op {{.confirmKey}} om te overschrijven.", "FetchRemoteTooltip": "Fetch remote", + "CheckoutCommitTooltip": "Check de geselecteerde branch uit als een detached HEAD.", + "NoBranchesFoundAtCommitTooltip": "Geen branches gevonden bij de geselecteerde commit.", "GitFlowOptions": "Laat git-flow opties zien", "NotAGitFlowBranch": "Dit lijkt geen git flow branch te zijn", "NewBranchNamePrompt": "Noem een nieuwe branch naam", @@ -376,6 +419,8 @@ "DiffingMenuTitle": "Diffen", "SwapDiff": "Keer diff richting om", "ViewDiffingOptions": "Open diff menu", + "OpenCommandLogMenu": "Commandolog opties weergeven", + "OpenCommandLogMenuTooltip": "Bekijk commandolog opties, bijv. commandolog tonen/verbergen en focus.", "ShowingGitDiff": "Laat output zien voor:", "CopyBranchNameToClipboard": "Kopieer branch name naar klembord", "CopyPathToClipboard": "Kopieer de bestandsnaam naar het klembord", @@ -386,31 +431,93 @@ "BranchNotFoundPrompt": "Branch niet gevonden. Creëer een nieuwe branch genaamd", "CreateNewBranchFromCommit": "Creëer nieuwe branch van commit", "ViewCommits": "Bekijk commits", + "RunningCustomCommandStatus": "Aangepast commando uitvoeren", "EnterSubmoduleTooltip": "Enter submodule", "CopySubmoduleNameToClipboard": "Kopieer submodule naam naar klembord", "NewSubmodule": "Voeg nieuwe submodule toe", "InitSubmoduleTooltip": "Initialiseer submodule", "ViewBulkSubmoduleOptions": "Bekijk bulk submodule opties", "NavigationTitle": "Lijstpaneel navigatie", + "ExtrasTitle": "Commandolog", "PullRequestURLCopiedToClipboard": "Pull-aanvraag-URL gekopieerd naar klembord", "CommitMessageCopiedToClipboard": "Commit message gekopieerd naar klembord", + "PatchCopiedToClipboard": "Patch gekopieerd naar klembord", + "MessageCopiedToClipboard": "Bericht gekopieerd naar klembord", "CopiedToClipboard": "gekopieerd naar klembord", + "ErrRepositoryMovedOrDeleted": "Kan repo niet vinden. Misschien is het verplaatst of verwijderd ¯\\_(ツ)_/¯", + "ErrWorktreeMovedOrRemoved": "Kan worktree niet vinden. Misschien is deze verplaatst of verwijderd ¯\\_(ツ)_/¯", + "CommandLog": "Commandolog", + "ToggleShowCommandLog": "Toon/verberg commando log", + "FocusCommandLog": "Focus commandolog", + "CommandLogHeader": "Je kunt dit paneel verbergen/focussen door op '%s'\n te drukken\n", + "RandomTip": "Willekeurige tip", + "ToggleWhitespaceInDiffView": "Witruimte weergeven in-/uitschakelen", "CreatePullRequestOptions": "Bekijk opties voor pull-aanvraag", + "DefaultBranch": "Standaard branch", + "SelectBranch": "Selecteer branch", + "SelectTargetRemote": "Selecteer target remote", + "NoValidRemoteName": "Een remote met naam '%s' bestaat niet", "CreatePullRequest": "Maak een pull-request", + "SelectConfigFile": "Selecteer configuratiefile", + "NoConfigFileFoundErr": "Configuratiefile niet gevonden", + "GitOutput": "Git output:", + "GitCommandFailed": "Git commando mislukt. Controleer commandolog voor details (open met %s)", + "OpenLogMenu": "Log opties weergeven", + "SortAlphabetical": "Alfabetisch", "ConfirmRevertCommit": "Weet u zeker dat u {{.selectedCommit}} ongedaan wilt maken?", + "SwitchToWorktree": "Overschakelen naar worktree", + "RemoveWorktree": "Worktree verwijderen", + "RemoveWorktreeTitle": "Worktree verwijderen", + "RemoveWorktreeMenuTitle": "Verwijder worktree '{{.worktreeName}}'?", + "RemoveWorktreeAndDeleteBranch": "Worktree en branch verwijderen", + "RemoveWorktreeAndDeleteBothBranches": "Worktree, lokale branch en remote branch verwijderen", + "WorktreeNotCheckedOutOnBranch": "Deze worktree kan niet worden uitgecheckt op een branch", + "WorktreesTitle": "Worktrees", + "WorktreeTitle": "Worktree", + "RemovingWorktree": "Worktree wordt verwijderd", + "AddingWorktree": "Worktree wordt toegevoegd", + "CantDeleteCurrentWorktree": "Je kan de huidige worktree niet verwijderen!", + "AlreadyInWorktree": "Je bent al in de geselecteerde worktree", + "CantDeleteMainWorktree": "Je kan de hoofdworktree niet verwijderen!", + "NoWorktreesThisRepo": "Geen worktrees", + "MissingWorktree": "(ontbreekt)", + "WorktreeLocationPromptCheckout": "Worktree voor branch '{{.branchName}}':", + "LcWorktree": "worktree", + "Name": "Naam", + "Branch": "Branch", + "Path": "Pad", + "MarkedBaseCommitStatus": "Gemarkeerd als basiscommit voor rebase", + "MarkAsBaseCommit": "Markeer als basiscommit voor rebase", + "MarkAsBaseCommitTooltip": "Selecteer een basiscommit voor de volgende rebase. Als je rebased op een branch worden alleen commits boven de basiscommit meegenomen. Hiervoor wordt het `git rebase --onto` commando gebruikt.", + "CancelMarkedBaseCommit": "Annuleer de gemarkeerde basiscommit", + "MarkedCommitMarker": "↑↑↑ Hier wordt de rebase gedaan ↑↑↑", + "FailedToOpenURL": "Fout bij het openen van URL %s\n\nError: %v", + "InvalidLazygitEditURL": "Ongeldige lazygit-edit URL-formaat: %s", + "NoCopiedCommits": "Geen gekopieerde commits", + "DisabledMenuItemPrefix": "Uitgeschakeld: ", + "QuickStartInteractiveRebase": "Start interactieve rebase", "ToggleRangeSelect": "Toggle drag selecteer", + "CustomCommands": "Aangepaste commando's", + "NoApplicableCommandsInThisContext": "(Geen toepasselijke commando's in deze context)", "Actions": { "CopyCommitAuthorToClipboard": "Kopieer commit auteur naar klembord", "CopyCommitAttributeToClipboard": "Kopieer naar klembord", "CopyCommitTagsToClipboard": "Kopieer commit tags naar klembord", "CopyPatchToClipboard": "Kopieer patch naar klembord", + "CustomCommand": "Aangepast commando", "Commit": "Commit", "Push": "Push", "Pull": "Pull", "OpenFile": "Open bestand", "CopyToClipboard": "Kopieer naar klembord", "CopySelectedTextToClipboard": "Kopieer geselecteerde tekst naar klembord", + "DeleteRemoteBranch": "Verwijder remote branch", + "SetBranchUpstream": "Stel upstream branch in", "AddRemote": "Voeg remote toe", + "RemoveRemote": "Verwijder remote", + "UpdateRemote": "Update remote", + "ApplyPatch": "Pas patch toe", + "Stash": "Stash", "RemoveSubmodule": "Verwijder submodule", "ResetSubmodule": "Reset submodule", "AddSubmodule": "Voeg submodule toe", @@ -441,7 +548,10 @@ "PoppingStash": "Pop stash %s", "DeletingBranch": "Verwijder branch '{{.branchName}}' (was {{.hash}})" }, - "BreakingChangesByVersion": {}, + "BreakingChangesMessage": "Je bent aan het updaten naar een nieuwe versie van lazygit waar incompatibele wijzigingen in zitten. Bekijk de onderstaande notities en update je configuratie indien nodig.\nVoor meer informatie, zie de volledige release notes op .", + "BreakingChangesByVersion": { + "0.41.0": "- Als je op 'g' drukt om het git reset menu te openen, is de 'mixed' optie nu de eerste en standaard optie, in plaats van 'soft'. Dit is om dat 'mixed' de meest gebruikte optie is.\n- Het commit message paneel doet nu automatisch aan zinsafbreking (voegt een nieuwe regel toe als de kantlijn bereikt is). Dit kan je aanpassen in de config met:\n\ngit:\n commit:\n autoWrapCommitMessage: true\n autoWrapWidth: 72\n\n- De 'v' knop was al gebruik om in de stagingweergave een range te selecteren, maar nu kun je die ook gebruiken om een range te selecteren vanuit andere weergaven. Jammergenoeg conflicteert dit met de 'v' keybinding voor het plakken van commits (cherry-pick), dus wordt dat nu gedaan met 'shift+V' en voor de consistentie, gaat kopieren met 'shift-C' in plaats van 'c'. Let op dat de 'v' keybinding niet de enige manier is om een range selectie te starten: je kan ook shift+pijltje omhoog/naar beneden gebruiken. Dus als je de cherry-pick keybindings volgens het oude gedrag wilt configureren, zet dan het volgende in je config:\n\nkeybinding:\n universal:\n toggleRangeSelect: \n commits:\n cherryPickCopy: 'c'\n pasteCommits: 'v'\n\n- Squashen van fixups met 'shift-S' opent nu een menu, met de standaard optie om alle fixup commits in de branch te squashen. Het originele gedrag, waarbij alleen de commits boven de geselecteerde commit werden gesquast is nog steeds te kiezen als tweede optie in dat menu.\n- Push/pull/fetch activiteitsstatus wordt nu weergegeven bij de branch en niet meer in een popup. Hierdoor kan je meerdere branches tegelijkertijd fetchen en de status hiervan bekijken.\n- De git log graph in de commit weergave is nu standaard altijd zichtbaar (voorheen was het alleen zichtbaar in gemaximaliseerde weergave). Als je dit te druk vindt, kan je het terug veranderen via ctrl+L -> 'Geef git graph weer' -> 'Wanneer gemaximaliseerd'\n- Op de spatiebalk drukken wanneer een remote branch geselecteerd is gaf eerst een dialoogvenster voor het invullen van een naam voor de nieuwe locale branch behorend bij de checkout van deze remote branch. In plaats daarvan wordt de remote branch nu meteen uitgecheckt, met de keuze voor een nieuwe locale branch met dezelfde naam, of een detatched head. Het oude gedrag is nog steeds beschikbaar via de 'n' keybinding.\n- Fliteren (bijv. als je op '/' drukt) is standaard minder fuzzy; alleen stukken van woorden of substrings worden nu gematched. Zoeken op meerdere substrings kan door ze te scheiden met spaties. Als je het oude gedrag wilt, stel dan dit in in je config:\n\ngui:\n filterMode: 'fuzzy'\n" + }, "ViewMergeConflictOptions": "Bekijk merge conflict opties", "ViewMergeConflictOptionsTooltip": "Bekijk opties voor het oplossen van mergeconflicten.", "NoFilesWithMergeConflicts": "Er zijn geen files met mergeconflicten.", diff --git a/pkg/i18n/translations/pt.json b/pkg/i18n/translations/pt.json index 640ce2d65..499e45e8b 100644 --- a/pkg/i18n/translations/pt.json +++ b/pkg/i18n/translations/pt.json @@ -226,7 +226,6 @@ "UpdateFailedErr": "Falha na atualização: {{.errMessage}}", "ConfirmQuitDuringUpdateTitle": "Atualmente atualizando", "ConfirmQuitDuringUpdate": "Uma atualização está em andamento. Tem certeza que deseja sair?", - "IntroPopupMessage": "\nObrigado por usar o lazygit! Sério, você é demais. Três coisas que queremos compartilhar com você:\n\n 1) Se quiser aprender sobre os recursos do lazygit, assista a este vídeo:\n https://youtu.be/CPLdltN7wgE\n\n 2) Não deixe de ler as últimas notas de lançamento em:\n https://github. um/jesseduffield/lazygit/releases\n\n 3) Se você estiver usando um git, isso o torna um programador! Com a sua ajuda, podemos tornar o\n lazygit melhor, então considere se tornar um contribuidor e se junte à diversão no\n https://github.com/jesseduffield/lazygit\n Ou apenas favoritar o repositório para espalhar o amor. \n\n4) Se o lazygit fez a sua vida mais fácil, você pode agradecer ao clicar no\n botão de doação no canto inferior direito. Doações não garantem prioridade no suporte, mas são muito bem vindas. \n\nPressione {{confirmationKey}} para continuar.\n", "NonReloadableConfigWarningTitle": "Configuração alterada", "NonReloadableConfigWarning": "As seguintes configurações foram alteradas, mas a mudança não tem efeito imediatamente. Encerre e reinicie o lazygit para que as mudanças tenham efeito:\n\n{{configs}}", "GitconfigParseErr": "Gogit falhou ao analisar seu arquivo gitconfig devido à presença de caracteres '\\' não citados. Removendo-os deve corrigir o problema.", diff --git a/pkg/i18n/translations/zh-CN.json b/pkg/i18n/translations/zh-CN.json index 6799fd0ae..3ae7d4e65 100644 --- a/pkg/i18n/translations/zh-CN.json +++ b/pkg/i18n/translations/zh-CN.json @@ -239,7 +239,6 @@ "UpdateFailedErr": "更新失败: {{.errMessage}}", "ConfirmQuitDuringUpdateTitle": "当前正在更新中...", "ConfirmQuitDuringUpdate": "当前正在更新中,您确定要退出吗?", - "IntroPopupMessage": "\n感谢使用 lazygit!您真是太棒了。有三件事想与您分享:\n\n 1) 如果您想了解 lazygit 的功能,请观看此视频:\n https://youtu.be/CPLdltN7wgE\n\n 2) 请务必阅读最新的发布说明:\n https://github.com/jesseduffield/lazygit/releases\n\n 3) 如果您在使用 git,那您就是程序员!在您的帮助下,我们可以让\n lazygit 变得更好,所以考虑成为贡献者,加入我们的乐趣吧:\n https://github.com/jesseduffield/lazygit\n 或者仅仅给仓库点个星,分享这份喜爱!\n\n 4) 如果 lazygit 让您的生活更轻松,您可以通过点击\n 右下角的捐赠按钮来表达感谢。捐赠不会获得优先支持,\n 但我们非常感激。\n\n按 {{confirmationKey}} 键开始。\n", "NonReloadableConfigWarningTitle": "配置已更改", "NonReloadableConfigWarning": "以下配置设置已更改,但更改不会立即生效。请退出并重新启动lazygit以使更改生效:\n\n{{configs}}", "GitconfigParseErr": "由于存在未加引号的'\\'字符,因此 Gogit 无法解析您的 gitconfig 文件。删除它们应该可以解决问题。", @@ -591,9 +590,6 @@ "ViewResetToUpstreamOptions": "查看上游重置选项", "NextScreenMode": "下一屏模式(正常/半屏/全屏)", "PrevScreenMode": "上一屏模式", - "CyclePagers": "切换分页器", - "CyclePagersTooltip": "从已配置的分页器列表中选择下一个分页器", - "CyclePagersDisabledReason": "未配置其他分页器", "StartSearch": "开始搜索", "StartFilter": "通过文本过滤当前视图", "SelectRemoteRepository": "为拉取请求选择基础仓库", @@ -1060,7 +1056,6 @@ "0.50.0": "- 拉取后,如果主分支落后于其上游分支,现在会自动前推。这对于自动保持主分支或 master 分支最新很有用。如果不希望这样,可以通过在配置中设置以下内容来禁用它:\n\ngit:\n autoForwardBranches: none\n\n相反,如果希望功能分支也这样做,可以将其设置为 'allBranches'。", "0.51.0": "- 自定义命令的 'subprocess'、'stream' 和 'showOutput' 字段已被替换为单个 'output' 字段。这应该是透明的,如果您在配置文件中使用了这些字段,它们应该已自动更新。但有一个显著变化:'stream' 字段过去意味着命令输出将流式传输到命令日志,并且命令将在伪终端 (pty) 中运行。我们将其转换为 'output: log',这意味着命令输出将流式传输到命令日志,但不使用 pty,假设这是大多数人想要的。如果您确实希望在 pty 中运行命令,可以将其更改为 'output: logWithPty'。", "0.54.0": "- 本地和远程分支的默认排序顺序已更改:过去本地分支是 'recency'(基于 reflog),远程分支是 'alphabetical'。这两者都已更改为 'date'(即提交者日期)。如果您更喜欢旧的默认设置,可以通过以下配置恢复:\n\ngit:\n localBranchSortOrder: recency\n remoteBranchSortOrder: alphabetical\n\n- 暂存区和自定义补丁构建视图中的默认选择模式已更改为块模式。在大多数情况下,这是更有用的模式,因为它通常可以节省大量按键。如果想切换回旧的行模式默认设置,可以通过在配置中添加以下内容来实现:\n\ngui:\n useHunkModeInStagingView: false\n", - "0.55.0": "- 原先绑定到 ctrl-z 的 'redo' 命令,现在改为绑定到 shift-Z。这是因为 ctrl-z 现在用于挂起应用程序;在 Linux 世界中,这是该功能的常用键绑定。如果你想恢复此更改,可以在配置中添加以下内容:\n\nkeybinding:\n universal:\n suspendApp: \n redo: \n\n- 'git.paging.useConfig' 选项已被移除。如果你之前依赖它来配置你的分页器,现在必须使用 'git.paging.pager' 选项重新明确设置分页器。", "0.62.0": "从提交描述编辑器提交变更的默认快捷键已从 Mac 上的 alt-enter 改为 command-enter,在 Linux 和 Windows 上改为 ctrl-enter;这些和很多多行编辑框里用的快捷键一样,比如 GitHub 评论里用的那种。很遗憾,并非所有终端都支持这些快捷键;更多说明见:https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md#terminal-compatibility。如果你想恢复这个改动,可以在配置里加上:\n\nkeybinding:\n universal:\n confirmInEditor: \n" }, "ViewMergeConflictOptions": "查看合并冲突选项", From a1a542479cc13a0acfdda0dec5efd1531cedf314 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 4 Aug 2026 09:19:40 +0200 Subject: [PATCH 58/97] Update docs and schema for release --- docs/Config.md | 61 ++++++++------- docs/Custom_DiffRenderers.md | 84 ++++++++++++++++++++ docs/Custom_Pagers.md | 108 -------------------------- docs/README.md | 2 +- docs/keybindings/Keybindings_en.md | 4 +- docs/keybindings/Keybindings_ja.md | 4 +- docs/keybindings/Keybindings_ko.md | 4 +- docs/keybindings/Keybindings_nl.md | 40 +++++----- docs/keybindings/Keybindings_pl.md | 4 +- docs/keybindings/Keybindings_pt.md | 4 +- docs/keybindings/Keybindings_ru.md | 4 +- docs/keybindings/Keybindings_zh-CN.md | 4 +- docs/keybindings/Keybindings_zh-TW.md | 4 +- schema/config.json | 103 +++++++++++++----------- 14 files changed, 208 insertions(+), 222 deletions(-) create mode 100644 docs/Custom_DiffRenderers.md delete mode 100644 docs/Custom_Pagers.md diff --git a/docs/Config.md b/docs/Config.md index 1d101be18..857a4e359 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -66,8 +66,8 @@ gui: # The number of spaces per tab; used for everything that's shown in the main # view, but probably mostly relevant for diffs. - # Note that when using a pager, the pager has its own tab width setting, so you - # need to pass it separately in the pager command. + # Note that when using a diff renderer, the renderer has its own tab width + # setting, so you need to pass it separately in the renderer command. tabWidth: 4 # If true, capture mouse events. @@ -336,13 +336,13 @@ gui: spinner: # The frames of the spinner animation. frames: - - '|' - - / - - '-' - - \ + - ●∙∙ + - ∙●∙ + - ∙∙● + - ∙●∙ # The "speed" of the spinner in milliseconds. - rate: 50 + rate: 180 # Status panel view. # One of 'dashboard' (default) | 'allBranchesLog' @@ -360,38 +360,39 @@ gui: # Config relating to git git: - # Array of pagers. Each entry has the following format: + # Array of diff renderers. Each entry has the following format: # - # # A name for the pager, shown in the notification when cycling pagers. - # # If not set, the name is derived from the first word of the pager - # # command (or of the external diff command). + # # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff' + # # | 'rawGit' + # type: "stdinFilter" + # + # # A name for the diff renderer, shown in the notification when cycling + # # renderers. If not set, the name is derived from the first word of the + # # renderer command. # name: "" # - # # Value of the --color arg in the git diff command. Some pagers want - # # this to be set to 'always' and some want it set to 'never' + # # Value of the --color arg in the git diff command. Only used for type + # # 'stdinFilter'. Some renderers want this to be set to 'always' and some + # # want it set to 'never'. # colorArg: "always" # + # # The command to use for rendering diffs. This is either a stdinFilter or + # # an external diff command, depending on the type field; not applicable if + # # the type is 'rawGit'. # # e.g. # # diff-so-fancy # # delta --dark --paging=never - # # ydiff -p cat -s --wrap --width={{columnWidth}} - # pager: "" + # # ydiff -p cat + # # difft --color=always + # command: "" # - # # e.g. 'difft --color=always' - # externalDiffCommand: "" + # # Extra arguments (array of strings) passed to the git command. Only + # # applicable if the type is 'rawGit'. + # args: [] # - # # If true, Lazygit will use git's `diff.external` config for paging. - # # The advantage over `externalDiffCommand` is that this can be - # # configured per file type in .gitattributes; see - # # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver. - # useExternalDiffGitConfig: false - # - # 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually - # exclusive; set at most one per entry. - # - # See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md + # See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md # for more information. - pagers: [] + diffRenderers: [] # Config relating to committing commit: @@ -714,8 +715,8 @@ keybinding: prevTab: '[' nextScreenMode: + prevScreenMode: _ - cyclePagers: '|' - cyclePagersReverse: \ + cycleDiffRenderers: '|' + cycleDiffRenderersReverse: \ undo: z redo: Z filteringMenu: diff --git a/docs/Custom_DiffRenderers.md b/docs/Custom_DiffRenderers.md new file mode 100644 index 000000000..1a6b1d5ce --- /dev/null +++ b/docs/Custom_DiffRenderers.md @@ -0,0 +1,84 @@ +# Custom Diff Renderers + +Custom diff renderers are useful for showing a better rendering of a diff than git's builtin raw diff, and using one is strongly recommended (I personally prefer delta myself, but that's a matter of personal preference). There are three types of diff renderers that lazygit supports: + +- **stdin filters**, e.g. [delta](#delta) and [diff-so-fancy](#diff-so-fancy). They take git's raw output as stdin and produce something nicer on stdout, and they are hooked up using git's GIT_PAGER mechanism. (These used to be called "custom pagers" in earlier lazygit versions.) +- **external diff programs**, e.g. difftastic; these are called using git's `--ext-diff` flag, and they take over diff generation from git completely rather than post-processing git's output. +- **git's raw output using custom arguments**; mainly useful for `--color-words` (or `--word-diff` if you are color blind). + +Diff renderers are configured with the `diffRenderers` array in the `git` section of lazygit's config file; it is an array because you can have multiple entries that you can cycle through with the `|` key. This can be useful if you usually prefer a particular diff renderer, but want to use a different one for certain kinds of diffs. + +Fields that are shared by all renderer types: + +- **type** The type of diff renderer; choices are `stdinFilter`, `extDiff`, or `rawGit`. `stdinFilter` is the default, because it's the most common one; so you can omit this if you use delta. +- **name** A name that is shown in the status bar toast when cycling renderers; defaults to the first word of the renderer command, but can be useful e.g. to distinguish "delta" from "delta side-by-side" if you have entries for both. + +Fields only for `stdinFilter`: + +- **command** The command line to use for `GIT_PAGER`. + +- **colorArg** whether you want the `--color=always` arg in your `git diff` command. Some diff renderers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most renderers need. + +Fields only for `extDiff`: + +- **command** The command line to use for the `diff.external` git config. If left empty, it uses the global value of git's `diff.external` config; this can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver. + + You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool. + +Fields only for `rawGit`: + +- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`) + +Here's an example for a multi-renderer setup: + +```yaml +git: + diffRenderers: + - command: delta --dark --paging=never + - command: ydiff -p cat + colorArg: never + - type: extDiff + command: difft --color=always --context={{diffContext}} + - type: rawGit + args: --color-words + name: color-words + - type: rawGit # git's default diff + name: default +``` + +## Delta: + +```yaml +git: + diffRenderers: + - command: delta --dark --paging=never +``` + +![](https://i.imgur.com/QJpQkF3.png) + +A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor. + +Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons. + +## Diff-so-fancy + +```yaml +git: + diffRenderers: + - command: diff-so-fancy +``` + +![](https://i.imgur.com/rjH1TpT.png) + +## ydiff + +```yaml +gui: + sidePanelWidth: 0.2 # gives you more space to show things side-by-side +git: + diffRenderers: + - colorArg: never + command: ydiff -p cat +``` + +![](https://i.imgur.com/vaa8z0H.png) diff --git a/docs/Custom_Pagers.md b/docs/Custom_Pagers.md deleted file mode 100644 index f74005c19..000000000 --- a/docs/Custom_Pagers.md +++ /dev/null @@ -1,108 +0,0 @@ -# Custom Pagers - -Lazygit supports custom pagers, [configured](/docs/Config.md) in the config.yml file (which can be opened by pressing `e` in the Status panel). - -Multiple pagers are supported; you can cycle through them with the `|` key. This can be useful if you usually prefer a particular pager, but want to use a different one for certain kinds of diffs. - -Pagers are configured with the `pagers` array in the git section; here's an example for a multi-pager setup (use an empty object `{}` for the default builtin diff display that doesn't use a pager): - -```yaml -git: - pagers: - - pager: delta --dark --paging=never - - pager: ydiff -p cat -s --wrap --width={{columnWidth}} - colorArg: never - - externalDiffCommand: difft --color=always - - {} # default, no pager used -``` - -The `colorArg` key is for whether you want the `--color=always` arg in your `git diff` command. Some pagers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most pagers need. - -## Delta: - -```yaml -git: - pagers: - - pager: delta --dark --paging=never -``` - -![](https://i.imgur.com/QJpQkF3.png) - -A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `pager:` config to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor. - -Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons. - -## Diff-so-fancy - -```yaml -git: - pagers: - - pager: diff-so-fancy -``` - -![](https://i.imgur.com/rjH1TpT.png) - -## ydiff - -```yaml -gui: - sidePanelWidth: 0.2 # gives you more space to show things side-by-side -git: - pagers: - - colorArg: never - pager: ydiff -p cat -s --wrap --width={{columnWidth}} -``` - -![](https://i.imgur.com/vaa8z0H.png) - -Be careful with this one, I think the homebrew and pip versions are behind master. I needed to directly download the ydiff script to get the no-pager functionality working. - -## Using external diff commands - -Some diff tools can't work as a simple pager like the ones above do, because they need access to the entire diff, so just post-processing git's diff is not enough for them. The most notable example is probably [difftastic](https://difftastic.wilfred.me.uk). - -These can be used in lazygit by using the `externalDiffCommand` config; in the case of difftastic, that could be - -```yaml -git: - pagers: - - externalDiffCommand: difft --color=always -``` - -The `colorArg` option is not used in this case. - -You can add whatever extra arguments you prefer for your difftool; for instance - -```yaml -git: - pagers: - - externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off -``` - -This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`: - -```sh -#!/bin/sh - -git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5" -``` - -And then use it in your git config like so: - -```yaml -git: - pagers: - - externalDiffCommand: ~/bin/color-words.sh -``` - -Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using - -```yaml -git: - pagers: - - useExternalDiffGitConfig: true -``` - -This can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver. - -`pager`, `externalDiffCommand`, and `useExternalDiffGitConfig` are alternative ways of producing the diff, so a pager entry may use at most one of them. diff --git a/docs/README.md b/docs/README.md index 1bc0bb6be..c586d9699 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ * [Configuration](./Config.md). * [Custom Commands](./Custom_Command_Keybindings.md) -* [Custom Pagers](./Custom_Pagers.md) +* [Custom Diff Renderers](./Custom_DiffRenderers.md) * [Dev docs](./dev) * [Keybindings](./keybindings) * [Undo/Redo](./Undoing.md) diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index 4aa202740..3ec731bf2 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | Refresh | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | Next screen mode (normal/half/fullscreen) | | | `` _ `` | Prev screen mode | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | Cancel | | | `` ? `` | Open keybindings menu | | | `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md index 5b9c798a1..6a3d9b5c1 100644 --- a/docs/keybindings/Keybindings_ja.md +++ b/docs/keybindings/Keybindings_ja.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | 更新 | Gitの状態を更新します(`git status`、`git branch`などをバックグラウンドで実行してパネルの内容を更新します)。これは`git fetch`を実行しません。 | | `` + `` | 次の画面モード(通常/半分/全画面) | | | `` _ `` | 前の画面モード | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | キャンセル | | | `` ? `` | キーバインディングメニューを開く | | | `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md index d1eb3afb1..a0e5d84dc 100644 --- a/docs/keybindings/Keybindings_ko.md +++ b/docs/keybindings/Keybindings_ko.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | 새로고침 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | 다음 스크린 모드 (normal/half/fullscreen) | | | `` _ `` | 이전 스크린 모드 | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | 취소 | | | `` ? `` | 매뉴 열기 | | | `` `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md index 0eb61729b..7d95e72dd 100644 --- a/docs/keybindings/Keybindings_nl.md +++ b/docs/keybindings/Keybindings_nl.md @@ -9,31 +9,31 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Wissel naar een recente repo | | | `` , K, (fn+up/shift+k) `` | Scroll naar beneden vanaf hoofdpaneel | | | `` , J, (fn+down/shift+j) `` | Scroll naar beneden vanaf hoofdpaneel | | -| `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. | +| `` @ `` | Commandolog opties weergeven | Bekijk commandolog opties, bijv. commandolog tonen/verbergen en focus. | | `` P `` | Push | Push de huidige branch naar de bijbehorende upstream-branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. | | `` p `` | Pull | Pull wijzigingen van de remote voor de huidige branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. | | `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.

The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. | | `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.

The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. | | `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view.

The default can be changed in the config file with the key 'git.diffContextSize'. | | `` { `` | Decrease diff context size | Decrease the amount of the context shown around changes in the diff view.

The default can be changed in the config file with the key 'git.diffContextSize'. | -| `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. | +| `` : `` | Voer shellcommando uit | Bring up a prompt where you can enter a shell command to execute. | | `` `` | Bekijk aangepaste patch opties | | | `` m `` | Bekijk merge/rebase opties | Toon abort/continue/skip opties voor huidige merge/rebase. | | `` R `` | Verversen | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | Volgende scherm modus (normaal/half/groot) | | | `` _ `` | Vorige scherm modus | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | Annuleren | | | `` ? `` | Open menu | | | `` `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W, `` | Open diff menu | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q, `` | Afsluiten | | | `` `` | Pauzeer de applicatie | | -| `` `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.

The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. | +| `` `` | Witruimte weergeven in-/uitschakelen | Toggle whether or not whitespace changes are shown in the diff view.

The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. | | `` `` | Verander config bestand | Open bestand in externe editor. | | `` z `` | Ongedaan maken (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | -| `` Z `` | Redo (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. | +| `` Z `` | Redo (via reflog) (experimenteel) | Het reflog wordt gebruikt om te bepalen welk git commando moet worden gebruikt om het laatste git commando te herhalen. Wijzigingen aan de working tree worden niet meegenomen, alleen command's zijn kandidaten. | ## Lijstpaneel navigatie @@ -161,25 +161,25 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` s `` | Squash | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. | | `` f `` | Fixup | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. | | `` c `` | Set fixup message | Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message. | -| `` r `` | Hernoem commit | Reword the selected commit's message. | +| `` r `` | Hernoem commit | Herschrijf de commit message van de geselecteerde commit. | | `` R `` | Hernoem commit met editor | | | `` d `` | Verwijder commit | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. | -| `` e `` | Edit (start interactive rebase) | Wijzig commit | -| `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.
If you would instead like to start an interactive rebase from the selected commit, press `e`. | +| `` e `` | Bewerken (start interactieve rebase) | Wijzig commit | +| `` i `` | Start interactieve rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.
If you would instead like to start an interactive rebase from the selected commit, press `e`. | | `` p `` | Pick | Kies commit (wanneer midden in rebase) | | `` F `` | Creëer fixup commit | Creëer fixup commit | | `` S `` | Apply fixup commits | Squash bovenstaande commits | | `` , `` | Verplaats commit 1 naar beneden | | | `` , `` | Verplaats commit 1 naar boven | | | `` V `` | Plak commits (cherry-pick) | | -| `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | +| `` B `` | Markeer als basiscommit voor rebase | Selecteer een basiscommit voor de volgende rebase. Als je rebased op een branch worden alleen commits boven de basiscommit meegenomen. Hiervoor wordt het `git rebase --onto` commando gebruikt. | | `` A `` | Amend | Wijzig commit met staged veranderingen | | `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. | -| `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | -| `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | -| `` `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | +| `` t `` | Revert | Maak een revert commit voor de geselecteerde commit, die de wijzigingen in deze commit terugdraait. | +| `` T `` | Tag commit | Maak een nieuwe tag die naar de geselecteerde commit wijst. Je wordt gevraagd om een tag naam en optionele omschrijving. | +| `` `` | Log opties weergeven | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | | `` G `` | Open pull request in browser | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | @@ -241,7 +241,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , h `` | Selecteer de vorige hunk | | | `` , l `` | Selecteer de volgende hunk | | | `` v `` | Toggle drag selecteer | | -| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. | | `` `` | Copy selected text to clipboard | | | `` o `` | Open bestand | Open bestand in standaardapplicatie. | | `` e `` | Verander bestand | Open bestand in externe editor. | @@ -255,7 +255,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | @@ -316,7 +316,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , h `` | Selecteer de vorige hunk | | | `` , l `` | Selecteer de volgende hunk | | | `` v `` | Toggle drag selecteer | | -| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | +| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. | | `` `` | Copy selected text to clipboard | | | `` `` | Toggle staged | Toggle lijnen staged / unstaged | | `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | @@ -361,7 +361,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | @@ -396,10 +396,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | Uitchecken | Geselecteerde tag uitchecken als detached HEAD. | -| `` n `` | Creëer tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | +| `` n `` | Creëer tag | Maak een nieuwe tag die naar de huidige commit wijst. Je wordt gevraagd om een tag naam en optionele beschrijving. | | `` w `` | New worktree | | | `` d `` | Verwijderen | View delete options for local/remote tag. | -| `` P `` | Push tag | Push the selected tag to a remote. You'll be prompted to select a remote. | +| `` P `` | Tag pushen | Push de geselecteerde tag naar een remote. Je krijgt de optie een remote te selecteren. | | `` g `` | Resetten | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open externe diff applicatie (git difftool) | | | `` 0 `` | Focus main view | | diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md index 719a7e6c4..ba46f7c46 100644 --- a/docs/keybindings/Keybindings_pl.md +++ b/docs/keybindings/Keybindings_pl.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | Odśwież | Odśwież stan git (tj. uruchom `git status`, `git branch`, itp. w tle, aby zaktualizować zawartość paneli). To nie uruchamia `git fetch`. | | `` + `` | Następny tryb ekranu (normalny/półpełny/pełnoekranowy) | | | `` _ `` | Poprzedni tryb ekranu | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | Anuluj | | | `` ? `` | Otwórz menu przypisań klawiszy | | | `` `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. | diff --git a/docs/keybindings/Keybindings_pt.md b/docs/keybindings/Keybindings_pt.md index 5fcba2688..3071613be 100644 --- a/docs/keybindings/Keybindings_pt.md +++ b/docs/keybindings/Keybindings_pt.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | Atualizar | Atualize o estado do git (ou seja, execute `git status`, `git branch`, etc em segundo plano para atualizar o conteúdo de painéis). Isso não executa `git fetch`. | | `` + `` | Modo de tela seguinte (normal/metade/tela cheia) | | | `` _ `` | Modo de tela anterior | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | Cancelar | | | `` ? `` | Abrir o menu de atalhos do teclado | | | `` `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md index 683135448..3d03f9ca6 100644 --- a/docs/keybindings/Keybindings_ru.md +++ b/docs/keybindings/Keybindings_ru.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | Обновить | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | Следующий режим экрана (нормальный/полуэкранный/полноэкранный) | | | `` _ `` | Предыдущий режим экрана | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | Отменить | | | `` ? `` | Открыть меню | | | `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md index b4f1134ec..9819cb982 100644 --- a/docs/keybindings/Keybindings_zh-CN.md +++ b/docs/keybindings/Keybindings_zh-CN.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | 刷新 | 刷新Git状态(即在后台运行`git status`、`git branch`等命令以更新面板内容)。此操作不会执行`git fetch`。 | | `` + `` | 下一屏模式(正常/半屏/全屏) | | | `` _ `` | 上一屏模式 | | -| `` \| `` | 切换分页器 | 从已配置的分页器列表中选择下一个分页器 | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | 取消 | | | `` ? `` | 打开菜单 | | | `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md index b50e25d35..1706a627e 100644 --- a/docs/keybindings/Keybindings_zh-TW.md +++ b/docs/keybindings/Keybindings_zh-TW.md @@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` R `` | 重新整理 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. | | `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | | | `` _ `` | 上一個螢幕模式 | | -| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. | -| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | +| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. | +| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. | | `` `` | 取消 | | | `` ? `` | 開啟選單 | | | `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/schema/config.json b/schema/config.json index 82dbebb0b..45a2b9efe 100644 --- a/schema/config.json +++ b/schema/config.json @@ -314,14 +314,58 @@ "type": "object", "description": "Custom icons for filenames and file extensions\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color" }, - "GitConfig": { + "DiffRendererConfig": { "properties": { - "pagers": { + "type": { + "type": "string", + "enum": [ + "stdinFilter", + "extDiff", + "rawGit" + ], + "description": "The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff' | 'rawGit'" + }, + "name": { + "type": "string", + "description": "A name for the diff renderer, shown in the notification when cycling renderers. If not set, the name is derived from the first word of the renderer command." + }, + "colorArg": { + "type": "string", + "enum": [ + "always", + "never" + ], + "description": "Value of the --color arg in the git diff command. Only used for type 'stdinFilter'. Some renderers want this to be set to 'always' and some want it set to 'never'." + }, + "command": { + "type": "string", + "description": "The command to use for rendering diffs. This is either a stdinFilter or an external diff command, depending on the type field; not applicable if the type is 'rawGit'.\ne.g.\ndiff-so-fancy\ndelta --dark --paging=never\nydiff -p cat\ndifft --color=always", + "examples": [ + "delta --dark --paging=never", + "diff-so-fancy", + "ydiff -p cat", + "difft --color=always" + ] + }, + "args": { "items": { - "$ref": "#/$defs/PagingConfig" + "type": "string" }, "type": "array", - "description": "Array of pagers. Each entry has the following format:\n\n # A name for the pager, shown in the notification when cycling pagers.\n # If not set, the name is derived from the first word of the pager\n # command (or of the external diff command).\n name: \"\"\n\n # Value of the --color arg in the git diff command. Some pagers want\n # this to be set to 'always' and some want it set to 'never'\n colorArg: \"always\"\n\n # e.g.\n # diff-so-fancy\n # delta --dark --paging=never\n # ydiff -p cat -s --wrap --width={{columnWidth}}\n pager: \"\"\n\n # e.g. 'difft --color=always'\n externalDiffCommand: \"\"\n\n # If true, Lazygit will use git's `diff.external` config for paging.\n # The advantage over `externalDiffCommand` is that this can be\n # configured per file type in .gitattributes; see\n # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.\n useExternalDiffGitConfig: false\n\n'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually exclusive; set at most one per entry.\n\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information." + "description": "Extra arguments (array of strings) passed to the git command. Only applicable if the type is 'rawGit'." + } + }, + "additionalProperties": false, + "type": "object" + }, + "GitConfig": { + "properties": { + "diffRenderers": { + "items": { + "$ref": "#/$defs/DiffRendererConfig" + }, + "type": "array", + "description": "Array of diff renderers. Each entry has the following format:\n\n # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'\n # | 'rawGit'\n type: \"stdinFilter\"\n\n # A name for the diff renderer, shown in the notification when cycling\n # renderers. If not set, the name is derived from the first word of the\n # renderer command.\n name: \"\"\n\n # Value of the --color arg in the git diff command. Only used for type\n # 'stdinFilter'. Some renderers want this to be set to 'always' and some\n # want it set to 'never'.\n colorArg: \"always\"\n\n # The command to use for rendering diffs. This is either a stdinFilter or\n # an external diff command, depending on the type field; not applicable if\n # the type is 'rawGit'.\n # e.g.\n # diff-so-fancy\n # delta --dark --paging=never\n # ydiff -p cat\n # difft --color=always\n command: \"\"\n\n # Extra arguments (array of strings) passed to the git command. Only\n # applicable if the type is 'rawGit'.\n args: []\n\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md for more information." }, "commit": { "$ref": "#/$defs/CommitConfig", @@ -535,7 +579,7 @@ "tabWidth": { "type": "integer", "minimum": 1, - "description": "The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.\nNote that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command.", + "description": "The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.\nNote that when using a diff renderer, the renderer has its own tab width setting, so you need to pass it separately in the renderer command.", "default": 4 }, "mouseEvents": { @@ -3163,7 +3207,7 @@ ], "default": "_" }, - "cyclePagers": { + "cycleDiffRenderers": { "oneOf": [ { "type": "string" @@ -3177,7 +3221,7 @@ ], "default": "|" }, - "cyclePagersReverse": { + "cycleDiffRenderersReverse": { "oneOf": [ { "type": "string" @@ -3544,41 +3588,6 @@ "type": "object", "description": "Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc" }, - "PagingConfig": { - "properties": { - "name": { - "type": "string", - "description": "A name for the pager, shown in the notification when cycling pagers. If not set, the name is derived from the first word of the pager command (or of the external diff command)." - }, - "colorArg": { - "type": "string", - "enum": [ - "always", - "never" - ], - "description": "Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never'" - }, - "pager": { - "type": "string", - "description": "e.g.\ndiff-so-fancy\ndelta --dark --paging=never\nydiff -p cat -s --wrap --width={{columnWidth}}", - "examples": [ - "delta --dark --paging=never", - "diff-so-fancy", - "ydiff -p cat -s --wrap --width={{columnWidth}}" - ] - }, - "externalDiffCommand": { - "type": "string", - "description": "e.g. 'difft --color=always'" - }, - "useExternalDiffGitConfig": { - "type": "boolean", - "description": "If true, Lazygit will use git's `diff.external` config for paging. The advantage over `externalDiffCommand` is that this can be configured per file type in .gitattributes; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver." - } - }, - "additionalProperties": false, - "type": "object" - }, "RefresherConfig": { "properties": { "refreshInterval": { @@ -3631,17 +3640,17 @@ "type": "array", "description": "The frames of the spinner animation.", "default": [ - "|", - "/", - "-", - "\\" + "●∙∙", + "∙●∙", + "∙∙●", + "∙●∙" ] }, "rate": { "type": "integer", "minimum": 1, "description": "The \"speed\" of the spinner in milliseconds.", - "default": 50 + "default": 180 } }, "additionalProperties": false, From 4ec91a0bf58e07ce040f08600cd0c6b64f996e07 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 16:58:37 +0200 Subject: [PATCH 59/97] Don't let integration tests race a background git repack Every `git commit` forks `git maintenance run --auto --quiet --detach`, and git 2.54 changed what that runs from the `gc` task to the "geometric" strategy. The geometric repack's auto condition passes its threshold of 100 to too_many_loose_objects(), which estimates the loose object count from the objects/17 fanout directory times 256, so the real trigger is two objects in that one directory -- where the old gc task needed 27. Fixture repos reach two easily: every CreateNCommits(n>=6) repo already stores the blob for file06.txt there, so a single commit object hashing into 17 (about 4% of fixtures with 10 commits, 15% with 40) tips it over, and from then on every commit in that repo forks a detached `git repack -d`, which prunes loose objects while the next fixture command -- or lazygit under test -- is still working in the same repo. That is where the CI panics during fixture setup come from: panic: error running command: [git commit -m commit-10] error: invalid object 100644 50d5612... for 'file09.txt' error: Error building trees The reported hashes are exactly the fixture blobs, so `git add` staged them correctly; they were unlinked underneath the commit. Only the "git latest" jobs saw this, since the pinned 2.32/2.38/2.44 jobs predate the strategy change. git's own test suite guards against the same thing by exporting GIT_TEST_MAINT_AUTO_DETACH=false ("Ensure that tests cannot race with background maintenance by default"). Turning maintenance off outright is stronger: no test repo needs it, and it also spares us a forked git process per commit. maintenance.auto has been honored since git 2.29, so it covers every version in the CI matrix. Measured on a 40-commit fixture: a background repack fired in 4 of 25 runs before, 0 of 25 after. --- test/global_git_config | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/global_git_config b/test/global_git_config index f4f47c003..b83ea57b4 100644 --- a/test/global_git_config +++ b/test/global_git_config @@ -8,3 +8,12 @@ allow = always [commit] gpgSign = false +[maintenance] + # Every `git commit` forks `git maintenance run --auto --detach`. Since git + # 2.54 that repacks as soon as two objects share the objects/17 fanout + # directory, which happens readily in a fixture repo, and `git repack -d` + # prunes loose objects while the next fixture command -- or lazygit itself -- + # is still working in the same repo. That surfaces as + # "error: invalid object for 'file09.txt'" / "Error building trees". + # Tests must never race a background repack. + auto = false From 34da956f5d6a473e24b869b9fae21d62db168b53 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 17:02:06 +0200 Subject: [PATCH 60/97] Don't let a broken fixture take down the whole test binary A failing setup step called Shell.fail, which panicked. Tests run as parallel subtests, so that panic aborted the entire test binary: one bad fixture cost us the results of all ~500 tests, and the failure was reported as a stack trace rather than against the test that caused it. Keep panicking to skip the remaining setup steps -- they would only produce follow-on failures -- but recover in createFixture and return the message as that test's error. All three clients already propagate an error from a test, so they report it the way they report any other failure. --- pkg/integration/components/runner.go | 30 ++++++++++++++++++++----- pkg/integration/components/test_test.go | 24 ++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/pkg/integration/components/runner.go b/pkg/integration/components/runner.go index 78cb5439f..aaaabd0a0 100644 --- a/pkg/integration/components/runner.go +++ b/pkg/integration/components/runner.go @@ -1,6 +1,7 @@ package components import ( + "errors" "fmt" "os" "os/exec" @@ -159,9 +160,7 @@ func prepareTestDir( return "", err } - workingDir := createFixture(test, paths, rootDir) - - return workingDir, nil + return createFixture(test, paths, rootDir) } func buildLazygit(testArgs RunTestArgs) error { @@ -182,22 +181,41 @@ func buildLazygit(testArgs RunTestArgs) error { return osCommand.Cmd.New(args).Run() } +// A failing setup step panics with this so that the remaining steps, which +// would only produce follow-on failures, are skipped. +type fixtureFailure string + // Sets up the fixture for test and returns the working directory to invoke // lazygit in. -func createFixture(test *IntegrationTest, paths Paths, rootDir string) string { +func createFixture(test *IntegrationTest, paths Paths, rootDir string) (workingDir string, err error) { + // Tests run as parallel subtests, and a panic escaping one of them takes + // down the whole test binary, discarding every other test's result along + // with it. Report a broken fixture as this test's error instead. + defer func() { + panicValue := recover() + if panicValue == nil { + return + } + failure, ok := panicValue.(fixtureFailure) + if !ok { + panic(panicValue) + } + err = errors.New(string(failure)) + }() + env := NewTestEnvironment(rootDir) env = append(env, fmt.Sprintf("%s=%s", PWD, paths.ActualRepo())) shell := NewShell( paths.ActualRepo(), env, - func(errorMsg string) { panic(errorMsg) }, + func(errorMsg string) { panic(fixtureFailure(errorMsg)) }, ) shell.Init() test.SetupRepo(shell) - return shell.dir + return shell.dir, nil } func testPath(rootdir string) string { diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 3a145bf43..cf0338dec 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -1,8 +1,11 @@ package components import ( + "os" + "path/filepath" "testing" + lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" @@ -158,6 +161,27 @@ func TestSuccess(t *testing.T) { assert.Equal(t, "", driver.failureMessage) } +func TestFailingFixture(t *testing.T) { + test := NewIntegrationTest(NewIntegrationTestArgs{ + Description: unitTestDescription, + SetupRepo: func(shell *Shell) { + shell.RunCommand([]string{"git", "checkout", "no-such-branch"}) + shell.CreateFile("reached.txt", "") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) {}, + }) + + paths := NewPaths(t.TempDir()) + assert.NoError(t, os.MkdirAll(paths.ActualRepo(), 0o777)) + + workingDir, err := createFixture(test, paths, lazycoreUtils.GetLazyRootDirectory()) + + assert.ErrorContains(t, err, "git checkout no-such-branch") + assert.Empty(t, workingDir) + // the steps following the failing one are skipped + assert.NoFileExists(t, filepath.Join(paths.ActualRepo(), "reached.txt")) +} + func TestGitVersionRestriction(t *testing.T) { scenarios := []struct { testName string From f8b7bab1abbd872b583553ab46d23ef009b105a9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 09:14:02 +0200 Subject: [PATCH 61/97] Decide the commit graph from the loaded list, not the filtering mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether a graph can be drawn was read from the filtering mode, while the graph itself is drawn over the commit list in the model. Those two only agree once the list has been reloaded for the new mode, and a filtering mode change reloads the list in the background, so in between we can be asked to draw a graph over a list the graph makes no sense for. That is not just cosmetic. Commits in a filtered list are almost never each other's parents, so no pipe ever terminates: the pipe set grows by one per row and every continuing pipe rescans it, which is cubic in the length of the list. Escaping out of filtering mode with a filtered list of 13000 commits — as you get once the 300 commit limit has been lifted, which happens for good as soon as the selection passes COMMIT_THRESHOLD — wedges the UI thread for around twenty minutes. Record whether the list was loaded with a filter, right where the list itself is stored, and decide from that. The graph now also stays up while the pre-change list is still on display, rather than vanishing a moment before the list it belongs to. --- pkg/gui/context/local_commits_context.go | 8 +++++++- pkg/gui/controllers/helpers/refresh_helper.go | 1 + pkg/gui/types/common.go | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 7f34ea7dc..4a99259fd 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -324,7 +324,13 @@ func (self *LocalCommitsViewModel) GetCommits() []*models.Commit { } func shouldShowGraph(c *ContextCommon) bool { - if c.Modes().Filtering.Active() { + // Whether we can draw a graph is a property of the commit list we have + // loaded, not of the filtering mode: turning filtering on or off only + // reaches the screen when the reloaded list does, and until then the graph + // has to keep matching the list that is still on display. Drawing one for a + // filtered list is also ruinously slow, because none of the commits in it + // are connected to each other, so no pipe ever terminates. + if c.Model().CommitsWereFilteredAtLastRefresh { return false } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 3db9ab9dc..d0327216c 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -817,6 +817,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits + self.c.Model().CommitsWereFilteredAtLastRefresh = captured.filterPath != "" || captured.filterAuthor != "" self.RefreshAuthors(commits) self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState if checkedOutRef != nil { diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 7091d46f7..2b5fb0ac7 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -349,6 +349,7 @@ type Model struct { BisectInfo *git_commands.BisectInfo WorkingTreeStateAtLastCommitRefresh models.WorkingTreeState + CommitsWereFilteredAtLastRefresh bool RemoteBranches []*models.RemoteBranch Tags []*models.Tag From f4968f6839409ae0c019dd026c50c8362de1cfcd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 07:10:57 +0200 Subject: [PATCH 62/97] Rename suppressRebasingMode to suppressWorkingTreeStateMode The mode it suppresses is active for any working tree state, not just a rebase: merging, cherry-picking and reverting show through the same indicator. Name it after what it hides. --- .../controllers/helpers/app_status_helper.go | 4 ++-- pkg/gui/controllers/helpers/mode_helper.go | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index d0bb03395..99664445c 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -97,7 +97,7 @@ func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f fu // Hide the rebasing-mode indicator (and its reset button) while we drive the // rebase ourselves; it reflects the transient on-disk state and would // otherwise flash on for the duration of the operation. - self.modeHelper.SetSuppressRebasingMode(true) + self.modeHelper.SetSuppressWorkingTreeStateMode(true) self.c.OnWorker(func(task gocui.Task) error { // End the block and restore the mode indicator once the operation and its // refresh have applied their UI updates: OnUIThread queues this after the @@ -105,7 +105,7 @@ func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f fu // enqueued by the time f returns), so the replayed keys act on the // refreshed state and any resulting rebase state shows correctly. defer self.c.OnUIThread(func() error { - self.modeHelper.SetSuppressRebasingMode(false) + self.modeHelper.SetSuppressWorkingTreeStateMode(false) return self.c.GocuiGui().EndBlockingEvents() }) return self.WithWaitingStatusImpl(message, f, task) diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 4947e42d1..50f35341b 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -12,12 +12,12 @@ import ( type ModeHelper struct { c *HelperCommon - diffHelper *DiffHelper - patchBuildingHelper *PatchBuildingHelper - cherryPickHelper *CherryPickHelper - mergeAndRebaseHelper *MergeAndRebaseHelper - bisectHelper *BisectHelper - suppressRebasingMode bool + diffHelper *DiffHelper + patchBuildingHelper *PatchBuildingHelper + cherryPickHelper *CherryPickHelper + mergeAndRebaseHelper *MergeAndRebaseHelper + bisectHelper *BisectHelper + suppressWorkingTreeStateMode bool } func NewModeHelper( @@ -130,7 +130,7 @@ func (self *ModeHelper) Statuses() []ModeStatus { }, { IsActive: func() bool { - return !self.suppressRebasingMode && self.c.Git().Status.WorkingTreeState().Any() + return !self.suppressWorkingTreeStateMode && self.c.Git().Status.WorkingTreeState().Any() }, InfoLabel: func() string { workingTreeState := self.c.Git().Status.WorkingTreeState() @@ -219,6 +219,6 @@ func ScopesToRefreshWhenFilteringModeChanges() []types.RefreshableView { } } -func (self *ModeHelper) SetSuppressRebasingMode(value bool) { - self.suppressRebasingMode = value +func (self *ModeHelper) SetSuppressWorkingTreeStateMode(value bool) { + self.suppressWorkingTreeStateMode = value } From e54cb4bf4265e73643cd4f8a77d080236b65d266 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 07:15:04 +0200 Subject: [PATCH 63/97] Decouple hiding the working tree state from blocking input Blocking keyboard input and hiding the working tree state mode are two separate concerns; they were fused into one helper because every caller so far wanted both. A caller that blocks input for something other than a rebase would then hide the "Rebasing" indicator for the duration of its operation, which has nothing to do with it. Make it an explicit option instead, so blocking input on its own doesn't imply anything about the modes on display. --- .../controllers/commits_files_controller.go | 5 +- .../controllers/helpers/app_status_helper.go | 24 +++--- .../controllers/helpers/cherry_pick_helper.go | 5 +- .../controllers/local_commits_controller.go | 80 +++++++++++++++---- pkg/gui/gui.go | 4 +- pkg/gui/popup/popup_handler.go | 8 +- pkg/gui/types/common.go | 16 +++- 7 files changed, 106 insertions(+), 36 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 6c8f49e1f..748355f67 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -339,7 +339,10 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN HandleConfirm: func() error { commits := self.c.Model().Commits selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx() - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RebasingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { var filePaths []string selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index 99664445c..44de70546 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -85,30 +85,32 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui. // WithWaitingStatusBlockingInput is like WithWaitingStatus, but it also blocks // keyboard input for the whole duration of the operation: keys the user presses // while it runs are buffered and replayed against the post-operation state (see -// gocui.BeginBlockingEvents). Use it for operations that manipulate an -// in-progress rebase or otherwise rewrite commits, where a racing keypress -// would target the wrong commit or todo. +// gocui.BeginBlockingEvents). Use it for operations whose following keypress +// depends on the state they produce, e.g. ones that manipulate an in-progress +// rebase or otherwise rewrite commits, where a racing keypress would target the +// wrong commit or todo. // // Must be called on the UI thread: the block is begun synchronously here, before // the operation is dispatched to a worker, so no keypress can slip through in // between. -func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) { +func (self *AppStatusHelper) WithWaitingStatusBlockingInput(opts types.WaitingStatusOpts, f func(gocui.Task) error) { self.c.GocuiGui().BeginBlockingEvents() - // Hide the rebasing-mode indicator (and its reset button) while we drive the - // rebase ourselves; it reflects the transient on-disk state and would - // otherwise flash on for the duration of the operation. - self.modeHelper.SetSuppressWorkingTreeStateMode(true) + if opts.HideWorkingTreeState { + self.modeHelper.SetSuppressWorkingTreeStateMode(true) + } self.c.OnWorker(func(task gocui.Task) error { // End the block and restore the mode indicator once the operation and its // refresh have applied their UI updates: OnUIThread queues this after the // refresh's model bounces and Then (which RefreshFromWorker has already // enqueued by the time f returns), so the replayed keys act on the - // refreshed state and any resulting rebase state shows correctly. + // refreshed state and any resulting working tree state shows correctly. defer self.c.OnUIThread(func() error { - self.modeHelper.SetSuppressWorkingTreeStateMode(false) + if opts.HideWorkingTreeState { + self.modeHelper.SetSuppressWorkingTreeStateMode(false) + } return self.c.GocuiGui().EndBlockingEvents() }) - return self.WithWaitingStatusImpl(message, f, task) + return self.WithWaitingStatusImpl(opts.Message, f, task) }) } diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index fc96b9d1b..f82f8843a 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -85,7 +85,10 @@ func (self *CherryPickHelper) Paste() error { HandleConfirm: func() error { mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) cherryPickedCommits := self.getData().CherryPickedCommits - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CherryPickingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.CherryPickingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.CherryPick) if mustStash { diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 17c1dcf30..642263d12 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -743,7 +743,10 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, HandleConfirm: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.SquashingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashCommitDown) return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx) }) @@ -767,7 +770,10 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star OnPress: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.FixingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx) }) @@ -780,7 +786,10 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star OnPress: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.FixingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage) return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C") }) @@ -891,7 +900,10 @@ func (self *LocalCommitsController) handleReword(summary string, description str self.c.Tr.RewordingStatus, nil, nil) } - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RewordingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RewordingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description) if err != nil { return err @@ -977,7 +989,10 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start if !isMerge { self.selectRebaseResultCommit(startIdx) } - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.DroppingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.DroppingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DropCommit) if isMerge { return self.dropMergeCommit(commits, startIdx) @@ -1002,7 +1017,10 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start commits := self.c.Model().Commits if !commits[endIdx].IsMerge() { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RebasingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "") return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{BatchUIUpdates: true}) @@ -1024,7 +1042,10 @@ func (self *LocalCommitsController) quickStartInteractiveRebase() error { func (self *LocalCommitsController) startInteractiveRebaseWithEdit( commitsToEdit []*models.Commit, ) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RebasingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.EditCommit) err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( @@ -1164,7 +1185,10 @@ func (self *LocalCommitsController) move( } commits := self.c.Model().Commits - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.MovingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { if offset > 0 { self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) } else { @@ -1209,7 +1233,10 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { selectedIdx := self.context().GetView().SelectedLineIdx() handleCommit = func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AmendCommit) err := self.c.Git().Rebase.AmendTo(commits, selectedIdx) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) @@ -1271,7 +1298,10 @@ func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, en } func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor) if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil { return err @@ -1287,7 +1317,10 @@ func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, e Title: self.c.Tr.SetAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor) if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil { return err @@ -1307,7 +1340,10 @@ func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, Title: self.c.Tr.AddCoAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor) if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil { return err @@ -1341,7 +1377,10 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.RevertCommit) mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RevertingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RevertingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { if mustStash { if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil { return err @@ -1392,7 +1431,10 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err selectedIdx := self.context().GetSelectedLineIdx() commits := self.c.Model().Commits branches := self.c.Model().Branches - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.CreatingFixupCommitStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil { return err } @@ -1500,7 +1542,10 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc selectedIdx := self.context().GetSelectedLineIdx() commits := self.c.Model().Commits branches := self.c.Model().Branches - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.CreatingFixupCommitStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil { return err } @@ -1561,7 +1606,10 @@ func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, reba // up by that many rows to stay on the same commit. Compute the target as an // absolute index now, on the current list. targetIdx := self.context().GetSelectedLineIdx() - selectionOffset - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.SquashingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits) err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 4dcd3c209..a7466d584 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -832,8 +832,8 @@ func NewGui( return nil }, func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) }, - func(message string, f func(gocui.Task) error) { - gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f) + func(opts types.WaitingStatusOpts, f func(gocui.Task) error) { + gui.helpers.AppStatus.WithWaitingStatusBlockingInput(opts, f) }, func(message string, kind types.ToastKind) { gui.helpers.AppStatus.Toast(message, kind) }, func() string { return gui.Views.Prompt.TextArea.GetContent() }, diff --git a/pkg/gui/popup/popup_handler.go b/pkg/gui/popup/popup_handler.go index 7c15c56ea..3b305a25d 100644 --- a/pkg/gui/popup/popup_handler.go +++ b/pkg/gui/popup/popup_handler.go @@ -19,7 +19,7 @@ type PopupHandler struct { currentContextFn func() types.Context createMenuFn func(types.CreateMenuOptions) error withWaitingStatusFn func(message string, f func(gocui.Task) error) - withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error) + withWaitingStatusBlockingInputFn func(opts types.WaitingStatusOpts, f func(gocui.Task) error) toastFn func(message string, kind types.ToastKind) getPromptInputFn func() string inDemo func() bool @@ -35,7 +35,7 @@ func NewPopupHandler( currentContextFn func() types.Context, createMenuFn func(types.CreateMenuOptions) error, withWaitingStatusFn func(message string, f func(gocui.Task) error), - withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error), + withWaitingStatusBlockingInputFn func(opts types.WaitingStatusOpts, f func(gocui.Task) error), toastFn func(message string, kind types.ToastKind), getPromptInputFn func() string, inDemo func() bool, @@ -76,8 +76,8 @@ func (self *PopupHandler) WithWaitingStatus(message string, f func(gocui.Task) e return nil } -func (self *PopupHandler) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error { - self.withWaitingStatusBlockingInputFn(message, f) +func (self *PopupHandler) WithWaitingStatusBlockingInput(opts types.WaitingStatusOpts, f func(gocui.Task) error) error { + self.withWaitingStatusBlockingInputFn(opts, f) return nil } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 2b5fb0ac7..b2b924bc0 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -171,7 +171,7 @@ type IPopupHandler interface { // Shows a popup prompting the user for input. Prompt(opts PromptOpts) WithWaitingStatus(message string, f func(gocui.Task) error) error - WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error + WithWaitingStatusBlockingInput(opts WaitingStatusOpts, f func(gocui.Task) error) error Menu(opts CreateMenuOptions) error Toast(message string) ErrorToast(message string) @@ -179,6 +179,20 @@ type IPopupHandler interface { GetPromptInput() string } +type WaitingStatusOpts struct { + // The message shown alongside the spinner while the operation runs. + Message string + + // When set, the working tree state mode (the yellow + // "Rebasing"/"Merging"/"Cherry-picking"/"Reverting" indicator, along with + // its abort button) stays hidden until the operation is done. Set it for + // operations that drive such a state themselves: the state they leave on + // disk while they run is transient, so surfacing it would flash the + // indicator on and offer to abort a sequence that lazygit is in the middle + // of running. + HideWorkingTreeState bool +} + type ToastKind int const ( From b30c7345139764cb13b86e2cd5c916fd6bd9cf5f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 07:18:55 +0200 Subject: [PATCH 64/97] Handle entering and leaving filtering mode in one place Setting a filter and clearing it are the same transition in opposite directions: mutate the mode, bring the screen mode in line with it, reload the views that depend on the filter, and put the selection somewhere sensible in the reloaded commit list. They were implemented twice, once in the filtering menu and once in ModeHelper, which is how the two came to repaint the commit list in different ways. Derive the screen mode and the panel switch from whether a filter is active after the change, so both directions fall out of the same code, and give ModeHelper the entry points for both. The filtering menu is left with nothing but the menu. --- pkg/gui/controllers/filtering_menu_action.go | 40 ++---------- pkg/gui/controllers/helpers/mode_helper.go | 64 +++++++++++++++++--- 2 files changed, 60 insertions(+), 44 deletions(-) diff --git a/pkg/gui/controllers/filtering_menu_action.go b/pkg/gui/controllers/filtering_menu_action.go index 7ae26c4ef..0bd7ca902 100644 --- a/pkg/gui/controllers/filtering_menu_action.go +++ b/pkg/gui/controllers/filtering_menu_action.go @@ -3,7 +3,6 @@ package controllers import ( "fmt" - "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -42,7 +41,7 @@ func (self *FilteringMenuAction) Call() error { menuItems = append(menuItems, &types.MenuItem{ Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, fileName), OnPress: func() error { - return self.setFilteringPath(fileName) + return self.c.Helpers().Mode.SetFilteringPath(fileName) }, Tooltip: tooltip, }) @@ -52,7 +51,7 @@ func (self *FilteringMenuAction) Call() error { menuItems = append(menuItems, &types.MenuItem{ Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, author), OnPress: func() error { - return self.setFilteringAuthor(author) + return self.c.Helpers().Mode.SetFilteringAuthor(author) }, Tooltip: tooltip, }) @@ -65,7 +64,7 @@ func (self *FilteringMenuAction) Call() error { FindSuggestionsFunc: self.c.Helpers().Suggestions.GetFilePathSuggestionsFunc(), Title: self.c.Tr.EnterFileName, HandleConfirm: func(response string) error { - return self.setFilteringPath(response) + return self.c.Helpers().Mode.SetFilteringPath(response) }, }) @@ -81,7 +80,7 @@ func (self *FilteringMenuAction) Call() error { FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), Title: self.c.Tr.EnterAuthor, HandleConfirm: func(response string) error { - return self.setFilteringAuthor(response) + return self.c.Helpers().Mode.SetFilteringAuthor(response) }, }) @@ -99,34 +98,3 @@ func (self *FilteringMenuAction) Call() error { return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.FilteringMenuTitle, Items: menuItems}) } - -func (self *FilteringMenuAction) setFilteringPath(path string) error { - self.c.Modes().Filtering.Reset() - self.c.Modes().Filtering.SetPath(path) - return self.setFiltering() -} - -func (self *FilteringMenuAction) setFilteringAuthor(author string) error { - self.c.Modes().Filtering.Reset() - self.c.Modes().Filtering.SetAuthor(author) - return self.setFiltering() -} - -func (self *FilteringMenuAction) setFiltering() error { - self.c.Modes().Filtering.SetSelectedCommitHash(self.c.Contexts().LocalCommits.GetSelectedCommitHash()) - - repoState := self.c.State().GetRepoState() - if repoState.GetScreenMode() == types.SCREEN_NORMAL { - repoState.SetScreenMode(types.SCREEN_HALF) - } - - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) - - self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() error { - self.c.Contexts().LocalCommits.SetSelection(0) - self.c.Contexts().LocalCommits.HandleFocus(types.OnFocusOpts{}) - return nil - }}) - - return nil -} diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 50f35341b..8b88205e0 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -182,16 +182,40 @@ func (self *ModeHelper) ExitFilterMode() error { return self.ClearFiltering() } +func (self *ModeHelper) SetFilteringPath(path string) error { + return self.setFiltering(func() { + self.c.Modes().Filtering.SetPath(path) + }) +} + +func (self *ModeHelper) SetFilteringAuthor(author string) error { + return self.setFiltering(func() { + self.c.Modes().Filtering.SetAuthor(author) + }) +} + +func (self *ModeHelper) setFiltering(setFilter func()) error { + self.changeFiltering( + func() { + // Whatever we were filtering by before is replaced, not added to + self.c.Modes().Filtering.Reset() + setFilter() + self.c.Modes().Filtering.SetSelectedCommitHash( + self.c.Contexts().LocalCommits.GetSelectedCommitHash()) + }, + func() { + self.c.Contexts().LocalCommits.SetSelection(0) + }, + ) + return nil +} + func (self *ModeHelper) ClearFiltering() error { selectedCommitHash := self.c.Contexts().LocalCommits.GetSelectedCommitHash() - self.c.Modes().Filtering.Reset() - if self.c.State().GetRepoState().GetScreenMode() == types.SCREEN_HALF { - self.c.State().GetRepoState().SetScreenMode(types.SCREEN_NORMAL) - } - self.c.Refresh(types.RefreshOptions{ - Scope: ScopesToRefreshWhenFilteringModeChanges(), - Then: func() error { + self.changeFiltering( + self.c.Modes().Filtering.Reset, + func() { // Find the commit that was last selected in filtering mode, and select it again after refreshing if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) { // If we couldn't find it (either because no commit was selected @@ -200,12 +224,36 @@ func (self *ModeHelper) ClearFiltering() error { // before we entered filtering self.c.Contexts().LocalCommits.SelectCommitByHash(self.c.Modes().Filtering.GetSelectedCommitHash()) } + }, + ) + return nil +} +// changeFiltering applies a change to the filtering mode: setFilter mutates the +// mode, then the screen mode and the focused panel are brought in line with it, +// the views whose contents depend on the filter are reloaded, and selectCommit +// puts the selection where it belongs in the reloaded commit list. +func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) { + setFilter() + + repoState := self.c.State().GetRepoState() + if self.c.Modes().Filtering.Active() { + if repoState.GetScreenMode() == types.SCREEN_NORMAL { + repoState.SetScreenMode(types.SCREEN_HALF) + } + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + } else if repoState.GetScreenMode() == types.SCREEN_HALF { + repoState.SetScreenMode(types.SCREEN_NORMAL) + } + + self.c.Refresh(types.RefreshOptions{ + Scope: ScopesToRefreshWhenFilteringModeChanges(), + Then: func() error { + selectCommit() self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) return nil }, }) - return nil } // Stashes really only need to be refreshed when filtering by path, not by author, but it's too much From 7e6d5ff7c1fc71452805055112c8ee739370c9d0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 07:29:22 +0200 Subject: [PATCH 65/97] Don't show an unfiltered list as if it were filtered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entering or leaving filtering mode switched the screen mode and the focused panel immediately, then reloaded the commit list in the background. The result was an unfiltered list presented in the layout that says "you are filtering", with nothing to say that anything was still happening — and in a big repo that state can last seconds. Before we stopped blocking the UI thread on refreshes, the reload happened before any of it, so the two always agreed; the price was a frozen UI for the duration. Do neither: reload on a worker, so the UI stays live, and hold back everything the user can see of the change until the new lists are ready, so they still land together in one frame. A waiting status says what is going on in the meantime, and blocking input means the keys pressed while it runs arrive after the change rather than acting on a list that is about to be replaced. --- pkg/gui/controllers/helpers/mode_helper.go | 69 ++++++++++++++-------- pkg/i18n/english.go | 4 ++ 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 8b88205e0..68f7ea149 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" @@ -195,7 +196,7 @@ func (self *ModeHelper) SetFilteringAuthor(author string) error { } func (self *ModeHelper) setFiltering(setFilter func()) error { - self.changeFiltering( + return self.changeFiltering( func() { // Whatever we were filtering by before is replaced, not added to self.c.Modes().Filtering.Reset() @@ -207,13 +208,12 @@ func (self *ModeHelper) setFiltering(setFilter func()) error { self.c.Contexts().LocalCommits.SetSelection(0) }, ) - return nil } func (self *ModeHelper) ClearFiltering() error { selectedCommitHash := self.c.Contexts().LocalCommits.GetSelectedCommitHash() - self.changeFiltering( + return self.changeFiltering( self.c.Modes().Filtering.Reset, func() { // Find the commit that was last selected in filtering mode, and select it again after refreshing @@ -226,33 +226,54 @@ func (self *ModeHelper) ClearFiltering() error { } }, ) - return nil } // changeFiltering applies a change to the filtering mode: setFilter mutates the -// mode, then the screen mode and the focused panel are brought in line with it, -// the views whose contents depend on the filter are reloaded, and selectCommit -// puts the selection where it belongs in the reloaded commit list. -func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) { +// mode, then the views whose contents depend on the filter are reloaded, and +// selectCommit puts the selection where it belongs in the reloaded commit list. +// +// Reloading the commit list can take seconds in a big repo, so it happens on a +// worker with a waiting status. Everything the user can see of the change waits +// for it: the screen mode, the focused panel and the reloaded lists all land in +// the same frame, from the refresh's Then, rather than framing an unfiltered +// list as if it were the filtered one. Until then the pre-change state stays on +// screen, and it stays consistent, because the only thing that has changed +// behind it is the filter that the reload is in the middle of applying. The one +// thing that can't wait is the mode indicator in the information panel: the +// filter has to be set before the reload can use it, so the indicator leads the +// lists by however long the reload takes. +// +// Input is blocked for the duration: the keys the user presses arrive after the +// change, which is where they meant them to go, and it keeps a second filter +// change from racing this one — they would both refresh with whichever filter +// happened to be set when their git commands ran. +func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) error { setFilter() - repoState := self.c.State().GetRepoState() - if self.c.Modes().Filtering.Active() { - if repoState.GetScreenMode() == types.SCREEN_NORMAL { - repoState.SetScreenMode(types.SCREEN_HALF) - } - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) - } else if repoState.GetScreenMode() == types.SCREEN_HALF { - repoState.SetScreenMode(types.SCREEN_NORMAL) - } + filtering := self.c.Modes().Filtering.Active() + message := lo.Ternary(filtering, self.c.Tr.ApplyingFilterStatus, self.c.Tr.RemovingFilterStatus) - self.c.Refresh(types.RefreshOptions{ - Scope: ScopesToRefreshWhenFilteringModeChanges(), - Then: func() error { - selectCommit() - self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) - return nil - }, + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{Message: message}, func(gocui.Task) error { + self.c.RefreshFromWorker(types.RefreshOptions{ + Scope: ScopesToRefreshWhenFilteringModeChanges(), + BatchUIUpdates: true, + Then: func() error { + repoState := self.c.State().GetRepoState() + if filtering { + if repoState.GetScreenMode() == types.SCREEN_NORMAL { + repoState.SetScreenMode(types.SCREEN_HALF) + } + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + } else if repoState.GetScreenMode() == types.SCREEN_HALF { + repoState.SetScreenMode(types.SCREEN_NORMAL) + } + + selectCommit() + self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) + return nil + }, + }) + return nil }) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index deb55d6e9..845155aed 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -441,6 +441,8 @@ type TranslationSet struct { ResettingStatus string CreatingFixupCommitStatus string MovingCommitsToNewBranchStatus string + ApplyingFilterStatus string + RemovingFilterStatus string CommitFiles string SubCommitsDynamicTitle string CommitFilesDynamicTitle string @@ -1598,6 +1600,8 @@ func EnglishTranslationSet() *TranslationSet { ResettingStatus: "Resetting", CreatingFixupCommitStatus: "Creating fixup commit", MovingCommitsToNewBranchStatus: "Moving commits to new branch", + ApplyingFilterStatus: "Applying filter", + RemovingFilterStatus: "Removing filter", CommitFiles: "Commit files", SubCommitsDynamicTitle: "Commits (%s)", CommitFilesDynamicTitle: "Diff files (%s)", From 1b901c7187d7d6d886c495d7bc441fcfc34f25bd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 11:57:15 +0200 Subject: [PATCH 66/97] Scroll the selection into view after a filtering mode change The commit list a filtering mode change leaves behind has nothing to do with the one that was showing, so the scroll position it inherits says nothing about where the selection ended up, and the selection can land anywhere off screen. PostRefreshUpdate only moves the cursor within the existing scroll position, so ask for the scroll separately, the way the commits refresh does when it moves the selection itself. Exiting filtering mode looked like it worked, but only by accident: the commits refresh recognizes the commit that was selected before it ran, selects it again at its new index, and scrolls because the index moved. That does nothing for the case where the commit is gone from the list, or for entering filtering mode, where we select the first commit ourselves. --- pkg/gui/controllers/helpers/mode_helper.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 68f7ea149..6e5139744 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -270,6 +270,11 @@ func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) e selectCommit() self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) + // The list we just selected in has nothing to do with the one + // that was showing, so wherever it was scrolled to says nothing + // about where the selection now is. PostRefreshUpdate leaves the + // scroll position alone, so ask for it separately. + self.c.Contexts().LocalCommits.FocusLine(true) return nil }, }) From d2a1a4f2a2f5a5c4515e6a02f02ff7e81dd3c9b9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 14:34:47 +0200 Subject: [PATCH 67/97] Start all commands of a pipeline before waiting for any of them PipeCommands ran every command in its own goroutine, each doing Start/read-stderr/Wait, with nothing ordering one goroutine's Start against another's Wait. That ordering matters: StdoutPipe registers the parent's read end in cmd.parentIOPipes, and Cmd.Wait closes those descriptors when it returns. The next command's Stdin is that very *os.File, and exec passes a user-supplied *os.File through untouched, so Start hands the child whatever the fd happens to be at that moment. If the producer finished and got reaped before the consumer's goroutine reached Start, that fd was already closed, File.Fd() returned -1, and the child was started with fd 0 closed -- reading nothing at all. The only caller is the pre-2.35 fallback in SaveStagedChanges, which pipes `git stash show -p` into `git apply -R`. Losing that race left git apply with an empty patch, so it failed with "unrecognized input", the following `git stash drop` never ran, and the user was left with a stray stash entry. This turned up as a flaky stash/stash_staged on the git 2.32.0 CI job; the newer-git jobs take the `git stash push --staged` path and never reach this code. Starting every command up front removes the race, and collecting stderr into buffers lets exec's own copying goroutines do the work. That also fixes two lesser problems in the same function: finalErrors was appended to from several goroutines without synchronization, and a failed Start was only logged, so a pipeline that never ran reported success. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/oscommands/os.go | 70 ++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/pkg/commands/oscommands/os.go b/pkg/commands/oscommands/os.go index f1ee86c4c..fed095a28 100644 --- a/pkg/commands/oscommands/os.go +++ b/pkg/commands/oscommands/os.go @@ -1,12 +1,12 @@ package oscommands import ( + "bytes" "io" "os" "os/exec" "path/filepath" "strings" - "sync" "github.com/go-errors/errors" "github.com/samber/lo" @@ -228,37 +228,47 @@ func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error { // keeping this here in case I adapt this code for some other purpose in the future // cmds[len(cmds)-1].Stdout = os.Stdout - finalErrors := []string{} - - wg := sync.WaitGroup{} - wg.Add(len(cmds)) - - for _, cmd := range cmds { - go utils.Safe(func() { - stderr, err := cmd.StderrPipe() - if err != nil { - c.Log.Error(err) - } - - if err := cmd.Start(); err != nil { - c.Log.Error(err) - } - - if b, err := io.ReadAll(stderr); err == nil { - if len(b) > 0 { - finalErrors = append(finalErrors, string(b)) - } - } - - if err := cmd.Wait(); err != nil { - c.Log.Error(err) - } - - wg.Done() - }) + stderrs := make([]bytes.Buffer, len(cmds)) + for i := range cmds { + cmds[i].Stderr = &stderrs[i] } - wg.Wait() + // Start every command before waiting for any of them: waiting for a command + // closes our end of the pipe that feeds the next one, and a command that + // hasn't been started by then would inherit a closed stdin. + started := 0 + var startErr error + for _, cmd := range cmds { + if err := cmd.Start(); err != nil { + startErr = err + break + } + + started++ + } + + finalErrors := []string{} + + if startErr != nil { + c.Log.Error(startErr) + finalErrors = append(finalErrors, startErr.Error()) + + // Without the rest of the pipeline to drain them, the commands we did + // start could block forever writing to a full pipe. + for _, cmd := range cmds[:started] { + _ = cmd.Process.Kill() + } + } + + for i, cmd := range cmds[:started] { + if err := cmd.Wait(); err != nil { + c.Log.Error(err) + } + + if stderrs[i].Len() > 0 { + finalErrors = append(finalErrors, stderrs[i].String()) + } + } if len(finalErrors) > 0 { return errors.New(strings.Join(finalErrors, "\n")) From a9bb960e5c53173de708d710ec0c948279bf98ea Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 16:44:38 +0200 Subject: [PATCH 68/97] Use nixfmt's current package name in the flake The nixpkgs package set renamed the Nix code formatter: what used to be called nixfmt-rfc-style is now simply nixfmt, and the old name lives on only as an alias. Both names resolve to the identical program, so this doesn't change how anything gets formatted. The rename comes first because the dependency update in the next commit makes that alias start printing a deprecation warning, so doing it in this order means no commit in the history evaluates with one. --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 8d0078d16..fcc58a044 100644 --- a/flake.nix +++ b/flake.nix @@ -109,8 +109,8 @@ }; treefmt = { - programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt-rfc-style.compiler; - programs.nixfmt.package = pkgs.nixfmt-rfc-style; + programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt.compiler; + programs.nixfmt.package = pkgs.nixfmt; programs.gofmt.enable = true; }; From 71ff5fd827a48255111adc22561496a6c3883f79 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 16:44:38 +0200 Subject: [PATCH 69/97] Update the versions pinned for the Nix build flake.lock pins the exact revision of everything the Nix build pulls in (the package set it takes git, Go and the formatters from, plus a few helper libraries), much like go.sum does for Go modules. Nothing refreshes it automatically and no CI job exercises it, so the pins had quietly drifted the better part of a year behind. The flake-compat entry also grows a "?rev=...&revCount=..." suffix on its download URL. That is not a version change: the server hosting the archive advertises those two values as part of the canonical URL now, and they only restate the rev and revCount that the same entry already lists as separate fields. The archive fetched is byte-for-byte identical either way. --- flake.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/flake.lock b/flake.lock index 55f0c3139..672a2d56c 100644 --- a/flake.lock +++ b/flake.lock @@ -7,7 +7,7 @@ "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", "revCount": 69, "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz" + "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz?rev=ff81ac966bb2cae68946d5ed5fc4994f96d0ffec&revCount=69" }, "original": { "type": "tarball", @@ -19,11 +19,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1759362264, - "narHash": "sha256-wfG0S7pltlYyZTM+qqlhJ7GMw2fTF4mLKCIVhLii/4M=", + "lastModified": 1785627969, + "narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "758cf7296bee11f1706a574c77d072b8a7baa881", + "rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a", "type": "github" }, "original": { @@ -34,11 +34,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1759831965, - "narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=", + "lastModified": 1785828668, + "narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c9b6fb798541223bbb396d287d16f43520250518", + "rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb", "type": "github" }, "original": { @@ -50,11 +50,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1754788789, - "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", + "lastModified": 1785031560, + "narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "a73b9c743612e4244d865a2fdee11865283c04e6", + "rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c", "type": "github" }, "original": { @@ -65,11 +65,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1754340878, - "narHash": "sha256-lgmUyVQL9tSnvvIvBp7x1euhkkCho7n3TMzgjdvgPoU=", + "lastModified": 1770107345, + "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", "owner": "nixos", "repo": "nixpkgs", - "rev": "cab778239e705082fe97bb4990e0d24c50924c04", + "rev": "4533d9293756b63904b7238acb84ac8fe4c8c2c4", "type": "github" }, "original": { @@ -108,11 +108,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1758728421, - "narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=", + "lastModified": 1785360170, + "narHash": "sha256-XE1lKgQ3eIO3E7zWryqcRsax+mYXod/5RHBn4YaR9YE=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1", + "rev": "d1187f8bc71fb8aab02395869ec3f5c1920f75c0", "type": "github" }, "original": { From e1391298aa1098e75e107f58542cd3ee221f530f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:13:36 +0200 Subject: [PATCH 70/97] Simplify the run_integration_tests.sh script Our minimum required git version is 2.32.0, which is the first version that does support the GIT_CONFIG_GLOBAL env var, so we don't need the ~/.gitconfig dance any more. --- scripts/run_integration_tests.sh | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/scripts/run_integration_tests.sh b/scripts/run_integration_tests.sh index 2bf010f19..0c659ebfa 100755 --- a/scripts/run_integration_tests.sh +++ b/scripts/run_integration_tests.sh @@ -2,16 +2,6 @@ echo "Running integration tests with $(git --version)" -# This is ugly, but older versions of git don't support the GIT_CONFIG_GLOBAL -# env var; the only way to run tests for these old versions is to copy our test -# config file to the actual global location. Move an existing file out of the -# way so that we can restore it at the end. -if test -f ~/.gitconfig; then - mv ~/.gitconfig ~/.gitconfig.lazygit.bak -fi - -cp test/global_git_config ~/.gitconfig - # if the LAZYGIT_GOCOVERDIR env var is set, we'll capture code coverage data if [ -n "$LAZYGIT_GOCOVERDIR" ]; then # Go expects us to either be running the test binary directly or running `go test`, but because @@ -33,10 +23,6 @@ else EXITCODE=$? fi -if test -f ~/.gitconfig.lazygit.bak; then - mv ~/.gitconfig.lazygit.bak ~/.gitconfig -fi - # If per-test timings were collected (LAZYGIT_TEST_TIMING points at the file the # harness appends to), print them sorted by slowest first so they show up in the # CI log. From 7cbd93f945558522694438138b48dd8625023533 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:17:45 +0200 Subject: [PATCH 71/97] Give the worktree loader tests their repos' git dirs The scenarios describe their repo by its paths but leave the git dirs empty, which no repo has. Unused for now; the loader is about to want them. Co-Authored-By: Claude Opus 5 (1M context) --- .../git_commands/worktree_loader_test.go | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/pkg/commands/git_commands/worktree_loader_test.go b/pkg/commands/git_commands/worktree_loader_test.go index 1127540ca..151f20e8c 100644 --- a/pkg/commands/git_commands/worktree_loader_test.go +++ b/pkg/commands/git_commands/worktree_loader_test.go @@ -23,8 +23,10 @@ func TestGetWorktrees(t *testing.T) { { testName: "Single worktree (main)", repoPaths: &RepoPaths{ - repoPath: "/path/to/repo", - worktreePath: "/path/to/repo", + repoPath: "/path/to/repo", + worktreePath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + worktreeGitDirPath: "/path/to/repo/.git", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, @@ -55,8 +57,10 @@ branch refs/heads/mybranch { testName: "Multiple worktrees (main + linked)", repoPaths: &RepoPaths{ - repoPath: "/path/to/repo", - worktreePath: "/path/to/repo", + repoPath: "/path/to/repo", + worktreePath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + worktreeGitDirPath: "/path/to/repo/.git", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, @@ -106,8 +110,10 @@ branch refs/heads/mybranch-worktree { testName: "Worktree missing path", repoPaths: &RepoPaths{ - repoPath: "/path/to/repo", - worktreePath: "/path/to/repo", + repoPath: "/path/to/repo", + worktreePath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + worktreeGitDirPath: "/path/to/repo/.git", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, @@ -136,8 +142,10 @@ branch refs/heads/missingbranch { testName: "In linked worktree", repoPaths: &RepoPaths{ - repoPath: "/path/to/repo", - worktreePath: "/path/to/repo-worktree", + repoPath: "/path/to/repo", + worktreePath: "/path/to/repo-worktree", + repoGitDirPath: "/path/to/repo/.git", + worktreeGitDirPath: "/path/to/repo/.git/worktrees/repo-worktree", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, @@ -187,8 +195,10 @@ branch refs/heads/mybranch-worktree { testName: "Detached HEAD worktree", repoPaths: &RepoPaths{ - repoPath: "/path/to/repo", - worktreePath: "/path/to/repo", + repoPath: "/path/to/repo", + worktreePath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + worktreeGitDirPath: "/path/to/repo/.git", }, before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, From e1b8ef048aeb615250d7547fc828b48920ca73e8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:26:14 +0200 Subject: [PATCH 72/97] Add a worktree loader test for being in a submodule A submodule's git dir doesn't live inside its working tree, and `git worktree list` reports it by its git dir. Lazygit compares that against the working tree path, so it recognizes neither the current nor the main worktree, and the UI ends up claiming we're in a linked worktree. Co-Authored-By: Claude Opus 5 (1M context) --- .../git_commands/worktree_loader_test.go | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pkg/commands/git_commands/worktree_loader_test.go b/pkg/commands/git_commands/worktree_loader_test.go index 151f20e8c..6f612bbd9 100644 --- a/pkg/commands/git_commands/worktree_loader_test.go +++ b/pkg/commands/git_commands/worktree_loader_test.go @@ -192,6 +192,48 @@ branch refs/heads/mybranch-worktree }, expectedErr: "", }, + { + testName: "In a submodule", + repoPaths: &RepoPaths{ + repoPath: "/path/to/repo/mysubmodule", + worktreePath: "/path/to/repo/mysubmodule", + repoGitDirPath: "/path/to/repo/.git/modules/mysubmodule", + worktreeGitDirPath: "/path/to/repo/.git/modules/mysubmodule", + }, + before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) { + // A submodule's git dir doesn't live inside its working tree, and + // `git worktree list` reports the git dir rather than the working + // tree it belongs to. + runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"}, + `worktree /path/to/repo/.git/modules/mysubmodule +HEAD d85cc9d281fa6ae1665c68365fc70e75e82a042d +branch refs/heads/mybranch +`, + nil) + + gitArgs := append(append([]string{"-C", "/path/to/repo/.git/modules/mysubmodule"}, getRevParseArgs()...), "--absolute-git-dir") + runner.ExpectGitArgs(gitArgs, "/path/to/repo/.git/modules/mysubmodule", nil) + + _ = fs.MkdirAll("/path/to/repo/.git/modules/mysubmodule", 0o755) + }, + expectedWorktrees: []*models.Worktree{ + { + /* EXPECTED: + IsMain: true, + IsCurrent: true, + ACTUAL: */ + IsMain: false, + IsCurrent: false, + Path: "/path/to/repo/.git/modules/mysubmodule", + IsPathMissing: false, + GitDir: "/path/to/repo/.git/modules/mysubmodule", + Branch: "mybranch", + Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d", + Name: "mysubmodule", + }, + }, + expectedErr: "", + }, { testName: "Detached HEAD worktree", repoPaths: &RepoPaths{ From d2d5bdc2bcd66d6eba2db9922442d0cf996e0d41 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 22:36:53 +0200 Subject: [PATCH 73/97] Identify the current and main worktree by git dir, not by path `git worktree list` reports the main worktree as the common git dir with a trailing "/.git" removed, which equals the working tree only when the git dir sits inside it. In a submodule, a bare repo, or a repo using core.worktree it doesn't, so comparing the reported path against the working tree path matches nothing: no worktree is recognized as current or as main. Most visibly, inside a submodule lazygit claimed we were in a linked worktree named after the submodule, and offered to remove that "worktree". Comparing git dirs identifies a worktree unambiguously, so use that. A worktree whose directory is gone has no git dir to compare, and there we still have nothing better than its path. The submodule tests were asserting the linked-worktree suffix in the status view; it is gone now, and the repo name still says which submodule we're in. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/worktree_loader.go | 30 ++++++++++++------- .../git_commands/worktree_loader_test.go | 4 --- pkg/integration/tests/submodule/enter.go | 2 +- .../tests/submodule/enter_nested.go | 2 +- pkg/integration/tests/submodule/reset.go | 2 +- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/pkg/commands/git_commands/worktree_loader.go b/pkg/commands/git_commands/worktree_loader.go index 0e3615f13..adf1e48dc 100644 --- a/pkg/commands/git_commands/worktree_loader.go +++ b/pkg/commands/git_commands/worktree_loader.go @@ -22,9 +22,6 @@ func NewWorktreeLoader(gitCommon *GitCommon) *WorktreeLoader { } func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { - currentRepoPath := self.repoPaths.RepoPath() - worktreePath := self.repoPaths.WorktreePath() - cmdArgs := NewGitCmd("worktree").Arg("list", "--porcelain").ToArgv() worktreesOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() if err != nil { @@ -54,17 +51,13 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { if strings.HasPrefix(splitLine, "worktree ") { path := strings.SplitN(splitLine, " ", 2)[1] - isMain := path == currentRepoPath - isCurrent := path == worktreePath - isPathMissing := self.pathExists(path) current = &models.Worktree{ - IsMain: isMain, - IsCurrent: isCurrent, - IsPathMissing: isPathMissing, + IsPathMissing: self.pathExists(path), Path: path, // we defer populating GitDir until a loop below so that - // we can parallelize the calls to git rev-parse + // we can parallelize the calls to git rev-parse, and + // IsMain/IsCurrent because they are derived from GitDir GitDir: "", } } else if strings.HasPrefix(splitLine, "HEAD ") { @@ -95,6 +88,23 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { } wg.Wait() + // Identify the current and the main worktree by their git dir rather than by + // their path: `git worktree list` reports the main worktree as the common + // git dir with a trailing "/.git" removed, which is the working tree only + // when the git dir sits inside it. In a submodule, a bare repo or a repo + // using core.worktree it doesn't, and comparing paths then matches nothing. + // A worktree whose directory is gone has no git dir to compare, so there we + // have nothing better than its path. + for _, worktree := range worktrees { + if worktree.GitDir != "" { + worktree.IsCurrent = worktree.GitDir == self.repoPaths.WorktreeGitDirPath() + worktree.IsMain = worktree.GitDir == self.repoPaths.RepoGitDirPath() + } else { + worktree.IsCurrent = worktree.Path == self.repoPaths.WorktreePath() + worktree.IsMain = worktree.Path == self.repoPaths.RepoPath() + } + } + names := getUniqueNamesFromPaths(lo.Map(worktrees, func(worktree *models.Worktree, _ int) string { return worktree.Path })) diff --git a/pkg/commands/git_commands/worktree_loader_test.go b/pkg/commands/git_commands/worktree_loader_test.go index 6f612bbd9..c537c6be4 100644 --- a/pkg/commands/git_commands/worktree_loader_test.go +++ b/pkg/commands/git_commands/worktree_loader_test.go @@ -218,12 +218,8 @@ branch refs/heads/mybranch }, expectedWorktrees: []*models.Worktree{ { - /* EXPECTED: IsMain: true, IsCurrent: true, - ACTUAL: */ - IsMain: false, - IsCurrent: false, Path: "/path/to/repo/.git/modules/mysubmodule", IsPathMissing: false, GitDir: "/path/to/repo/.git/modules/mysubmodule", diff --git a/pkg/integration/tests/submodule/enter.go b/pkg/integration/tests/submodule/enter.go index b768ed40e..67df35276 100644 --- a/pkg/integration/tests/submodule/enter.go +++ b/pkg/integration/tests/submodule/enter.go @@ -29,7 +29,7 @@ var Enter = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Status().Content(Contains("repo")) } assertInSubmodule := func() { - t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)")) + t.Views().Status().Content(Contains("my_submodule_path")) } assertInParentRepo() diff --git a/pkg/integration/tests/submodule/enter_nested.go b/pkg/integration/tests/submodule/enter_nested.go index 24cdf5261..1fc96425c 100644 --- a/pkg/integration/tests/submodule/enter_nested.go +++ b/pkg/integration/tests/submodule/enter_nested.go @@ -37,7 +37,7 @@ var EnterNested = NewIntegrationTest(NewIntegrationTestArgs{ // enter the nested submodule PressEnter() - t.Views().Status().Content(Contains("innerSubPath(innerSubName)")) + t.Views().Status().Content(Contains("innerSubPath")) t.Views().Commits().ContainsLines( Contains("initial inner commit"), ) diff --git a/pkg/integration/tests/submodule/reset.go b/pkg/integration/tests/submodule/reset.go index d671066a1..6c23bbd85 100644 --- a/pkg/integration/tests/submodule/reset.go +++ b/pkg/integration/tests/submodule/reset.go @@ -31,7 +31,7 @@ var Reset = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Status().Content(Contains("repo")) } assertInSubmodule := func() { - t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)")) + t.Views().Status().Content(Contains("my_submodule_path")) } assertInParentRepo() From 180039e78c7eda0b7f0698a970a4380d5a51710f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:26:58 +0200 Subject: [PATCH 74/97] Add a repo paths test for a repo with a separate work tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the work tree lives somewhere else entirely — set up with core.worktree or --work-tree — we're still in the main worktree, so RepoPath() should be the work tree, as its own doc comment says. Instead we derive it from the git dir's location, which lands somewhere that isn't a worktree at all, and the repo name follows it. The ACTUAL lines are indented as they will be once the EXPECTED ones replace them, rather than as gofumpt wants them while the comment markers are still splitting the struct's alignment. That leaves this one file not gofumpt-clean until the next commit, in exchange for a diff there that shows only the lines that actually change. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/repo_paths_test.go | 62 ++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index 29c40acee..54da6b978 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -123,6 +123,68 @@ func TestGetRepoPaths(t *testing.T) { }), Err: nil, }, + { + // A repo whose work tree lives somewhere else entirely, as set up by + // core.worktree or by --work-tree. We're in the main worktree, but the + // git dir is not inside it. + Name: "repo with a separate work tree", + BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { + mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{ + // --show-toplevel + `C:\path\to\worktree`, + // --git-dir + `C:\path\to\repo\.git`, + // --git-common-dir + `C:\path\to\repo\.git`, + // --is-bare-repository + "false", + // --show-superproject-working-tree + }, []string{ + // --show-toplevel + "/path/to/worktree", + // --git-dir + "/path/to/repo/.git", + // --git-common-dir + "/path/to/repo/.git", + // --is-bare-repository + "false", + // --show-superproject-working-tree + }) + runner.ExpectGitArgs( + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + strings.Join(mockOutput, "\n"), + nil) + }, + Path: "/path/to/repo", + Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ + worktreePath: `C:\path\to\worktree`, + worktreeGitDirPath: `C:\path\to\repo\.git`, + /* EXPECTED: + repoPath: `C:\path\to\worktree`, + ACTUAL: */ + repoPath: `C:\path\to\repo`, + repoGitDirPath: `C:\path\to\repo\.git`, + /* EXPECTED: + repoName: `worktree`, + ACTUAL: */ + repoName: `repo`, + isBareRepo: false, + }, &RepoPaths{ + worktreePath: "/path/to/worktree", + worktreeGitDirPath: "/path/to/repo/.git", + /* EXPECTED: + repoPath: "/path/to/worktree", + ACTUAL: */ + repoPath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + /* EXPECTED: + repoName: "worktree", + ACTUAL: */ + repoName: "repo", + isBareRepo: false, + }), + Err: nil, + }, { Name: "submodule", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { From e17ed2484ce8dfaebc0c7c3ad9d9d2d4a0b7d2c3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:29:20 +0200 Subject: [PATCH 75/97] Use the work tree as the repo path when it is the main worktree RepoPath() is meant to be the same as WorktreePath() when we're in the main worktree, but we derived it from the git dir's location instead. That is only the same thing when the git dir lives inside the work tree. With core.worktree, --work-tree, or a .git file pointing at a repo dir that isn't called .git, it lands on a directory that isn't a worktree at all, and the repo name we show follows it there. A worktree that has the repo's common git dir to itself is the main worktree, so use its path. That subsumes the submodule case, whose git dir lives under the superproject's .git/modules but is still the submodule's own common dir; --show-superproject-working-tree is now only needed for a linked worktree of a submodule. The existing bare repo test asserted a git output that can't occur (a work tree and --is-bare-repository=true at once), but the rest of it is the shape of a repo opened with --git-dir/--work-tree, where the new repo path is the correct one. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/repo_paths.go | 27 +++++++++++++++----- pkg/commands/git_commands/repo_paths_test.go | 20 +++------------ 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index c64debfc5..6783c0971 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -95,15 +95,30 @@ func GetRepoPathsForDir( repoGitDirPath := gitDirResults[2] isBareRepo := gitDirResults[3] == "true" - // If we're in a submodule, --show-superproject-working-tree will return - // a value, meaning gitDirResults will be length 5. In that case - // return the worktree path as the repoPath. Otherwise we're in a - // normal repo or a worktree so return the parent of the git common - // dir (repoGitDirPath) + // A worktree that has the repo's common git dir to itself is the repo's main + // worktree, so it is the repoPath. That holds for a submodule as well: its + // git dir lives under the superproject's .git/modules, but it is still the + // submodule's own common dir. + isMainWorktree := worktreeGitDirPath == repoGitDirPath + + // If we're in a submodule, --show-superproject-working-tree will return a + // value, meaning gitDirResults will be length 5. That only tells us anything + // new for a linked worktree of a submodule, which isMainWorktree misses. isSubmodule := len(gitDirResults) == 5 + // Otherwise we're in a linked worktree, and the repoPath is the repo's main + // worktree. git won't tell us where that is: `git worktree list` reports it + // as the common git dir with a trailing "/.git" removed, which is this same + // derivation. So take the directory holding the common git dir. That is the + // main worktree of an ordinary repo, and of a bare one it is the directory + // its worktrees live in. It is not the main worktree of a repo that moved + // that elsewhere with core.worktree; there we end up naming the git dir's + // directory, which means that the repo name we display in the status panel + // isn't correct, and we start looking for .lazygit.yml in the wrong place. + // Both of those are not severe enough to justify the extra git call to get + // the real main worktree, so we accept this for this rather niche use case. var repoPath string - if isSubmodule { + if isMainWorktree || isSubmodule { repoPath = worktreePath } else { repoPath = filepath.Dir(repoGitDirPath) diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index 54da6b978..25fd1b2e0 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -109,16 +109,16 @@ func TestGetRepoPaths(t *testing.T) { Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ worktreePath: `C:\path\to\repo`, worktreeGitDirPath: `C:\path\to\bare_repo\bare.git`, - repoPath: `C:\path\to\bare_repo`, + repoPath: `C:\path\to\repo`, repoGitDirPath: `C:\path\to\bare_repo\bare.git`, - repoName: `bare_repo`, + repoName: `repo`, isBareRepo: true, }, &RepoPaths{ worktreePath: "/path/to/repo", worktreeGitDirPath: "/path/to/bare_repo/bare.git", - repoPath: "/path/to/bare_repo", + repoPath: "/path/to/repo", repoGitDirPath: "/path/to/bare_repo/bare.git", - repoName: "bare_repo", + repoName: "repo", isBareRepo: true, }), Err: nil, @@ -159,28 +159,16 @@ func TestGetRepoPaths(t *testing.T) { Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ worktreePath: `C:\path\to\worktree`, worktreeGitDirPath: `C:\path\to\repo\.git`, - /* EXPECTED: repoPath: `C:\path\to\worktree`, - ACTUAL: */ - repoPath: `C:\path\to\repo`, repoGitDirPath: `C:\path\to\repo\.git`, - /* EXPECTED: repoName: `worktree`, - ACTUAL: */ - repoName: `repo`, isBareRepo: false, }, &RepoPaths{ worktreePath: "/path/to/worktree", worktreeGitDirPath: "/path/to/repo/.git", - /* EXPECTED: repoPath: "/path/to/worktree", - ACTUAL: */ - repoPath: "/path/to/repo", repoGitDirPath: "/path/to/repo/.git", - /* EXPECTED: repoName: "worktree", - ACTUAL: */ - repoName: "repo", isBareRepo: false, }), Err: nil, From ca6c0500e61dce27f0bbd45c3bb876d0e37068a0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 22:45:10 +0200 Subject: [PATCH 76/97] Don't clear gui.git when we fail to open a repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onNewRepo also runs when switching repos, and a failure there leaves us in the repo we came from — with a nil GitCommand, which nothing else is prepared for. Only assign once we have one. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/gui.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index a7466d584..b92c1733e 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -340,8 +340,10 @@ func (gui *Gui) onSwitchToNewRepo(startArgs appTypes.StartArgs, contextKey types } func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.ContextKey) error { - var err error - gui.git, err = commands.NewGitCommand( + // Don't assign to gui.git until we know we have one: this also runs when + // switching repos, and leaving the field nil would take down the repo we + // were in before, which is where the error puts us back. + git, err := commands.NewGitCommand( gui.Common, gui.gitVersion, gui.os, @@ -351,6 +353,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context if err != nil { return err } + gui.git = git err = gui.Config.ReloadUserConfigForRepo(gui.getPerRepoConfigFiles()) if err != nil { From e10a2f6a271e953b16b46af5725c5124e38724f4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:29:41 +0200 Subject: [PATCH 77/97] Stop promising bare repo support "does not yet support" reads as a promise that it will, but it's quite likely that it never will. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/i18n/english.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 845155aed..da157ff78 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -1619,7 +1619,7 @@ func EnglishTranslationSet() *TranslationSet { DiscardFileChangesPromptResetPatch: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\n\nNote: This will reset the active custom patch!", DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.", CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ", - BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not yet support bare repos. Open most recent repo? (y/n) ", + BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not support bare repos. Open most recent repo? (y/n) ", InitialBranch: "Branch name? (leave empty for git's default): ", NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.", IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.", From 0ce248d1bf25ef7d422fe51441b7cf63643cdea9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 22:49:32 +0200 Subject: [PATCH 78/97] Recognize a repo that has no work tree instead of bailing out git makes `rev-parse --show-toplevel` fatal when there's no work tree, so asking for it together with everything else meant we never got an answer at all for a bare repo: GetRepoPaths returned an error, nobody ever saw IsBareRepo() == true, and lazygit either died with a stack trace or decided we weren't in a repository. That's what you got for opening it in a directory holding a bare repo and a .git file pointing at it, which is a normal way to keep a repo and its worktrees together. Ask again without --show-toplevel when the first query fails: the other queries work fine without a work tree, so if they now succeed we know we're in a bare repo, and the existing prompt offering to open a recent repo does its job. If they fail too we're not in a repo at all, and the first error already says so. --is-bare-repository is gone from the query: a work tree implies core.bare is false, so it could only ever come back false there, and what matters to us is whether there is a work tree to show, which is what we now go by. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/app/errors.go | 2 +- pkg/commands/git.go | 7 ++ pkg/commands/git_commands/repo_paths.go | 55 ++++++++++++-- pkg/commands/git_commands/repo_paths_test.go | 77 +++++++++----------- pkg/i18n/english.go | 2 + 5 files changed, 91 insertions(+), 52 deletions(-) diff --git a/pkg/app/errors.go b/pkg/app/errors.go index 506fec276..ee24cff49 100644 --- a/pkg/app/errors.go +++ b/pkg/app/errors.go @@ -16,7 +16,7 @@ type errorMapping struct { func knownError(tr *i18n.TranslationSet, err error) (string, bool) { errorMessage := err.Error() - knownErrorMessages := []string{minGitVersionErrorMessage(tr)} + knownErrorMessages := []string{minGitVersionErrorMessage(tr), tr.BareRepoNotSupported} if lo.Contains(knownErrorMessages, errorMessage) { return errorMessage, true diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 6a08c1d2b..a5fc0e608 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -67,6 +67,13 @@ func NewGitCommand( return nil, errors.Errorf("Error getting repo paths: %v", err) } + // A bare repo has no worktree for us to work in. Callers that can offer the + // user something better (app.setupRepo) check for this first; getting here + // means nobody could, e.g. because --git-dir was pointed at a bare repo. + if repoPaths.IsBareRepo() { + return nil, errors.New(cmn.Tr.BareRepoNotSupported) + } + err = os.Chdir(repoPaths.WorktreePath()) if err != nil { return nil, utils.WrapError(err) diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 6783c0971..89a86640b 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -22,7 +22,8 @@ type RepoPaths struct { } // Path to the current worktree. If we're in the main worktree, this will -// be the same as RepoPath() +// be the same as RepoPath(). It is empty for a bare repo, which has no +// worktree at all. func (self *RepoPaths) WorktreePath() string { return self.worktreePath } @@ -53,6 +54,9 @@ func (self *RepoPaths) RepoName() string { return self.repoName } +// Whether the repo has no worktree, so that there is nothing for lazygit to +// show. Note that this isn't quite git's core.bare: a repo that calls itself +// non-bare but doesn't have a worktree either counts as bare for us. func (self *RepoPaths) IsBareRepo() bool { return self.isBareRepo } @@ -84,16 +88,18 @@ func GetRepoPathsForDir( dir string, cmd oscommands.ICmdObjBuilder, ) (*RepoPaths, error) { - gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree") + gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree") if err != nil { - return nil, err + // --show-toplevel is the only one of these that needs a work tree, and + // git makes it fatal when there isn't one. So this may just mean we're in + // a repo that has no work tree. + return getBareRepoPathsForDir(dir, cmd, err) } gitDirResults := strings.Split(utils.NormalizeLinefeeds(gitDirOutput), "\n") worktreePath := gitDirResults[0] worktreeGitDirPath := gitDirResults[1] repoGitDirPath := gitDirResults[2] - isBareRepo := gitDirResults[3] == "true" // A worktree that has the repo's common git dir to itself is the repo's main // worktree, so it is the repoPath. That holds for a submodule as well: its @@ -102,9 +108,9 @@ func GetRepoPathsForDir( isMainWorktree := worktreeGitDirPath == repoGitDirPath // If we're in a submodule, --show-superproject-working-tree will return a - // value, meaning gitDirResults will be length 5. That only tells us anything + // value, meaning gitDirResults will be length 4. That only tells us anything // new for a linked worktree of a submodule, which isMainWorktree misses. - isSubmodule := len(gitDirResults) == 5 + isSubmodule := len(gitDirResults) == 4 // Otherwise we're in a linked worktree, and the repoPath is the repo's main // worktree. git won't tell us where that is: `git worktree list` reports it @@ -131,7 +137,42 @@ func GetRepoPathsForDir( repoPath: repoPath, repoGitDirPath: repoGitDirPath, repoName: repoName, - isBareRepo: isBareRepo, + isBareRepo: false, + }, nil +} + +// getBareRepoPathsForDir is the fallback for when we couldn't ask git for the +// work tree. Everything but --show-toplevel works fine without one, so if the +// remaining queries succeed we are in a bare repo, and we return what we know +// about it with an empty worktreePath. If they fail too we simply aren't in a +// repo, and the caller's original error says so better than ours would. +func getBareRepoPathsForDir( + dir string, + cmd oscommands.ICmdObjBuilder, + errWithWorktree error, +) (*RepoPaths, error) { + output, err := callGitRevParseWithDir(cmd, dir, "--absolute-git-dir", "--git-common-dir") + if err != nil { + return nil, errWithWorktree + } + + results := strings.Split(utils.NormalizeLinefeeds(output), "\n") + repoGitDirPath := results[1] + // A bare repo has no worktree, and so no repo path in the sense the caller + // with a worktree means. It doesn't matter much what we say here, because + // nobody reads it: whoever is handed a bare repo either offers to open a + // recent one instead (app.setupRepo) or is turned away by NewGitCommand. The + // directory holding the git dir is the nearest thing there is to a repo + // path. + repoPath := filepath.Dir(repoGitDirPath) + + return &RepoPaths{ + worktreePath: "", + worktreeGitDirPath: results[0], + repoPath: repoPath, + repoGitDirPath: repoGitDirPath, + repoName: filepath.Base(repoPath), + isBareRepo: true, }, nil } diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index 25fd1b2e0..fed4905a8 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -38,8 +38,6 @@ func TestGetRepoPaths(t *testing.T) { `C:\path\to\repo\.git`, // --git-common-dir `C:\path\to\repo\.git`, - // --is-bare-repository - "false", // --show-superproject-working-tree }, []string{ // --show-toplevel @@ -48,12 +46,10 @@ func TestGetRepoPaths(t *testing.T) { "/path/to/repo/.git", // --git-common-dir "/path/to/repo/.git", - // --is-bare-repository - "false", // --show-superproject-working-tree }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, @@ -76,49 +72,45 @@ func TestGetRepoPaths(t *testing.T) { Err: nil, }, { + // git refuses to answer --show-toplevel when there's no work tree, so + // we have to ask a second time without it. Name: "bare repo", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { - // setup for main worktree + runner.ExpectGitArgs( + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), + "", + errors.New("fatal: this operation must be run in a work tree")) + mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{ - // --show-toplevel - `C:\path\to\repo`, // --git-dir - `C:\path\to\bare_repo\bare.git`, + `C:\path\to\project\bare.git`, // --git-common-dir - `C:\path\to\bare_repo\bare.git`, - // --is-bare-repository - `true`, - // --show-superproject-working-tree + `C:\path\to\project\bare.git`, }, []string{ - // --show-toplevel - "/path/to/repo", // --git-dir - "/path/to/bare_repo/bare.git", + "/path/to/project/bare.git", // --git-common-dir - "/path/to/bare_repo/bare.git", - // --is-bare-repository - "true", - // --show-superproject-working-tree + "/path/to/project/bare.git", }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"), strings.Join(mockOutput, "\n"), nil) }, - Path: "/path/to/repo", + Path: "/path/to/project", Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ - worktreePath: `C:\path\to\repo`, - worktreeGitDirPath: `C:\path\to\bare_repo\bare.git`, - repoPath: `C:\path\to\repo`, - repoGitDirPath: `C:\path\to\bare_repo\bare.git`, - repoName: `repo`, + worktreePath: "", + worktreeGitDirPath: `C:\path\to\project\bare.git`, + repoPath: `C:\path\to\project`, + repoGitDirPath: `C:\path\to\project\bare.git`, + repoName: `project`, isBareRepo: true, }, &RepoPaths{ - worktreePath: "/path/to/repo", - worktreeGitDirPath: "/path/to/bare_repo/bare.git", - repoPath: "/path/to/repo", - repoGitDirPath: "/path/to/bare_repo/bare.git", - repoName: "repo", + worktreePath: "", + worktreeGitDirPath: "/path/to/project/bare.git", + repoPath: "/path/to/project", + repoGitDirPath: "/path/to/project/bare.git", + repoName: "project", isBareRepo: true, }), Err: nil, @@ -136,8 +128,6 @@ func TestGetRepoPaths(t *testing.T) { `C:\path\to\repo\.git`, // --git-common-dir `C:\path\to\repo\.git`, - // --is-bare-repository - "false", // --show-superproject-working-tree }, []string{ // --show-toplevel @@ -146,12 +136,10 @@ func TestGetRepoPaths(t *testing.T) { "/path/to/repo/.git", // --git-common-dir "/path/to/repo/.git", - // --is-bare-repository - "false", // --show-superproject-working-tree }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, @@ -183,8 +171,6 @@ func TestGetRepoPaths(t *testing.T) { `C:\path\to\repo\.git\modules\submodule1`, // --git-common-dir `C:\path\to\repo\.git\modules\submodule1`, - // --is-bare-repository - `false`, // --show-superproject-working-tree `C:\path\to\repo`, }, []string{ @@ -194,13 +180,11 @@ func TestGetRepoPaths(t *testing.T) { "/path/to/repo/.git/modules/submodule1", // --git-common-dir "/path/to/repo/.git/modules/submodule1", - // --is-bare-repository - "false", // --show-superproject-working-tree "/path/to/repo", }) runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) }, @@ -226,7 +210,12 @@ func TestGetRepoPaths(t *testing.T) { Name: "git rev-parse returns an error", BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { runner.ExpectGitArgs( - append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"), + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), + "", + errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git")) + // we're not in a repo at all, so asking about a bare one fails too + runner.ExpectGitArgs( + append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"), "", errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git")) }, @@ -234,7 +223,7 @@ func TestGetRepoPaths(t *testing.T) { Expected: nil, Err: func(getRevParseArgs argFn) error { args := strings.Join(getRevParseArgs(), " ") - return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --is-bare-repository --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args) + return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args) }, }, } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index da157ff78..f71e61adb 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -461,6 +461,7 @@ type TranslationSet struct { DisabledForGPG string CreateRepo string BareRepo string + BareRepoNotSupported string InitialBranch string NoRecentRepositories string IncorrectNotARepository string @@ -1620,6 +1621,7 @@ func EnglishTranslationSet() *TranslationSet { DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.", CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ", BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not support bare repos. Open most recent repo? (y/n) ", + BareRepoNotSupported: "Lazygit does not support bare repos.", InitialBranch: "Branch name? (leave empty for git's default): ", NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.", IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.", From 3d80e466ceffb5639fc45b48b91c100233890c7b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:34:46 +0200 Subject: [PATCH 79/97] Say why runInParentModule can name a relative directory Its working directory resolves against the process rather than against the repo the command builder pins commands to, which is only safe because nothing but foreground commands come through here. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/submodule.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index d8c1208bc..e2012942f 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -225,6 +225,13 @@ func (self *SubmoduleCommands) UpdateAll() error { // temporarily chdir-ing the process there, which would leak the parent // module's directory into whatever other commands run concurrently (e.g. a // background refresh's). +// +// That directory is relative, so it resolves against the process working +// directory rather than against the repo directory the command builder +// otherwise pins commands to. Only foreground commands the user issued end up +// here, and lazygit won't switch repos while one of those is in flight, so the +// two are the same directory; don't call this from background work, where they +// need not be. func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error { if submodule.ParentModule != nil { cmdObj.SetWd(submodule.ParentModule.FullPath()) From 616d75a1fa0d9b3995d3dd7b3ab9ca19560d7780 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 09:35:42 +0200 Subject: [PATCH 80/97] Run Reset in the parent module the way the other commands do Reset told git to change directory with -C while runInParentModule does it by setting the command's working directory, but they were computing the same directory for the same reason. Use the helper, so that there is one place that knows what running in a nested submodule's parent means. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/submodule.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index e2012942f..f31bd6196 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -199,16 +199,11 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { } func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error { - parentDir := "" - if submodule.ParentModule != nil { - parentDir = submodule.ParentModule.FullPath() - } cmdArgs := NewGitCmd("submodule"). Arg("update", "--init", "--force", "--", submodule.Path). - DirIf(parentDir != "", parentDir). ToArgv() - return self.cmd.New(cmdArgs).Run() + return self.runInParentModule(submodule, self.cmd.New(cmdArgs)) } func (self *SubmoduleCommands) UpdateAll() error { From 34d41b5d51b22843f053f8164212a95ee055dd31 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 22:56:15 +0200 Subject: [PATCH 81/97] Don't let our repo answer for a different one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GIT_DIR and GIT_WORK_TREE tell git where our repo is, and every command we run inherits them — including the ones we point at a submodule or another worktree. git resolves those against our repo instead, and says nothing about it: with GIT_DIR set, `git -C mysub log -1` reports the superproject's commit. So opening lazygit with --git-dir/--work-tree quietly broke resolving submodule conflicts, stashing and resetting a submodule, and detaching another worktree; the worktree list came back claiming every worktree shared our git dir. Drop the two variables from the commands that address another repo. Co-Authored-By: Claude Opus 5 (1M context) --- .../git_commands/git_command_builder.go | 10 ++++++++ pkg/commands/git_commands/repo_paths.go | 25 ++++++++++++++++++- pkg/commands/git_commands/submodule.go | 10 ++++---- pkg/commands/git_commands/submodule_test.go | 24 ++++++++++++++++++ pkg/commands/git_commands/worktree.go | 2 +- pkg/commands/git_commands/worktree_loader.go | 2 +- pkg/env/env.go | 19 +++++++++----- 7 files changed, 78 insertions(+), 14 deletions(-) diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index 49e27ec1f..f1a7c87b4 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -6,6 +6,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/env" ) // OptionalLocksEnvVar is the name of the environment variable that tells git @@ -18,6 +19,15 @@ import ( // that opts back in is the foreground files refresh; see FileLoader.gitStatus. const OptionalLocksEnvVar = "GIT_OPTIONAL_LOCKS" +// forOtherRepo prepares a command that operates on a repo other than the one +// we have open — a submodule, or another worktree. GIT_DIR and GIT_WORK_TREE +// say where our repo is, and every command we run inherits them, so a command +// pointed at a different repo would be resolved against ours instead: `git -C +// log` would silently log the superproject's commits. +func forOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj { + return cmdObj.RemoveEnvVar(env.GitDirEnvVar).RemoveEnvVar(env.GitWorkTreeEnvVar) +} + // convenience struct for building git commands. Especially useful when // including conditional args type GitCommandBuilder struct { diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 89a86640b..9dbb853ef 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -176,17 +176,40 @@ func getBareRepoPathsForDir( }, nil } +// Asks git about the repo at dir. This is how we find our own repo, so it has +// to be answered the way git itself would answer it there, GIT_DIR and +// GIT_WORK_TREE included. func callGitRevParseWithDir( cmd oscommands.ICmdObjBuilder, dir string, gitRevArgs ...string, ) (string, error) { + return runGitRevParse(newGitRevParseCmd(cmd, dir, gitRevArgs...)) +} + +// Asks git about a repo that isn't the one we have open; see forOtherRepo. +func callGitRevParseInOtherRepo( + cmd oscommands.ICmdObjBuilder, + dir string, + gitRevArgs ...string, +) (string, error) { + return runGitRevParse(forOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...))) +} + +func newGitRevParseCmd( + cmd oscommands.ICmdObjBuilder, + dir string, + gitRevArgs ...string, +) *oscommands.CmdObj { gitRevParse := NewGitCmd("rev-parse").Arg("--path-format=absolute").Arg(gitRevArgs...) if dir != "" { gitRevParse.Dir(dir) } - gitCmd := cmd.New(gitRevParse.ToArgv()).DontLog() + return cmd.New(gitRevParse.ToArgv()).DontLog() +} + +func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) { res, err := gitCmd.RunWithOutput() if err != nil { return "", errors.Errorf("'%s' failed: %v", gitCmd.ToString(), err) diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index f31bd6196..7400f0514 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -157,7 +157,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string Config("log.showsignature=false"). ToArgv() - summary, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() + summary, err := forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() return strings.TrimSpace(summary), err } @@ -167,7 +167,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string // caller then stages the submodule to record the resolution. func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error { cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv() - return self.cmd.New(cmdArgs).Run() + return forOtherRepo(self.cmd.New(cmdArgs)).Run() } // ConflictSideLog returns a oneline log, run inside the submodule, of the commits @@ -179,7 +179,7 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi Arg("--oneline", "--color=always", otherSide+".."+side). ToArgv() - return self.cmd.New(cmdArgs).DontLog().RunWithOutput() + return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput() } func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { @@ -195,7 +195,7 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { Arg("--include-untracked"). ToArgv() - return self.cmd.New(cmdArgs).Run() + return forOtherRepo(self.cmd.New(cmdArgs)).Run() } func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error { @@ -229,7 +229,7 @@ func (self *SubmoduleCommands) UpdateAll() error { // need not be. func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error { if submodule.ParentModule != nil { - cmdObj.SetWd(submodule.ParentModule.FullPath()) + forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath())) } return cmdObj.Run() } diff --git a/pkg/commands/git_commands/submodule_test.go b/pkg/commands/git_commands/submodule_test.go index 279c963df..d449d81de 100644 --- a/pkg/commands/git_commands/submodule_test.go +++ b/pkg/commands/git_commands/submodule_test.go @@ -1,10 +1,13 @@ package git_commands import ( + "strings" "testing" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/env" + "github.com/samber/lo" "github.com/stretchr/testify/assert" ) @@ -80,6 +83,27 @@ func TestSubmoduleCheckoutConflictCommit(t *testing.T) { runner.CheckForMissingCalls() } +// A command that runs inside a submodule mustn't inherit the GIT_DIR and +// GIT_WORK_TREE that say where the superproject is; git would answer it from +// there instead, and the answer would look perfectly plausible. +func TestSubmoduleCommandDoesntUseOurGitLocation(t *testing.T) { + t.Setenv(env.GitDirEnvVar, "/path/to/repo/.git") + t.Setenv(env.GitWorkTreeEnvVar, "/path/to/repo") + + runner := oscommands.NewFakeRunner(t). + ExpectFunc("has neither GIT_DIR nor GIT_WORK_TREE", func(cmdObj *oscommands.CmdObj) bool { + return lo.NoneBy(cmdObj.GetEnvVars(), func(envVar string) bool { + return strings.HasPrefix(envVar, env.GitDirEnvVar+"=") || + strings.HasPrefix(envVar, env.GitWorkTreeEnvVar+"=") + }) + }, "bbbbbbb the subject\n", nil) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + _, err := instance.GetCommitSummary("mysub", "bbbbbbb") + assert.NoError(t, err) + runner.CheckForMissingCalls() +} + func TestSubmoduleConflictSideLog(t *testing.T) { runner := oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"-C", "mysub", "log", "--oneline", "--color=always", "ccccccc..bbbbbbb"}, "bbbbbbb left\n", nil) diff --git a/pkg/commands/git_commands/worktree.go b/pkg/commands/git_commands/worktree.go index 986bb6d42..64748b878 100644 --- a/pkg/commands/git_commands/worktree.go +++ b/pkg/commands/git_commands/worktree.go @@ -51,7 +51,7 @@ func (self *WorktreeCommands) Delete(worktreePath string, force bool) error { func (self *WorktreeCommands) Detach(worktreePath string) error { cmdArgs := NewGitCmd("checkout").Arg("--detach").GitDir(filepath.Join(worktreePath, ".git")).ToArgv() - return self.cmd.New(cmdArgs).Run() + return forOtherRepo(self.cmd.New(cmdArgs)).Run() } func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) { diff --git a/pkg/commands/git_commands/worktree_loader.go b/pkg/commands/git_commands/worktree_loader.go index adf1e48dc..f7577c870 100644 --- a/pkg/commands/git_commands/worktree_loader.go +++ b/pkg/commands/git_commands/worktree_loader.go @@ -77,7 +77,7 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) { if worktree.IsPathMissing { return } - gitDir, err := callGitRevParseWithDir(self.cmd, worktree.Path, "--absolute-git-dir") + gitDir, err := callGitRevParseInOtherRepo(self.cmd, worktree.Path, "--absolute-git-dir") if err != nil { self.Log.Warnf("Could not find git dir for worktree %s: %v", worktree.Path, err) return diff --git a/pkg/env/env.go b/pkg/env/env.go index 1ade5b8c6..74d3f7072 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -6,23 +6,30 @@ import ( // This package encapsulates accessing/mutating the ENV of the program. +// The variables with which git can be told where a repo is, rather than having +// it find out from the working directory. +const ( + GitDirEnvVar = "GIT_DIR" + GitWorkTreeEnvVar = "GIT_WORK_TREE" +) + func GetGitDirEnv() string { - return os.Getenv("GIT_DIR") + return os.Getenv(GitDirEnvVar) } func SetGitDirEnv(value string) { - os.Setenv("GIT_DIR", value) + os.Setenv(GitDirEnvVar, value) } func GetWorkTreeEnv() string { - return os.Getenv("GIT_WORK_TREE") + return os.Getenv(GitWorkTreeEnvVar) } func SetWorkTreeEnv(value string) { - os.Setenv("GIT_WORK_TREE", value) + os.Setenv(GitWorkTreeEnvVar, value) } func UnsetGitLocationEnvVars() { - _ = os.Unsetenv("GIT_DIR") - _ = os.Unsetenv("GIT_WORK_TREE") + _ = os.Unsetenv(GitDirEnvVar) + _ = os.Unsetenv(GitWorkTreeEnvVar) } From d19af37ee761b5f57d120c6879b76ebe8a6bbf8d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 23:08:40 +0200 Subject: [PATCH 82/97] Tell git where the repo is when it can't find it itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git finds a repo by looking for a .git in the directory a command runs in. Lazygit runs its commands in the work tree, so that normally works — but not when the git dir lives somewhere else entirely, which is what core.worktree and --work-tree are for. Lazygit chdir'd into such a work tree and then ran commands that couldn't see any repo from there, so opening a repo with core.worktree set panicked on startup. It only worked with --git-dir because that leaves GIT_DIR in the environment for every command to inherit. Work out at startup whether git can find the repo from its work tree, and when it can't, put GIT_DIR and GIT_WORK_TREE on every command the repo's builder produces. As with the working directory the builder pins (527124d0e0e4), these also go into the process env — subprocesses don't come through the builder — but the commands don't read them from there, because the process env belongs to whichever repo we have switched to since. Working out whether git can find the repo means asking git, rather than reading the .git file, whose contents can spell the same directory differently than git does. The extra query is skipped for a repo whose git dir is simply its .git directory, which is nearly all of them. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git.go | 9 ++- pkg/commands/git_cmd_obj_builder.go | 14 +++- pkg/commands/git_cmd_obj_builder_test.go | 20 ++++++ pkg/commands/git_commands/repo_paths.go | 43 ++++++++++++ pkg/commands/git_commands/repo_paths_test.go | 19 +++++ pkg/env/env.go | 13 ++++ pkg/integration/tests/test_list.go | 1 + .../worktree/separate_work_tree_config.go | 70 +++++++++++++++++++ 8 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 pkg/integration/tests/worktree/separate_work_tree_config.go diff --git a/pkg/commands/git.go b/pkg/commands/git.go index a5fc0e608..69cddfa48 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -11,6 +11,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/utils" ) @@ -79,6 +80,12 @@ func NewGitCommand( return nil, utils.WrapError(err) } + // Everything we run through the command builder gets told where the repo is + // by the builder itself, but subprocesses don't go through it: user-defined + // custom commands, an editor, and the lazygit we re-enter as git's sequence + // editor during a rebase. Put it in the process env for those. + env.SetGitLocationEnvVars(repoPaths.GitLocationEnvVars()) + // Pin the config reads to the repo directory like all other git commands // (see NewGitCmdObjBuilder); the config commands run outside that builder. gitConfig.SetDir(repoPaths.WorktreePath()) @@ -101,7 +108,7 @@ func NewGitCommandAux( repoPaths *git_commands.RepoPaths, diffRendererConfigManager *config.DiffRendererConfigManager, ) *GitCommand { - cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath()) + cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath(), repoPaths.GitLocationEnvVars()) // here we're doing a bunch of dependency injection for each of our commands structs. // This is admittedly messy, but allows us to test each command struct in isolation, diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 9c3cd50d4..d879019eb 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -20,6 +20,13 @@ type gitCmdObjBuilder struct { // the old builder) must keep running its commands against the repo it // started in, not whichever one the process has since moved to. repoDir string + + // The env vars every command we produce gets: the optional-locks one below, + // plus the repo's git location if it has one (see + // RepoPaths.GitLocationEnvVars). Those are in the process env too, but for + // the same reason as repoDir we don't rely on that: the process env belongs + // to whichever repo lazygit has since switched to. + envVars []string } var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} @@ -30,7 +37,7 @@ var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} // only the foreground files refresh) opt back in via CmdObj.RemoveEnvVar. var defaultEnvVar = git_commands.OptionalLocksEnvVar + "=0" -func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string) *gitCmdObjBuilder { +func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string, gitLocationEnvVars []string) *gitCmdObjBuilder { // the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase) updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner { return &gitCmdObjRunner{ @@ -43,15 +50,16 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild return &gitCmdObjBuilder{ innerBuilder: updatedBuilder, repoDir: repoDir, + envVars: append([]string{defaultEnvVar}, gitLocationEnvVars...), } } func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj { - return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar).SetWd(self.repoDir) + return self.innerBuilder.New(args).AddEnvVars(self.envVars...).SetWd(self.repoDir) } func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj { - return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar).SetWd(self.repoDir) + return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(self.envVars...).SetWd(self.repoDir) } func (self *gitCmdObjBuilder) Quote(str string) string { diff --git a/pkg/commands/git_cmd_obj_builder_test.go b/pkg/commands/git_cmd_obj_builder_test.go index 28e21501c..e969baa00 100644 --- a/pkg/commands/git_cmd_obj_builder_test.go +++ b/pkg/commands/git_cmd_obj_builder_test.go @@ -18,6 +18,7 @@ func TestGitCmdObjBuilderDisablesOptionalLocksByDefault(t *testing.T) { utils.NewDummyLog(), oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), "/path/to/repo", + nil, ) assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0") @@ -34,8 +35,27 @@ func TestGitCmdObjBuilderPinsCommandsToRepoDir(t *testing.T) { utils.NewDummyLog(), oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), "/path/to/repo", + nil, ) assert.Equal(t, "/path/to/repo", builder.New([]string{"git", "status"}).GetCmd().Dir) assert.Equal(t, "/path/to/repo", builder.NewShell("git status", "").GetCmd().Dir) } + +// A repo whose git dir isn't in its worktree can't be found by running a +// command there, so the builder has to tell every command where it is; see +// RepoPaths.GitLocationEnvVars. The process env says the same thing, but only +// for the repo lazygit is in right now, which isn't necessarily this one. +func TestGitCmdObjBuilderPinsCommandsToGitLocation(t *testing.T) { + builder := NewGitCmdObjBuilder( + utils.NewDummyLog(), + oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), + "/path/to/worktree", + []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"}, + ) + + assert.Subset(t, builder.New([]string{"git", "status"}).GetEnvVars(), + []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"}) + assert.Subset(t, builder.NewShell("git status", "").GetEnvVars(), + []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"}) +} diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 9dbb853ef..4e2f8ab1a 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -8,6 +8,7 @@ import ( "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/spf13/afero" ) @@ -19,6 +20,7 @@ type RepoPaths struct { repoGitDirPath string repoName string isBareRepo bool + gitLocationEnvVars []string } // Path to the current worktree. If we're in the main worktree, this will @@ -61,6 +63,16 @@ func (self *RepoPaths) IsBareRepo() bool { return self.isBareRepo } +// The environment that tells git where this repo is, as "NAME=value" entries. +// It is empty for the vast majority of repos, which git finds for itself by +// looking for a .git in the directory a command runs in. It is only non-empty +// when that doesn't work — when the git dir lives somewhere else entirely, +// because of core.worktree or --work-tree — and then every command addressing +// the repo has to carry it. +func (self *RepoPaths) GitLocationEnvVars() []string { + return self.gitLocationEnvVars +} + // Returns the repo paths for a typical repo func MockRepoPaths(currentPath string) *RepoPaths { return &RepoPaths{ @@ -138,9 +150,40 @@ func GetRepoPathsForDir( repoGitDirPath: repoGitDirPath, repoName: repoName, isBareRepo: false, + gitLocationEnvVars: gitLocationEnvVars(cmd, worktreePath, worktreeGitDirPath), }, nil } +// gitLocationEnvVars works out whether git can find the repo by itself when a +// command runs in its worktree, and if it can't, returns the environment that +// tells git where it is. See RepoPaths.GitLocationEnvVars. +func gitLocationEnvVars( + cmd oscommands.ICmdObjBuilder, + worktreePath string, + worktreeGitDirPath string, +) []string { + // The ordinary repo, where the git dir sits in the worktree. Both paths are + // git's own answers from the same invocation, so they are spelled alike and + // comparing them is safe. + if worktreeGitDirPath == filepath.Join(worktreePath, ".git") { + return nil + } + + // A linked worktree or a submodule instead has a .git file naming its git + // dir, and git follows that just as happily. We could read the file, but the + // path in it may well name the same directory differently than git did + // above, so ask git to resolve it — from the worktree and nothing else. + discoveredGitDirPath, err := callGitRevParseInOtherRepo(cmd, worktreePath, "--absolute-git-dir") + if err == nil && discoveredGitDirPath == worktreeGitDirPath { + return nil + } + + return []string{ + env.GitDirEnvVar + "=" + worktreeGitDirPath, + env.GitWorkTreeEnvVar + "=" + worktreePath, + } +} + // getBareRepoPathsForDir is the fallback for when we couldn't ask git for the // work tree. Everything but --show-toplevel works fine without one, so if the // remaining queries succeed we are in a bare repo, and we return what we know diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index fed4905a8..e2827f22b 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -142,6 +142,14 @@ func TestGetRepoPaths(t *testing.T) { append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) + + // asking git to find the repo from the work tree gets us nowhere, + // because there is no .git there + worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\worktree`, "/path/to/worktree") + runner.ExpectGitArgs( + append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...), + "", + errors.New("fatal: not a git repository (or any of the parent directories): .git")) }, Path: "/path/to/repo", Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ @@ -151,6 +159,7 @@ func TestGetRepoPaths(t *testing.T) { repoGitDirPath: `C:\path\to\repo\.git`, repoName: `worktree`, isBareRepo: false, + gitLocationEnvVars: []string{`GIT_DIR=C:\path\to\repo\.git`, `GIT_WORK_TREE=C:\path\to\worktree`}, }, &RepoPaths{ worktreePath: "/path/to/worktree", worktreeGitDirPath: "/path/to/repo/.git", @@ -158,6 +167,7 @@ func TestGetRepoPaths(t *testing.T) { repoGitDirPath: "/path/to/repo/.git", repoName: "worktree", isBareRepo: false, + gitLocationEnvVars: []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"}, }), Err: nil, }, @@ -187,6 +197,15 @@ func TestGetRepoPaths(t *testing.T) { append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), strings.Join(mockOutput, "\n"), nil) + + // git finds the submodule's git dir from its work tree, via the + // .git file there + worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\submodule1`, "/path/to/repo/submodule1") + gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git\modules\submodule1`, "/path/to/repo/.git/modules/submodule1") + runner.ExpectGitArgs( + append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...), + gitDir, + nil) }, Path: "/path/to/repo/submodule1", Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ diff --git a/pkg/env/env.go b/pkg/env/env.go index 74d3f7072..5203577ae 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -2,6 +2,7 @@ package env import ( "os" + "strings" ) // This package encapsulates accessing/mutating the ENV of the program. @@ -33,3 +34,15 @@ func UnsetGitLocationEnvVars() { _ = os.Unsetenv(GitDirEnvVar) _ = os.Unsetenv(GitWorkTreeEnvVar) } + +// SetGitLocationEnvVars sets the location variables from "NAME=value" entries, +// clearing both first so that only what is given remains. Passing nothing is +// how you say the repo is to be found from the working directory. +func SetGitLocationEnvVars(envVars []string) { + UnsetGitLocationEnvVars() + for _, envVar := range envVars { + if name, value, ok := strings.Cut(envVar, "="); ok { + os.Setenv(name, value) + } + } +} diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a6b5aeafd..9fbc7ef28 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -541,6 +541,7 @@ var tests = []*components.IntegrationTest{ worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, + worktree.SeparateWorkTreeConfig, worktree.SymlinkIntoRepoSubdir, worktree.WorktreeInRepo, } diff --git a/pkg/integration/tests/worktree/separate_work_tree_config.go b/pkg/integration/tests/worktree/separate_work_tree_config.go new file mode 100644 index 000000000..e6c036b20 --- /dev/null +++ b/pkg/integration/tests/worktree/separate_work_tree_config.go @@ -0,0 +1,70 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// This case is like bare_repo_worktree_config.go, except that lazygit isn't +// told where the git dir is: it is started in the directory containing it, and +// finds it the way git does. The work tree is somewhere else entirely, so git +// can't find its way back from there, and every command we run has to be told +// where the repo is. + +var SeparateWorkTreeConfig = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Open lazygit in the git dir of a repo whose work tree is elsewhere, and add a file and commit", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + // we're going to have a directory structure like this: + // project + // - repo (holds the .git dir, and nothing else; lazygit starts here) + // - worktree (holds the files) + // + // 'repo' is the repository/directory that all lazygit tests start in + + shell.CreateFileAndAdd("blah", "original content\n") + shell.Commit("initial commit") + + // point the repo at a work tree outside of it (core.worktree is + // relative to the .git dir), and fill that work tree from HEAD + shell.CreateDir("../worktree") + shell.SetConfig("core.worktree", "../../worktree") + shell.RunCommand([]string{"git", "reset", "--hard"}) + + // the copy of the file we committed from is not in the work tree, so + // git no longer knows anything about it + shell.DeleteFile("blah") + + shell.UpdateFile("../worktree/blah", "updated content\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Lines( + Contains("initial commit"), + ) + + t.Views().Files(). + IsFocused(). + Lines( + Contains(" M blah"), // shows as modified + ). + PressPrimaryAction(). + Press(keys.Files.CommitChanges) + + t.ExpectPopup().CommitMessagePanel(). + Title(Equals("Commit summary")). + Type("Add blah"). + Confirm() + + t.Views().Files(). + IsEmpty() + + t.Views().Commits(). + Lines( + Contains("Add blah"), + Contains("initial commit"), + ) + }, +}) From 9b1078a2ca827ab415318d2a395a50b273e08fc1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 23:11:26 +0200 Subject: [PATCH 83/97] Make StringStack generic Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/gui.go | 6 +++--- pkg/gui/types/common.go | 2 +- pkg/utils/stack.go | 28 ++++++++++++++++++++++++++++ pkg/utils/string_stack.go | 27 --------------------------- 4 files changed, 32 insertions(+), 31 deletions(-) create mode 100644 pkg/utils/stack.go delete mode 100644 pkg/utils/string_stack.go diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index b92c1733e..a463a435b 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -96,7 +96,7 @@ type Gui struct { // when you enter into a submodule we'll append the superproject's path to this array // so that you can return to the superproject - RepoPathStack *utils.StringStack + RepoPathStack *utils.Stack[string] // this tells us whether our views have been initially set up ViewsSetup bool @@ -158,7 +158,7 @@ type StateAccessor struct { var _ types.IStateAccessor = new(StateAccessor) -func (self *StateAccessor) GetRepoPathStack() *utils.StringStack { +func (self *StateAccessor) GetRepoPathStack() *utils.Stack[string] { return self.gui.RepoPathStack } @@ -799,7 +799,7 @@ func NewGui( viewBufferManagerMap: map[string]*tasks.ViewBufferManager{}, viewPtmxMap: map[string]oscommands.Pty{}, showRecentRepos: showRecentRepos, - RepoPathStack: &utils.StringStack{}, + RepoPathStack: &utils.Stack[string]{}, RepoStateMap: map[Repo]*GuiRepoState{}, GuiLog: []string{}, diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index b2b924bc0..169b5278e 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -404,7 +404,7 @@ type HasUrn interface { } type IStateAccessor interface { - GetRepoPathStack() *utils.StringStack + GetRepoPathStack() *utils.Stack[string] GetRepoState() IRepoStateAccessor GetDiffRendererConfigManager() *config.DiffRendererConfigManager // tells us whether we're currently updating lazygit diff --git a/pkg/utils/stack.go b/pkg/utils/stack.go new file mode 100644 index 000000000..9cac1f563 --- /dev/null +++ b/pkg/utils/stack.go @@ -0,0 +1,28 @@ +package utils + +type Stack[T any] struct { + stack []T +} + +func (self *Stack[T]) Push(item T) { + self.stack = append(self.stack, item) +} + +func (self *Stack[T]) Pop() T { + if len(self.stack) == 0 { + var zero T + return zero + } + n := len(self.stack) - 1 + last := self.stack[n] + self.stack = self.stack[:n] + return last +} + +func (self *Stack[T]) IsEmpty() bool { + return len(self.stack) == 0 +} + +func (self *Stack[T]) Clear() { + self.stack = nil +} diff --git a/pkg/utils/string_stack.go b/pkg/utils/string_stack.go deleted file mode 100644 index c2d18c70c..000000000 --- a/pkg/utils/string_stack.go +++ /dev/null @@ -1,27 +0,0 @@ -package utils - -type StringStack struct { - stack []string -} - -func (self *StringStack) Push(s string) { - self.stack = append(self.stack, s) -} - -func (self *StringStack) Pop() string { - if len(self.stack) == 0 { - return "" - } - n := len(self.stack) - 1 - last := self.stack[n] - self.stack = self.stack[:n] - return last -} - -func (self *StringStack) IsEmpty() bool { - return len(self.stack) == 0 -} - -func (self *StringStack) Clear() { - self.stack = []string{} -} From 06b421ad0c10c49a2dd4beaced382b67984b75c9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 23:17:21 +0200 Subject: [PATCH 84/97] Remember how to get back to a repo we entered a submodule from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entering a submodule clears GIT_DIR and GIT_WORK_TREE, as it must: they say where the superproject is. But the stack we push the superproject onto so that escape brings us back only held its path, and for a repo opened with --git-dir/--work-tree the path leads nowhere — git can't find a repo there. Escaping out of a submodule of a dotfile repo failed with "not a git repository", or, if some unrelated repo happened to lie above the work tree, quietly switched to that one instead. Push the environment onto the stack along with the path, taken from the repo paths rather than from the process env, so that it also covers a repo we worked the location out for ourselves. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/env/env.go | 12 ++++ pkg/gui/controllers/helpers/repos_helper.go | 44 +++++++++--- pkg/gui/gui.go | 10 +-- pkg/gui/types/common.go | 11 ++- .../submodule/enter_from_dotfile_bare_repo.go | 72 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 pkg/integration/tests/submodule/enter_from_dotfile_bare_repo.go diff --git a/pkg/env/env.go b/pkg/env/env.go index 5203577ae..391fa6ac6 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -35,6 +35,18 @@ func UnsetGitLocationEnvVars() { _ = os.Unsetenv(GitWorkTreeEnvVar) } +// GetGitLocationEnvVars returns the location variables that are set, as +// "NAME=value" entries. +func GetGitLocationEnvVars() []string { + envVars := []string{} + for _, name := range []string{GitDirEnvVar, GitWorkTreeEnvVar} { + if value := os.Getenv(name); value != "" { + envVars = append(envVars, name+"="+value) + } + } + return envVars +} + // SetGitLocationEnvVars sets the location variables from "NAME=value" entries, // clearing both first so that only what is given remains. Passing nothing is // how you say the repo is to be found from the working directory. diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index a61ad0013..d3b6bfe29 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -54,7 +54,10 @@ func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error if err != nil { return err } - self.c.State().GetRepoPathStack().Push(wd) + self.c.State().GetRepoPathStack().Push(types.RepoLocation{ + Path: wd, + GitLocationEnvVars: self.c.Git().RepoPaths.GitLocationEnvVars(), + }) return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) } @@ -164,7 +167,7 @@ func (self *ReposHelper) SwitchToParentRepo() error { if self.switchRefusedBecauseBusy() { return nil } - return self.switchTo(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) + return self.switchToLocation(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) } func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error { @@ -189,23 +192,41 @@ func (self *ReposHelper) switchRefusedBecauseBusy() bool { return false } -// switchTo switches lazygit to the repository (or worktree) at the given path. -// It runs synchronously on the UI thread: the switch swaps gui.State (in -// resetState) and reassigns gui.git and the process cwd, all of which the UI -// thread also reads, so doing it here rather than on a worker avoids racing -// those reads. The heavy data loading is still dispatched asynchronously by the -// refresh that onNewRepo kicks off. +// switchTo switches lazygit to the repository (or worktree) at the given path, +// which git is expected to find from that path alone. That's true of every repo +// we switch to without having been there before. func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error { - env.UnsetGitLocationEnvVars() + return self.switchToLocation(types.RepoLocation{Path: path}, errMsg, contextKey) +} + +// switchToLocation switches lazygit to the repository (or worktree) at the +// given location. It runs synchronously on the UI thread: the switch swaps +// gui.State (in resetState) and reassigns gui.git and the process cwd, all of +// which the UI thread also reads, so doing it here rather than on a worker +// avoids racing those reads. The heavy data loading is still dispatched +// asynchronously by the refresh that onNewRepo kicks off. +// +// Everything from here on has to find the repo the way git does, from the +// directory we're about to change to, so the location's environment goes into +// the process env before we do. Usually that just clears whatever the repo +// we're leaving needed, but going back to a repo whose git dir isn't in its +// work tree (a dotfile repo opened with --git-dir/--work-tree, say) is the +// reason we remember the environment at all: nothing in the path leads to its +// git dir. On failure we put back what the repo we're staying in needs. +func (self *ReposHelper) switchToLocation(location types.RepoLocation, errMsg string, contextKey types.ContextKey) error { originalPath, err := os.Getwd() if err != nil { return nil } + originalGitLocationEnvVars := env.GetGitLocationEnvVars() - msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path}) + env.SetGitLocationEnvVars(location.GitLocationEnvVars) + + msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": location.Path}) self.c.LogCommand(msg, false) - if err := os.Chdir(path); err != nil { + if err := os.Chdir(location.Path); err != nil { + env.SetGitLocationEnvVars(originalGitLocationEnvVars) if os.IsNotExist(err) { return errors.New(errMsg) } @@ -213,6 +234,7 @@ func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.C } if err := commands.VerifyInGitRepo(self.c.OS()); err != nil { + env.SetGitLocationEnvVars(originalGitLocationEnvVars) if err := os.Chdir(originalPath); err != nil { return err } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index a463a435b..bde383caf 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -94,9 +94,9 @@ type Gui struct { Mutexes types.Mutexes - // when you enter into a submodule we'll append the superproject's path to this array - // so that you can return to the superproject - RepoPathStack *utils.Stack[string] + // when you enter into a submodule we'll append the superproject's location to + // this array so that you can return to the superproject + RepoPathStack *utils.Stack[types.RepoLocation] // this tells us whether our views have been initially set up ViewsSetup bool @@ -158,7 +158,7 @@ type StateAccessor struct { var _ types.IStateAccessor = new(StateAccessor) -func (self *StateAccessor) GetRepoPathStack() *utils.Stack[string] { +func (self *StateAccessor) GetRepoPathStack() *utils.Stack[types.RepoLocation] { return self.gui.RepoPathStack } @@ -799,7 +799,7 @@ func NewGui( viewBufferManagerMap: map[string]*tasks.ViewBufferManager{}, viewPtmxMap: map[string]oscommands.Pty{}, showRecentRepos: showRecentRepos, - RepoPathStack: &utils.Stack[string]{}, + RepoPathStack: &utils.Stack[types.RepoLocation]{}, RepoStateMap: map[Repo]*GuiRepoState{}, GuiLog: []string{}, diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 169b5278e..75cb53018 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -403,8 +403,17 @@ type HasUrn interface { URN() string } +// RepoLocation is everything it takes to open a repo again: the directory to +// change to, plus the environment telling git where the repo is for the repos +// git can't find from that directory (see RepoPaths.GitLocationEnvVars), which +// is empty for all the others. +type RepoLocation struct { + Path string + GitLocationEnvVars []string +} + type IStateAccessor interface { - GetRepoPathStack() *utils.Stack[string] + GetRepoPathStack() *utils.Stack[RepoLocation] GetRepoState() IRepoStateAccessor GetDiffRendererConfigManager() *config.DiffRendererConfigManager // tells us whether we're currently updating lazygit diff --git a/pkg/integration/tests/submodule/enter_from_dotfile_bare_repo.go b/pkg/integration/tests/submodule/enter_from_dotfile_bare_repo.go new file mode 100644 index 000000000..e5537c5f9 --- /dev/null +++ b/pkg/integration/tests/submodule/enter_from_dotfile_bare_repo.go @@ -0,0 +1,72 @@ +package submodule + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// Entering a submodule and escaping back out again, in a repo that git can only +// find because we were told where it is (--git-dir/--work-tree). Entering the +// submodule has to leave that behind, since it says where the superproject is, +// so coming back out has to bring it along again. + +var EnterFromDotfileBareRepo = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Enter a submodule of a dotfile bare repo and escape back out again", + ExtraCmdArgs: []string{"--git-dir={{.actualPath}}/.bare", "--work-tree={{.actualPath}}/repo"}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + // we're going to have a directory structure like this: + // project + // - .bare (the git dir) + // - repo (the work tree, with no .git of its own) + // - my_submodule_name (the submodule's remote) + // + // The work tree is called 'repo' because that's the directory that all + // lazygit tests start in + + // make a repo for the submodule to be cloned from, using the .git dir + // that every test starts with + shell.EmptyCommit("initial submodule commit") + shell.Clone("my_submodule_name") + + // now turn the test repo into a dotfile-style bare repo + shell.DeleteFile(".git") + shell.RunCommand([]string{"git", "init", "--bare", "../.bare"}) + gitInBareRepo := []string{"git", "--git-dir=../.bare", "--work-tree=."} + shell.RunCommand(append(gitInBareRepo, "checkout", "-b", "mybranch")) + shell.CreateFile("blah", "blah\n") + shell.RunCommand(append(gitInBareRepo, "add", "blah")) + shell.RunCommand(append(gitInBareRepo, "commit", "-m", "initial commit")) + shell.RunCommand(append(gitInBareRepo, "-c", "protocol.file.allow=always", "submodule", + "add", "--name", "my_submodule_name", "../my_submodule_name", "my_submodule_path")) + shell.RunCommand(append(gitInBareRepo, "commit", "-m", "add submodule")) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + assertInParentRepo := func() { + t.Views().Status().Content(Contains("repo")) + t.Views().Commits().Lines( + Contains("add submodule"), + Contains("initial commit"), + ) + } + + assertInParentRepo() + + t.Views().Submodules().Focus(). + Lines( + Contains("my_submodule_name").IsSelected(), + ). + PressEnter() + + t.Views().Status().Content(Contains("my_submodule_path")) + t.Views().Commits().Lines( + Contains("initial submodule commit"), + ) + + t.Views().Files().IsFocused().PressEscape() + + assertInParentRepo() + t.Views().Submodules().IsFocused() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 9fbc7ef28..9598982d3 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -441,6 +441,7 @@ var tests = []*components.IntegrationTest{ status.LogCmdStatusPanelAllBranchesLog, submodule.Add, submodule.Enter, + submodule.EnterFromDotfileBareRepo, submodule.EnterNested, submodule.Remove, submodule.RemoveNested, From f141fcc5703d851bf93a4caa58d1f1c94370f959 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 8 Aug 2026 11:06:18 +0200 Subject: [PATCH 85/97] Open the repo when lazygit is started in its .git dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running lazygit in a .git dir got you told you were in a bare repo, which you weren't: the worktree was sitting right there, one directory up. git's own convention is that a git dir called .git belongs to the directory holding it — that's how `git worktree list` names the main worktree — so ask that directory, and if it is a worktree, open the repo we were really being asked about. The git dirs that aren't called .git keep the answer they had. A linked worktree's and a submodule's do have a worktree, but nothing we look at says where, so we would be guessing; a bare repo's has none to find. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/repo_paths.go | 49 +++++++++++++++++-- pkg/commands/git_commands/repo_paths_test.go | 42 ++++++++++++++++ .../tests/misc/start_in_git_dir.go | 34 +++++++++++++ pkg/integration/tests/test_list.go | 1 + 4 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 pkg/integration/tests/misc/start_in_git_dir.go diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 4e2f8ab1a..61afc943f 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -56,9 +56,19 @@ func (self *RepoPaths) RepoName() string { return self.repoName } -// Whether the repo has no worktree, so that there is nothing for lazygit to -// show. Note that this isn't quite git's core.bare: a repo that calls itself -// non-bare but doesn't have a worktree either counts as bare for us. +// Whether we found no worktree, so that there is nothing for lazygit to show. +// Note that this isn't quite git's core.bare: a repo that calls itself non-bare +// but whose worktree we couldn't find counts as bare for us too. Concretely, +// this is true when we're in +// +// - a genuinely bare repo; +// - the git dir of a linked worktree (.git/worktrees/x), whose worktree is +// recorded but not somewhere we look; +// - a repo that keeps its worktree somewhere only GIT_WORK_TREE knows, such +// as a vcsh-style dotfiles repo that hasn't been given core.worktree. +// +// The .git dir of an ordinary repo is not one of them: GetRepoPathsForDir +// notices the worktree holding it and hands back that repo instead. func (self *RepoPaths) IsBareRepo() bool { return self.isBareRepo } @@ -99,6 +109,39 @@ func GetRepoPaths( func GetRepoPathsForDir( dir string, cmd oscommands.ICmdObjBuilder, +) (*RepoPaths, error) { + repoPaths, err := repoPathsForDir(dir, cmd) + if err != nil || !repoPaths.IsBareRepo() { + return repoPaths, err + } + + // We're in a git dir rather than in a working tree, which usually just means + // somebody ran lazygit in the .git of an ordinary repo. git's convention is + // that a git dir called .git belongs to the directory holding it, so look + // there: if that is a working tree, it is the repo we were asked about, and + // there's no reason to make the user go up a directory and try again. + // + // The git dirs that aren't called .git keep the paths we have. A linked + // worktree's (.git/worktrees/x) and a submodule's (.git/modules/x) do have a + // working tree, but only the directory holding a .git tells us where, so we + // would be guessing. A bare repo's has none to find. + if filepath.Base(repoPaths.WorktreeGitDirPath()) != ".git" { + return repoPaths, nil + } + + pathsFromWorkTree, err := repoPathsForDir(filepath.Dir(repoPaths.WorktreeGitDirPath()), cmd) + if err != nil || pathsFromWorkTree.IsBareRepo() { + return repoPaths, nil + } + return pathsFromWorkTree, nil +} + +// repoPathsForDir asks git about the repo at dir, and reports a bare repo when +// there is no working tree there. Unlike GetRepoPathsForDir it never looks +// anywhere but dir, which is what keeps that one from going round in circles. +func repoPathsForDir( + dir string, + cmd oscommands.ICmdObjBuilder, ) (*RepoPaths, error) { gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree") if err != nil { diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index e2827f22b..c7b7705b4 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -115,6 +115,48 @@ func TestGetRepoPaths(t *testing.T) { }), Err: nil, }, + { + // Standing in the .git dir of an ordinary repo: git refuses to name a + // work tree, but the directory holding the .git is one, so we open the + // repo from there. + Name: "in a repo's .git dir", + BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) { + gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git`, "/path/to/repo/.git") + worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo`, "/path/to/repo") + + runner.ExpectGitArgs( + append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), + "", + errors.New("fatal: this operation must be run in a work tree")) + runner.ExpectGitArgs( + append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"), + strings.Join([]string{gitDir, gitDir}, "\n"), + nil) + + // asking again from the directory holding the .git + runner.ExpectGitArgs( + append(append([]string{"-C", worktree}, getRevParseArgs()...), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"), + strings.Join([]string{worktree, gitDir, gitDir}, "\n"), + nil) + }, + Path: "/path/to/repo/.git", + Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{ + worktreePath: `C:\path\to\repo`, + worktreeGitDirPath: `C:\path\to\repo\.git`, + repoPath: `C:\path\to\repo`, + repoGitDirPath: `C:\path\to\repo\.git`, + repoName: `repo`, + isBareRepo: false, + }, &RepoPaths{ + worktreePath: "/path/to/repo", + worktreeGitDirPath: "/path/to/repo/.git", + repoPath: "/path/to/repo", + repoGitDirPath: "/path/to/repo/.git", + repoName: "repo", + isBareRepo: false, + }), + Err: nil, + }, { // A repo whose work tree lives somewhere else entirely, as set up by // core.worktree or by --work-tree. We're in the main worktree, but the diff --git a/pkg/integration/tests/misc/start_in_git_dir.go b/pkg/integration/tests/misc/start_in_git_dir.go new file mode 100644 index 000000000..5fffcc2a9 --- /dev/null +++ b/pkg/integration/tests/misc/start_in_git_dir.go @@ -0,0 +1,34 @@ +package misc + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StartInGitDir = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Start lazygit in a repo's .git dir, and have it open the repo", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("blah", "original content\n") + shell.Commit("initial commit") + shell.UpdateFile("blah", "updated content\n") + + // this is where lazygit will start + shell.Chdir(".git") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Lines( + Contains("initial commit"), + ) + + // we're in the work tree the .git belongs to, not in the .git itself + t.Views().Files(). + IsFocused(). + Lines( + Contains(" M blah"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 9598982d3..bc2c25e09 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -353,6 +353,7 @@ var tests = []*components.IntegrationTest{ misc.DirenvUnloadsOnBlockedEnvrc, misc.InitialOpen, misc.RecentReposOnLaunch, + misc.StartInGitDir, patch_building.Apply, patch_building.ApplyInReverse, patch_building.ApplyInReverseWithConflict, From da7ca77c9db1c683e929f006f2582b055c08fb66 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 22:09:25 +0200 Subject: [PATCH 86/97] AGENTS.md additions --- AGENTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 96c5ba758..d621604be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -390,12 +390,32 @@ Avoid phrasings like: - "cleaner than the previous approach" - "we used to ... but ..." - "after trying X, we found Y" +- "X rather than Y", where Y is what the code did before the change The iteration story is sometimes worth preserving — but it belongs in the commit message, which is the durable record of *why this change was made*. The code comment should make sense to someone who has never seen any prior version and is just trying to understand the file as it currently exists. +The tell is subtler than an explicit "we used to". A comment that justifies the +code against an alternative — "run it on a worker rather than blocking the UI", +"switch panels in `Then` rather than a moment earlier" — is history in disguise +whenever that alternative is what the code did before the change. It reads as +ordinary rationale, but the reader has no way to know the contrast is with a +version that no longer exists. + +So the check to apply is: would you have written this comment if you were +writing the file from scratch, with no diff in mind? If not, the sentence +belongs in the commit message. + +## Don't justify routine call sites + +If the codebase calls a helper in twenty places without explanation, your +twenty-first call site doesn't need one either. A comment there says "something +here is unusual"; when nothing is, it's noise — and it invites exactly the kind +of before/after justification the section above warns about. Look at the +neighboring call sites before writing one: if they're bare, match them. + ## Don't present "live with the bug" as an option When you're investigating a defect and laying out fix options for the user, From 544f3b834b10632962a9d325a61e15a6290af069 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 21:24:57 +0200 Subject: [PATCH 87/97] Apply the panel updates after stash operations in a single frame Stashing and popping change both the stash list and the files list. With each scope updating the UI as soon as its own refresh is done, the two panels visibly change at different times; with gui.shrinkSidePanelsToContent that also means their sizes change at different times than their contents. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/files_controller.go | 5 ++++- pkg/gui/controllers/stash_controller.go | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 567b0b6e5..62751eb35 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1513,7 +1513,10 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro if err := stashFunc(stashComment); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) + self.c.Refresh(types.RefreshOptions{ + BatchUIUpdates: true, + Scope: []types.RefreshableView{types.STASH, types.FILES}, + }) return nil }, AllowEmptyInput: true, diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index d01fc8dbf..949068460 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -199,7 +199,10 @@ func (self *StashController) postStashRefresh() { // the remaining stash entries, and acting on the next entry in quick // succession (confirming the popup and pressing the key again right away) // must see the refreshed list, or it would target the wrong stash. - self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) + self.c.RefreshBlockingInput(types.RefreshOptions{ + BatchUIUpdates: true, + Scope: []types.RefreshableView{types.STASH, types.FILES}, + }) } func (self *StashController) handleNewBranchOffStashEntry(stashEntry *models.StashEntry) error { From 6c567d1eb6ce7fae3ac1ecc61e8f2238f26ec78e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 21:26:20 +0200 Subject: [PATCH 88/97] Switch to the files panel from the post-stash refresh's Then Pushing the files context right after kicking off the refresh moves the focus (and, with gui.shrinkSidePanelsToContent, resizes the panels) a frame before the refreshed stash and files lists arrive. Doing it from Then puts it in the same frame as the data it belongs to. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/stash_controller.go | 32 ++++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 949068460..65de7f243 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -122,14 +122,8 @@ func (self *StashController) handleStashApply(stashEntry *models.StashEntry) err HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.ApplyStash) err := self.c.Git().Stash.Apply(stashEntry.Index) - self.postStashRefresh() - if err != nil { - return err - } - if self.c.UserConfig().Gui.SwitchToFilesAfterStashApply { - self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) - } - return nil + self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashApply) + return err }, }) } @@ -139,14 +133,8 @@ func (self *StashController) handleStashPop(stashEntry *models.StashEntry) error self.c.LogAction(self.c.Tr.Actions.PopStash) self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.PoppingStash, stashEntry.Hash), false) err := self.c.Git().Stash.Pop(stashEntry.Index) - self.postStashRefresh() - if err != nil { - return err - } - if self.c.UserConfig().Gui.SwitchToFilesAfterStashPop { - self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) - } - return nil + self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashPop) + return err } if self.c.UserConfig().Gui.SkipStashWarning { @@ -194,7 +182,9 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) return nil } -func (self *StashController) postStashRefresh() { +// postStashRefresh refreshes the panels that applying or popping a stash +// affects, moving the focus to the files panel if switchToFiles is set. +func (self *StashController) postStashRefresh(switchToFiles bool) { // Block input until the refresh has landed: popping shifts the indices of // the remaining stash entries, and acting on the next entry in quick // succession (confirming the popup and pressing the key again right away) @@ -202,6 +192,14 @@ func (self *StashController) postStashRefresh() { self.c.RefreshBlockingInput(types.RefreshOptions{ BatchUIUpdates: true, Scope: []types.RefreshableView{types.STASH, types.FILES}, + Then: func() error { + // Switch panels from here, so that the focus change lands in the + // same frame as the refreshed panel contents. + if switchToFiles { + self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) + } + return nil + }, }) } From ed22322ec84019c32772d95577de8049ae3f1653 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 21:28:33 +0200 Subject: [PATCH 89/97] Collapse the stash range selection from the refresh's Then Collapsing the range before kicking off the refresh paints the new selection against the list as it was before the drop, so for a frame the entries that were just dropped are still on screen (and, with gui.shrinkSidePanelsToContent, the panel is still at its old size). Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/stash_controller.go | 32 ++++++++++++++++++------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 65de7f243..5d78a84ca 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -163,19 +163,33 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) // iteration lets the workers race and an earlier, stale result can // land last. The indices are captured up front and we drop // highest-first, so the remaining lower indices stay valid without - // an intervening refresh. Block input until the refresh has - // landed, so that dropping the next entry in quick succession - // (confirming and pressing the key again right away) sees the - // refreshed list and not the stale, pre-drop indices. - defer self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + // an intervening refresh. + var dropErr error for i := len(stashEntries) - 1; i >= 0; i-- { self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false) - if err := self.c.Git().Stash.Drop(stashEntries[i].Index); err != nil { - return err + if dropErr = self.c.Git().Stash.Drop(stashEntries[i].Index); dropErr != nil { + break } } - self.context().CollapseRangeSelectionToTop() - return nil + // Block input until the refresh has landed, so that dropping the + // next entry in quick succession (confirming and pressing the key + // again right away) sees the refreshed list and not the stale, + // pre-drop indices. + self.c.RefreshBlockingInput(types.RefreshOptions{ + Scope: []types.RefreshableView{types.STASH}, + Then: func() error { + // Collapse the range selection from here, so that it lands + // in the same frame as the shortened list. The refresh has + // painted the list by the time Then runs, so the new + // selection needs a focus update of its own. + if dropErr == nil { + self.context().CollapseRangeSelectionToTop() + self.context().HandleFocus(types.OnFocusOpts{}) + } + return nil + }, + }) + return dropErr }, }) From 4c39b0b90367126bbde5e4e00388e33bcd520c9c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 06:54:55 +0200 Subject: [PATCH 90/97] Run the stash operations with a waiting status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating and applying a stash both touch every changed file, so in a large repo they can take long enough to be noticeable — and running them on the UI thread meant the confirmation popup stayed on screen, frozen, for the whole operation. Run them on a worker instead, with a spinner, and keep blocking input for their duration so that the type-ahead guarantee the refresh used to provide still holds. Dropping stays on the UI thread: it only rewrites the stash reflog, so it's fast no matter how big the stashes are. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/files_controller.go | 22 ++++++++------ pkg/gui/controllers/stash_controller.go | 39 ++++++++++++++++--------- pkg/i18n/english.go | 6 ++++ 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 62751eb35..be8e94477 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1508,16 +1508,20 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.StashChanges, HandleConfirm: func(stashComment string) error { - self.c.LogAction(action) + return self.c.WithWaitingStatusBlockingInput( + types.WaitingStatusOpts{Message: self.c.Tr.StashingStatus}, + func(gocui.Task) error { + self.c.LogAction(action) - if err := stashFunc(stashComment); err != nil { - return err - } - self.c.Refresh(types.RefreshOptions{ - BatchUIUpdates: true, - Scope: []types.RefreshableView{types.STASH, types.FILES}, - }) - return nil + if err := stashFunc(stashComment); err != nil { + return err + } + self.c.RefreshFromWorker(types.RefreshOptions{ + BatchUIUpdates: true, + Scope: []types.RefreshableView{types.STASH, types.FILES}, + }) + return nil + }) }, AllowEmptyInput: true, }) diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 5d78a84ca..03011e421 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -120,21 +121,29 @@ func (self *StashController) handleStashApply(stashEntry *models.StashEntry) err Title: self.c.Tr.StashApply, Prompt: self.c.Tr.SureApplyStashEntry, HandleConfirm: func() error { - self.c.LogAction(self.c.Tr.Actions.ApplyStash) - err := self.c.Git().Stash.Apply(stashEntry.Index) - self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashApply) - return err + return self.c.WithWaitingStatusBlockingInput( + types.WaitingStatusOpts{Message: self.c.Tr.ApplyingStashStatus}, + func(gocui.Task) error { + self.c.LogAction(self.c.Tr.Actions.ApplyStash) + err := self.c.Git().Stash.Apply(stashEntry.Index) + self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashApply) + return err + }) }, }) } func (self *StashController) handleStashPop(stashEntry *models.StashEntry) error { pop := func() error { - self.c.LogAction(self.c.Tr.Actions.PopStash) - self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.PoppingStash, stashEntry.Hash), false) - err := self.c.Git().Stash.Pop(stashEntry.Index) - self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashPop) - return err + return self.c.WithWaitingStatusBlockingInput( + types.WaitingStatusOpts{Message: self.c.Tr.PoppingStashStatus}, + func(gocui.Task) error { + self.c.LogAction(self.c.Tr.Actions.PopStash) + self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.PoppingStash, stashEntry.Hash), false) + err := self.c.Git().Stash.Pop(stashEntry.Index) + self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashPop) + return err + }) } if self.c.UserConfig().Gui.SkipStashWarning { @@ -198,12 +207,14 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) // postStashRefresh refreshes the panels that applying or popping a stash // affects, moving the focus to the files panel if switchToFiles is set. +// +// Call it from the worker that ran the stash command, from inside a +// WithWaitingStatusBlockingInput: popping shifts the indices of the remaining +// stash entries, so acting on the next entry in quick succession (confirming +// the popup and pressing the key again right away) has to be held off until +// the refreshed list is in place, or it would target the wrong stash. func (self *StashController) postStashRefresh(switchToFiles bool) { - // Block input until the refresh has landed: popping shifts the indices of - // the remaining stash entries, and acting on the next entry in quick - // succession (confirming the popup and pressing the key again right away) - // must see the refreshed list, or it would target the wrong stash. - self.c.RefreshBlockingInput(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ BatchUIUpdates: true, Scope: []types.RefreshableView{types.STASH, types.FILES}, Then: func() error { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index f71e61adb..6d41d31d6 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -443,6 +443,9 @@ type TranslationSet struct { MovingCommitsToNewBranchStatus string ApplyingFilterStatus string RemovingFilterStatus string + StashingStatus string + ApplyingStashStatus string + PoppingStashStatus string CommitFiles string SubCommitsDynamicTitle string CommitFilesDynamicTitle string @@ -1603,6 +1606,9 @@ func EnglishTranslationSet() *TranslationSet { MovingCommitsToNewBranchStatus: "Moving commits to new branch", ApplyingFilterStatus: "Applying filter", RemovingFilterStatus: "Removing filter", + StashingStatus: "Stashing", + ApplyingStashStatus: "Applying stash", + PoppingStashStatus: "Popping stash", CommitFiles: "Commit files", SubCommitsDynamicTitle: "Commits (%s)", CommitFilesDynamicTitle: "Diff files (%s)", From bc9fafff02d14fe07ae242c44a5d069ec790cf3f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 11:46:54 +0200 Subject: [PATCH 91/97] Make the conflict marker size a parameter of our marker matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git doesn't always write conflict markers of seven characters: the conflict-marker-size gitattribute overrides that per file, and it is set for good reasons — for file types whose regular content tends to contain marker-looking lines, such as documentation about merging, or test scripts. We hard-code seven characters everywhere we look for markers, so none of that works. Prepare for honoring the attribute by threading the marker size through everything that recognizes a marker, carried on the file model. Nothing fills it in yet, so we still use git's default size of seven everywhere, and matching is unchanged: a marker consists of exactly that many marker characters, and all but the "=======" one are followed by a space and a label. --- pkg/commands/models/file.go | 12 ++- pkg/gui/controllers/files_controller.go | 4 +- .../helpers/merge_conflicts_helper.go | 18 +++-- pkg/gui/controllers/helpers/refresh_helper.go | 3 +- pkg/gui/filetree/file_node.go | 2 +- pkg/gui/mergeconflicts/find_conflicts.go | 79 +++++++++++++------ pkg/gui/mergeconflicts/find_conflicts_test.go | 78 ++++++++++++++++-- pkg/gui/mergeconflicts/state.go | 17 +++- pkg/gui/mergeconflicts/state_test.go | 2 +- 9 files changed, 165 insertions(+), 50 deletions(-) diff --git a/pkg/commands/models/file.go b/pkg/commands/models/file.go index e48696a4f..9eedfb1fc 100644 --- a/pkg/commands/models/file.go +++ b/pkg/commands/models/file.go @@ -18,10 +18,14 @@ type File struct { Deleted bool HasMergeConflicts bool HasInlineMergeConflicts bool - DisplayString string - ShortStatus string // e.g. 'AD', ' A', 'M ', '??' - LinesDeleted int - LinesAdded int + // How long the conflict markers in this file are, taken from its + // conflict-marker-size gitattribute; 0 if it doesn't have that attribute. We + // only look this up for files that have inline merge conflicts. + ConflictMarkerSize int + DisplayString string + ShortStatus string // e.g. 'AD', ' A', 'M ', '??' + LinesDeleted int + LinesAdded int // If true, this must be a worktree folder IsWorktree bool diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index be8e94477..41f9197fe 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -328,7 +328,7 @@ func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) { // (it was resolved in an editor), in which case the caller should fall back to // showing the file's diff. func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool { - hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath()) + hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.File) if err != nil { return true } @@ -1264,7 +1264,7 @@ func (self *FilesController) switchToMerge() error { return nil } - return self.c.Helpers().MergeConflicts.SwitchToMerge(file.Path) + return self.c.Helpers().MergeConflicts.SwitchToMerge(file) } func (self *FilesController) createStashMenu() error { diff --git a/pkg/gui/controllers/helpers/merge_conflicts_helper.go b/pkg/gui/controllers/helpers/merge_conflicts_helper.go index 34ae285f0..3928ecebf 100644 --- a/pkg/gui/controllers/helpers/merge_conflicts_helper.go +++ b/pkg/gui/controllers/helpers/merge_conflicts_helper.go @@ -1,6 +1,7 @@ package helpers import ( + "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -17,14 +18,14 @@ func NewMergeConflictsHelper( } } -func (self *MergeConflictsHelper) SetMergeState(path string) (bool, error) { +func (self *MergeConflictsHelper) SetMergeState(file *models.File) (bool, error) { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() - return self.setMergeStateWithoutLock(path) + return self.setMergeStateWithoutLock(file.Path, file.ConflictMarkerSize) } -func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, error) { +func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string, markerSize int) (bool, error) { content, err := self.c.Git().File.Cat(path) if err != nil { return false, err @@ -34,7 +35,7 @@ func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, e self.context().SetUserScrolling(false) } - self.context().GetState().SetContent(content, path) + self.context().GetState().SetContent(content, path, markerSize) return !self.context().GetState().NoConflicts(), nil } @@ -72,7 +73,8 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() - hasConflicts, err := self.setMergeStateWithoutLock(self.context().GetState().GetPath()) + state := self.context().GetState() + hasConflicts, err := self.setMergeStateWithoutLock(state.GetPath(), state.GetMarkerSize()) if err != nil { return false, err } @@ -84,9 +86,9 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) { return false, nil } -func (self *MergeConflictsHelper) SwitchToMerge(path string) error { - if self.context().GetState().GetPath() != path { - hasConflicts, err := self.SetMergeState(path) +func (self *MergeConflictsHelper) SwitchToMerge(file *models.File) error { + if self.context().GetState().GetPath() != file.Path { + hasConflicts, err := self.SetMergeState(file) if err != nil { return err } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d0327216c..53505ba6c 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1305,7 +1305,8 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // process working directory, which may already point at another // repo if the user switched while this refresh was in flight. hasConflicts, err := mergeconflicts.FileHasConflictMarkers( - filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path)) + filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path), + file.ConflictMarkerSize) if err != nil { self.c.Log.Error(err) } else if !hasConflicts { diff --git a/pkg/gui/filetree/file_node.go b/pkg/gui/filetree/file_node.go index 0836eaf02..04c98f1fa 100644 --- a/pkg/gui/filetree/file_node.go +++ b/pkg/gui/filetree/file_node.go @@ -51,7 +51,7 @@ func (self *FileNode) GetHasInlineMergeConflicts() bool { if !file.HasInlineMergeConflicts { return false } - hasConflicts, _ := mergeconflicts.FileHasConflictMarkers(file.Path) + hasConflicts, _ := mergeconflicts.FileHasConflictMarkers(file.Path, file.ConflictMarkerSize) return hasConflicts }) } diff --git a/pkg/gui/mergeconflicts/find_conflicts.go b/pkg/gui/mergeconflicts/find_conflicts.go index 5fe45624e..a296baf74 100644 --- a/pkg/gui/mergeconflicts/find_conflicts.go +++ b/pkg/gui/mergeconflicts/find_conflicts.go @@ -2,7 +2,6 @@ package mergeconflicts import ( "bufio" - "bytes" "io" "os" "strings" @@ -22,7 +21,23 @@ const ( NOT_A_MARKER ) -func findConflicts(content string) []*mergeConflict { +// The number of characters a conflict marker consists of, unless the file's +// conflict-marker-size gitattribute says otherwise. +const defaultConflictMarkerSize = 7 + +// The marker size that everything in here takes is the conflict-marker-size +// gitattribute of the file being examined, which is 0 for a file that doesn't +// have that attribute. Git falls back to its default size in that case, so we +// do the same. +func effectiveMarkerSize(markerSize int) int { + if markerSize < 1 { + return defaultConflictMarkerSize + } + + return markerSize +} + +func findConflicts(content string, markerSize int) []*mergeConflict { conflicts := make([]*mergeConflict, 0) if content == "" { @@ -31,7 +46,7 @@ func findConflicts(content string) []*mergeConflict { var newConflict *mergeConflict for i, line := range utils.SplitLines(content) { - switch determineLineType(line) { + switch determineLineType(line, markerSize) { case START: newConflict = &mergeConflict{start: i, ancestor: -1} case ANCESTOR: @@ -57,35 +72,57 @@ func findConflicts(content string) []*mergeConflict { return conflicts } -var ( - CONFLICT_START = "<<<<<<< " - CONFLICT_END = ">>>>>>> " - CONFLICT_START_BYTES = []byte(CONFLICT_START) - CONFLICT_END_BYTES = []byte(CONFLICT_END) -) +func determineLineType(line string, markerSize int) LineType { + markerSize = effectiveMarkerSize(markerSize) -func determineLineType(line string) LineType { // TODO: find out whether we ever actually get this prefix trimmedLine := strings.TrimPrefix(line, "++") switch { - case strings.HasPrefix(trimmedLine, CONFLICT_START): + case isConflictMarker(trimmedLine, '<', markerSize): return START - case strings.HasPrefix(trimmedLine, "||||||| "): + case isConflictMarker(trimmedLine, '|', markerSize): return ANCESTOR - case trimmedLine == "=======": + case isTargetMarker(trimmedLine, markerSize): return TARGET - case strings.HasPrefix(trimmedLine, CONFLICT_END): + case isConflictMarker(trimmedLine, '>', markerSize): return END default: return NOT_A_MARKER } } +// Tells us whether the line begins with markerSize repetitions of markerChar. +func hasMarkerPrefix[T string | []byte](line T, markerChar byte, markerSize int) bool { + if len(line) < markerSize { + return false + } + + for i := range markerSize { + if line[i] != markerChar { + return false + } + } + + return true +} + +// A start, ancestor or end marker is followed by a space and a label, e.g. +// "<<<<<<< HEAD". +func isConflictMarker[T string | []byte](line T, markerChar byte, markerSize int) bool { + return hasMarkerPrefix(line, markerChar, markerSize) && + len(line) > markerSize && line[markerSize] == ' ' +} + +// The marker separating the two sides of a conflict never has a label after it. +func isTargetMarker(line string, markerSize int) bool { + return hasMarkerPrefix(line, '=', markerSize) && len(line) == markerSize +} + // tells us whether a file actually has inline merge conflicts. We need to run this // because git will continue showing a status of 'UU' even after the conflicts have // been resolved in the user's editor -func FileHasConflictMarkers(path string) (bool, error) { +func FileHasConflictMarkers(path string, markerSize int) (bool, error) { file, err := os.Open(path) if err != nil { return false, err @@ -93,22 +130,20 @@ func FileHasConflictMarkers(path string) (bool, error) { defer file.Close() - return fileHasConflictMarkersAux(file) + return fileHasConflictMarkersAux(file, markerSize) } // Efficiently scans through a file looking for merge conflict markers. Returns true if it does -func fileHasConflictMarkersAux(file io.Reader) (bool, error) { +func fileHasConflictMarkersAux(file io.Reader, markerSize int) (bool, error) { + markerSize = effectiveMarkerSize(markerSize) + scanner := bufio.NewScanner(file) scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize)) for scanner.Scan() { line := scanner.Bytes() // only searching for start/end markers because the others are more ambiguous - if bytes.HasPrefix(line, CONFLICT_START_BYTES) { - return true, nil - } - - if bytes.HasPrefix(line, CONFLICT_END_BYTES) { + if isConflictMarker(line, '<', markerSize) || isConflictMarker(line, '>', markerSize) { return true, nil } } diff --git a/pkg/gui/mergeconflicts/find_conflicts_test.go b/pkg/gui/mergeconflicts/find_conflicts_test.go index c763aa51f..97a61a3c4 100644 --- a/pkg/gui/mergeconflicts/find_conflicts_test.go +++ b/pkg/gui/mergeconflicts/find_conflicts_test.go @@ -8,9 +8,12 @@ import ( ) func TestDetermineLineType(t *testing.T) { + // A markerSize of 0 means the file has no conflict-marker-size gitattribute, + // so git's default size applies. type scenario struct { - line string - expected LineType + line string + markerSize int + expected LineType } scenarios := []scenario{ @@ -54,17 +57,62 @@ func TestDetermineLineType(t *testing.T) { line: "||||||| adf33b9", expected: ANCESTOR, }, + { + line: "<<<<<<<<", + expected: NOT_A_MARKER, + }, + { + line: strings.Repeat("<", 32) + " HEAD", + markerSize: 32, + expected: START, + }, + { + line: strings.Repeat("|", 32) + " adf33b9", + markerSize: 32, + expected: ANCESTOR, + }, + { + line: strings.Repeat("=", 32), + markerSize: 32, + expected: TARGET, + }, + { + line: strings.Repeat(">", 32) + " blah", + markerSize: 32, + expected: END, + }, + // A file gets a bigger marker size precisely because its regular content + // tends to contain marker-looking lines, so lines with the default size + // must not be mistaken for markers + { + line: "<<<<<<< HEAD", + markerSize: 32, + expected: NOT_A_MARKER, + }, + { + line: "=======", + markerSize: 32, + expected: NOT_A_MARKER, + }, + { + line: strings.Repeat("=", 33), + markerSize: 32, + expected: NOT_A_MARKER, + }, } for _, s := range scenarios { - assert.EqualValues(t, s.expected, determineLineType(s.line)) + assert.EqualValues(t, s.expected, determineLineType(s.line, s.markerSize), s.line) } } func TestFindConflictsAux(t *testing.T) { + // A markerSize of 0 means the file has no conflict-marker-size gitattribute, + // so git's default size applies. type scenario struct { - content string - expected bool + content string + markerSize int + expected bool } scenarios := []scenario{ @@ -92,12 +140,28 @@ func TestFindConflictsAux(t *testing.T) { content: "a\nb\nc\n<<<<<<< ", expected: true, }, + { + content: "a\nb\nc\n" + strings.Repeat("<", 32) + " HEAD", + markerSize: 32, + expected: true, + }, + { + content: "a\nb\nc\n" + strings.Repeat(">", 32) + " blah", + markerSize: 32, + expected: true, + }, + // Marker-looking lines of the default size are the file's regular content + { + content: "a\nb\nc\n<<<<<<< HEAD\n=======\n>>>>>>> blah", + markerSize: 32, + expected: false, + }, } for _, s := range scenarios { reader := strings.NewReader(s.content) - result, err := fileHasConflictMarkersAux(reader) + result, err := fileHasConflictMarkersAux(reader, s.markerSize) assert.NoError(t, err) - assert.EqualValues(t, s.expected, result) + assert.EqualValues(t, s.expected, result, s.content) } } diff --git a/pkg/gui/mergeconflicts/state.go b/pkg/gui/mergeconflicts/state.go index 047241353..d38e0c754 100644 --- a/pkg/gui/mergeconflicts/state.go +++ b/pkg/gui/mergeconflicts/state.go @@ -12,6 +12,9 @@ type State struct { // path of the file with the conflicts path string + // the file's conflict-marker-size gitattribute, or 0 if it doesn't have one + markerSize int + // This is a stack of the file content. It is used to undo changes. // The last item is the current file content. contents []string @@ -74,12 +77,13 @@ func (s *State) currentConflict() *mergeConflict { } // this is for starting a new merge conflict session -func (s *State) SetContent(content string, path string) { - if content == s.GetContent() && path == s.path { +func (s *State) SetContent(content string, path string, markerSize int) { + if content == s.GetContent() && path == s.path && markerSize == s.markerSize { return } s.path = path + s.markerSize = markerSize s.contents = []string{} s.PushContent(content) } @@ -88,7 +92,7 @@ func (s *State) SetContent(content string, path string) { // state func (s *State) PushContent(content string) { s.contents = append(s.contents, content) - s.setConflicts(findConflicts(content)) + s.setConflicts(findConflicts(content, s.markerSize)) } func (s *State) GetContent() string { @@ -103,6 +107,10 @@ func (s *State) GetPath() string { return s.path } +func (s *State) GetMarkerSize() int { + return s.markerSize +} + func (s *State) Undo() bool { if len(s.contents) <= 1 { return false @@ -112,7 +120,7 @@ func (s *State) Undo() bool { newContent := s.GetContent() // We could be storing the old conflicts and selected index on a stack too. - s.setConflicts(findConflicts(newContent)) + s.setConflicts(findConflicts(newContent, s.markerSize)) return true } @@ -147,6 +155,7 @@ func (s *State) AllConflictsResolved() bool { func (s *State) Reset() { s.contents = []string{} s.path = "" + s.markerSize = 0 } // we're not resetting selectedIndex here because the user typically would want diff --git a/pkg/gui/mergeconflicts/state_test.go b/pkg/gui/mergeconflicts/state_test.go index 7a9ee8c26..06f8fa6bb 100644 --- a/pkg/gui/mergeconflicts/state_test.go +++ b/pkg/gui/mergeconflicts/state_test.go @@ -116,7 +116,7 @@ baz for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { - assert.EqualValues(t, s.expected, findConflicts(s.content)) + assert.EqualValues(t, s.expected, findConflicts(s.content, defaultConflictMarkerSize)) }) } } From c3450f9406814d759ac93680b549b89c6aaa2cc2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 11:53:37 +0200 Subject: [PATCH 92/97] Add tests demonstrating that we ignore the conflict-marker-size gitattribute When a file's conflict markers aren't seven characters long we don't recognize them at all. Two things go wrong: we consider the file's conflicts resolved, so we stage it and offer to continue the merge a moment after stopping at it; and pressing enter on it shows its diff instead of the merge conflicts view, leaving no way to resolve it in lazygit. --- .../conflict_marker_size_not_auto_staged.go | 49 +++++++++++++++++++ .../conflicts/conflict_marker_size_resolve.go | 45 +++++++++++++++++ pkg/integration/tests/shared/conflicts.go | 16 ++++++ pkg/integration/tests/test_list.go | 2 + 4 files changed, 112 insertions(+) create mode 100644 pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go create mode 100644 pkg/integration/tests/conflicts/conflict_marker_size_resolve.go diff --git a/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go b/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go new file mode 100644 index 000000000..2b32ddd61 --- /dev/null +++ b/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go @@ -0,0 +1,49 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +var ConflictMarkerSizeNotAutoStaged = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Doesn't auto-stage an unresolved file whose conflict-marker-size gitattribute makes its markers longer than usual", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shared.SetCustomConflictMarkerSize(shell) + shared.CreateMergeConflictFile(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Common().PretendMergeOrRebaseStartedInLazygit() + + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU file").IsSelected(), + ). + // Each refresh checks whether the conflicts are still there + Press(keys.Universal.Refresh). + /* EXPECTED: + // They are, so the file doesn't get staged and we don't get asked to + // continue the merge + Lines( + Contains("UU file").IsSelected(), + ). + // Once they really are resolved, we do + Tap(func() { + t.Shell().UpdateFile("file", "resolved content") + }). + Press(keys.Universal.Refresh). + Tap(func() { + t.Common().ContinueOnConflictsResolved("merge") + }). + IsEmpty() + ACTUAL: */ + Tap(func() { + t.Common().ContinueOnConflictsResolved("merge") + }). + IsEmpty() + }, +}) diff --git a/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go b/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go new file mode 100644 index 000000000..6e40c633f --- /dev/null +++ b/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go @@ -0,0 +1,45 @@ +package conflicts + +import ( + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +var ConflictMarkerSizeResolve = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Resolves a conflict in a file whose conflict-marker-size gitattribute makes its markers longer than usual", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shared.SetCustomConflictMarkerSize(shell) + shared.CreateMergeConflictFileMultiple(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + startMarker := strings.Repeat("<", shared.CustomConflictMarkerSize) + + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU file").IsSelected(), + ). + PressEnter() + + /* EXPECTED: + t.Views().MergeConflicts(). + IsFocused(). + SelectedLines( + Contains(startMarker+" HEAD"), + Contains("First Change"), + Contains(strings.Repeat("=", shared.CustomConflictMarkerSize)), + ). + PressPrimaryAction(). + Content(DoesNotContain(startMarker + " HEAD\nFirst Change")) + ACTUAL: */ + // We don't recognize the markers, so instead of the merge conflicts view + // we get the file's diff + t.Views().Main().Content(Contains(startMarker + " HEAD")) + }, +}) diff --git a/pkg/integration/tests/shared/conflicts.go b/pkg/integration/tests/shared/conflicts.go index b84c8c7ad..c8319acf4 100644 --- a/pkg/integration/tests/shared/conflicts.go +++ b/pkg/integration/tests/shared/conflicts.go @@ -1,6 +1,8 @@ package shared import ( + "fmt" + . "github.com/jesseduffield/lazygit/pkg/integration/components" ) @@ -28,6 +30,20 @@ Second Change File ` +// A conflict-marker-size that isn't git's default of 7. It's set for file types +// whose regular content tends to contain marker-looking lines, e.g. +// documentation about merging, or test scripts. +const CustomConflictMarkerSize = 32 + +// Makes git write conflict markers of CustomConflictMarkerSize characters into +// the file that the setups below create conflicts in. Call this before one of +// them. +var SetCustomConflictMarkerSize = func(shell *Shell) { + shell.CreateFileAndAdd(".gitattributes", + fmt.Sprintf("file conflict-marker-size=%d\n", CustomConflictMarkerSize)). + Commit("set a custom conflict marker size") +} + // prepares us for a rebase/merge that has conflicts var MergeConflictsSetup = func(shell *Shell) { shell. diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index bc2c25e09..15ef6f8c7 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -165,6 +165,8 @@ var tests = []*components.IntegrationTest{ config.NegativeRefspec, config.RemoteNamedStar, config.SidePanelsInPerRepoConfig, + conflicts.ConflictMarkerSizeNotAutoStaged, + conflicts.ConflictMarkerSizeResolve, conflicts.ContinuePromptDismissedWhenResolvedExternally, conflicts.Filter, conflicts.MergeFileBoth, From 5481436d8c1d5c9af9fd1d184758ed7ffa9467b6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 11:59:01 +0200 Subject: [PATCH 93/97] Honor the conflict-marker-size gitattribute Ask git for the attribute of every conflicted file whenever we load the file status, so that we recognize the markers it actually wrote. Files that are set up this way are precisely the ones whose regular content tends to contain marker-looking lines, so matching a run of at least seven characters instead is not an option: we'd take the file's own content for markers and then never consider its conflicts resolved. One `git check-attr` call covers all conflicted files at once; asking per file would take seconds when hundreds of files are conflicted, and it would hurt worst on Windows, where spawning a process is expensive. Because the lookup rides along with the file status, it costs nothing when there are no conflicts, and editing .gitattributes during a merge takes effect on the next refresh. --- pkg/commands/git_commands/file_loader.go | 60 +++++++++++++++++++ pkg/commands/git_commands/file_loader_test.go | 56 +++++++++++++++++ .../conflict_marker_size_not_auto_staged.go | 6 -- .../conflicts/conflict_marker_size_resolve.go | 5 -- 4 files changed, 116 insertions(+), 11 deletions(-) diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 7e2bdf0f3..3f960cb30 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -8,6 +8,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/samber/lo" ) type FileLoaderConfig interface { @@ -88,6 +89,8 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File files = append(files, file) } + self.setConflictMarkerSizes(files) + // Go through the files to see if any of these files are actually worktrees // so that we can render them correctly worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath()) @@ -111,6 +114,63 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File return files } +// Looks up how long the conflict markers in the conflicted files are. We ask +// git for all of them at once, because spawning a process per file would be +// painfully slow when hundreds of files are conflicted (especially on Windows). +func (self *FileLoader) setConflictMarkerSizes(files []*models.File) { + conflictedFiles := lo.Filter(files, func(file *models.File, _ int) bool { + return file.HasInlineMergeConflicts + }) + if len(conflictedFiles) == 0 { + return + } + + paths := lo.Map(conflictedFiles, func(file *models.File, _ int) string { + return file.Path + }) + + markerSizes, err := self.getConflictMarkerSizes(paths) + if err != nil { + self.Log.Error(err) + return + } + + for _, file := range conflictedFiles { + file.ConflictMarkerSize = markerSizes[file.Path] + } +} + +func (self *FileLoader) getConflictMarkerSizes(paths []string) (map[string]int, error) { + cmdArgs := NewGitCmd("check-attr"). + Arg("-z"). + Arg("--stdin"). + Arg("conflict-marker-size"). + ToArgv() + + // -z makes git both read the paths and write its output NUL-separated, so + // that paths containing newlines don't throw us off. + output, _, err := self.cmd.New(cmdArgs). + SetStdin(strings.Join(paths, "\x00")). + DontLog(). + RunWithOutputs() + if err != nil { + return nil, err + } + + markerSizes := map[string]int{} + fields := strings.Split(output, "\x00") + // Each path yields a path/attribute/value triple; the value is either a + // number or something like "unspecified", in which case we leave the marker + // size at 0 to say that git's default applies. + for i := 0; i+2 < len(fields); i += 3 { + if markerSize, err := strconv.Atoi(fields[i+2]); err == nil && markerSize > 0 { + markerSizes[fields[i]] = markerSize + } + } + + return markerSizes, nil +} + type FileDiff struct { LinesAdded int LinesDeleted int diff --git a/pkg/commands/git_commands/file_loader_test.go b/pkg/commands/git_commands/file_loader_test.go index ec1f502f1..23602ff9e 100644 --- a/pkg/commands/git_commands/file_loader_test.go +++ b/pkg/commands/git_commands/file_loader_test.go @@ -37,6 +37,10 @@ func TestFileGetStatusFiles(t *testing.T) { ExpectGitArgs([]string{"diff", "--numstat", "-z", "HEAD"}, "4\t1\tfile1.txt\x001\t0\tfile2.txt\x002\t2\tfile3.txt\x000\t2\tfile4.txt\x002\t2\tfile5.txt", nil, + ). + ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"}, + "file5.txt\x00conflict-marker-size\x00unspecified\x00", + nil, ), showNumstatInFilesView: true, expectedFiles: []*models.File{ @@ -112,6 +116,58 @@ func TestFileGetStatusFiles(t *testing.T) { }, }, }, + { + testName: "Conflicted files with a conflict-marker-size attribute", + similarityThreshold: 50, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, + "UU file1.txt\x00UU file2.txt\x00UU file3.txt\x00 M file4.txt", + nil, + ). + ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"}, + "file1.txt\x00conflict-marker-size\x0032\x00"+ + "file2.txt\x00conflict-marker-size\x00unspecified\x00"+ + "file3.txt\x00conflict-marker-size\x00nonsense\x00", + nil, + ), + expectedFiles: []*models.File{ + { + Path: "file1.txt", + HasUnstagedChanges: true, + Tracked: true, + HasMergeConflicts: true, + HasInlineMergeConflicts: true, + ConflictMarkerSize: 32, + DisplayString: "UU file1.txt", + ShortStatus: "UU", + }, + { + Path: "file2.txt", + HasUnstagedChanges: true, + Tracked: true, + HasMergeConflicts: true, + HasInlineMergeConflicts: true, + DisplayString: "UU file2.txt", + ShortStatus: "UU", + }, + { + Path: "file3.txt", + HasUnstagedChanges: true, + Tracked: true, + HasMergeConflicts: true, + HasInlineMergeConflicts: true, + DisplayString: "UU file3.txt", + ShortStatus: "UU", + }, + { + Path: "file4.txt", + HasUnstagedChanges: true, + Tracked: true, + DisplayString: " M file4.txt", + ShortStatus: " M", + }, + }, + }, { testName: "File with new line char", similarityThreshold: 50, diff --git a/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go b/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go index 2b32ddd61..52931c192 100644 --- a/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go +++ b/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go @@ -25,7 +25,6 @@ var ConflictMarkerSizeNotAutoStaged = NewIntegrationTest(NewIntegrationTestArgs{ ). // Each refresh checks whether the conflicts are still there Press(keys.Universal.Refresh). - /* EXPECTED: // They are, so the file doesn't get staged and we don't get asked to // continue the merge Lines( @@ -40,10 +39,5 @@ var ConflictMarkerSizeNotAutoStaged = NewIntegrationTest(NewIntegrationTestArgs{ t.Common().ContinueOnConflictsResolved("merge") }). IsEmpty() - ACTUAL: */ - Tap(func() { - t.Common().ContinueOnConflictsResolved("merge") - }). - IsEmpty() }, }) diff --git a/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go b/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go index 6e40c633f..13a647931 100644 --- a/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go +++ b/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go @@ -27,7 +27,6 @@ var ConflictMarkerSizeResolve = NewIntegrationTest(NewIntegrationTestArgs{ ). PressEnter() - /* EXPECTED: t.Views().MergeConflicts(). IsFocused(). SelectedLines( @@ -37,9 +36,5 @@ var ConflictMarkerSizeResolve = NewIntegrationTest(NewIntegrationTestArgs{ ). PressPrimaryAction(). Content(DoesNotContain(startMarker + " HEAD\nFirst Change")) - ACTUAL: */ - // We don't recognize the markers, so instead of the merge conflicts view - // we get the file's diff - t.Views().Main().Content(Contains(startMarker + " HEAD")) }, }) From d0078bf05c57948c74bed7ee54af8c08052df7f3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 12:00:18 +0200 Subject: [PATCH 94/97] Recognize conflict markers that have no label Git only writes the space after a marker when there is a label to write after it, and the label can be empty: `git checkout -m` with the diff3 conflict style, for instance, has no name for the common ancestor, so it writes a bare "|||||||" line. --- pkg/gui/mergeconflicts/find_conflicts.go | 6 ++++-- pkg/gui/mergeconflicts/find_conflicts_test.go | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/pkg/gui/mergeconflicts/find_conflicts.go b/pkg/gui/mergeconflicts/find_conflicts.go index a296baf74..e57c16635 100644 --- a/pkg/gui/mergeconflicts/find_conflicts.go +++ b/pkg/gui/mergeconflicts/find_conflicts.go @@ -108,10 +108,12 @@ func hasMarkerPrefix[T string | []byte](line T, markerChar byte, markerSize int) } // A start, ancestor or end marker is followed by a space and a label, e.g. -// "<<<<<<< HEAD". +// "<<<<<<< HEAD". The label can be missing though, in which case git doesn't +// write the space either; `git checkout -m` with the diff3 conflict style does +// that for the ancestor marker, for example. func isConflictMarker[T string | []byte](line T, markerChar byte, markerSize int) bool { return hasMarkerPrefix(line, markerChar, markerSize) && - len(line) > markerSize && line[markerSize] == ' ' + (len(line) == markerSize || line[markerSize] == ' ') } // The marker separating the two sides of a conflict never has a label after it. diff --git a/pkg/gui/mergeconflicts/find_conflicts_test.go b/pkg/gui/mergeconflicts/find_conflicts_test.go index 97a61a3c4..28839126f 100644 --- a/pkg/gui/mergeconflicts/find_conflicts_test.go +++ b/pkg/gui/mergeconflicts/find_conflicts_test.go @@ -61,6 +61,19 @@ func TestDetermineLineType(t *testing.T) { line: "<<<<<<<<", expected: NOT_A_MARKER, }, + // Markers without a label + { + line: "<<<<<<<", + expected: START, + }, + { + line: "|||||||", + expected: ANCESTOR, + }, + { + line: ">>>>>>>", + expected: END, + }, { line: strings.Repeat("<", 32) + " HEAD", markerSize: 32, @@ -136,6 +149,10 @@ func TestFindConflictsAux(t *testing.T) { content: " <<<<<<< ", expected: false, }, + { + content: ">>>>>>>", + expected: true, + }, { content: "a\nb\nc\n<<<<<<< ", expected: true, From d3a2c872933670cd084b157a4fe02cb07c5ea216 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:47:46 +0000 Subject: [PATCH 95/97] Bump actions/setup-go from 6 to 7 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/release.yml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6d2fdf6d..f85875dc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - name: Checkout code uses: actions/checkout@v7 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: 1.25.x - name: Test code @@ -93,7 +93,7 @@ jobs: path: ~/git-${{matrix.git-version}} key: ${{runner.os}}-git-${{matrix.git-version}} - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: 1.25.x - name: Print git version @@ -130,7 +130,7 @@ jobs: - name: Checkout code uses: actions/checkout@v7 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: 1.25.x - name: Build linux binary @@ -157,7 +157,7 @@ jobs: - name: Checkout code uses: actions/checkout@v7 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: 1.25.x - name: Check Vendor Directory @@ -183,7 +183,7 @@ jobs: - name: Checkout code uses: actions/checkout@v7 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: 1.25.x - name: Check formatting @@ -206,7 +206,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: 1.25.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3cf63bbf..b7755cc2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,7 +160,7 @@ jobs: git push origin "refs/tags/$NEW_TAG" - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: 1.25.x From d6cf948dcab07202c56333e307f4808ca9b089e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:52:34 +0000 Subject: [PATCH 96/97] Bump github.com/kyokomi/emoji/v2 from 2.2.13 to 2.2.14 Bumps [github.com/kyokomi/emoji/v2](https://github.com/kyokomi/emoji) from 2.2.13 to 2.2.14. - [Release notes](https://github.com/kyokomi/emoji/releases) - [Commits](https://github.com/kyokomi/emoji/compare/v2.2.13...v2.2.14) --- updated-dependencies: - dependency-name: github.com/kyokomi/emoji/v2 dependency-version: 2.2.14 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 +- .../kyokomi/emoji/v2/emoji_codemap.go | 160 +++++++++++------- vendor/modules.txt | 4 +- 4 files changed, 102 insertions(+), 68 deletions(-) diff --git a/go.mod b/go.mod index 515bb8f1b..7f5c1b710 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3 - github.com/kyokomi/emoji/v2 v2.2.13 + github.com/kyokomi/emoji/v2 v2.2.14 github.com/lucasb-eyer/go-colorful v1.4.0 github.com/mgutz/str v1.2.0 github.com/mitchellh/go-ps v1.0.0 diff --git a/go.sum b/go.sum index 1b8cb7d66..9d99088d6 100644 --- a/go.sum +++ b/go.sum @@ -73,8 +73,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= -github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= +github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80= +github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= diff --git a/vendor/github.com/kyokomi/emoji/v2/emoji_codemap.go b/vendor/github.com/kyokomi/emoji/v2/emoji_codemap.go index a6e039e4b..017a5c478 100644 --- a/vendor/github.com/kyokomi/emoji/v2/emoji_codemap.go +++ b/vendor/github.com/kyokomi/emoji/v2/emoji_codemap.go @@ -62,6 +62,7 @@ func emojiCode() map[string]string { ":Leo:": "\u264c", ":Libra:": "\u264e", ":Mrs._Claus:": "\U0001f936", + ":Mx_Claus:": "\U0001f9d1\u200d\U0001f384", ":NEW_button:": "\U0001f195", ":NG_button:": "\U0001f196", ":OK_button:": "\U0001f197", @@ -220,6 +221,7 @@ func emojiCode() map[string]string { ":bald_man:": "\U0001f468\u200d\U0001f9b2", ":bald_person:": "\U0001f9d1\u200d\U0001f9b2", ":bald_woman:": "\U0001f469\u200d\U0001f9b2", + ":ballet_dancer:": "\U0001f9d1\u200d\U0001fa70", ":ballet_shoes:": "\U0001fa70", ":balloon:": "\U0001f388", ":ballot_box:": "\U0001f5f3", @@ -764,6 +766,7 @@ func emojiCode() map[string]string { ":disappointed_face:": "\U0001f61e", ":disappointed_relieved:": "\U0001f625", ":disguised_face:": "\U0001f978", + ":distorted_face:": "\U0001faea", ":divide:": "\u2797", ":dividers:": "\U0001f5c2", ":diving_mask:": "\U0001f93f", @@ -890,6 +893,7 @@ func emojiCode() map[string]string { ":face_savoring_food:": "\U0001f60b", ":face_screaming_in_fear:": "\U0001f631", ":face_vomiting:": "\U0001f92e", + ":face_with_bags_under_eyes:": "\U0001fae9", ":face_with_cowboy_hat:": "\U0001f920", ":face_with_crossed-out_eyes:": "\U0001f635", ":face_with_diagonal_mouth:": "\U0001fae4", @@ -1018,12 +1022,14 @@ func emojiCode() map[string]string { ":ferry:": "\u26f4\ufe0f", ":field_hockey:": "\U0001f3d1", ":field_hockey_stick_and_ball:": "\U0001f3d1", + ":fight_cloud:": "\U0001faef", ":fiji:": "\U0001f1eb\U0001f1ef", ":file_cabinet:": "\U0001f5c4\ufe0f", ":file_folder:": "\U0001f4c1", ":film_frames:": "\U0001f39e\ufe0f", ":film_projector:": "\U0001f4fd\ufe0f", ":film_strip:": "\U0001f39e\ufe0f", + ":fingerprint:": "\U0001fac6", ":fingers_crossed:": "\U0001f91e", ":fingers_crossed_tone1:": "\U0001f91e\U0001f3fb", ":fingers_crossed_tone2:": "\U0001f91e\U0001f3fc", @@ -1251,6 +1257,7 @@ func emojiCode() map[string]string { ":flag-rs:": "\U0001f1f7\U0001f1f8", ":flag-rw:": "\U0001f1f7\U0001f1fc", ":flag-sa:": "\U0001f1f8\U0001f1e6", + ":flag-sark:": "\U0001f1e8\U0001f1f6", ":flag-sb:": "\U0001f1f8\U0001f1e7", ":flag-sc:": "\U0001f1f8\U0001f1e8", ":flag-scotland:": "\U0001f3f4\U000e0067\U000e0062\U000e0073\U000e0063\U000e0074\U000e007f", @@ -1504,6 +1511,7 @@ func emojiCode() map[string]string { ":flag_Réunion:": "\U0001f1f7\U0001f1ea", ":flag_Samoa:": "\U0001f1fc\U0001f1f8", ":flag_San_Marino:": "\U0001f1f8\U0001f1f2", + ":flag_Sark:": "\U0001f1e8\U0001f1f6", ":flag_Saudi_Arabia:": "\U0001f1f8\U0001f1e6", ":flag_Scotland:": "\U0001f3f4\U000e0067\U000e0062\U000e0073\U000e0063\U000e0074\U000e007f", ":flag_Senegal:": "\U0001f1f8\U0001f1f3", @@ -1995,6 +2003,7 @@ func emojiCode() map[string]string { ":haircut:": "\U0001f487\u200d\u2640\ufe0f", ":haircut_man:": "\U0001f487\u200d\u2642\ufe0f", ":haircut_woman:": "\U0001f487\u200d\u2640\ufe0f", + ":hairy_creature:": "\U0001fac8", ":haiti:": "\U0001f1ed\U0001f1f9", ":hamburger:": "\U0001f354", ":hammer:": "\U0001f528", @@ -2012,43 +2021,44 @@ func emojiCode() map[string]string { ":hand_splayed_tone5:": "\U0001f590\U0001f3ff", ":hand_with_fingers_splayed:": "\U0001f590", ":hand_with_index_finger_and_thumb_crossed:": "\U0001faf0", - ":handbag:": "\U0001f45c", - ":handball:": "\U0001f93e", - ":handball_person:": "\U0001f93e", - ":handshake:": "\U0001f91d", - ":hankey:": "\U0001f4a9", - ":hash:": "#\ufe0f\u20e3", - ":hatched_chick:": "\U0001f425", - ":hatching_chick:": "\U0001f423", - ":head_bandage:": "\U0001f915", - ":head_shaking_horizontally:": "\U0001f642\u200d\u2194\ufe0f", - ":head_shaking_vertically:": "\U0001f642\u200d\u2195\ufe0f", - ":headphone:": "\U0001f3a7", - ":headphones:": "\U0001f3a7", - ":headstone:": "\U0001faa6", - ":health_worker:": "\U0001f9d1\u200d\u2695\ufe0f", - ":hear-no-evil_monkey:": "\U0001f649", - ":hear_no_evil:": "\U0001f649", - ":heard_mcdonald_islands:": "\U0001f1ed\U0001f1f2", - ":heart:": "\u2764\ufe0f", - ":heart_decoration:": "\U0001f49f", - ":heart_exclamation:": "\u2763", - ":heart_eyes:": "\U0001f60d", - ":heart_eyes_cat:": "\U0001f63b", - ":heart_hands:": "\U0001faf6", - ":heart_on_fire:": "\u2764\ufe0f\u200d\U0001f525", - ":heart_suit:": "\u2665", - ":heart_with_arrow:": "\U0001f498", - ":heart_with_ribbon:": "\U0001f49d", - ":heartbeat:": "\U0001f493", - ":heartpulse:": "\U0001f497", - ":hearts:": "\u2665\ufe0f", - ":heavy_check_mark:": "\u2714\ufe0f", - ":heavy_division_sign:": "\u2797", - ":heavy_dollar_sign:": "\U0001f4b2", - ":heavy_equals_sign:": "\U0001f7f0", - ":heavy_exclamation_mark:": "\u2757", - ":heavy_heart_exclamation:": "\u2763\ufe0f", + ":handbag:": "\U0001f45c", + ":handball:": "\U0001f93e", + ":handball_person:": "\U0001f93e", + ":handshake:": "\U0001f91d", + ":hankey:": "\U0001f4a9", + ":harp:": "\U0001fa89", + ":hash:": "#\ufe0f\u20e3", + ":hatched_chick:": "\U0001f425", + ":hatching_chick:": "\U0001f423", + ":head_bandage:": "\U0001f915", + ":head_shaking_horizontally:": "\U0001f642\u200d\u2194\ufe0f", + ":head_shaking_vertically:": "\U0001f642\u200d\u2195\ufe0f", + ":headphone:": "\U0001f3a7", + ":headphones:": "\U0001f3a7", + ":headstone:": "\U0001faa6", + ":health_worker:": "\U0001f9d1\u200d\u2695\ufe0f", + ":hear-no-evil_monkey:": "\U0001f649", + ":hear_no_evil:": "\U0001f649", + ":heard_mcdonald_islands:": "\U0001f1ed\U0001f1f2", + ":heart:": "\u2764\ufe0f", + ":heart_decoration:": "\U0001f49f", + ":heart_exclamation:": "\u2763", + ":heart_eyes:": "\U0001f60d", + ":heart_eyes_cat:": "\U0001f63b", + ":heart_hands:": "\U0001faf6", + ":heart_on_fire:": "\u2764\ufe0f\u200d\U0001f525", + ":heart_suit:": "\u2665", + ":heart_with_arrow:": "\U0001f498", + ":heart_with_ribbon:": "\U0001f49d", + ":heartbeat:": "\U0001f493", + ":heartpulse:": "\U0001f497", + ":hearts:": "\u2665\ufe0f", + ":heavy_check_mark:": "\u2714\ufe0f", + ":heavy_division_sign:": "\u2797", + ":heavy_dollar_sign:": "\U0001f4b2", + ":heavy_equals_sign:": "\U0001f7f0", + ":heavy_exclamation_mark:": "\u2757", + ":heavy_heart_exclamation:": "\u2763\ufe0f", ":heavy_heart_exclamation_mark_ornament:": "\u2763\ufe0f", ":heavy_minus_sign:": "\u2796", ":heavy_multiplication_x:": "\u2716\ufe0f", @@ -2237,6 +2247,7 @@ func emojiCode() map[string]string { ":ladder:": "\U0001fa9c", ":lady_beetle:": "\U0001f41e", ":ladybug:": "\U0001f41e", + ":landslide:": "\U0001f6d8", ":lantern:": "\U0001f3ee", ":laos:": "\U0001f1f1\U0001f1e6", ":laptop:": "\U0001f4bb", @@ -2263,6 +2274,7 @@ func emojiCode() map[string]string { ":latvia:": "\U0001f1f1\U0001f1fb", ":laughing:": "\U0001f606", ":leaf_fluttering_in_wind:": "\U0001f343", + ":leafless_tree:": "\U0001fabe", ":leafy_green:": "\U0001f96c", ":leaves:": "\U0001f343", ":lebanon:": "\U0001f1f1\U0001f1e7", @@ -3146,6 +3158,7 @@ func emojiCode() map[string]string { ":orange_heart:": "\U0001f9e1", ":orange_square:": "\U0001f7e7", ":orangutan:": "\U0001f9a7", + ":orca:": "\U0001facd", ":orthodox_cross:": "\u2626\ufe0f", ":otter:": "\U0001f9a6", ":outbox_tray:": "\U0001f4e4", @@ -3702,6 +3715,7 @@ func emojiCode() map[string]string { ":rolling_on_the_floor_laughing:": "\U0001f923", ":romania:": "\U0001f1f7\U0001f1f4", ":rooster:": "\U0001f413", + ":root_vegetable:": "\U0001fadc", ":rose:": "\U0001f339", ":rosette:": "\U0001f3f5\ufe0f", ":rotating_light:": "\U0001f6a8", @@ -3810,6 +3824,7 @@ func emojiCode() map[string]string { ":shopping_trolley:": "\U0001f6d2", ":shortcake:": "\U0001f370", ":shorts:": "\U0001fa73", + ":shovel:": "\U0001fa8f", ":shower:": "\U0001f6bf", ":shrimp:": "\U0001f990", ":shrug:": "\U0001f937", @@ -3938,6 +3953,7 @@ func emojiCode() map[string]string { ":spiral_note_pad:": "\U0001f5d2\ufe0f", ":spiral_notepad:": "\U0001f5d2", ":spiral_shell:": "\U0001f41a", + ":splatter:": "\U0001fadf", ":spock-hand:": "\U0001f596", ":sponge:": "\U0001f9fd", ":spoon:": "\U0001f944", @@ -4129,6 +4145,7 @@ func emojiCode() map[string]string { ":tram_car:": "\U0001f68b", ":transgender_flag:": "\U0001f3f3\ufe0f\u200d\u26a7\ufe0f", ":transgender_symbol:": "\u26a7\ufe0f", + ":treasure_chest:": "\U0001fa8e", ":triangular_flag:": "\U0001f6a9", ":triangular_flag_on_post:": "\U0001f6a9", ":triangular_ruler:": "\U0001f4d0", @@ -4139,6 +4156,7 @@ func emojiCode() map[string]string { ":triumph:": "\U0001f624", ":troll:": "\U0001f9cc", ":trolleybus:": "\U0001f68e", + ":trombone:": "\U0001fa8a", ":trophy:": "\U0001f3c6", ":tropical_drink:": "\U0001f379", ":tropical_fish:": "\U0001f420", @@ -4881,6 +4899,7 @@ func emojiRevCode() map[string][]string { "\U0001f1e8\U0001f1f3": {":cn:", ":flag_cn:", ":flag_China:"}, "\U0001f1e8\U0001f1f4": {":flag-co:", ":flag_co:", ":colombia:", ":flag_Colombia:"}, "\U0001f1e8\U0001f1f5": {":flag-cp:", ":flag_cp:", ":clipperton_island:", ":flag_Clipperton_Island:"}, + "\U0001f1e8\U0001f1f6": {":flag-sark:", ":flag_Sark:"}, "\U0001f1e8\U0001f1f7": {":flag-cr:", ":flag_cr:", ":costa_rica:", ":flag_Costa_Rica:"}, "\U0001f1e8\U0001f1fa": {":cuba:", ":flag-cu:", ":flag_cu:", ":flag_Cuba:"}, "\U0001f1e8\U0001f1fb": {":flag-cv:", ":flag_cv:", ":cape_verde:", ":flag_Cape_Verde:"}, @@ -5246,7 +5265,7 @@ func emojiRevCode() map[string][]string { "\U0001f381": {":gift:", ":wrapped_gift:"}, "\U0001f382": {":birthday:", ":birthday_cake:"}, "\U0001f383": {":jack-o-lantern:", ":jack_o_lantern:"}, - "\U0001f384": {":Christmas_tree:", ":christmas_tree:"}, + "\U0001f384": {":christmas_tree:", ":Christmas_tree:"}, "\U0001f385": {":santa:", ":Santa_Claus:"}, "\U0001f385\U0001f3fb": {":santa_tone1:"}, "\U0001f385\U0001f3fc": {":santa_tone2:"}, @@ -5465,7 +5484,7 @@ func emojiRevCode() map[string][]string { "\U0001f3ec": {":department_store:"}, "\U0001f3ed": {":factory:"}, "\U0001f3ee": {":lantern:", ":izakaya_lantern:", ":red_paper_lantern:"}, - "\U0001f3ef": {":Japanese_castle:", ":japanese_castle:"}, + "\U0001f3ef": {":japanese_castle:", ":Japanese_castle:"}, "\U0001f3f0": {":castle:", ":european_castle:"}, "\U0001f3f3": {":flag_white:", ":white_flag:"}, "\U0001f3f3\ufe0f": {":waving_white_flag:"}, @@ -5608,7 +5627,7 @@ func emojiRevCode() map[string][]string { "\U0001f44b\U0001f3fd": {":wave_tone3:"}, "\U0001f44b\U0001f3fe": {":wave_tone4:"}, "\U0001f44b\U0001f3ff": {":wave_tone5:"}, - "\U0001f44c": {":OK_hand:", ":ok_hand:"}, + "\U0001f44c": {":ok_hand:", ":OK_hand:"}, "\U0001f44c\U0001f3fb": {":ok_hand_tone1:"}, "\U0001f44c\U0001f3fc": {":ok_hand_tone2:"}, "\U0001f44c\U0001f3fd": {":ok_hand_tone3:"}, @@ -6169,7 +6188,7 @@ func emojiRevCode() map[string][]string { "\U0001f4a1": {":bulb:", ":light_bulb:"}, "\U0001f4a2": {":anger:", ":anger_symbol:"}, "\U0001f4a3": {":bomb:"}, - "\U0001f4a4": {":ZZZ:", ":zzz:"}, + "\U0001f4a4": {":zzz:", ":ZZZ:"}, "\U0001f4a5": {":boom:", ":collision:"}, "\U0001f4a6": {":sweat_drops:", ":sweat_droplets:"}, "\U0001f4a7": {":droplet:"}, @@ -6466,8 +6485,8 @@ func emojiRevCode() map[string][]string { "\U0001f5fa": {":map:"}, "\U0001f5fa\ufe0f": {":world_map:"}, "\U0001f5fb": {":mount_fuji:"}, - "\U0001f5fc": {":Tokyo_tower:", ":tokyo_tower:"}, - "\U0001f5fd": {":Statue_of_Liberty:", ":statue_of_liberty:"}, + "\U0001f5fc": {":tokyo_tower:", ":Tokyo_tower:"}, + "\U0001f5fd": {":statue_of_liberty:", ":Statue_of_Liberty:"}, "\U0001f5fe": {":japan:", ":map_of_Japan:"}, "\U0001f5ff": {":moai:", ":moyai:"}, "\U0001f600": {":grinning:", ":grinning_face:"}, @@ -6544,7 +6563,7 @@ func emojiRevCode() map[string][]string { "\U0001f642\u200d\u2195\ufe0f": {":head_shaking_vertically:"}, "\U0001f643": {":upside_down:", ":upside-down_face:", ":upside_down_face:"}, "\U0001f644": {":roll_eyes:", ":rolling_eyes:", ":face_with_rolling_eyes:"}, - "\U0001f645": {":person_gesturing_NO:", ":person_gesturing_no:"}, + "\U0001f645": {":person_gesturing_no:", ":person_gesturing_NO:"}, "\U0001f645\U0001f3fb": {":person_gesturing_no_tone1:"}, "\U0001f645\U0001f3fb\u200d\u2640\ufe0f": {":woman_gesturing_no_tone1:"}, "\U0001f645\U0001f3fb\u200d\u2642\ufe0f": {":man_gesturing_no_tone1:"}, @@ -6560,9 +6579,9 @@ func emojiRevCode() map[string][]string { "\U0001f645\U0001f3ff": {":person_gesturing_no_tone5:"}, "\U0001f645\U0001f3ff\u200d\u2640\ufe0f": {":woman_gesturing_no_tone5:"}, "\U0001f645\U0001f3ff\u200d\u2642\ufe0f": {":man_gesturing_no_tone5:"}, - "\U0001f645\u200d\u2640\ufe0f": {":no_good:", ":ng_woman:", ":no_good_woman:", ":woman-gesturing-no:", ":woman_gesturing_NO:", ":woman_gesturing_no:"}, - "\U0001f645\u200d\u2642\ufe0f": {":ng_man:", ":no_good_man:", ":man-gesturing-no:", ":man_gesturing_NO:", ":man_gesturing_no:"}, - "\U0001f646": {":ok_person:", ":person_gesturing_OK:", ":person_gesturing_ok:"}, + "\U0001f645\u200d\u2640\ufe0f": {":no_good:", ":ng_woman:", ":no_good_woman:", ":woman-gesturing-no:", ":woman_gesturing_no:", ":woman_gesturing_NO:"}, + "\U0001f645\u200d\u2642\ufe0f": {":ng_man:", ":no_good_man:", ":man-gesturing-no:", ":man_gesturing_no:", ":man_gesturing_NO:"}, + "\U0001f646": {":ok_person:", ":person_gesturing_ok:", ":person_gesturing_OK:"}, "\U0001f646\U0001f3fb": {":person_gesturing_ok_tone1:"}, "\U0001f646\U0001f3fb\u200d\u2640\ufe0f": {":woman_gesturing_ok_tone1:"}, "\U0001f646\U0001f3fb\u200d\u2642\ufe0f": {":man_gesturing_ok_tone1:"}, @@ -6578,8 +6597,8 @@ func emojiRevCode() map[string][]string { "\U0001f646\U0001f3ff": {":person_gesturing_ok_tone5:"}, "\U0001f646\U0001f3ff\u200d\u2640\ufe0f": {":woman_gesturing_ok_tone5:"}, "\U0001f646\U0001f3ff\u200d\u2642\ufe0f": {":man_gesturing_ok_tone5:"}, - "\U0001f646\u200d\u2640\ufe0f": {":ok_woman:", ":woman-gesturing-ok:", ":woman_gesturing_OK:", ":woman_gesturing_ok:"}, - "\U0001f646\u200d\u2642\ufe0f": {":ok_man:", ":man-gesturing-ok:", ":man_gesturing_OK:", ":man_gesturing_ok:"}, + "\U0001f646\u200d\u2640\ufe0f": {":ok_woman:", ":woman-gesturing-ok:", ":woman_gesturing_ok:", ":woman_gesturing_OK:"}, + "\U0001f646\u200d\u2642\ufe0f": {":ok_man:", ":man-gesturing-ok:", ":man_gesturing_ok:", ":man_gesturing_OK:"}, "\U0001f647": {":bow:", ":person_bowing:"}, "\U0001f647\U0001f3fb": {":person_bowing_tone1:"}, "\U0001f647\U0001f3fb\u200d\u2640\ufe0f": {":woman_bowing_tone1:"}, @@ -6831,6 +6850,7 @@ func emojiRevCode() map[string][]string { "\U0001f6d5": {":hindu_temple:"}, "\U0001f6d6": {":hut:"}, "\U0001f6d7": {":elevator:"}, + "\U0001f6d8": {":landslide:"}, "\U0001f6dc": {":wireless:"}, "\U0001f6dd": {":playground_slide:"}, "\U0001f6de": {":wheel:"}, @@ -7186,7 +7206,7 @@ func emojiRevCode() map[string][]string { "\U0001f993": {":zebra:", ":zebra_face:"}, "\U0001f994": {":hedgehog:"}, "\U0001f995": {":sauropod:"}, - "\U0001f996": {":T-Rex:", ":t-rex:", ":t_rex:"}, + "\U0001f996": {":t-rex:", ":T-Rex:", ":t_rex:"}, "\U0001f997": {":cricket:"}, "\U0001f998": {":kangaroo:"}, "\U0001f999": {":llama:"}, @@ -7267,7 +7287,7 @@ func emojiRevCode() map[string][]string { "\U0001f9d1\u200d\U0001f33e": {":farmer:"}, "\U0001f9d1\u200d\U0001f373": {":cook:"}, "\U0001f9d1\u200d\U0001f37c": {":person_feeding_baby:"}, - "\U0001f9d1\u200d\U0001f384": {":mx_claus:"}, + "\U0001f9d1\u200d\U0001f384": {":mx_claus:", ":Mx_Claus:"}, "\U0001f9d1\u200d\U0001f393": {":student:"}, "\U0001f9d1\u200d\U0001f3a4": {":singer:"}, "\U0001f9d1\u200d\U0001f3a8": {":artist:"}, @@ -7294,6 +7314,7 @@ func emojiRevCode() map[string][]string { "\U0001f9d1\u200d\U0001f9d1\u200d\U0001f9d2\u200d\U0001f9d2": {":family_adult_adult_child_child:"}, "\U0001f9d1\u200d\U0001f9d2": {":family_adult_child:"}, "\U0001f9d1\u200d\U0001f9d2\u200d\U0001f9d2": {":family_adult_child_child:"}, + "\U0001f9d1\u200d\U0001fa70": {":ballet_dancer:"}, "\U0001f9d1\u200d\u2695\ufe0f": {":health_worker:"}, "\U0001f9d1\u200d\u2696\ufe0f": {":judge:"}, "\U0001f9d1\u200d\u2708\ufe0f": {":pilot:"}, @@ -7520,6 +7541,10 @@ func emojiRevCode() map[string][]string { "\U0001fa86": {":nesting_dolls:"}, "\U0001fa87": {":maracas:"}, "\U0001fa88": {":flute:"}, + "\U0001fa89": {":harp:"}, + "\U0001fa8a": {":trombone:"}, + "\U0001fa8e": {":treasure_chest:"}, + "\U0001fa8f": {":shovel:"}, "\U0001fa90": {":ringed_planet:"}, "\U0001fa91": {":chair:"}, "\U0001fa92": {":razor:"}, @@ -7566,6 +7591,7 @@ func emojiRevCode() map[string][]string { "\U0001fabb": {":hyacinth:"}, "\U0001fabc": {":jellyfish:"}, "\U0001fabd": {":wing:"}, + "\U0001fabe": {":leafless_tree:"}, "\U0001fabf": {":goose:"}, "\U0001fac0": {":anatomical_heart:"}, "\U0001fac1": {":lungs:"}, @@ -7573,6 +7599,9 @@ func emojiRevCode() map[string][]string { "\U0001fac3": {":pregnant_man:"}, "\U0001fac4": {":pregnant_person:"}, "\U0001fac5": {":person_with_crown:"}, + "\U0001fac6": {":fingerprint:"}, + "\U0001fac8": {":hairy_creature:"}, + "\U0001facd": {":orca:"}, "\U0001face": {":moose:"}, "\U0001facf": {":donkey:"}, "\U0001fad0": {":blueberries:"}, @@ -7587,6 +7616,8 @@ func emojiRevCode() map[string][]string { "\U0001fad9": {":jar:"}, "\U0001fada": {":ginger_root:"}, "\U0001fadb": {":pea_pod:"}, + "\U0001fadc": {":root_vegetable:"}, + "\U0001fadf": {":splatter:"}, "\U0001fae0": {":melting_face:"}, "\U0001fae1": {":saluting_face:"}, "\U0001fae2": {":face_with_open_eyes_and_hand_over_mouth:"}, @@ -7596,6 +7627,9 @@ func emojiRevCode() map[string][]string { "\U0001fae6": {":biting_lip:"}, "\U0001fae7": {":bubbles:"}, "\U0001fae8": {":shaking_face:"}, + "\U0001fae9": {":face_with_bags_under_eyes:"}, + "\U0001faea": {":distorted_face:"}, + "\U0001faef": {":fight_cloud:"}, "\U0001faf0": {":hand_with_index_finger_and_thumb_crossed:"}, "\U0001faf1": {":rightwards_hand:"}, "\U0001faf2": {":leftwards_hand:"}, @@ -7709,18 +7743,18 @@ func emojiRevCode() map[string][]string { "\u263a\ufe0f": {":relaxed:"}, "\u2640\ufe0f": {":female_sign:"}, "\u2642\ufe0f": {":male_sign:"}, - "\u2648": {":Aries:", ":aries:"}, - "\u2649": {":Taurus:", ":taurus:"}, - "\u264a": {":Gemini:", ":gemini:"}, - "\u264b": {":Cancer:", ":cancer:"}, - "\u264c": {":Leo:", ":leo:"}, - "\u264d": {":Virgo:", ":virgo:"}, - "\u264e": {":Libra:", ":libra:"}, + "\u2648": {":aries:", ":Aries:"}, + "\u2649": {":taurus:", ":Taurus:"}, + "\u264a": {":gemini:", ":Gemini:"}, + "\u264b": {":cancer:", ":Cancer:"}, + "\u264c": {":leo:", ":Leo:"}, + "\u264d": {":virgo:", ":Virgo:"}, + "\u264e": {":libra:", ":Libra:"}, "\u264f": {":Scorpio:", ":scorpius:"}, - "\u2650": {":Sagittarius:", ":sagittarius:"}, - "\u2651": {":Capricorn:", ":capricorn:"}, - "\u2652": {":Aquarius:", ":aquarius:"}, - "\u2653": {":Pisces:", ":pisces:"}, + "\u2650": {":sagittarius:", ":Sagittarius:"}, + "\u2651": {":capricorn:", ":Capricorn:"}, + "\u2652": {":aquarius:", ":Aquarius:"}, + "\u2653": {":pisces:", ":Pisces:"}, "\u265f\ufe0f": {":chess_pawn:"}, "\u2660": {":spade_suit:"}, "\u2660\ufe0f": {":spades:"}, @@ -7763,7 +7797,7 @@ func emojiRevCode() map[string][]string { "\u26c5": {":partly_sunny:", ":sun_behind_cloud:"}, "\u26c8": {":thunder_cloud_rain:", ":cloud_with_lightning_and_rain:"}, "\u26c8\ufe0f": {":thunder_cloud_and_rain:"}, - "\u26ce": {":Ophiuchus:", ":ophiuchus:"}, + "\u26ce": {":ophiuchus:", ":Ophiuchus:"}, "\u26cf\ufe0f": {":pick:"}, "\u26d1": {":helmet_with_cross:", ":rescue_worker’s_helmet:"}, "\u26d1\ufe0f": {":rescue_worker_helmet:", ":helmet_with_white_cross:"}, diff --git a/vendor/modules.txt b/vendor/modules.txt index 005d79a73..4a5e0849f 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -96,8 +96,8 @@ github.com/karimkhaleel/jsonschema # github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 ## explicit github.com/kr/logfmt -# github.com/kyokomi/emoji/v2 v2.2.13 -## explicit; go 1.14 +# github.com/kyokomi/emoji/v2 v2.2.14 +## explicit; go 1.21 github.com/kyokomi/emoji/v2 # github.com/lucasb-eyer/go-colorful v1.4.0 ## explicit; go 1.12 From f416a4ba6a75cfb677a6e4a571056884a1319c6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:57:21 +0000 Subject: [PATCH 97/97] Bump mheap/github-action-required-labels from 5.5.2 to 5.6.0 Bumps [mheap/github-action-required-labels](https://github.com/mheap/github-action-required-labels) from 5.5.2 to 5.6.0. - [Release notes](https://github.com/mheap/github-action-required-labels/releases) - [Commits](https://github.com/mheap/github-action-required-labels/compare/0ac283b4e65c1fb28ce6079dea5546ceca98ccbe...23e10fde7e062233401931a0eece796cd9bf3177) --- updated-dependencies: - dependency-name: mheap/github-action-required-labels dependency-version: 5.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/check-required-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-required-label.yml b/.github/workflows/check-required-label.yml index a7e1d97c7..eb681cc5c 100644 --- a/.github/workflows/check-required-label.yml +++ b/.github/workflows/check-required-label.yml @@ -8,7 +8,7 @@ jobs: check-required-label: runs-on: ubuntu-latest steps: - - uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5 + - uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5 with: mode: exactly count: 1