From dd0029dd3536c986cbaa1aecfb0c49d207dc396d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 27 Sep 2026 14:40:46 +0200 Subject: [PATCH 01/11] Use accordion mode in diff_commits demo When running the demos at a hight of 35, as the new recording mechanism will, this demo failed because the commits list was too small to show both commits at the same time, and NavigateToLine has a bug that prevents it from finding it by going to the top and pressing down until it matches. We are going to fix that bug in the next commit for similar future situations, but we also solve the problem here by setting the side panels to accordion mode so that more commits are visible; this looks better for this demo anyway. --- pkg/integration/tests/demo/diff_commits.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/integration/tests/demo/diff_commits.go b/pkg/integration/tests/demo/diff_commits.go index ddc7b7ba7a0..bfabdb86569 100644 --- a/pkg/integration/tests/demo/diff_commits.go +++ b/pkg/integration/tests/demo/diff_commits.go @@ -15,6 +15,7 @@ var DiffCommits = NewIntegrationTest(NewIntegrationTestArgs{ config.GetUserConfig().Gui.ShowFileTree = false config.GetUserConfig().Gui.ShowCommandLog = false + config.GetUserConfig().Gui.ExpandFocusedSidePanel = true }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(50) From a2dd3327eb386bb71fd6bd0278e7da4cda98b6c2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 24 Sep 2026 21:55:40 +0200 Subject: [PATCH 02/11] Check the line we start on when navigating to a list item NavigateToLine looks for its target among the lines the view has rendered. When a list is scrolled, the view holds only the part of it that is on screen, so the target may not be among them. For that case the helper jumps to the top of the list and walks down instead. That walk presses a key before it looks, so it never sees the item it starts on, which after jumping is the first item of the list. A target sitting there is reported as missing. The shorter the terminal, the more of a list is scrolled out of view and the more often that walk is needed, so this surfaced once the demos began running in a 35 line terminal. Check the line the walk starts on before moving off it. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/integration/components/view_driver.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 818385ca1e5..b5168487a9c 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -905,6 +905,14 @@ func (self *ViewDriver) NavigateToLine(matcher *TextMatcher) *ViewDriver { keyPress = func() { self.SelectPreviousItem() } } + // The loop below only looks at the lines it moves onto, so the line we are + // starting from has to be checked here. After jumping to the top that line + // is the first item of the list, and for a list we had to scroll through + // it is a plausible target. + if ok, _ := matcher.test(view.BufferLines()[selectedLineIdx]); ok { + return self + } + for range maxNumKeyPresses { keyPress() idx := self.getSelectedLineIdx() From f3e8d8801903aa70a8adad4f7df5b664f922ef47 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 24 Sep 2026 21:48:32 +0200 Subject: [PATCH 03/11] Run demos at the size they are recorded at How a demo behaves depends on how much of a list fits on the screen, and `just e2e` ran demos on a 150x100 screen while we record them at 120x35. So a demo could pass the test suite and still fail partway through a recording, and there was no way to find out short of recording it. Give a demo the recording size when it doesn't ask for a size of its own. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/integration/components/test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/integration/components/test.go b/pkg/integration/components/test.go index 6eab23ae04a..064449d105d 100644 --- a/pkg/integration/components/test.go +++ b/pkg/integration/components/test.go @@ -23,6 +23,14 @@ const ( defaultHeight = 100 ) +// The terminal size that demo/settings.tape gives a recording. How a demo +// behaves depends on how much of a list fits on the screen, so run demos at +// the size they will be recorded at. Keep these in step with that file. +const ( + demoWidth = 120 + demoHeight = 35 +) + type IntegrationTest struct { name string description string @@ -213,6 +221,10 @@ func (self *IntegrationTest) Run(gui integrationTypes.GuiDriver) { func (self *IntegrationTest) HeadlessDimensions() (int, int) { if self.width == 0 && self.height == 0 { + if self.isDemo { + return demoWidth, demoHeight + } + return defaultWidth, defaultHeight } From 115e3fb389c9c2dc59c710a29a5939231ad31848 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 24 Sep 2026 20:08:10 +0200 Subject: [PATCH 04/11] Update demos and readme for new staging-in-main-view functionality We don't bother rendering the updated or new demos yet, because we are about to change them to a different format. --- README.md | 14 ++- pkg/integration/tests/demo/custom_patch.go | 93 +++++++++++++------ .../tests/demo/remove_hunk_from_commit.go | 82 ++++++++++++++++ ...stage_lines.go => stage_hunks_or_lines.go} | 38 +++++--- pkg/integration/tests/test_list.go | 3 +- 5 files changed, 182 insertions(+), 48 deletions(-) create mode 100644 pkg/integration/tests/demo/remove_hunk_from_commit.go rename pkg/integration/tests/demo/{stage_lines.go => stage_hunks_or_lines.go} (63%) diff --git a/README.md b/README.md index aafd2768d65..25ab6848381 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ If you're a mere mortal like me and you're tired of hearing how powerful git is - [Elevator Pitch](#elevator-pitch) - [Table of contents](#table-of-contents) - [Features](#features) - - [Stage individual lines](#stage-individual-lines) + - [Stage hunks or individual lines](#stage-hunks-or-individual-lines) - [Interactive Rebase](#interactive-rebase) - [Cherry-pick](#cherry-pick) - [Bisect](#bisect) @@ -133,11 +133,11 @@ Lazygit is not my fulltime job but it is a hefty part time job so if you want to ## Features -### Stage individual lines +### Stage hunks or individual lines -Press `` on a changed file to focus its diff in the main view. Press `` on the selected line to stage it, or press `v` to start selecting a range of lines. You can also press `a` to switch to hunk selection mode. When a file has both staged and unstaged changes, use `` to move between the two diff panes; the same actions stage or unstage the selection depending on the pane. +Press `0` on a changed file to focus its diff in the main view. The selection covers a whole hunk to begin with, so `` stages that hunk and moves on to the next one. When you want only part of a hunk, press `a` for line-by-line selection and `v` to select a range of lines. What you staged shows up in the pane below. Press `` to move between the two panes; `` unstages in the lower one. Press `c` to commit without leaving the diff. -![stage_lines](../assets/demo/stage_lines-compressed.gif) +![stage_hunks_or_lines](../assets/demo/stage_hunks_or_lines-compressed.gif) ### Interactive Rebase @@ -195,12 +195,16 @@ You can create worktrees to have multiple branches going at once without the nee You can build a custom patch from an old commit and then remove the patch from the commit, split out a new commit, apply the patch in reverse to the index, and more. -In this example we have a redundant comment that we want to remove from an old commit. We hit `` on the commit to view its files, then `` on a file to focus its diff. From there, `` adds the selected comment line to the custom patch and `ctrl+p` opens the custom patch options, where we choose to remove the patch from the original commit. +In this example an old commit contains a change that belongs in a commit of its own. We hit `0` on the commit to focus its diff. `` on the hunk we want to move adds it to the custom patch, and `ctrl+p` opens the custom patch options, where we choose to move the patch into a new commit. Learn more in the [Rebase magic Youtube tutorial](https://youtu.be/4XaToVut_hs). ![custom_patch](../assets/demo/custom_patch-compressed.gif) +If you only want to remove a hunk from an old commit, you don't need a custom patch for that. Select the hunk in the commit's diff and press `d`. Lazygit rewrites the commit without it, running an interactive rebase in the background. + +![remove_hunk_from_commit](../assets/demo/remove_hunk_from_commit-compressed.gif) + ### Rebase from marked base commit Say you're on a feature branch that was itself branched off of the develop branch, and you've decided you'd rather be branching off the master branch. You need a way to rebase only the commits from your feature branch. In this demo we check to see which was the last commit on the develop branch, then press `shift+b` to mark that commit as our base commit, then press `r` on the master branch to rebase onto it, only bringing across the commits from our feature branch. Then we push our changes with `shift+p`. diff --git a/pkg/integration/tests/demo/custom_patch.go b/pkg/integration/tests/demo/custom_patch.go index 9235963973d..6e2dfa93b41 100644 --- a/pkg/integration/tests/demo/custom_patch.go +++ b/pkg/integration/tests/demo/custom_patch.go @@ -10,66 +10,99 @@ var usersFileContent = `package main import "fmt" func main() { - // TODO: verify that this actually works + fmt.Println(shims.Greeting()) + serve() +} + +func serve() { + fmt.Println("listening on :8080") +} +` + +var usersFileContentWithLogging = `package main + +import "fmt" + +func main() { fmt.Println("hello world") + serve() +} + +func logRequest(path string) { + fmt.Println("request:", path) +} + +func serve() { + fmt.Println("listening on :8080") } ` var CustomPatch = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Remove a line from an old commit", + Description: "Split a change out of an old commit into a new one", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, SetupConfig: func(cfg *config.AppConfig) { setDefaultDemoConfig(cfg) - cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false }, SetupRepo: func(shell *Shell) { shell.CreateNCommitsWithRandomMessages(30) shell.NewBranch("feature/user-authentication") shell.EmptyCommit("Add user authentication feature") - shell.CreateFileAndAdd("src/users.go", "package main\n") + shell.CreateFileAndAdd("src/users.go", usersFileContent) shell.Commit("Fix local session storage") shell.CreateFile("src/authentication.go", "package main") shell.CreateFile("src/session.go", "package main") - shell.UpdateFileAndAdd("src/users.go", usersFileContent) - shell.EmptyCommit("Stop using shims") + shell.UpdateFileAndAdd("src/users.go", usersFileContentWithLogging) + shell.Commit("Stop using shims") shell.UpdateFileAndAdd("src/authentication.go", "package authentication") shell.UpdateFileAndAdd("src/session.go", "package session") shell.Commit("Enhance user authentication feature") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.SetCaptionPrefix("Remove a line from an old commit") + t.SetCaptionPrefix("Split a change out of an old commit") t.Wait(1000) t.Views().Commits(). Focus(). NavigateToLine(Contains("Stop using shims")). Wait(1000). - PressEnter(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Wait(1000). + Press(keys.Universal.NextItem). + SelectedLines( + Contains("+func logRequest(path string) {"), + Contains(`fmt.Println("request:", path)`), + Contains("+}"), + Equals("+"), + ). + Wait(500). + SetCaptionPrefix("Add the hunk to a custom patch"). + PressPrimaryAction(). + Wait(1000). + SetCaptionPrefix("Move the patch into a new commit"). + Press(keys.Universal.CreatePatchOptionsMenu). Tap(func() { - t.Views().CommitFiles(). - IsFocused(). - NavigateToLine(Contains("users.go")). - Wait(1000). - PressEnter(). - Tap(func() { - t.Views().Main(). - IsFocused(). - NavigateToLine(Contains("TODO")). - Wait(500). - PressPrimaryAction(). - PressEscape() - }). - Press(keys.Universal.CreatePatchOptionsMenu). - Tap(func() { - t.ExpectPopup().Menu(). - Title(Equals("Patch options")). - Select(Contains("Remove patch from original commit")). - Wait(500). - Confirm() - }). - PressEscape() + t.ExpectPopup().Menu(). + Title(Equals("Patch options")). + Select(Contains("Move patch into new commit after the original commit")). + Wait(500). + Confirm() + + t.ExpectPopup().CommitMessagePanel(). + Type("Add request logging"). + Confirm() }) + + t.Views().Commits(). + IsFocused(). + TopLines( + Contains("Enhance user authentication feature"), + Contains("Add request logging"), + Contains("Stop using shims"), + ) }, }) diff --git a/pkg/integration/tests/demo/remove_hunk_from_commit.go b/pkg/integration/tests/demo/remove_hunk_from_commit.go new file mode 100644 index 00000000000..c7fad1949d8 --- /dev/null +++ b/pkg/integration/tests/demo/remove_hunk_from_commit.go @@ -0,0 +1,82 @@ +package demo + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var greetingFileContent = `package main + +import "fmt" + +func greet(name string) { + fmt.Println("hello", name) +} + +func farewell(name string) { + fmt.Println("bye", name) +} +` + +var greetingFileContentWithDebugLine = `package main + +import "fmt" + +func greet(name string) { + fmt.Println("hello there", name) +} + +func farewell(name string) { + fmt.Println("DEBUG: saying bye to", name) + fmt.Println("bye", name) +} +` + +var RemoveHunkFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Remove a hunk from an old commit", + ExtraCmdArgs: []string{}, + Skip: false, + IsDemo: true, + SetupConfig: func(cfg *config.AppConfig) { + setDefaultDemoConfig(cfg) + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommitsWithRandomMessages(30) + shell.NewBranch("feature/user-authentication") + shell.CreateFileAndAdd("src/greeting.go", greetingFileContent) + shell.Commit("Greet users when they sign in") + shell.UpdateFileAndAdd("src/greeting.go", greetingFileContentWithDebugLine) + shell.Commit("Make the greeting friendlier") + shell.EmptyCommit("Expire sessions after a day") + shell.EmptyCommit("Enhance user authentication feature") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.SetCaptionPrefix("Remove a hunk from an old commit") + t.Wait(1000) + + t.Views().Commits(). + Focus(). + NavigateToLine(Contains("Make the greeting friendlier")). + Wait(1000). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Wait(1000). + Press(keys.Universal.NextItem). + SelectedLines( + Contains(`fmt.Println("DEBUG: saying bye to", name)`), + ). + Wait(1000). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Confirmation(). + Title(Equals("Discard lines from commit")). + Content(AnyString()). + Wait(1000). + Confirm() + }). + Wait(1000). + Content(Contains(`fmt.Println("hello there", name)`).DoesNotContain("DEBUG")) + }, +}) diff --git a/pkg/integration/tests/demo/stage_lines.go b/pkg/integration/tests/demo/stage_hunks_or_lines.go similarity index 63% rename from pkg/integration/tests/demo/stage_lines.go rename to pkg/integration/tests/demo/stage_hunks_or_lines.go index 8d05749f637..5457ecfc0ec 100644 --- a/pkg/integration/tests/demo/stage_lines.go +++ b/pkg/integration/tests/demo/stage_hunks_or_lines.go @@ -30,11 +30,11 @@ Simple terminal UI for git Just do brew install lazygit and bada bing bada boom you have begun on the path of laziness. - +TODO: mention the other install methods ` -var StageLines = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Stage individual lines", +var StageHunksOrLines = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Stage hunks or individual lines", ExtraCmdArgs: []string{}, Skip: false, IsDemo: true, @@ -51,29 +51,43 @@ var StageLines = NewIntegrationTest(NewIntegrationTestArgs{ shell.UpdateFile("docs/README.md", updatedFile) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.SetCaptionPrefix("Stage individual lines") + t.SetCaptionPrefix("Stage a hunk") t.Wait(1000) t.Views().Files(). IsFocused(). - PressEnter() + Press(keys.Universal.FocusMainView) t.Views().Main(). IsFocused(). + SelectedLines( + Contains("-Simple terminal UI for git commands"), + Contains("+Simple terminal UI for git"), + Contains("+(Not too simple though)"), + ). + Wait(1000). + PressPrimaryAction(). + Wait(1000). + SetCaptionPrefix("Stage individual lines"). + Press(keys.Main.ToggleSelectHunk). + Wait(500). Press(keys.Universal.ToggleRangeSelect). PressFast(keys.Universal.NextItem). - PressFast(keys.Universal.NextItem). + SelectedLines( + Contains("+Just do brew install lazygit"), + Contains("+boom you have begun"), + ). Wait(500). PressPrimaryAction(). - Wait(500). - PressEscape() - - t.Views().Files(). - IsFocused(). + Wait(1000). + SelectedLines( + Contains("+TODO: mention the other install methods"), + ). + SetCaptionPrefix("Commit our changes"). Press(keys.Files.CommitChanges). Tap(func() { t.ExpectPopup().CommitMessagePanel(). - Type("Update tagline"). + Type("Update tagline and install instructions"). Confirm() }) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 29d18db02a3..54fe8f2b34c 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -220,7 +220,8 @@ var tests = []*components.IntegrationTest{ demo.InteractiveRebase, demo.NukeWorkingTree, demo.RebaseOnto, - demo.StageLines, + demo.RemoveHunkFromCommit, + demo.StageHunksOrLines, demo.Undo, demo.WorktreeCreateFromBranches, diff.CopyToClipboard, From 212142ff746812e698d5c80bff7fbbeb641bff47 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 5 Oct 2026 17:42:58 +0200 Subject: [PATCH 05/11] Confirm the nuke prompt in the nuke_working_tree demo The recording of this demo stops with the confirmation popup still on screen. The explosion animation and the emptied Files panel that the demo exists to show never happen at all. Nuking the working tree has asked for confirmation since 238fdd573c, and the demo only selects the menu item, so the nuke never runs. The demo asserted nothing about the outcome, so it stayed green all the while. Answer the confirmation, and assert that the Files panel ends up empty. That assertion also holds the recording open until the animation has played out and the panel has been refreshed. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/integration/tests/demo/nuke_working_tree.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/integration/tests/demo/nuke_working_tree.go b/pkg/integration/tests/demo/nuke_working_tree.go index 5ff2a5dd623..9db2cc1124c 100644 --- a/pkg/integration/tests/demo/nuke_working_tree.go +++ b/pkg/integration/tests/demo/nuke_working_tree.go @@ -39,6 +39,15 @@ var NukeWorkingTree = NewIntegrationTest(NewIntegrationTestArgs{ Title(Equals("")). Select(Contains("Nuke working tree")). Confirm() - }) + + t.ExpectPopup().Confirmation(). + Title(Equals("Nuke working tree")). + Content(AnyString()). + Wait(1000). + Confirm() + }). + // Retries until the explosion animation has played out and the panel has + // been refreshed, so that the recording shows both. + IsEmpty() }, }) From 0026f853c79f2ca66f5058ed895f77fc6a6e4497 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 22 Sep 2026 20:22:05 +0200 Subject: [PATCH 06/11] Give the tests a visible inactive border colour The integration test config asks for `black` inactive borders. In a recording that comes out as a mid grey, because the recording theme remaps the terminal's black to #7a7a7a. In an ordinary terminal it is real black, so when you watch a test with `just e2e-cli` the inactive frames all but disappear against the background. Name the grey directly instead (but a little bit brighter than the #7a we had before), so that the frames look the same either way. Co-Authored-By: Claude Opus 5 (1M context) --- test/default_test_config/config.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/default_test_config/config.yml b/test/default_test_config/config.yml index e5a893a13c4..52aaa9aac26 100644 --- a/test/default_test_config/config.yml +++ b/test/default_test_config/config.yml @@ -8,8 +8,11 @@ gui: activeBorderColor: - green - bold + # Not the "black" of the terminal palette: that is a real black in an + # ordinary terminal, which makes the frames all but invisible when you run + # a test with `just e2e-cli`. inactiveBorderColor: - - black + - '#999999' # Not important in tests but it creates clutter in demos showRandomTip: false # The expected output of the tests draws the commit graph with box drawing From d24bb08384e725d7d731b08e5424016e298da9d8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 22 Sep 2026 19:30:19 +0200 Subject: [PATCH 07/11] Record demos with vhs and publish them to GitHub's attachment store The demo gifs in the README start on their own, loop without telling the reader where a run begins, and give no way to pause, seek or replay. A video with the browser's own controls fixes all three, but GitHub plays a video in a README only when it is served from its own attachment store. If you commit one to the assets branch and link it the way we link the images, GitHub drops the whole