From 6b8853c02793ed17bc404ac5d3ac30d576468e72 Mon Sep 17 00:00:00 2001 From: doprz <52579214+doprz@users.noreply.github.com> Date: Thu, 14 Aug 2025 21:49:49 -0500 Subject: [PATCH 01/23] feat(nix): add comprehensive nix flake Co-authored-by: Eveeifyeve --- .gitignore | 6 +++ CONTRIBUTING.md | 25 ++++++++++ README.md | 55 +++++++++++++++++++-- default.nix | 12 +++++ flake.lock | 127 ++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 125 +++++++++++++++++++++++++++++++++++++++++++++++ shell.nix | 12 +++++ 7 files changed, 357 insertions(+), 5 deletions(-) create mode 100644 default.nix create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 shell.nix diff --git a/.gitignore b/.gitignore index 9b0ebaef6b5..ef4dfcac65e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ __debug_bin* demo/output/* coverage.out + +# Nix +result +result-* +.direnv +.envrc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c2bab21d34..0c99f4af694 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,31 @@ To run lazygit from within the integrated terminal just go `go run main.go` This allows you to contribute to Lazygit without needing to install anything on your local machine. The Codespace has all the necessary tools and extensions pre-installed. +## Using Nix for development + +If you use Nix, you can leverage the included flake to set up a complete development environment with all necessary dependencies: + +```sh +nix develop +``` + +This will drop you into a development shell that includes: +* Latest Go toolchain +* golangci-lint for code linting +* git and make + +You can also build and run lazygit using nix: + +```sh +# Build lazygit +nix build + +# Run lazygit directly +nix run +``` + +The nix flake supports multiple architectures (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin) and provides a consistent development environment across different systems. + ## Code of conduct Please note by participating in this project, you agree to abide by the [code of conduct]. diff --git a/README.md b/README.md index b322e81d3e3..bae6e63753d 100644 --- a/README.md +++ b/README.md @@ -388,19 +388,64 @@ sudo zypper ar https://download.opensuse.org/repositories/devel:/languages:/go/$ sudo zypper ref && sudo zypper in lazygit ``` -### NixOs +### NixOS -On NixOs lazygit is packaged with nix and distributed via nixpkgs. -You can try the lazygit without installing it with: +#### Using lazygit from nixpkgs + +On NixOS, lazygit is packaged with nix and distributed via nixpkgs. +You can try lazygit without installing it with: ```sh 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/). + +#### Using the official lazygit flake + +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 +nix run . +``` + +**Build lazygit from source:** +```sh +nix build github:jesseduffield/lazygit +# or from a local clone +nix build . +``` -Or you can add lazygit to you `configuration.nix` using the `environment.systemPackages` option. -More details can be found via NixOs search [page](https://search.nixos.org/). +**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 +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"; + + outputs = { self, nixpkgs, lazygit }: { + # Use the overlay + nixpkgs.overlays = [ lazygit.overlays.default ]; + }; +} +``` ### Flox diff --git a/default.nix b/default.nix new file mode 100644 index 00000000000..c48b1fb8cbb --- /dev/null +++ b/default.nix @@ -0,0 +1,12 @@ +(import ( + let + lock = builtins.fromJSON (builtins.readFile ./flake.lock); + nodeName = lock.nodes.root.inputs.flake-compat; + in + fetchTarball { + url = + lock.nodes.${nodeName}.locked.url + or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz"; + sha256 = lock.nodes.${nodeName}.locked.narHash; + } +) { src = ./.; }).defaultNix diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000000..6d026ba12bd --- /dev/null +++ b/flake.lock @@ -0,0 +1,127 @@ +{ + "nodes": { + "flake-compat": { + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "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" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1754487366, + "narHash": "sha256-pHYj8gUBapuUzKV/kN/tR3Zvqc7o6gdFB9XKXIp1SQ8=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "af66ad14b28a127c5c0f3bbb298218fc63528a18", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1756217674, + "narHash": "sha256-TH1SfSP523QI7kcPiNtMAEuwZR3Jdz0MCDXPs7TS8uo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4e7667a90c167f7a81d906e5a75cba4ad8bee620", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1753579242, + "narHash": "sha256-zvaMGVn14/Zz8hnp4VWT9xVnhc8vuL3TStRqwk22biA=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "0f36c44e01a6129be94e3ade315a5883f0228a6e", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1754340878, + "narHash": "sha256-lgmUyVQL9tSnvvIvBp7x1euhkkCho7n3TMzgjdvgPoU=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "cab778239e705082fe97bb4990e0d24c50924c04", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "systems": "systems", + "treefmt-nix": "treefmt-nix" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1755934250, + "narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000000..0535e10177a --- /dev/null +++ b/flake.nix @@ -0,0 +1,125 @@ +{ + description = "A simple terminal UI for git commands"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + systems.url = "github:nix-systems/default"; + flake-parts.url = "github:hercules-ci/flake-parts"; + flake-compat.url = "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + }; + + outputs = + inputs@{ flake-parts, systems, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = import systems; + imports = [ + inputs.treefmt-nix.flakeModule + ]; + + perSystem = + { + pkgs, + system, + ... + }: + let + goMod = builtins.readFile ./go.mod; + versionMatch = builtins.match ".*go[[:space:]]([0-9]+\\.[0-9]+)(\\.[0-9]+)?.*" goMod; + + goVersion = + if versionMatch != null then + builtins.head versionMatch + else + throw "Could not extract Go version from go.mod"; + + goOverlay = final: prev: { + go = prev."go_${builtins.replaceStrings [ "." ] [ "_" ] goVersion}"; + }; + + lazygit = pkgs.buildGoModule rec { + pname = "lazygit"; + version = "dev"; + + gitCommit = inputs.self.rev or inputs.self.dirtyRev or "dev"; + + src = ./.; + vendorHash = null; + + # Disable integration tests that require specific environment + doCheck = false; + + nativeBuildInputs = with pkgs; [ + git + makeWrapper + ]; + buildInputs = [ pkgs.git ]; + + ldflags = [ + "-s" + "-w" + "-X main.commit=${gitCommit}" + "-X main.version=${version}" + "-X main.buildSource=nix" + ]; + + postInstall = '' + wrapProgram $out/bin/lazygit \ + --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.git ]} + ''; + + meta = { + description = "A simple terminal UI for git commands"; + homepage = "https://github.com/jesseduffield/lazygit"; + license = pkgs.lib.licenses.mit; + maintainers = [ "jesseduffield" ]; + platforms = pkgs.lib.platforms.unix; + mainProgram = "lazygit"; + }; + }; + in + { + _module.args.pkgs = import inputs.nixpkgs { + inherit system; + overlays = [ goOverlay ]; + config = { }; + }; + + packages = { + default = lazygit; + inherit lazygit; + }; + + devShells.default = pkgs.mkShell { + name = "lazygit-dev"; + + buildInputs = with pkgs; [ + # Go toolchain + go + gotools + + # Development tools + git + gnumake + ]; + + # Environment variables for development + CGO_ENABLED = "0"; + }; + + treefmt = { + programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt-rfc-style.compiler; + programs.nixfmt.package = pkgs.nixfmt-rfc-style; + programs.gofmt.enable = true; + }; + + checks.build = lazygit; + }; + + flake = { + overlays.default = final: prev: { + lazygit = inputs.self.packages.${final.system}.lazygit; + }; + }; + }; +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000000..459e1526e79 --- /dev/null +++ b/shell.nix @@ -0,0 +1,12 @@ +(import ( + let + lock = builtins.fromJSON (builtins.readFile ./flake.lock); + nodeName = lock.nodes.root.inputs.flake-compat; + in + fetchTarball { + url = + lock.nodes.${nodeName}.locked.url + or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz"; + sha256 = lock.nodes.${nodeName}.locked.narHash; + } +) { src = ./.; }).shellNix From d0c6e27fee9b4c7d944a1afd0ad30731b8566059 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Sep 2025 14:17:24 +0200 Subject: [PATCH 02/23] 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. --- pkg/gui/controllers/files_controller.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index bf71bf04a73..d71db5c3fe7 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1221,13 +1221,14 @@ func normalisedSelectedNodes(selectedNodes []*filetree.FileNode) []*filetree.Fil } func isDescendentOfSelectedNodes(node *filetree.FileNode, selectedNodes []*filetree.FileNode) bool { + nodePath := node.GetPath() + for _, selectedNode := range selectedNodes { if selectedNode.IsFile() { continue } selectedNodePath := selectedNode.GetPath() - nodePath := node.GetPath() if strings.HasPrefix(nodePath, selectedNodePath+"/") { return true From 302b621b6811a2b863cd856dd85191bdaeebfa5d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Sep 2025 14:20:47 +0200 Subject: [PATCH 03/23] Fix: make isDescendentOfSelectedNodes 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 isDescendentOfSelectedNodes would return false for these. Working with the internal paths (i.e. without stripping the leading "./") fixes this. --- pkg/gui/controllers/files_controller.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index d71db5c3fe7..e512cb49821 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1221,14 +1221,14 @@ func normalisedSelectedNodes(selectedNodes []*filetree.FileNode) []*filetree.Fil } func isDescendentOfSelectedNodes(node *filetree.FileNode, selectedNodes []*filetree.FileNode) bool { - nodePath := node.GetPath() + nodePath := node.GetInternalPath() for _, selectedNode := range selectedNodes { if selectedNode.IsFile() { continue } - selectedNodePath := selectedNode.GetPath() + selectedNodePath := selectedNode.GetInternalPath() if strings.HasPrefix(nodePath, selectedNodePath+"/") { return true From 610ac68635b272ee73b853959dca8bfd7bf43cb6 Mon Sep 17 00:00:00 2001 From: doprz <52579214+doprz@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:39:07 -0500 Subject: [PATCH 04/23] fix(nix): use nixos-unstable Fixed jesseduffield/lazygit#4947 --- flake.lock | 26 +++++++++++++------------- flake.nix | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/flake.lock b/flake.lock index 6d026ba12bd..55f0c3139bd 100644 --- a/flake.lock +++ b/flake.lock @@ -19,11 +19,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1754487366, - "narHash": "sha256-pHYj8gUBapuUzKV/kN/tR3Zvqc7o6gdFB9XKXIp1SQ8=", + "lastModified": 1759362264, + "narHash": "sha256-wfG0S7pltlYyZTM+qqlhJ7GMw2fTF4mLKCIVhLii/4M=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "af66ad14b28a127c5c0f3bbb298218fc63528a18", + "rev": "758cf7296bee11f1706a574c77d072b8a7baa881", "type": "github" }, "original": { @@ -34,27 +34,27 @@ }, "nixpkgs": { "locked": { - "lastModified": 1756217674, - "narHash": "sha256-TH1SfSP523QI7kcPiNtMAEuwZR3Jdz0MCDXPs7TS8uo=", + "lastModified": 1759831965, + "narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4e7667a90c167f7a81d906e5a75cba4ad8bee620", + "rev": "c9b6fb798541223bbb396d287d16f43520250518", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.05", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "nixpkgs-lib": { "locked": { - "lastModified": 1753579242, - "narHash": "sha256-zvaMGVn14/Zz8hnp4VWT9xVnhc8vuL3TStRqwk22biA=", + "lastModified": 1754788789, + "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "0f36c44e01a6129be94e3ade315a5883f0228a6e", + "rev": "a73b9c743612e4244d865a2fdee11865283c04e6", "type": "github" }, "original": { @@ -108,11 +108,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1755934250, - "narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=", + "lastModified": 1758728421, + "narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5", + "rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 0535e10177a..b8069e9f746 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "A simple terminal UI for git commands"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; systems.url = "github:nix-systems/default"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-compat.url = "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"; From 703f053a7e7243df7a1286de142194cbfc885c18 Mon Sep 17 00:00:00 2001 From: Krystof Gartner Date: Thu, 11 Sep 2025 14:09:09 +0200 Subject: [PATCH 05/23] Add merge options menu Replace merge-tool with merge options menu that allows resolving all conflicts for selected files as ours, theirs, or union, while still providing access to the merge tool. --- docs/Config.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 | 4 +- 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 +- pkg/commands/git_commands/working_tree.go | 44 +++++ pkg/config/app_config.go | 1 + pkg/config/app_config_test.go | 2 +- pkg/config/user_config.go | 4 +- pkg/gui/command_log_panel.go | 4 +- pkg/gui/controllers.go | 2 +- pkg/gui/controllers/files_controller.go | 71 +++++++- .../helpers/working_tree_helper.go | 153 +++++++++++++++++- .../controllers/merge_conflicts_controller.go | 14 +- pkg/i18n/english.go | 16 +- .../tests/conflicts/merge_file_both.go | 77 +++++++++ .../tests/conflicts/merge_file_current.go | 76 +++++++++ .../tests/conflicts/merge_file_incoming.go | 76 +++++++++ pkg/integration/tests/shared/conflicts.go | 17 ++ pkg/integration/tests/test_list.go | 3 + schema/config.json | 2 +- 26 files changed, 556 insertions(+), 44 deletions(-) create mode 100644 pkg/integration/tests/conflicts/merge_file_both.go create mode 100644 pkg/integration/tests/conflicts/merge_file_current.go create mode 100644 pkg/integration/tests/conflicts/merge_file_incoming.go diff --git a/docs/Config.md b/docs/Config.md index 4bd589fe095..f9dadd8eaed 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -619,7 +619,7 @@ keybinding: viewResetOptions: D fetch: f toggleTreeView: '`' - openMergeTool: M + openMergeOptions: M openStatusFilter: copyFileInfoToClipboard: "y" collapseAll: '-' diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index 01a935102f7..e42ebee1c51 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.md @@ -153,7 +153,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Toggle file tree view | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Open external diff tool (git difftool) | | -| `` M `` | Open external merge tool | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | @@ -210,7 +210,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` z `` | Undo | Undo last merge conflict resolution. | | `` e `` | Edit file | Open file in external editor. | | `` o `` | Open file | Open file in default application. | -| `` M `` | Open external merge tool | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Return to files panel | | ## Main panel (normal) diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md index 9215f63dd7a..207b0d39749 100644 --- a/docs/keybindings/Keybindings_ja.md +++ b/docs/keybindings/Keybindings_ja.md @@ -235,7 +235,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` D `` | リセット | 作業ツリーのリセットオプション(例:作業ツリーの完全破棄)を表示します。 | | `` ` `` | ファイルツリービューを切り替え | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | 外部差分ツールを開く(git difftool) | | -| `` M `` | 外部マージツールを開く | `git mergetool`を実行します。 | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | フェッチ | リモートから変更をフェッチします。 | | `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます | | `` = `` | すべてのファイルを展開 | ファイルツリー内のすべてのディレクトリを展開します | @@ -292,7 +292,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` z `` | 元に戻す | 最後のマージコンフリクト解決を元に戻します。 | | `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 | | `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | -| `` M `` | 外部マージツールを開く | `git mergetool`を実行します。 | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | ファイルパネルに戻る | | ## メインパネル(通常) diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md index 96ef98ad077..e0d83760a54 100644 --- a/docs/keybindings/Keybindings_ko.md +++ b/docs/keybindings/Keybindings_ko.md @@ -152,7 +152,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` z `` | 되돌리기 | Undo last merge conflict resolution. | | `` e `` | 파일 편집 | Open file in external editor. | | `` o `` | 파일 닫기 | Open file in default application. | -| `` M `` | Git mergetool를 열기 | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | 파일 목록으로 돌아가기 | | ## 메인 패널 (Normal) @@ -396,7 +396,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` D `` | 초기화 | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | 파일 트리뷰로 전환 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Open external diff tool (git difftool) | | -| `` M `` | Git mergetool를 열기 | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md index 71c35fcdfff..9fea316a725 100644 --- a/docs/keybindings/Keybindings_nl.md +++ b/docs/keybindings/Keybindings_nl.md @@ -78,7 +78,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Open external diff tool (git difftool) | | -| `` M `` | Open external merge tool | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | @@ -218,7 +218,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` z `` | Ongedaan maken | Undo last merge conflict resolution. | | `` e `` | Verander bestand | Open file in external editor. | | `` o `` | Open bestand | Open file in default application. | -| `` M `` | Open external merge tool | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Ga terug naar het bestanden paneel | | ## Normaal diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md index 5f776570922..92b9c63880f 100644 --- a/docs/keybindings/Keybindings_pl.md +++ b/docs/keybindings/Keybindings_pl.md @@ -193,7 +193,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` z `` | Cofnij | Cofnij ostatnie rozwiązanie konfliktu scalania. | | `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | -| `` M `` | Otwórz zewnętrzne narzędzie scalania | Uruchom `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Wróć do panelu plików | | ## Panel główny (zatwierdzanie) @@ -252,7 +252,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` D `` | Reset | Wyświetl opcje resetu dla drzewa roboczego (np. zniszczenie drzewa roboczego). | | `` ` `` | Przełącz widok drzewa plików | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | -| `` M `` | Otwórz zewnętrzne narzędzie scalania | Uruchom `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Pobierz | Pobierz zmiany ze zdalnego serwera. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | diff --git a/docs/keybindings/Keybindings_pt.md b/docs/keybindings/Keybindings_pt.md index c6349bb8303..1ae9178c1c2 100644 --- a/docs/keybindings/Keybindings_pt.md +++ b/docs/keybindings/Keybindings_pt.md @@ -78,7 +78,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` D `` | Restaurar | Opções de redefinição de exibição para árvore de trabalho (por exemplo, nukando a árvore de trabalho). | | `` ` `` | Alternar exibição de árvore de arquivo | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | -| `` M `` | Abrir ferramenta de merge externa | Execute `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Buscar | Buscar alterações do controle remoto. | | `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos | | `` = `` | Expandir todos os arquivos | Expandir todos os diretórios na árvore do arquivo | @@ -278,7 +278,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` z `` | Desfazer | Desfazer resolução de conflitos de última mesclagem. | | `` e `` | Editar arquivo | Abrir arquivo no editor externo. | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | -| `` M `` | Abrir ferramenta de merge externa | Execute `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Retornar ao painel de arquivos | | ## Painel principal (patch build) diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md index 2586a78e4f2..4c46c54793e 100644 --- a/docs/keybindings/Keybindings_ru.md +++ b/docs/keybindings/Keybindings_ru.md @@ -122,7 +122,7 @@ _Связки клавиш_ | `` z `` | Отменить | Undo last merge conflict resolution. | | `` e `` | Редактировать файл | Open file in external editor. | | `` o `` | Открыть файл | Open file in default application. | -| `` M `` | Открыть внешний инструмент слияния (git mergetool) | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | Вернуться к панели файлов | | ## Главная панель (сборка патчей) @@ -390,7 +390,7 @@ _Связки клавиш_ | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Переключить вид дерева файлов | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | Open external diff tool (git difftool) | | -| `` M `` | Открыть внешний инструмент слияния (git mergetool) | Run `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Получить изменения | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md index decb0e6035c..ffc50aedae9 100644 --- a/docs/keybindings/Keybindings_zh-CN.md +++ b/docs/keybindings/Keybindings_zh-CN.md @@ -216,7 +216,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` D `` | 重置 | 查看工作树的重置选项(例如:清除工作树)。 | | `` ` `` | 切换文件树视图 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | 使用外部差异比较工具(git difftool) | | -| `` M `` | 打开外部合并工具(git mergetool) | 执行 `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | 抓取 | 从远程获取变更 | | `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 | | `` = `` | 展开全部文件 | 展开文件树中的全部目录 | @@ -305,7 +305,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` z `` | 撤销 | 撤消上次合并冲突解决 | | `` e `` | 编辑文件 | 使用外部编辑器打开文件 | | `` o `` | 打开文件 | 使用默认程序打开该文件 | -| `` M `` | 打开外部合并工具(git mergetool) | 执行 `git mergetool`. | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | 返回文件面板 | | ## 正在暂存 diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md index 66fae12b701..2e7e58e7e17 100644 --- a/docs/keybindings/Keybindings_zh-TW.md +++ b/docs/keybindings/Keybindings_zh-TW.md @@ -97,7 +97,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` z `` | 復原 | Undo last merge conflict resolution. | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | -| `` M `` | 開啟外部合併工具 | 執行 `git mergetool`。 | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` `` | 返回檔案面板 | | ## 主面板(預存) @@ -347,7 +347,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` `` | 開啟外部差異工具 (git difftool) | | -| `` M `` | 開啟外部合併工具 | 執行 `git mergetool`。 | +| `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | 擷取 | 同步遠端異動 | | `` - `` | Collapse all files | Collapse all directories in the files tree | | `` = `` | Expand all files | Expand all directories in the file tree | diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index e6b066d346f..ffa1d99b6d8 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -407,3 +408,46 @@ func (self *WorkingTreeCommands) ResetMixed(ref string) error { return self.cmd.New(cmdArgs).Run() } + +func (self *WorkingTreeCommands) ShowFileAtStage(path string, stage int) (string, error) { + cmdArgs := NewGitCmd("show"). + Arg(fmt.Sprintf(":%d:%s", stage, path)). + ToArgv() + + return self.cmd.New(cmdArgs).RunWithOutput() +} + +func (self *WorkingTreeCommands) ObjectIDAtStage(path string, stage int) (string, error) { + cmdArgs := NewGitCmd("rev-parse"). + Arg(fmt.Sprintf(":%d:%s", stage, path)). + ToArgv() + + output, err := self.cmd.New(cmdArgs).RunWithOutput() + if err != nil { + return "", err + } + + return strings.TrimSpace(output), nil +} + +func (self *WorkingTreeCommands) MergeFileForFiles(strategy string, oursFilepath string, baseFilepath string, theirsFilepath string) (string, error) { + cmdArgs := NewGitCmd("merge-file"). + Arg(strategy). + Arg("--stdout"). + Arg(oursFilepath, baseFilepath, theirsFilepath). + ToArgv() + + return self.cmd.New(cmdArgs).RunWithOutput() +} + +// OIDs mode (Git 2.43+) +func (self *WorkingTreeCommands) MergeFileForObjectIDs(strategy string, oursID string, baseID string, theirsID string) (string, error) { + cmdArgs := NewGitCmd("merge-file"). + Arg(strategy). + Arg("--stdout"). + Arg("--object-id"). + Arg(oursID, baseID, theirsID). + ToArgv() + + return self.cmd.New(cmdArgs).RunWithOutput() +} diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index ba1252b6ef6..dde4b538248 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -272,6 +272,7 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([] {[]string{"gui", "skipUnstageLineWarning"}, "skipDiscardChangeWarning"}, {[]string{"keybinding", "universal", "executeCustomCommand"}, "executeShellCommand"}, {[]string{"gui", "windowSize"}, "screenMode"}, + {[]string{"keybinding", "files", "openMergeTool"}, "openMergeOptions"}, } for _, pathToReplace := range pathsToReplace { diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index faba2e4e848..cc7ffea790d 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -897,7 +897,7 @@ keybinding: toggleStagedAll: a viewResetOptions: D fetch: f - openMergeTool: M + openMergeOptions: M openStatusFilter: copyFileInfoToClipboard: "y" collapseAll: '-' diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index c30d030aeaf..0323e6de1fb 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -487,7 +487,7 @@ type KeybindingFilesConfig struct { ViewResetOptions string `yaml:"viewResetOptions"` Fetch string `yaml:"fetch"` ToggleTreeView string `yaml:"toggleTreeView"` - OpenMergeTool string `yaml:"openMergeTool"` + OpenMergeOptions string `yaml:"openMergeOptions"` OpenStatusFilter string `yaml:"openStatusFilter"` CopyFileInfoToClipboard string `yaml:"copyFileInfoToClipboard"` CollapseAll string `yaml:"collapseAll"` @@ -950,7 +950,7 @@ func GetDefaultConfig() *UserConfig { ViewResetOptions: "D", Fetch: "f", ToggleTreeView: "`", - OpenMergeTool: "M", + OpenMergeOptions: "M", OpenStatusFilter: "", ConfirmDiscard: "x", CopyFileInfoToClipboard: "y", diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go index c0319f5c0c6..d4b847c946d 100644 --- a/pkg/gui/command_log_panel.go +++ b/pkg/gui/command_log_panel.go @@ -124,8 +124,8 @@ func (gui *Gui) getRandomTip() string { formattedKey(config.Universal.Remove), ), fmt.Sprintf( - "If you need to pull out the big guns to resolve merge conflicts, you can press '%s' in the files panel to open 'git mergetool'", - formattedKey(config.Files.OpenMergeTool), + "If you need to pull out the big guns to resolve merge conflicts, you can press '%s' in the files panel to open merge options", + formattedKey(config.Files.OpenMergeOptions), ), fmt.Sprintf( "To revert a commit, press '%s' on that commit", diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index f3ea245cfc2..2f729a7b50b 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -98,7 +98,7 @@ func (gui *Gui) resetHelpersAndControllers() { Bisect: bisectHelper, Suggestions: suggestionsHelper, Files: helpers.NewFilesHelper(helperCommon), - WorkingTree: helpers.NewWorkingTreeHelper(helperCommon, refsHelper, commitsHelper, gpgHelper), + WorkingTree: helpers.NewWorkingTreeHelper(helperCommon, refsHelper, commitsHelper, gpgHelper, rebaseHelper), Tags: helpers.NewTagsHelper(helperCommon, commitsHelper, gpgHelper), BranchesHelper: helpers.NewBranchesHelper(helperCommon, worktreeHelper), GPG: helpers.NewGpgHelper(helperCommon), diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index e512cb49821..4a3951c4829 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -178,10 +178,13 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types Description: self.c.Tr.OpenDiffTool, }, { - Key: opts.GetKey(opts.Config.Files.OpenMergeTool), - Handler: self.c.Helpers().WorkingTree.OpenMergeTool, - Description: self.c.Tr.OpenMergeTool, - Tooltip: self.c.Tr.OpenMergeToolTooltip, + Key: opts.GetKey(opts.Config.Files.OpenMergeOptions), + Handler: self.withItems(self.openMergeConflictMenu), + Description: self.c.Tr.ViewMergeConflictOptions, + Tooltip: self.c.Tr.ViewMergeConflictOptionsTooltip, + GetDisabledReason: self.require(self.itemsSelected(self.canOpenMergeConflictMenu)), + OpensMenu: true, + DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Files.Fetch), @@ -1024,6 +1027,34 @@ func (self *FilesController) createStashMenu() error { }) } +func (self *FilesController) openMergeConflictMenu(nodes []*filetree.FileNode) error { + normalizedNodes := flattenSelectedNodesToFiles(nodes) + + fileNodesWithConflicts := lo.Filter(normalizedNodes, func(node *filetree.FileNode, _ int) bool { + return node.File != nil && node.File.HasInlineMergeConflicts + }) + + filepaths := lo.Map(fileNodesWithConflicts, func(node *filetree.FileNode, _ int) string { + return node.GetPath() + }) + + return self.c.Helpers().WorkingTree.CreateMergeConflictMenu(filepaths) +} + +func (self *FilesController) canOpenMergeConflictMenu(nodes []*filetree.FileNode) *types.DisabledReason { + normalizedNodes := flattenSelectedNodesToFiles(nodes) + + hasFileNodesWithConflicts := lo.SomeBy(normalizedNodes, func(node *filetree.FileNode) bool { + return node.File != nil && node.File.HasInlineMergeConflicts + }) + + if !hasFileNodesWithConflicts { + return &types.DisabledReason{Text: self.c.Tr.NoFilesWithMergeConflicts} + } + + return nil +} + func (self *FilesController) openCopyMenu() error { node := self.context().GetSelected() @@ -1237,6 +1268,38 @@ func isDescendentOfSelectedNodes(node *filetree.FileNode, selectedNodes []*filet return false } +// BFS algorithm for expanding directories into their children, +// and for collecting the unique file nodes +func flattenSelectedNodesToFiles(selectedNodes []*filetree.FileNode) []*filetree.FileNode { + queue := append(make([]*filetree.FileNode, 0, len(selectedNodes)), selectedNodes...) + visited := set.New[string]() + var files []*filetree.FileNode + + for len(queue) > 0 { + // pop node from queue + node := queue[0] + queue = queue[1:] + + nodeID := node.ID() + if visited.Includes(nodeID) { + continue + } + visited.Add(nodeID) + + if node.File != nil { + // unique file node -> collect it + files = append(files, node) + continue + } + + // directory node -> enqueue children + for _, ch := range node.Children { + queue = append(queue, &filetree.FileNode{Node: ch}) + } + } + return files +} + func someNodesHaveUnstagedChanges(nodes []*filetree.FileNode) bool { return lo.SomeBy(nodes, (*filetree.FileNode).GetHasUnstagedChanges) } diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index e7bc74fa904..9e6e362966d 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -3,21 +3,24 @@ package helpers import ( "errors" "fmt" + "os" "regexp" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) type WorkingTreeHelper struct { - c *HelperCommon - refHelper *RefsHelper - commitsHelper *CommitsHelper - gpgHelper *GpgHelper + c *HelperCommon + refHelper *RefsHelper + commitsHelper *CommitsHelper + gpgHelper *GpgHelper + mergeAndRebaseHelper *MergeAndRebaseHelper } func NewWorkingTreeHelper( @@ -25,12 +28,14 @@ func NewWorkingTreeHelper( refHelper *RefsHelper, commitsHelper *CommitsHelper, gpgHelper *GpgHelper, + mergeAndRebaseHelper *MergeAndRebaseHelper, ) *WorkingTreeHelper { return &WorkingTreeHelper{ - c: c, - refHelper: refHelper, - commitsHelper: commitsHelper, - gpgHelper: gpgHelper, + c: c, + refHelper: refHelper, + commitsHelper: commitsHelper, + gpgHelper: gpgHelper, + mergeAndRebaseHelper: mergeAndRebaseHelper, } } @@ -247,3 +252,135 @@ func (self *WorkingTreeHelper) commitPrefixConfigsForRepo() []config.CommitPrefi return self.c.UserConfig().Git.CommitPrefix } + +func (self *WorkingTreeHelper) mergeFile(filepath string, strategy string) (string, error) { + if self.c.Git().Version.IsOlderThan(2, 43, 0) { + return self.mergeFileWithTempFiles(filepath, strategy) + } + + return self.mergeFileWithObjectIDs(filepath, strategy) +} + +func (self *WorkingTreeHelper) mergeFileWithTempFiles(filepath string, strategy string) (string, error) { + showToTempFile := func(stage int, label string) (string, error) { + output, err := self.c.Git().WorkingTree.ShowFileAtStage(filepath, stage) + if err != nil { + return "", err + } + + f, err := os.CreateTemp(self.c.GetConfig().GetTempDir(), "mergefile-"+label+"-*") + if err != nil { + return "", err + } + defer f.Close() + + if _, err := f.Write([]byte(output)); err != nil { + return "", err + } + + return f.Name(), nil + } + + baseFilepath, err := showToTempFile(1, "base") + if err != nil { + return "", err + } + defer os.Remove(baseFilepath) + + oursFilepath, err := showToTempFile(2, "ours") + if err != nil { + return "", err + } + defer os.Remove(oursFilepath) + + theirsFilepath, err := showToTempFile(3, "theirs") + if err != nil { + return "", err + } + defer os.Remove(theirsFilepath) + + return self.c.Git().WorkingTree.MergeFileForFiles(strategy, oursFilepath, baseFilepath, theirsFilepath) +} + +func (self *WorkingTreeHelper) mergeFileWithObjectIDs(filepath, strategy string) (string, error) { + baseID, err := self.c.Git().WorkingTree.ObjectIDAtStage(filepath, 1) + if err != nil { + return "", err + } + + oursID, err := self.c.Git().WorkingTree.ObjectIDAtStage(filepath, 2) + if err != nil { + return "", err + } + + theirsID, err := self.c.Git().WorkingTree.ObjectIDAtStage(filepath, 3) + if err != nil { + return "", err + } + + return self.c.Git().WorkingTree.MergeFileForObjectIDs(strategy, oursID, baseID, theirsID) +} + +func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []string) error { + onMergeStrategySelected := func(strategy string) error { + for _, filepath := range selectedFilepaths { + output, err := self.mergeFile(filepath, strategy) + if err != nil { + return err + } + + if err = os.WriteFile(filepath, []byte(output), 0o644); err != nil { + return err + } + } + + err := self.c.Git().WorkingTree.StageFiles(selectedFilepaths, nil) + self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) + return err + } + + cmdColor := style.FgBlue + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.MergeConflictOptionsTitle, + Items: []*types.MenuItem{ + { + LabelColumns: []string{ + self.c.Tr.UseCurrentChanges, + cmdColor.Sprint("git merge-file --ours"), + }, + OnPress: func() error { + return onMergeStrategySelected("--ours") + }, + Key: 'c', + }, + { + LabelColumns: []string{ + self.c.Tr.UseIncomingChanges, + cmdColor.Sprint("git merge-file --theirs"), + }, + OnPress: func() error { + return onMergeStrategySelected("--theirs") + }, + Key: 'i', + }, + { + LabelColumns: []string{ + self.c.Tr.UseBothChanges, + cmdColor.Sprint("git merge-file --union"), + }, + OnPress: func() error { + return onMergeStrategySelected("--union") + }, + Key: 'b', + }, + { + LabelColumns: []string{ + self.c.Tr.OpenMergeTool, + cmdColor.Sprint("git mergetool"), + }, + OnPress: self.OpenMergeTool, + Key: 'm', + }, + }, + }) +} diff --git a/pkg/gui/controllers/merge_conflicts_controller.go b/pkg/gui/controllers/merge_conflicts_controller.go index 23c53f08857..dc358bb0318 100644 --- a/pkg/gui/controllers/merge_conflicts_controller.go +++ b/pkg/gui/controllers/merge_conflicts_controller.go @@ -112,10 +112,11 @@ func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts) Tag: "navigation", }, { - Key: opts.GetKey(opts.Config.Files.OpenMergeTool), - Handler: self.c.Helpers().WorkingTree.OpenMergeTool, - Description: self.c.Tr.OpenMergeTool, - Tooltip: self.c.Tr.OpenMergeToolTooltip, + Key: opts.GetKey(opts.Config.Files.OpenMergeOptions), + Handler: self.openMergeConflictMenu, + Description: self.c.Tr.ViewMergeConflictOptions, + Tooltip: self.c.Tr.ViewMergeConflictOptionsTooltip, + OpensMenu: true, DisplayOnScreen: true, }, { @@ -320,6 +321,11 @@ func (self *MergeConflictsController) onLastConflictResolved() { self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}) } +func (self *MergeConflictsController) openMergeConflictMenu() error { + filepath := self.context().GetState().GetPath() + return self.c.Helpers().WorkingTree.CreateMergeConflictMenu([]string{filepath}) +} + func (self *MergeConflictsController) withRenderAndFocus(f func() error) func() error { return self.withLock(func() error { if err := f(); err != nil { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d162d20dc20..b750d8ff715 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -63,7 +63,6 @@ type TranslationSet struct { ToggleTreeViewTooltip string OpenDiffTool string OpenMergeTool string - OpenMergeToolTooltip string Refresh string RefreshTooltip string Push string @@ -898,6 +897,13 @@ type TranslationSet struct { BreakingChangesTitle string BreakingChangesMessage string BreakingChangesByVersion map[string]string + ViewMergeConflictOptions string + ViewMergeConflictOptionsTooltip string + NoFilesWithMergeConflicts string + MergeConflictOptionsTitle string + UseCurrentChanges string + UseIncomingChanges string + UseBothChanges string } type Bisect struct { @@ -1136,7 +1142,6 @@ func EnglishTranslationSet() *TranslationSet { ToggleTreeViewTooltip: "Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.\n\nThe default can be changed in the config file with the key 'gui.showFileTree'.", OpenDiffTool: "Open external diff tool (git difftool)", OpenMergeTool: "Open external merge tool", - OpenMergeToolTooltip: "Run `git mergetool`.", Refresh: "Refresh", RefreshTooltip: "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`.", Push: "Push", @@ -1970,6 +1975,13 @@ func EnglishTranslationSet() *TranslationSet { CustomCommands: "Custom commands", NoApplicableCommandsInThisContext: "(No applicable commands in this context)", SelectCommitsOfCurrentBranch: "Select commits of current branch", + ViewMergeConflictOptions: "View merge conflict options", + ViewMergeConflictOptionsTooltip: "View options for resolving merge conflicts.", + NoFilesWithMergeConflicts: "There are no files with merge conflicts.", + MergeConflictOptionsTitle: "Resolve merge conflicts", + UseCurrentChanges: "Use current changes", + UseIncomingChanges: "Use incoming changes", + UseBothChanges: "Use both", Actions: Actions{ // TODO: combine this with the original keybinding descriptions (those are all in lowercase atm) diff --git a/pkg/integration/tests/conflicts/merge_file_both.go b/pkg/integration/tests/conflicts/merge_file_both.go new file mode 100644 index 00000000000..08357212530 --- /dev/null +++ b/pkg/integration/tests/conflicts/merge_file_both.go @@ -0,0 +1,77 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +func testDataBoth() (original, current, incoming, final string) { + original = ` +1 +2 +3 +4 +5 +6 +` + current = ` +1a +2 +3 +4 +5a +6 +` + incoming = ` +1 +2 +3b +4 +5b +6 +` + final = ` +1a +2 +3b +4 +5a +5b +6 +` + return original, current, incoming, final +} + +var MergeFileBoth = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Conflicting file can be resolved to 'union' (both changes) version via merge-file", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + original, current, incoming, _ := testDataBoth() + shared.CreateMergeConflictFileForMergeFileTests(shell, original, current, incoming) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + _, _, _, expected := testDataBoth() + + t.Views().Files(). + IsFocused(). + Lines( + Contains("file").IsSelected(), + ) + + t.GlobalPress(keys.Files.OpenMergeOptions) + + t.ExpectPopup().Menu(). + Title(Equals("Resolve merge conflicts")). + Select(Contains("Use both")). // merge-file --union + Confirm() + + t.Common().ContinueOnConflictsResolved("merge") + + t.Views().Files().IsEmpty() + + t.FileSystem().FileContent("file", Equals(expected)) + }, +}) diff --git a/pkg/integration/tests/conflicts/merge_file_current.go b/pkg/integration/tests/conflicts/merge_file_current.go new file mode 100644 index 00000000000..b809174465f --- /dev/null +++ b/pkg/integration/tests/conflicts/merge_file_current.go @@ -0,0 +1,76 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +func testDataCurrent() (original, current, incoming, final string) { + original = ` +1 +2 +3 +4 +5 +6 +` + current = ` +1 +2 +3 +4 +5a +6 +` + incoming = ` +1b +2 +3 +4 +5b +6 +` + final = ` +1b +2 +3 +4 +5a +6 +` + return original, current, incoming, final +} + +var MergeFileCurrent = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Conflicting file can be resolved to 'ours' (current changes) version via merge-file", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + original, current, incoming, _ := testDataCurrent() + shared.CreateMergeConflictFileForMergeFileTests(shell, original, current, incoming) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + _, _, _, expected := testDataCurrent() + + t.Views().Files(). + IsFocused(). + Lines( + Contains("file").IsSelected(), + ) + + t.GlobalPress(keys.Files.OpenMergeOptions) + + t.ExpectPopup().Menu(). + Title(Equals("Resolve merge conflicts")). + Select(Contains("Use current changes")). // merge-file --ours + Confirm() + + t.Common().ContinueOnConflictsResolved("merge") + + t.Views().Files().IsEmpty() + + t.FileSystem().FileContent("file", Equals(expected)) + }, +}) diff --git a/pkg/integration/tests/conflicts/merge_file_incoming.go b/pkg/integration/tests/conflicts/merge_file_incoming.go new file mode 100644 index 00000000000..8216b4a4d09 --- /dev/null +++ b/pkg/integration/tests/conflicts/merge_file_incoming.go @@ -0,0 +1,76 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +func testDataIncoming() (original, current, incoming, final string) { + original = ` +1 +2 +3 +4 +5 +6 +` + current = ` +1a +2 +3 +4 +5a +6 +` + incoming = ` +1 +2 +3 +4 +5b +6 +` + final = ` +1a +2 +3 +4 +5b +6 +` + return original, current, incoming, final +} + +var MergeFileIncoming = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Conflicting file can be resolved to 'theirs' (incoming changes) version via merge-file", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + original, current, incoming, _ := testDataIncoming() + shared.CreateMergeConflictFileForMergeFileTests(shell, original, current, incoming) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + _, _, _, expected := testDataIncoming() + + t.Views().Files(). + IsFocused(). + Lines( + Contains("file").IsSelected(), + ) + + t.GlobalPress(keys.Files.OpenMergeOptions) + + t.ExpectPopup().Menu(). + Title(Equals("Resolve merge conflicts")). + Select(Contains("Use incoming changes")). // merge-file --theirs + Confirm() + + t.Common().ContinueOnConflictsResolved("merge") + + t.Views().Files().IsEmpty() + + t.FileSystem().FileContent("file", Equals(expected)) + }, +}) diff --git a/pkg/integration/tests/shared/conflicts.go b/pkg/integration/tests/shared/conflicts.go index 4f4a1954f90..b84c8c7add0 100644 --- a/pkg/integration/tests/shared/conflicts.go +++ b/pkg/integration/tests/shared/conflicts.go @@ -157,3 +157,20 @@ var CreateMergeConflictFileMultiple = func(shell *Shell) { shell.RunCommandExpectError([]string{"git", "merge", "--no-edit", "second-change-branch"}) } + +var CreateMergeConflictFileForMergeFileTests = func(shell *Shell, originalFileContent string, currentChangeFileContent string, incomingChangeFileContent string) { + shell. + NewBranch("original-branch"). + EmptyCommit("one"). + CreateFileAndAdd("file", originalFileContent). + Commit("original"). + NewBranch("current-change-branch"). + UpdateFileAndAdd("file", currentChangeFileContent). + Commit("first change"). + Checkout("original-branch"). + NewBranch("incoming-change-branch"). + UpdateFileAndAdd("file", incomingChangeFileContent). + Commit("second change"). + Checkout("current-change-branch"). + RunCommandExpectError([]string{"git", "merge", "--no-edit", "incoming-change-branch"}) +} diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a292227b388..ed0299b1877 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -150,6 +150,9 @@ var tests = []*components.IntegrationTest{ config.NegativeRefspec, config.RemoteNamedStar, conflicts.Filter, + conflicts.MergeFileBoth, + conflicts.MergeFileCurrent, + conflicts.MergeFileIncoming, conflicts.ResolveExternally, conflicts.ResolveMultipleFiles, conflicts.ResolveNoAutoStage, diff --git a/schema/config.json b/schema/config.json index aa4e41012c6..c9cd926827c 100644 --- a/schema/config.json +++ b/schema/config.json @@ -1115,7 +1115,7 @@ "type": "string", "default": "`" }, - "openMergeTool": { + "openMergeOptions": { "type": "string", "default": "M" }, From 5fad3ab0470855507ce449f33904709335d2f3f0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Oct 2025 13:38:04 +0200 Subject: [PATCH 06/23] Refresh the main view for the current side panel when a popup is showing The logic in postRefreshUpdate would only rerender the main view if the context being updated is the current view. This is not the case when a popup is showing; but we still want to render the main view in that case, behind the popup. This happens for example when we refresh the Files scope, we determine that all conflicts have been resolved and show a popup asking to continue the merge or rebase, but the postRefreshUpdate of the Files context only happens when the popup is already showing, so we would still see the conflict markers behind the popup, which is rather confusing. --- pkg/gui/view_helpers.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 081b2a42665..10cb8d48b16 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -157,6 +157,11 @@ func (gui *Gui) postRefreshUpdate(c types.Context) { sidePanelContext.HandleRenderToMain() } } + } else if c.GetKey() == gui.State.ContextMgr.CurrentStatic().GetKey() { + // If our view is not the current one, but it is the current static context, then this + // can only mean that a popup is showing. In that case we want to refresh the main view + // behind the popup. + c.HandleRenderToMain() } } } From 5811f2945cca70f49c0ffaea7e8bccbfb16bea78 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Oct 2025 17:29:27 +0200 Subject: [PATCH 07/23] Extract a InternalTreePathForFilePath helper function --- pkg/gui/filetree/build_tree.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/gui/filetree/build_tree.go b/pkg/gui/filetree/build_tree.go index 8e6e9264b17..91e6d198655 100644 --- a/pkg/gui/filetree/build_tree.go +++ b/pkg/gui/filetree/build_tree.go @@ -162,9 +162,13 @@ func join(strs []string) string { } func SplitFileTreePath(path string, showRootItem bool) []string { + return split(InternalTreePathForFilePath(path, showRootItem)) +} + +func InternalTreePathForFilePath(path string, showRootItem bool) string { if showRootItem { - return split("./" + path) + return "./" + path } - return split(path) + return path } From 7fe73c1ee2be92c80b8f1d19109d90c94b665ebd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Oct 2025 10:31:24 +0200 Subject: [PATCH 08/23] When entering a commit in path filtering mode, select the filtered path --- .../switch_to_diff_files_controller.go | 11 +++++ .../filetree/commit_file_tree_view_model.go | 12 +++++ ...lect_filtered_file_when_entering_commit.go | 47 +++++++++++++++++++ ..._file_when_entering_commit_no_root_item.go | 47 +++++++++++++++++++ pkg/integration/tests/test_list.go | 2 + 5 files changed, 119 insertions(+) create mode 100644 pkg/integration/tests/filter_by_path/select_filtered_file_when_entering_commit.go create mode 100644 pkg/integration/tests/filter_by_path/select_filtered_file_when_entering_commit_no_root_item.go diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index f704f8bd447..86e9275588a 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -1,6 +1,8 @@ package controllers import ( + "path/filepath" + "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -90,6 +92,15 @@ func (self *SwitchToDiffFilesController) enter() error { Scope: []types.RefreshableView{types.COMMIT_FILES}, }) + if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" { + path, err := filepath.Rel(self.c.Git().RepoPaths.RepoPath(), filterPath) + if err != nil { + path = filterPath + } + commitFilesContext.CommitFileTreeViewModel.SelectPath( + filepath.ToSlash(path), self.c.UserConfig().Gui.ShowRootItemInFileTree) + } + self.c.Context().Push(commitFilesContext, types.OnFocusOpts{}) return nil } diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go index 82c478a3ed4..02b0fff9a86 100644 --- a/pkg/gui/filetree/commit_file_tree_view_model.go +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -191,3 +191,15 @@ func (self *CommitFileTreeViewModel) ExpandAll() { self.SetSelectedLineIdx(index) } } + +// Try to select the given path if present. If it doesn't exist, or one of the parent directories is +// collapsed, do nothing. +// Note that filepath is an actual file path, not an internal tree path as with e.g. +// ToggleCollapsed. It must be a relative path (relative to the repo root), and it must contain +// forward slashes rather than backslashes even on Windows. +func (self *CommitFileTreeViewModel) SelectPath(filepath string, showRootItem bool) { + index, found := self.GetIndexForPath(InternalTreePathForFilePath(filepath, showRootItem)) + if found { + self.SetSelection(index) + } +} diff --git a/pkg/integration/tests/filter_by_path/select_filtered_file_when_entering_commit.go b/pkg/integration/tests/filter_by_path/select_filtered_file_when_entering_commit.go new file mode 100644 index 00000000000..5b73d00a51c --- /dev/null +++ b/pkg/integration/tests/filter_by_path/select_filtered_file_when_entering_commit.go @@ -0,0 +1,47 @@ +package filter_by_path + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectFilteredFileWhenEnteringCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Filter commits by file path, then enter a commit and ensure the file is selected", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "") + shell.CreateFileAndAdd("dir/file2", "") + shell.Commit("add files") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.GlobalPress(keys.Universal.FilteringMenu) + t.ExpectPopup().Menu(). + Title(Equals("Filtering")). + Select(Contains("Enter path to filter by")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("Enter path:")). + Type("dir/file2"). + Confirm() + + t.Views().Commits(). + Focus(). + Lines( + Contains("add files").IsSelected(), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /"), + Equals(" ▼ dir"), + Equals(" A file2").IsSelected(), + Equals(" A file1"), + ) + }, +}) diff --git a/pkg/integration/tests/filter_by_path/select_filtered_file_when_entering_commit_no_root_item.go b/pkg/integration/tests/filter_by_path/select_filtered_file_when_entering_commit_no_root_item.go new file mode 100644 index 00000000000..9846d109a59 --- /dev/null +++ b/pkg/integration/tests/filter_by_path/select_filtered_file_when_entering_commit_no_root_item.go @@ -0,0 +1,47 @@ +package filter_by_path + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectFilteredFileWhenEnteringCommitNoRootItem = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Filter commits by file path, then enter a commit and ensure the file is selected (with the show root item config off)", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowRootItemInFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "") + shell.CreateFileAndAdd("dir/file2", "") + shell.Commit("add files") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.GlobalPress(keys.Universal.FilteringMenu) + t.ExpectPopup().Menu(). + Title(Equals("Filtering")). + Select(Contains("Enter path to filter by")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("Enter path:")). + Type("dir/file2"). + Confirm() + + t.Views().Commits(). + Focus(). + Lines( + Contains("add files").IsSelected(), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ dir"), + Equals(" A file2").IsSelected(), + Equals("A file1"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index ed0299b1877..6c36d91720a 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -242,6 +242,8 @@ var tests = []*components.IntegrationTest{ filter_by_path.KeepSameCommitSelectedOnExit, filter_by_path.RewordCommitInFilteringMode, filter_by_path.SelectFile, + filter_by_path.SelectFilteredFileWhenEnteringCommit, + filter_by_path.SelectFilteredFileWhenEnteringCommitNoRootItem, filter_by_path.ShowDiffsForRenamedFile, filter_by_path.TypeFile, interactive_rebase.AdvancedInteractiveRebase, From 43811cdad838ab0f161271b86d0c139d6d7baf38 Mon Sep 17 00:00:00 2001 From: stk Date: Mon, 6 Oct 2025 16:07:55 +0200 Subject: [PATCH 09/23] Export a LAZYGIT_COLUMNS variable to "pty" tasks on Windows This makes it possible to pass it to an external diff command that is used like a pager. An example for this can be seen in the added documentation in the next commit. --- pkg/gui/pty_windows.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/gui/pty_windows.go b/pkg/gui/pty_windows.go index 3324fa87d68..39577a19931 100644 --- a/pkg/gui/pty_windows.go +++ b/pkg/gui/pty_windows.go @@ -1,6 +1,7 @@ package gui import ( + "fmt" "os/exec" "github.com/jesseduffield/gocui" @@ -11,5 +12,6 @@ func (gui *Gui) onResize() error { } func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { + cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", view.InnerWidth())) return gui.newCmdTask(view, cmd, prefix) } From 2abd76eaa39363a163fa582e25bbea365855ac61 Mon Sep 17 00:00:00 2001 From: stk Date: Mon, 6 Oct 2025 16:45:49 +0200 Subject: [PATCH 10/23] Add documentation for pager workaround on Windows --- docs/Custom_Pagers.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/Custom_Pagers.md b/docs/Custom_Pagers.md index 77bb25d25bf..5e5d51372b5 100644 --- a/docs/Custom_Pagers.md +++ b/docs/Custom_Pagers.md @@ -2,7 +2,7 @@ Lazygit supports custom pagers, [configured](/docs/Config.md) in the config.yml file (which can be opened by pressing `e` in the Status panel). -Support does not extend to Windows users, because we're making use of a package which doesn't have Windows support. +Support does not extend to Windows users, because we're making use of a package which doesn't have Windows support. However, see [below](#emulating-custom-pagers-on-windows) for a workaround. ## Default: @@ -84,3 +84,30 @@ git: ``` 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. + +## Emulating custom pagers on Windows + +There is a trick to emulate custom pagers on Windows using a Powershell script configured as an external diff command. It's not perfect, but certainly better than nothing. To do this, save the following script as `lazygit-pager.ps1` at a convenient place on your disk: + +```pwsh +#!/usr/bin/env pwsh + +$old = $args[1].Replace('\', '/') +$new = $args[4].Replace('\', '/') +$path = $args[0] +git diff --no-index --no-ext-diff $old $new + | %{ $_.Replace($old, $path).Replace($new, $path) } + | delta --width=$env:LAZYGIT_COLUMNS +``` + +Use the pager of your choice with the arguments you like in the last line of the script. Personally I wouldn't want to use lazygit anymore without delta's `--hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"` args, see [above](#delta). + +In your lazygit config, use + +```yml +git: + paging: + externalDiffCommand: "C:/wherever/lazygit-pager.ps1" +``` + +The main limitation of this approach compared to a "real" pager is that renames are not displayed correctly; they are shown as if they were modifications of the old file. (This affects only the hunk headers; the diff itself is always correct.) From b755f527542a516d8061f73c12e0456dc99055d3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Oct 2025 13:07:33 +0200 Subject: [PATCH 11/23] Use C locale instead of en_US.UTF-8 to force English language Some users reported that en_US.UTF-8 is not available on their systems, leading to warnings in the command log. "C" also forces the language to English, and is guaranteed to be available everywhere. --- pkg/commands/git_commands/rebase.go | 8 ++++---- pkg/commands/oscommands/cmd_obj_runner.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index d882d9b1a9a..6d89276d6e3 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -244,8 +244,8 @@ func (self *RebaseCommands) PrepareInteractiveRebaseCommand(opts PrepareInteract cmdObj.AddEnvVars( "DEBUG="+debug, - "LANG=en_US.UTF-8", // Force using EN as language - "LC_ALL=en_US.UTF-8", // Force using EN as language + "LANG=C", // Force using English language + "LC_ALL=C", // Force using English language "GIT_SEQUENCE_EDITOR="+gitSequenceEditor, ) @@ -277,8 +277,8 @@ func (self *RebaseCommands) GitRebaseEditTodo(todosFileContent []byte) error { cmdObj.AddEnvVars( "DEBUG="+debug, - "LANG=en_US.UTF-8", // Force using EN as language - "LC_ALL=en_US.UTF-8", // Force using EN as language + "LANG=C", // Force using English language + "LC_ALL=C", // Force using English language "GIT_EDITOR="+ex, "GIT_SEQUENCE_EDITOR="+ex, ) diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go index b19ec89b530..49e608a60c6 100644 --- a/pkg/commands/oscommands/cmd_obj_runner.go +++ b/pkg/commands/oscommands/cmd_obj_runner.go @@ -330,7 +330,7 @@ func (self *cmdObjRunner) runAndDetectCredentialRequest( promptUserForCredential func(CredentialType) <-chan string, ) error { // setting the output to english so we can parse it for a username/password request - cmdObj.AddEnvVars("LANG=en_US.UTF-8", "LC_ALL=en_US.UTF-8") + cmdObj.AddEnvVars("LANG=C", "LC_ALL=C") return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) { tr := io.TeeReader(handler.stdoutPipe, cmdWriter) From 99a83efc34a6acb91f69d96d233e029e801f7a8c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Oct 2025 13:08:14 +0200 Subject: [PATCH 12/23] Set LC_MESSAGES too I'm not sure this is necessary, but it doesn't hurt. --- pkg/commands/git_commands/rebase.go | 10 ++++++---- pkg/commands/oscommands/cmd_obj_runner.go | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index 6d89276d6e3..a497490847a 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -244,8 +244,9 @@ func (self *RebaseCommands) PrepareInteractiveRebaseCommand(opts PrepareInteract cmdObj.AddEnvVars( "DEBUG="+debug, - "LANG=C", // Force using English language - "LC_ALL=C", // Force using English language + "LANG=C", // Force using English language + "LC_ALL=C", // Force using English language + "LC_MESSAGES=C", // Force using English language "GIT_SEQUENCE_EDITOR="+gitSequenceEditor, ) @@ -277,8 +278,9 @@ func (self *RebaseCommands) GitRebaseEditTodo(todosFileContent []byte) error { cmdObj.AddEnvVars( "DEBUG="+debug, - "LANG=C", // Force using English language - "LC_ALL=C", // Force using English language + "LANG=C", // Force using English language + "LC_ALL=C", // Force using English language + "LC_MESSAGES=C", // Force using English language "GIT_EDITOR="+ex, "GIT_SEQUENCE_EDITOR="+ex, ) diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go index 49e608a60c6..953937706b1 100644 --- a/pkg/commands/oscommands/cmd_obj_runner.go +++ b/pkg/commands/oscommands/cmd_obj_runner.go @@ -330,7 +330,7 @@ func (self *cmdObjRunner) runAndDetectCredentialRequest( promptUserForCredential func(CredentialType) <-chan string, ) error { // setting the output to english so we can parse it for a username/password request - cmdObj.AddEnvVars("LANG=C", "LC_ALL=C") + cmdObj.AddEnvVars("LANG=C", "LC_ALL=C", "LC_MESSAGES=C") return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) { tr := io.TeeReader(handler.stdoutPipe, cmdWriter) From 19f88290a228d1d93f1f2380cb95880ecfad1ab3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Oct 2025 17:19:24 +0200 Subject: [PATCH 13/23] Change allBranchesLogCmdIndex to an int I know that uint was chosen to document that it can't be negative (not sure why the "8" was chosen, though). That's pointless in languages that don't enforce this though: you can subtract 1 from uint8(0) and you'll get something that doesn't make sense; that's not any better than getting -1. I'm not a fan of using unsigned types in general (at least in languages like go or C++), and they usually just make the code more cumbersome without real benefits. --- pkg/commands/git_commands/branch.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 76b5727c734..37a661748f0 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -13,7 +13,7 @@ import ( type BranchCommands struct { *GitCommon - allBranchesLogCmdIndex uint8 // keeps track of current all branches log command + allBranchesLogCmdIndex int // keeps track of current all branches log command } func NewBranchCommands(gitCommon *GitCommon) *BranchCommands { @@ -285,7 +285,7 @@ func (self *BranchCommands) AllBranchesLogCmdObj() *oscommands.CmdObj { func (self *BranchCommands) RotateAllBranchesLogIdx() { n := len(self.allBranchesLogCandidates()) i := self.allBranchesLogCmdIndex - self.allBranchesLogCmdIndex = uint8((int(i) + 1) % n) + self.allBranchesLogCmdIndex = (i + 1) % n } func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches *MainBranches) (bool, error) { From 220cde13765ca92221325c2a9085562ed2c8d7fe Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Oct 2025 17:22:56 +0200 Subject: [PATCH 14/23] Fix potential crash when reloading the config after removing a branch log command When cycling to the last branch log command, and then editing the config to remove one or more log commands, lazygit would crash with an out of bounds panic when returning to it. Fix this by resetting the log index to 0 when it is out of bounds. (I think resetting to 0 is better than clamping, although it doesn't matter much.) --- pkg/commands/git_commands/branch.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 37a661748f0..4a2fbd51f6b 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -278,6 +278,10 @@ func (self *BranchCommands) allBranchesLogCandidates() []string { func (self *BranchCommands) AllBranchesLogCmdObj() *oscommands.CmdObj { candidates := self.allBranchesLogCandidates() + if self.allBranchesLogCmdIndex >= len(candidates) { + self.allBranchesLogCmdIndex = 0 + } + i := self.allBranchesLogCmdIndex return self.cmd.New(str.ToArgv(candidates[i])).DontLog() } From bf56a61b71a52924a2ec64a40d03a16950f35cbe Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Oct 2025 17:05:32 +0200 Subject: [PATCH 15/23] Change condition that determines whether we are showing the dashboard Doesn't make a difference currently, since the title is either StatusTitle when the dashboard is showing, or LogTitle when one of the branch logs is showing. This is going to change in the next commit, though. --- pkg/gui/controllers/status_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go index 41ed5c496f2..35545d11a6a 100644 --- a/pkg/gui/controllers/status_controller.go +++ b/pkg/gui/controllers/status_controller.go @@ -196,7 +196,7 @@ func (self *StatusController) switchToOrRotateAllBranchesLogs() { // A bit of a hack to ensure we only rotate to the next branch log command // if we currently are looking at a branch log. Otherwise, we should just show // the current index (if we are coming from the dashboard). - if self.c.Views().Main.Title == self.c.Tr.LogTitle { + if self.c.Views().Main.Title != self.c.Tr.StatusTitle { self.c.Git().Branch.RotateAllBranchesLogIdx() } self.showAllBranchLogs() From 5d02cba7210c052286dbbe7c09ff8b8d968c5871 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Oct 2025 17:15:19 +0200 Subject: [PATCH 16/23] Show "Log (x of y)" in the title bar when there is more than one branch log command --- pkg/commands/git_commands/branch.go | 6 ++++++ pkg/gui/controllers/status_controller.go | 6 +++++- pkg/i18n/english.go | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 4a2fbd51f6b..13abbd69744 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -292,6 +292,12 @@ func (self *BranchCommands) RotateAllBranchesLogIdx() { self.allBranchesLogCmdIndex = (i + 1) % n } +func (self *BranchCommands) GetAllBranchesLogIdxAndCount() (int, int) { + n := len(self.allBranchesLogCandidates()) + i := self.allBranchesLogCmdIndex + return i, n +} + func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches *MainBranches) (bool, error) { branchesToCheckAgainst := []string{"HEAD"} if branch.RemoteBranchStoredLocally() { diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go index 35545d11a6a..d2c65809524 100644 --- a/pkg/gui/controllers/status_controller.go +++ b/pkg/gui/controllers/status_controller.go @@ -181,10 +181,14 @@ func (self *StatusController) showAllBranchLogs() { cmdObj := self.c.Git().Branch.AllBranchesLogCmdObj() task := types.NewRunPtyTask(cmdObj.GetCmd()) + title := self.c.Tr.LogTitle + if i, n := self.c.Git().Branch.GetAllBranchesLogIdxAndCount(); n > 1 { + title = fmt.Sprintf(self.c.Tr.LogXOfYTitle, i+1, n) + } self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ - Title: self.c.Tr.LogTitle, + Title: title, Task: task, }, }) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index b750d8ff715..30123efaaa2 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -30,6 +30,7 @@ type TranslationSet struct { RegularMergeTooltip string NormalTitle string LogTitle string + LogXOfYTitle string CommitSummary string CredentialsUsername string CredentialsPassword string @@ -1110,6 +1111,7 @@ func EnglishTranslationSet() *TranslationSet { MergingTitle: "Main panel (merging)", NormalTitle: "Main panel (normal)", LogTitle: "Log", + LogXOfYTitle: "Log (%d of %d)", CommitSummary: "Commit summary", CredentialsUsername: "Username", CredentialsPassword: "Password", From 21483b259c4f63b27ce7a4ef7f8ef93174f289ab Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 5 Oct 2025 14:23:05 +0200 Subject: [PATCH 17/23] Rename AddFileInWorktree to AddFileInWorktreeOrSubmodule It works for submodules too. Also, pass file name and file content explicitly; the existing tests don't care about these, but when writing tests that do, it makes them easier to understand. --- pkg/integration/components/shell.go | 6 +++--- pkg/integration/tests/worktree/force_remove_worktree.go | 2 +- .../tests/worktree/remove_worktree_from_branch.go | 2 +- pkg/integration/tests/worktree/reset_window_tabs.go | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go index 4c833f2abbf..faa6fff1384 100644 --- a/pkg/integration/components/shell.go +++ b/pkg/integration/components/shell.go @@ -428,11 +428,11 @@ func (self *Shell) AddWorktreeCheckout(base string, path string) *Shell { }) } -func (self *Shell) AddFileInWorktree(worktreePath string) *Shell { - self.CreateFile(filepath.Join(worktreePath, "content"), "content") +func (self *Shell) AddFileInWorktreeOrSubmodule(worktreePath string, filePath string, content string) *Shell { + self.CreateFile(filepath.Join(worktreePath, filePath), content) self.RunCommand([]string{ - "git", "-C", worktreePath, "add", "content", + "git", "-C", worktreePath, "add", filePath, }) return self diff --git a/pkg/integration/tests/worktree/force_remove_worktree.go b/pkg/integration/tests/worktree/force_remove_worktree.go index 23d0b9a886b..0d3bf90cfc6 100644 --- a/pkg/integration/tests/worktree/force_remove_worktree.go +++ b/pkg/integration/tests/worktree/force_remove_worktree.go @@ -17,7 +17,7 @@ var ForceRemoveWorktree = NewIntegrationTest(NewIntegrationTestArgs{ shell.EmptyCommit("commit 2") shell.EmptyCommit("commit 3") shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") - shell.AddFileInWorktree("../linked-worktree") + shell.AddFileInWorktreeOrSubmodule("../linked-worktree", "file", "content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Worktrees(). diff --git a/pkg/integration/tests/worktree/remove_worktree_from_branch.go b/pkg/integration/tests/worktree/remove_worktree_from_branch.go index 7ef9d0ae9f7..f0b8de4e64c 100644 --- a/pkg/integration/tests/worktree/remove_worktree_from_branch.go +++ b/pkg/integration/tests/worktree/remove_worktree_from_branch.go @@ -17,7 +17,7 @@ var RemoveWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ shell.EmptyCommit("commit 2") shell.EmptyCommit("commit 3") shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") - shell.AddFileInWorktree("../linked-worktree") + shell.AddFileInWorktreeOrSubmodule("../linked-worktree", "file", "content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). diff --git a/pkg/integration/tests/worktree/reset_window_tabs.go b/pkg/integration/tests/worktree/reset_window_tabs.go index c24373be857..12c4526eb77 100644 --- a/pkg/integration/tests/worktree/reset_window_tabs.go +++ b/pkg/integration/tests/worktree/reset_window_tabs.go @@ -24,7 +24,7 @@ var ResetWindowTabs = NewIntegrationTest(NewIntegrationTestArgs{ shell.EmptyCommit("commit 2") shell.EmptyCommit("commit 3") shell.AddWorktree("mybranch", "../linked-worktree", "newbranch") - shell.AddFileInWorktree("../linked-worktree") + shell.AddFileInWorktreeOrSubmodule("../linked-worktree", "file", "content") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { // focus the remotes tab i.e. the second tab in the branches window From 0904bf9969804bbe4c380fbac0889400e5676cc4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 5 Oct 2025 14:52:28 +0200 Subject: [PATCH 18/23] Add test demonstrating the problem When dropping changes to the submodule, we expect it to get rolled back to the previous version; however, it is removed entirely instead. --- pkg/integration/components/shell.go | 14 +++++ .../tests/commit/discard_submodule_changes.go | 57 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 72 insertions(+) create mode 100644 pkg/integration/tests/commit/discard_submodule_changes.go diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go index faa6fff1384..b89e08d2bf3 100644 --- a/pkg/integration/components/shell.go +++ b/pkg/integration/components/shell.go @@ -170,6 +170,10 @@ func (self *Shell) Commit(message string) *Shell { return self.RunCommand([]string{"git", "commit", "-m", message}) } +func (self *Shell) CommitInWorktreeOrSubmodule(worktreePath string, message string) *Shell { + return self.RunCommand([]string{"git", "-C", worktreePath, "commit", "-m", message}) +} + func (self *Shell) EmptyCommit(message string) *Shell { return self.RunCommand([]string{"git", "commit", "--allow-empty", "-m", message}) } @@ -438,6 +442,16 @@ func (self *Shell) AddFileInWorktreeOrSubmodule(worktreePath string, filePath st return self } +func (self *Shell) UpdateFileInWorktreeOrSubmodule(worktreePath string, filePath string, content string) *Shell { + self.UpdateFile(filepath.Join(worktreePath, filePath), content) + + self.RunCommand([]string{ + "git", "-C", worktreePath, "add", filePath, + }) + + return self +} + func (self *Shell) MakeExecutable(path string) *Shell { // 0755 sets the executable permission for owner, and read/execute permissions for group and others err := os.Chmod(filepath.Join(self.dir, path), 0o755) diff --git a/pkg/integration/tests/commit/discard_submodule_changes.go b/pkg/integration/tests/commit/discard_submodule_changes.go new file mode 100644 index 00000000000..df54dcd8711 --- /dev/null +++ b/pkg/integration/tests/commit/discard_submodule_changes.go @@ -0,0 +1,57 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardSubmoduleChanges = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discarding changes to a submodule from an old commit.", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("Initial commit") + shell.CloneIntoSubmodule("submodule", "submodule") + shell.Commit("Add submodule") + + shell.AddFileInWorktreeOrSubmodule("submodule", "file", "content") + shell.CommitInWorktreeOrSubmodule("submodule", "add file in submodule") + shell.GitAdd("submodule") + shell.Commit("Update submodule") + + shell.UpdateFileInWorktreeOrSubmodule("submodule", "file", "changed content") + shell.CommitInWorktreeOrSubmodule("submodule", "change file in submodule") + shell.GitAdd("submodule") + shell.Commit("Update submodule again") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("Update submodule again").IsSelected(), + Contains("Update submodule"), + Contains("Add submodule"), + Contains("Initial commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("M submodule").IsSelected(), + ). + Press(keys.Universal.Remove) + + t.ExpectPopup().Confirmation(). + Title(Equals("Discard file changes")). + Content(Contains("Are you sure you want to remove changes to the selected file(s) from this commit?")). + Confirm() + + t.Shell().RunCommand([]string{"git", "submodule", "update"}) + /* EXPECTED: + t.FileSystem().FileContent("submodule/file", Equals("content")) + ACTUAL: */ + t.FileSystem().PathNotPresent("submodule/file") + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 6c36d91720a..1abba20f6bb 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -119,6 +119,7 @@ var tests = []*components.IntegrationTest{ commit.CreateTag, commit.DisableCopyCommitMessageBody, commit.DiscardOldFileChanges, + commit.DiscardSubmoduleChanges, commit.DoNotShowBranchMarkerForHeadCommit, commit.FailHooksThenCommitNoHooks, commit.FindBaseCommitForFixup, From c1e52fc807689a12c2f05b1304391d6c95782aae Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 5 Oct 2025 10:51:55 +0200 Subject: [PATCH 19/23] Fix dropping submodule changes from a commit Our logic to decide if a file needs to be checked out from the previous commit or deleted because it didn't exist in the previous commit didn't work for submodules. We were using `git cat-file -e` to ask whether the file existed, but this returns an error for submodules, so we were always deleting those instead of reverting them back to their previous state. Switch to using `git ls-tree -- file` instead, which works for both files and submodules. --- pkg/commands/git_commands/rebase.go | 15 +++++++++++---- pkg/commands/git_commands/rebase_test.go | 2 +- .../tests/commit/discard_submodule_changes.go | 3 --- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index a497490847a..152c88bbcca 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -521,10 +521,17 @@ func (self *RebaseCommands) DiscardOldFileChanges(commits []*models.Commit, comm } for _, filePath := range filePaths { - // check if file exists in previous commit (this command returns an error if the file doesn't exist) - cmdArgs := NewGitCmd("cat-file").Arg("-e", "HEAD^:"+filePath).ToArgv() - - if err := self.cmd.New(cmdArgs).Run(); err != nil { + doesFileExistInPreviousCommit := false + if commitIndex < len(commits)-1 { + // check if file exists in previous commit (this command returns an empty string if the file doesn't exist) + cmdArgs := NewGitCmd("ls-tree").Arg("--name-only", "HEAD^", "--", filePath).ToArgv() + output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() + if err != nil { + return err + } + doesFileExistInPreviousCommit = strings.TrimRight(output, "\n") == filePath + } + if !doesFileExistInPreviousCommit { if err := self.os.Remove(filePath); err != nil { return err } diff --git a/pkg/commands/git_commands/rebase_test.go b/pkg/commands/git_commands/rebase_test.go index 40385bf0966..46f1fcc1cc1 100644 --- a/pkg/commands/git_commands/rebase_test.go +++ b/pkg/commands/git_commands/rebase_test.go @@ -139,7 +139,7 @@ func TestRebaseDiscardOldFileChanges(t *testing.T) { fileName: []string{"test999.txt"}, runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"rebase", "--interactive", "--autostash", "--keep-empty", "--no-autosquash", "--rebase-merges", "abcdef"}, "", nil). - ExpectGitArgs([]string{"cat-file", "-e", "HEAD^:test999.txt"}, "", nil). + ExpectGitArgs([]string{"ls-tree", "--name-only", "HEAD^", "--", "test999.txt"}, "test999.txt\n", nil). ExpectGitArgs([]string{"checkout", "HEAD^", "--", "test999.txt"}, "", nil). ExpectGitArgs([]string{"commit", "--amend", "--no-edit", "--allow-empty", "--allow-empty-message"}, "", nil). ExpectGitArgs([]string{"rebase", "--continue"}, "", nil), diff --git a/pkg/integration/tests/commit/discard_submodule_changes.go b/pkg/integration/tests/commit/discard_submodule_changes.go index df54dcd8711..b457a3a41b3 100644 --- a/pkg/integration/tests/commit/discard_submodule_changes.go +++ b/pkg/integration/tests/commit/discard_submodule_changes.go @@ -49,9 +49,6 @@ var DiscardSubmoduleChanges = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() t.Shell().RunCommand([]string{"git", "submodule", "update"}) - /* EXPECTED: t.FileSystem().FileContent("submodule/file", Equals("content")) - ACTUAL: */ - t.FileSystem().PathNotPresent("submodule/file") }, }) From 0bd65aec990ac9658dd3da584b43f7cf1f6a9b50 Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Mon, 22 Sep 2025 22:36:53 +0900 Subject: [PATCH 20/23] Use `ignore` directive to ignore test files not to be formatted We have adopted the workaround passing a result of `git ls-files '*.go' ':!vendor'` to gofumpt in #4809. Currently, gofumpt suports `ignore` directive. So we can use it without any workarounds. --- Makefile | 4 +--- go.mod | 3 +++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 5132211298c..822444fd6cd 100644 --- a/Makefile +++ b/Makefile @@ -34,11 +34,9 @@ test: unit-test integration-test-all generate: go generate ./... -# If you execute `gofumpt -l -w .`, it will format all Go files in the current directory, including `test/_results/*` files. -# We pass only Git-tracked Go files to gofumpt because we don't want to format the test results or get errors from it. .PHONY: format format: - git ls-files '*.go' ':!vendor' | xargs gofumpt -l -w + go run mvdan.cc/gofumpt@v0.9.1 -l -w . .PHONY: lint lint: diff --git a/go.mod b/go.mod index a425a279731..81f7d8df325 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,9 @@ module github.com/jesseduffield/lazygit go 1.25.0 +// This is necessary to ignore test files when executing gofumpt. +ignore ./test + require ( dario.cat/mergo v1.0.1 github.com/adrg/xdg v0.4.0 From b6628a0cb7b9011834546074fa336d66d59f61cb Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Mon, 22 Sep 2025 22:42:58 +0900 Subject: [PATCH 21/23] Specify return value where name return value is used After [v0.9.0](https://github.com/mvdan/gofumpt/releases/tag/v0.9.0), gofumpt prohibits "naked return" for the sake of clarity. This makes more readable when "named return value" is used. For more infomation for "prohibition of naked return": https://github.com/mvdan/gofumpt/issues/285. --- pkg/commands/oscommands/copy.go | 28 +++++++++++++-------------- pkg/gui/presentation/branches_test.go | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/commands/oscommands/copy.go b/pkg/commands/oscommands/copy.go index c6d83e23b00..c35c87f9fbc 100644 --- a/pkg/commands/oscommands/copy.go +++ b/pkg/commands/oscommands/copy.go @@ -38,13 +38,13 @@ import ( func CopyFile(src, dst string) (err error) { in, err := os.Open(src) if err != nil { - return //nolint: nakedret + return err } defer in.Close() out, err := os.Create(dst) if err != nil { - return //nolint: nakedret + return err } defer func() { if e := out.Close(); e != nil { @@ -54,24 +54,24 @@ func CopyFile(src, dst string) (err error) { _, err = io.Copy(out, in) if err != nil { - return //nolint: nakedret + return err } err = out.Sync() if err != nil { - return //nolint: nakedret + return err } si, err := os.Stat(src) if err != nil { - return //nolint: nakedret + return err } err = os.Chmod(dst, si.Mode()) if err != nil { - return //nolint: nakedret + return err } - return //nolint: nakedret + return err } // CopyDir recursively copies a directory tree, attempting to preserve permissions. @@ -91,7 +91,7 @@ func CopyDir(src string, dst string) (err error) { _, err = os.Stat(dst) if err != nil && !os.IsNotExist(err) { - return //nolint: nakedret + return err } if err == nil { // it exists so let's remove it @@ -102,12 +102,12 @@ func CopyDir(src string, dst string) (err error) { err = os.MkdirAll(dst, si.Mode()) if err != nil { - return //nolint: nakedret + return err } entries, err := os.ReadDir(src) if err != nil { - return //nolint: nakedret + return err } for _, entry := range entries { @@ -117,13 +117,13 @@ func CopyDir(src string, dst string) (err error) { if entry.IsDir() { err = CopyDir(srcPath, dstPath) if err != nil { - return //nolint: nakedret + return err } } else { var info os.FileInfo info, err = entry.Info() if err != nil { - return //nolint: nakedret + return err } // Skip symlinks. @@ -133,10 +133,10 @@ func CopyDir(src string, dst string) (err error) { err = CopyFile(srcPath, dstPath) if err != nil { - return //nolint: nakedret + return err } } } - return //nolint: nakedret + return err } diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go index 347802a6ee5..04e380538f3 100644 --- a/pkg/gui/presentation/branches_test.go +++ b/pkg/gui/presentation/branches_test.go @@ -18,7 +18,7 @@ import ( func makeAtomic(v int32) (result atomic.Int32) { result.Store(v) - return //nolint: nakedret + return result } func Test_getBranchDisplayStrings(t *testing.T) { From 464b49fc07e5e80ba54169f14cf39a3df75b7b26 Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Mon, 22 Sep 2025 22:53:30 +0900 Subject: [PATCH 22/23] Use pointer of `atomic.Int32` By running gofumpt@0.9.1, the return value of `makeAtomic` function is specified. After that, golangci-lint shows an error like `return copies lock value: sync/atomic.Int32 contains sync/atomic.noCopy`. This happens because atomic values should not be copied. So I made a change using them as pointers. --- pkg/gui/presentation/branches_test.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go index 04e380538f3..a73c7aa56dc 100644 --- a/pkg/gui/presentation/branches_test.go +++ b/pkg/gui/presentation/branches_test.go @@ -16,9 +16,10 @@ import ( "github.com/xo/terminfo" ) -func makeAtomic(v int32) (result atomic.Int32) { +func makeAtomic(v int32) *atomic.Int32 { + var result atomic.Int32 result.Store(v) - return result + return &result } func Test_getBranchDisplayStrings(t *testing.T) { @@ -109,7 +110,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { branch: &models.Branch{ Name: "branch_name", Recency: "1m", - BehindBaseBranch: makeAtomic(2), + BehindBaseBranch: *makeAtomic(2), }, itemOperation: types.ItemOperationNone, fullDescription: false, @@ -126,7 +127,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { UpstreamRemote: "origin", AheadForPull: "0", BehindForPull: "0", - BehindBaseBranch: makeAtomic(2), + BehindBaseBranch: *makeAtomic(2), }, itemOperation: types.ItemOperationNone, fullDescription: false, @@ -143,7 +144,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { UpstreamRemote: "origin", AheadForPull: "3", BehindForPull: "5", - BehindBaseBranch: makeAtomic(2), + BehindBaseBranch: *makeAtomic(2), }, itemOperation: types.ItemOperationNone, fullDescription: false, @@ -247,7 +248,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { UpstreamRemote: "origin", AheadForPull: "3", BehindForPull: "5", - BehindBaseBranch: makeAtomic(4), + BehindBaseBranch: *makeAtomic(4), }, itemOperation: types.ItemOperationNone, fullDescription: false, From 3665734d27a08212afd21954bdd7e738f4c036b2 Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Mon, 22 Sep 2025 23:39:52 +0900 Subject: [PATCH 23/23] Update golangci-lint to v2.5.0 gofumpt version for local developement is updated to `v0.9.1`. This commit updates golangci-lint to `v2.5.0` which is the lastest and the first version supports `gofumpt@v0.9.1` to match the behavior of local dev and ci. This version of golangci-lint supports go 1.24 as well. --- .github/workflows/ci.yml | 2 +- scripts/golangci-lint-shim.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7612deb7e8..ee656b191a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,7 +171,7 @@ jobs: uses: golangci/golangci-lint-action@v8 with: # If you change this, make sure to also update scripts/golangci-lint-shim.sh - version: v2.4.0 + version: v2.5.0 - name: errors run: golangci-lint run if: ${{ failure() }} diff --git a/scripts/golangci-lint-shim.sh b/scripts/golangci-lint-shim.sh index a85ccc4d712..82aa8da9cee 100755 --- a/scripts/golangci-lint-shim.sh +++ b/scripts/golangci-lint-shim.sh @@ -3,6 +3,6 @@ set -e # Must be kept in sync with the version in .github/workflows/ci.yml -version="v2.4.0" +version="v2.5.0" go run "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$version" "$@"