diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..644cd0954 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [ckolkey] diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 711bddf38..05eab19a8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -39,3 +39,20 @@ jobs: with: bundler-cache: true - run: bundle exec rubocop + + lua_types: + name: lua-typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Homebrew/actions/setup-homebrew@master + - run: brew install lua-language-server + - uses: luarocks/gh-actions-lua@v10 + with: + luaVersion: luajit + - uses: luarocks/gh-actions-luarocks@v5 + with: + luaRocksVersion: "3.12.1" + - run: | + luarocks install llscheck + llscheck lua/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86d4776f1..0a397c918 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,10 @@ jobs: git config --global core.compression 0 git clone https://github.com/nvim-lua/plenary.nvim tmp/plenary git clone https://github.com/nvim-telescope/telescope.nvim tmp/telescope + git clone https://github.com/sindrets/diffview.nvim tmp/diffview + git clone https://github.com/nvim-telescope/telescope-fzf-native.nvim tmp/telescope-fzf-native + cd tmp/telescope-fzf-native + make - name: E2E Test run: | diff --git a/.luarc.json b/.luarc.json index b03227d4b..3a2495a62 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,6 +1,6 @@ { - "$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json", - "Lua.diagnostics.disable": [ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "diagnostics.disable": [ "redefined-local" ], "diagnostics.globals": [ @@ -9,5 +9,6 @@ "describe", "before_each" ], - "workspace.checkThirdParty": "Disable", + "workspace.checkThirdParty": false, + "runtime.version": "LuaJIT" } diff --git a/.ruby-version b/.ruby-version index bea438e9a..9c25013db 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.1 +3.3.6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac606bc0e..10fd3df64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,9 +50,23 @@ Simply clone *Neogit* to your project directory of choice to be able to use your Logging is a useful tool for inspecting what happens in the code and in what order. Neogit uses [`Plenary`](https://github.com/nvim-lua/plenary.nvim) for logging. -Export the environment variables `NEOGIT_LOG_CONSOLE="sync"` to enable logging, and `NEOGIT_LOG_LEVEL="debug"` for more -verbose logging. +#### Enabling logging via environment variables +- To enable logging to console, export `NEOGIT_LOG_CONSOLE="sync"` +- To enable logging to a file, export `NEOGIT_LOG_FILE="true"` +- For more verbose logging, set the log level to `debug` via `NEOGIT_LOG_LEVEL="debug"` + +#### Enabling logging via lua api + +To turn on logging while neovim is already running, you can use: + +```lua +:lua require("neogit.logger").config.use_file = true -- for logs to ~/.cache/nvim/neogit.log. +:lua require("neogit.logger").config.use_console = true -- for logs to console. +:lua require("neogit.logger").config.level = 'debug' -- to set the log level +``` + +#### Using the logger from the neogit codebase ```lua local logger = require("neogit.logger") @@ -99,9 +113,11 @@ See [the test documentation for more details](./tests/README.md). ### Linting Additionally, linting is enforced using `selene` to catch common errors, most of which are also caught by -`lua-language-server`. +`lua-language-server`. Source code spell checking is done via `typos`. -```sh make lint ``` +```sh +make lint +``` ### Formatting diff --git a/Gemfile b/Gemfile index f88bcb270..e906795c1 100644 --- a/Gemfile +++ b/Gemfile @@ -18,3 +18,5 @@ gem "rubocop-performance" gem "rubocop-rspec" gem "super_diff" gem "tmpdir" + +gem "lefthook", "~> 1.7" diff --git a/Gemfile.lock b/Gemfile.lock index 905e68bdf..212087ae9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -43,6 +43,7 @@ GEM reline (>= 0.4.2) json (2.7.2) language_server-protocol (3.17.0.3) + lefthook (1.7.22) logger (1.6.0) minitest (5.25.1) msgpack (1.7.2) @@ -124,6 +125,7 @@ GEM PLATFORMS arm64-darwin-22 arm64-darwin-23 + arm64-darwin-24 x64-mingw-ucrt x86_64-darwin-20 x86_64-linux @@ -134,6 +136,7 @@ DEPENDENCIES debug fuubar git + lefthook (~> 1.7) neovim pastel quickfix_formatter @@ -145,7 +148,7 @@ DEPENDENCIES tmpdir RUBY VERSION - ruby 3.3.1p55 + ruby 3.3.6p108 BUNDLED WITH - 2.4.21 + 2.5.23 diff --git a/Makefile b/Makefile index 5005f526e..19efec42a 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,17 @@ test: TEMP_DIR=$$TEMP_DIR TEST_FILES=$$TEST_FILES GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null NVIM_APPNAME=neogit-test nvim --headless -S "./tests/init.lua" +specs: + bundle install && CI=1 bundle exec rspec --format Fuubar + lint: selene --config selene/config.toml lua typos -lint-short: - selene --config selene/config.toml --display-style Quiet lua - format: stylua . -.PHONY: format lint test +typecheck: + llscheck lua/ + +.PHONY: format lint typecheck diff --git a/README.md b/README.md index 6bf878492..5ab8cae3c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,17 @@ +
+
+
+ + Warp sponsorship + + +### [Warp, the intelligent terminal for developers](https://www.warp.dev/neogit) +#### [Try running neogit in Warp](https://www.warp.dev/neogit)
+ +
+ +
+
@@ -37,19 +51,10 @@ Here's an example spec for [Lazy](https://github.com/folke/lazy.nvim), but you'r -- Only one of these is needed. "nvim-telescope/telescope.nvim", -- optional "ibhagwan/fzf-lua", -- optional - "echasnovski/mini.pick", -- optional + "nvim-mini/mini.pick", -- optional + "folke/snacks.nvim", -- optional }, - config = true } - -``` - -If you're not using lazy, you'll need to require and setup the plugin like so: - -```lua --- init.lua -local neogit = require('neogit') -neogit.setup {} ``` ## Compatibility @@ -58,7 +63,7 @@ The `master` branch will always be compatible with the latest **stable** release ## Configuration -You can configure neogit by running the `neogit.setup()` function, passing a table as the argument. +You can configure neogit by running the `require('neogit').setup {}` function, passing a table as the argument.
Default Config @@ -73,6 +78,8 @@ neogit.setup { disable_context_highlighting = false, -- Disables signs for sections/items/hunks disable_signs = false, + -- Offer to force push when branches diverge + prompt_force_push = true, -- Changes what mode the Commit Editor starts in. `true` will leave nvim in normal mode, `false` will change nvim to -- insert mode, and `"auto"` will change nvim to insert mode IF the commit message is empty, otherwise leaving it in -- normal mode. @@ -85,13 +92,35 @@ neogit.setup { }, -- "ascii" is the graph the git CLI generates -- "unicode" is the graph like https://github.com/rbong/vim-flog + -- "kitty" is the graph like https://github.com/isakbm/gitgraph.nvim - use https://github.com/rbong/flog-symbols if you don't use Kitty graph_style = "ascii", - -- Used to generate URL's for branch popup action "pull request". + -- Show relative date by default. When set, use `strftime` to display dates + commit_date_format = nil, + log_date_format = nil, + -- Show message with spinning animation when a git command is running. + process_spinner = false, + -- Used to generate URL's for branch popup action "pull request", "open commit" and "open tree" git_services = { - ["github.com"] = "https://github.com/$%7Bowner%7D/$%7Brepository%7D/compare/$%7Bbranch_name%7D?expand=1", - ["bitbucket.org"] = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/pull-requests/new?source=${branch_name}&t=1", - ["gitlab.com"] = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/merge_requests/new?merge_request[source_branch]=${branch_name}", - ["azure.com"] = "https://dev.azure.com/$%7Bowner%7D/_git/$%7Brepository%7D/pullrequestcreate?sourceRef=${branch_name}&targetRef=${target}", + ["github.com"] = { + pull_request = "https://github.com/$%7Bowner%7D/$%7Brepository%7D/compare/$%7Bbranch_name%7D?expand=1", + commit = "https://github.com/$%7Bowner%7D/$%7Brepository%7D/commit/$%7Boid%7D", + tree = "https://${host}/$%7Bowner%7D/$%7Brepository%7D/tree/$%7Bbranch_name%7D", + }, + ["bitbucket.org"] = { + pull_request = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/pull-requests/new?source=${branch_name}&t=1", + commit = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/commits/$%7Boid%7D", + tree = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/branch/$%7Bbranch_name%7D", + }, + ["gitlab.com"] = { + pull_request = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/merge_requests/new?merge_request[source_branch]=${branch_name}", + commit = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/-/commit/$%7Boid%7D", + tree = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/-/tree/$%7Bbranch_name%7D?ref_type=heads", + }, + ["azure.com"] = { + pull_request = "https://dev.azure.com/$%7Bowner%7D/_git/$%7Brepository%7D/pullrequestcreate?sourceRef=${branch_name}&targetRef=${target}", + commit = "", + tree = "", + }, }, -- Allows a different telescope sorter. Defaults to 'fuzzy_with_index_bias'. The example below will use the native fzf -- sorter instead. By default, this function returns `nil`. @@ -103,13 +132,7 @@ neogit.setup { -- Scope persisted settings on a per-project basis use_per_project_settings = true, -- Table of settings to never persist. Uses format "Filetype--cli-value" - ignored_settings = { - "NeogitPushPopup--force-with-lease", - "NeogitPushPopup--force", - "NeogitPullPopup--rebase", - "NeogitCommitPopup--allow-empty", - "NeogitRevertPopup--no-edit", - }, + ignored_settings = {}, -- Configure highlight group features highlight = { italic = true, @@ -126,16 +149,36 @@ neogit.setup { -- Flag description: https://git-scm.com/docs/git-branch#Documentation/git-branch.txt---sortltkeygt -- Sorting keys: https://git-scm.com/docs/git-for-each-ref#_options sort_branches = "-committerdate", + -- Value passed to the `---order` flag of the `git log` command + -- Determines how commits are traversed and displayed in the log / graph: + -- "topo" topological order (parents always before children, good for graphs, slower on large repos) + -- "date" chronological order by commit date + -- "author-date" chronological order by author date + -- "" disable explicit ordering (fastest, recommended for very large repos) + commit_order = "topo" + -- Default for new branch name prompts + initial_branch_name = "", -- Change the default way of opening neogit kind = "tab", - -- Disable line numbers and relative line numbers + -- Floating window style + floating = { + relative = "editor", + width = 0.8, + height = 0.7, + style = "minimal", + border = "rounded", + }, + -- Disable line numbers disable_line_numbers = true, + -- Disable relative line numbers + disable_relative_line_numbers = true, -- The time after which an output console is shown for slow running commands console_timeout = 2000, -- Automatically show console if a command takes more than console_timeout milliseconds auto_show_console = true, -- Automatically close the console if the process exits with a 0 (success) status auto_close_console = true, + notification_icon = "󰊢", status = { show_head_commit_hash = true, recent_commit_count = 10, @@ -191,15 +234,18 @@ neogit.setup { merge_editor = { kind = "auto", }, - tag_editor = { - kind = "auto", - }, preview_buffer = { - kind = "floating", + kind = "floating_console", }, popup = { kind = "split", }, + stash = { + kind = "tab", + }, + refs_view = { + kind = "tab", + }, signs = { -- { CLOSED, OPENED } hunk = { "", "" }, @@ -226,6 +272,11 @@ neogit.setup { -- is also selected then telescope is used instead -- Requires you to have `echasnovski/mini.pick` installed. mini_pick = nil, + + -- If enabled, uses snacks.picker for menu selection. If the telescope integration + -- is also selected then telescope is used instead + -- Requires you to have `folke/snacks.nvim` installed. + snacks = nil, }, sections = { -- Reverting/Cherry Picking @@ -279,6 +330,9 @@ neogit.setup { ["q"] = "Close", [""] = "Submit", [""] = "Abort", + [""] = "PrevMessage", + [""] = "NextMessage", + [""] = "ResetMessage", }, commit_editor_I = { [""] = "Submit", @@ -314,21 +368,32 @@ neogit.setup { [""] = "Previous", [""] = "Next", [""] = "Previous", - [""] = "MultiselectToggleNext", - [""] = "MultiselectTogglePrevious", + [""] = "InsertCompletion", + [""] = "CopySelection", + [""] = "MultiselectToggleNext", + [""] = "MultiselectTogglePrevious", [""] = "NOP", + [""] = "ScrollWheelDown", + [""] = "ScrollWheelUp", + [""] = "NOP", + [""] = "NOP", + [""] = "MouseClick", + ["<2-LeftMouse>"] = "NOP", }, -- Setting any of these to `false` will disable the mapping. popup = { ["?"] = "HelpPopup", ["A"] = "CherryPickPopup", - ["D"] = "DiffPopup", + ["d"] = "DiffPopup", ["M"] = "RemotePopup", ["P"] = "PushPopup", ["X"] = "ResetPopup", ["Z"] = "StashPopup", + ["i"] = "IgnorePopup", + ["t"] = "TagPopup", ["b"] = "BranchPopup", ["B"] = "BisectPopup", + ["w"] = "WorktreePopup", ["c"] = "CommitPopup", ["f"] = "FetchPopup", ["l"] = "LogPopup", @@ -336,26 +401,29 @@ neogit.setup { ["p"] = "PullPopup", ["r"] = "RebasePopup", ["v"] = "RevertPopup", - ["w"] = "WorktreePopup", }, status = { - ["k"] = "MoveUp", ["j"] = "MoveDown", - ["q"] = "Close", + ["k"] = "MoveUp", ["o"] = "OpenTree", + ["q"] = "Close", ["I"] = "InitRepo", ["1"] = "Depth1", ["2"] = "Depth2", ["3"] = "Depth3", ["4"] = "Depth4", + ["Q"] = "Command", [""] = "Toggle", + ["za"] = "Toggle", + ["zo"] = "OpenFold", ["x"] = "Discard", ["s"] = "Stage", ["S"] = "StageUnstaged", [""] = "StageAll", - ["K"] = "Untrack", ["u"] = "Unstage", + ["K"] = "Untrack", ["U"] = "UnstageStaged", + ["y"] = "ShowRefs", ["$"] = "CommandHistory", ["Y"] = "YankSelected", [""] = "RefreshBuffer", @@ -370,6 +438,8 @@ neogit.setup { ["]c"] = "OpenOrScrollDown", [""] = "PeekUp", [""] = "PeekDown", + [""] = "NextSection", + [""] = "PreviousSection", }, }, } @@ -415,6 +485,7 @@ The `kind` option can be one of the following values: - `split_below` - `split_below_all` - `vsplit` +- `floating` - `auto` (`vsplit` if window would have 80 cols, otherwise `split`) ## Popups @@ -470,6 +541,7 @@ Neogit emits the following events: | `NeogitTagDelete` | A tag was removed | `{ name: string }` | | `NeogitCherryPick` | One or more commits were cherry-picked | `{ commits: string[] }` | | `NeogitMerge` | A merge finished | `{ branch: string, args = string[], status: "ok"\|"conflict" }` | +| `NeogitStash` | A stash finished | `{ success: boolean }` | ## Versioning @@ -479,6 +551,15 @@ Neogit follows semantic versioning. See [CONTRIBUTING.md](https://github.com/NeogitOrg/neogit/blob/master/CONTRIBUTING.md) for more details. -## Credit +## Contributors + + + + + +## Special Thanks + +- [kolja](https://github.com/kolja) for the Neogit Logo +- [gitgraph.nvim](https://github.com/isakbm/gitgraph.nvim) for the "kitty" git graph renderer +- [vim-flog](https://github.com/rbong/vim-flog) for the "unicode" git graph renderer -Thank you to [kolja](https://github.com/kolja) for the Neogit Logo diff --git a/bin/specs b/bin/specs index 4539bfb51..aebb9e8ac 100755 --- a/bin/specs +++ b/bin/specs @@ -11,11 +11,96 @@ gemfile do gem "debug" end +require "async" +require "async/barrier" +require "async/semaphore" + COLOR = Pastel.new def now = Process.clock_gettime(Process::CLOCK_MONOTONIC) -tests = Dir["spec/**/*_spec.rb"] -length = tests.max_by(&:size).size +class Runner # rubocop:disable Style/Documentation + def initialize(test, spinner, length) + @test = test + @spinner = spinner + @length = length + @title = test.gsub("spec/", "") + @retries = 0 + @failed_lines = [] + end + + def register + spinner.update(test: title, padding: " " * (length - test.length)) + spinner.auto_spin + self + end + + def call(results, failures) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + start! + + loop do + output, wait = run + results[test] = JSON.parse(output) + + time = results[test].dig("summary", "duration").round(3) + + if wait.value.success? + register_success!(time) + break + elsif retries < 5 + @failed_lines = JSON.parse(output)["examples"] + .select { _1["status"] == "failed" } + .map { _1["line_number"] } + .uniq + + @retries += 1 + register_retry! + else + failures << test + register_failure!(time) + break + end + end + end + + private + + attr_reader :title, :spinner, :test, :length, :retries + + def start! + spinner.update(test: COLOR.blue(title)) + end + + def run + failed = @failed_lines.empty? ? "" : "[#{@failed_lines.join(',')}]" + stdin, stdout, wait = Open3.popen2( + { "CI" => "1" }, + "bundle exec rspec #{test}#{failed} --format json --order random" + ) + + stdin.close + output = stdout.read.lines.last + stdout.close + + [output, wait] + end + + def register_success!(time) + spinner.update(test: COLOR.green(title)) + spinner.success(COLOR.green(time)) + end + + def register_retry! + spinner.update(test: "#{COLOR.yellow(title)} (#{retries})") + end + + def register_failure!(time) + spinner.update(test: COLOR.red(title)) + spinner.error(COLOR.red(time)) + end +end + +tests = Dir["spec/**/*_spec.rb"] +length = tests.max_by(&:size).size spinners = TTY::Spinner::Multi.new( COLOR.blue(":spinner Running #{tests.size} specs"), format: :bouncing_ball, @@ -27,37 +112,22 @@ failures = [] start = now -Sync do |parent| # rubocop:disable Metrics/BlockLength - tests.map do |test| - parent.async do - spinner = spinners.register( - ":test:padding\t", - success_mark: COLOR.green.bold("+"), - error_mark: COLOR.red.bold("x") - ) +barrier = Async::Barrier.new +Sync do + semaphore = Async::Semaphore.new(Etc.nprocessors - 2, parent: barrier) - title = test.gsub("spec/", "") - spinner.update(test: title, padding: " " * (length - test.length)) - spinner.auto_spin + runners = tests.map do |test| + spinner = spinners.register( + ":test:padding\t", + success_mark: COLOR.green.bold("+"), + error_mark: COLOR.red.bold("x") + ) - stdin, stdout, wait = Open3.popen2({ "CI" => "1" }, "bundle exec rspec #{test} --format json --order random") - stdin.close - - output = stdout.read.lines.last - results[test] = JSON.parse(output) - stdout.close - - time = results[test].dig("summary", "duration").round(3) + Runner.new(test, spinner, length).register + end - if wait.value.success? - spinner.update(test: COLOR.green(title)) - spinner.success(COLOR.green(time)) - else - failures << test - spinner.update(test: COLOR.red(title)) - spinner.error(COLOR.red(time)) - end - end + runners.map do |runner| + semaphore.async { runner.call(results, failures) } end.map(&:wait) end @@ -68,7 +138,7 @@ if failures.any? output = results[test] puts "\nFail: #{output.dig('examples', 0, 'full_description')}" - puts " #{test}" + puts " #{test}:#{output.dig('examples', 0, 'line_number')}" puts " #{output.dig('examples', 0, 'exception', 'class')}" puts " #{output.dig('examples', 0, 'exception', 'message')}" end diff --git a/doc/neogit.txt b/doc/neogit.txt index 8cc136c1b..5616a1d92 100644 --- a/doc/neogit.txt +++ b/doc/neogit.txt @@ -18,6 +18,8 @@ CONTENTS *neogit_contents* 4. Events |neogit_events| 5. Highlights |neogit_highlights| 6. API |neogit_api| + • Popup Builder |neogit_popup_builder| + • Customizing Popups |neogit_custom_popups| 7. Usage |neogit_usage| 8. Popups *neogit_popups* • Bisect |neogit_bisect_popup| @@ -82,152 +84,263 @@ to Neovim users. ============================================================================== 2. Plugin Setup *neogit_setup_plugin* -TODO: Detail what these do + >lua + local neogit = require("neogit") - use_default_keymaps = true, + neogit.setup { + -- Hides the hints at the top of the status buffer disable_hint = false, + -- Disables changing the buffer highlights based on where the cursor is. disable_context_highlighting = false, + -- Disables signs for sections/items/hunks disable_signs = false, - graph_style = "ascii", + -- Offer to force push when branches diverge + prompt_force_push = true, + -- Changes what mode the Commit Editor starts in. `true` will leave nvim in normal mode, `false` will change nvim to + -- insert mode, and `"auto"` will change nvim to insert mode IF the commit message is empty, otherwise leaving it in + -- normal mode. + disable_insert_on_commit = "auto", + -- When enabled, will watch the `.git/` directory for changes and refresh the status buffer in response to filesystem + -- events. filewatcher = { - enabled = true, + interval = 1000, + enabled = true, }, - telescope_sorter = function() - return nil - end, + -- "ascii" is the graph the git CLI generates + -- "unicode" is the graph like https://github.com/rbong/vim-flog + -- "kitty" is the graph like https://github.com/isakbm/gitgraph.nvim - use https://github.com/rbong/flog-symbols if you don't use Kitty + graph_style = "ascii", + -- Show relative date by default. When set, use `strftime` to display dates + commit_date_format = nil, + log_date_format = nil, + -- Used to generate URL's for branch popup action "pull request" or opening a commit. git_services = { - ["github.com"] = "https://github.com/$%7Bowner%7D/$%7Brepository%7D/compare/$%7Bbranch_name%7D?expand=1", - ["bitbucket.org"] = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/pull-requests/new?source=${branch_name}&t=1", - ["gitlab.com"] = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/merge_requests/new?merge_request[source_branch]=${branch_name}", - ["azure.com"] = "https://dev.azure.com/$%7Bowner%7D/_git/$%7Brepository%7D/pullrequestcreate?sourceRef=${branch_name}&targetRef=${target}", + ["github.com"] = { + pull_request = "https://github.com/$%7Bowner%7D/$%7Brepository%7D/compare/$%7Bbranch_name%7D?expand=1", + commit = "https://github.com/$%7Bowner%7D/$%7Brepository%7D/commit/$%7Boid%7D", + tree = "https://${host}/$%7Bowner%7D/$%7Brepository%7D/tree/$%7Bbranch_name%7D", + }, + ["bitbucket.org"] = { + pull_request = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/pull-requests/new?source=${branch_name}&t=1", + commit = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/commits/$%7Boid%7D", + tree = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/branch/$%7Bbranch_name%7D", + }, + ["gitlab.com"] = { + pull_request = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/merge_requests/new?merge_request[source_branch]=${branch_name}", + commit = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/-/commit/$%7Boid%7D", + tree = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/-/tree/$%7Bbranch_name%7D?ref_type=heads", + }, + ["azure.com"] = { + pull_request = "https://dev.azure.com/$%7Bowner%7D/_git/$%7Brepository%7D/pullrequestcreate?sourceRef=${branch_name}&targetRef=${target}", + commit = "", + tree = "", + }, }, + -- Allows a different telescope sorter. Defaults to 'fuzzy_with_index_bias'. The example below will use the native fzf + -- sorter instead. By default, this function returns `nil`. + telescope_sorter = function() + return require("telescope").extensions.fzf.native_fzf_sorter() + end, + -- Persist the values of switches/options within and across sessions + remember_settings = true, + -- Scope persisted settings on a per-project basis + use_per_project_settings = true, + -- Table of settings to never persist. Uses format "Filetype--cli-value" + ignored_settings = {}, + -- Configure highlight group features highlight = { - italic = true, - bold = true, - underline = true, + italic = true, + bold = true, + underline = true }, - disable_insert_on_commit = "auto", - use_per_project_settings = true, - show_head_commit_hash = true, - remember_settings = true, - fetch_after_checkout = false, + -- Set to false if you want to be responsible for creating _ALL_ keymappings + use_default_keymaps = true, + -- Neogit refreshes its internal state after specific events, which can be expensive depending on the repository size. + -- Disabling `auto_refresh` will make it so you have to manually refresh the status after you open it. auto_refresh = true, + -- Value used for `--sort` option for `git branch` command + -- By default, branches will be sorted by commit date descending + -- Flag description: https://git-scm.com/docs/git-branch#Documentation/git-branch.txt---sortltkeygt + -- Sorting keys: https://git-scm.com/docs/git-for-each-ref#_options sort_branches = "-committerdate", + -- Value passed to the `---order` flag of the `git log` command + -- Determines how commits are traversed and displayed in the log / graph: + -- "topo" topological order (parents always before children, good for graphs, slower on large repos) + -- "date" chronological order by commit date + -- "author-date" chronological order by author date + -- "" disable explicit ordering (fastest, recommended for very large repos) + commit_order = "topo" + -- Default for new branch name prompts + initial_branch_name = "", + -- Change the default way of opening neogit kind = "tab", + -- Floating window style + floating = { + relative = "editor", + width = 0.8, + height = 0.7, + style = "minimal", + border = "rounded", + }, + -- Disable line numbers disable_line_numbers = true, + -- Disable relative line numbers + disable_relative_line_numbers = true, -- The time after which an output console is shown for slow running commands console_timeout = 2000, -- Automatically show console if a command takes more than console_timeout milliseconds auto_show_console = true, + -- Automatically close the console if the process exits with a 0 (success) status + auto_close_console = true, notification_icon = "󰊢", status = { - recent_commit_count = 10, - HEAD_folded = false, + show_head_commit_hash = true, + recent_commit_count = 10, + HEAD_padding = 10, + HEAD_folded = false, + mode_padding = 3, + mode_text = { + M = "modified", + N = "new file", + A = "added", + D = "deleted", + C = "copied", + U = "updated", + R = "renamed", + DD = "unmerged", + AU = "unmerged", + UD = "unmerged", + UA = "unmerged", + DU = "unmerged", + AA = "unmerged", + UU = "unmerged", + ["?"] = "", + }, }, commit_editor = { - kind = "tab", + kind = "tab", + show_staged_diff = true, + -- Accepted values: + -- "split" to show the staged diff below the commit editor + -- "vsplit" to show it to the right + -- "split_above" Like :top split + -- "vsplit_left" like :vsplit, but open to the left + -- "auto" "vsplit" if window would have 80 cols, otherwise "split" + staged_diff_split_kind = "split", + spell_check = true, }, commit_select_view = { - kind = "tab", + kind = "tab", }, commit_view = { - kind = "vsplit", - verify_commit = vim.fn.executable("gpg") == 1, + kind = "vsplit", + verify_commit = vim.fn.executable("gpg") == 1, -- Can be set to true or false, otherwise we try to find the binary }, log_view = { - kind = "tab", + kind = "tab", }, rebase_editor = { - kind = "auto", + kind = "auto", }, reflog_view = { - kind = "tab", + kind = "tab", }, merge_editor = { - kind = "auto", - }, - description_editor = { - kind = "auto", - }, - tag_editor = { - kind = "auto", + kind = "auto", }, preview_buffer = { - kind = "split", + kind = "floating_console", }, popup = { - kind = "split", + kind = "split", + }, + stash = { + kind = "tab", }, refs_view = { - kind = "tab", + kind = "tab", }, signs = { - hunk = { "", "" }, - item = { ">", "v" }, - section = { ">", "v" }, + -- { CLOSED, OPENED } + hunk = { "", "" }, + item = { ">", "v" }, + section = { ">", "v" }, }, + -- Each Integration is auto-detected through plugin presence, however, it can be disabled by setting to `false` integrations = { - telescope = nil, - diffview = nil, - fzf_lua = nil, - mini_pick = nil, + -- If enabled, use telescope for menu selection rather than vim.ui.select. + -- Allows multi-select and some things that vim.ui.select doesn't. + telescope = nil, + -- Neogit only provides inline diffs. If you want a more traditional way to look at diffs, you can use `diffview`. + -- The diffview integration enables the diff popup. + -- + -- Requires you to have `sindrets/diffview.nvim` installed. + diffview = nil, + + -- If enabled, uses fzf-lua for menu selection. If the telescope integration + -- is also selected then telescope is used instead + -- Requires you to have `ibhagwan/fzf-lua` installed. + fzf_lua = nil, + + -- If enabled, uses mini.pick for menu selection. If the telescope integration + -- is also selected then telescope is used instead + -- Requires you to have `echasnovski/mini.pick` installed. + mini_pick = nil, + + -- If enabled, uses snacks.picker for menu selection. If the telescope integration + -- is also selected then telescope is used instead + -- Requires you to have `folke/snacks.nvim` installed. + snacks = nil, }, sections = { - sequencer = { + -- Reverting/Cherry Picking + sequencer = { folded = false, hidden = false, - }, - bisect = { - folded = false, - hidden = false, - }, - untracked = { + }, + untracked = { folded = false, hidden = false, - }, - unstaged = { + }, + unstaged = { folded = false, hidden = false, - }, - staged = { + }, + staged = { folded = false, hidden = false, - }, - stashes = { + }, + stashes = { folded = true, hidden = false, - }, - unpulled_upstream = { + }, + unpulled_upstream = { folded = true, hidden = false, - }, - unmerged_upstream = { + }, + unmerged_upstream = { folded = false, hidden = false, - }, - unpulled_pushRemote = { + }, + unpulled_pushRemote = { folded = true, hidden = false, - }, - unmerged_pushRemote = { + }, + unmerged_pushRemote = { folded = false, hidden = false, - }, - recent = { + }, + recent = { folded = true, hidden = false, - }, - rebase = { + }, + rebase = { folded = true, hidden = false, - }, + }, }, - ignored_settings = { - "NeogitPushPopup--force-with-lease", - "NeogitPushPopup--force", - "NeogitPullPopup--rebase", - "NeogitCommitPopup--allow-empty", } + > ============================================================================== Commit Signing / GPG Integration *neogit_setup_gpg* @@ -295,70 +408,82 @@ The following mappings can all be customized via the setup function. } finder = { - [""] = "Select", - [""] = "Close", - [""] = "Close", - [""] = "Next", - [""] = "Previous", - [""] = "Next", - [""] = "Previous", - [""] = "MultiselectToggleNext", - [""] = "MultiselectTogglePrevious", + [""] = "Select", + [""] = "Close", + [""] = "Close", + [""] = "Next", + [""] = "Previous", + [""] = "Next", + [""] = "Previous", + [""] = "InsertCompletion", + [""] = "CopySelection", + [""] = "MultiselectToggleNext", + [""] = "MultiselectTogglePrevious", + [""] = "NOP", + [""] = "ScrollWheelDown", + [""] = "ScrollWheelUp", + [""] = "NOP", + [""] = "NOP", + [""] = "MouseClick", + ["<2-LeftMouse>"] = "NOP", } popup = { ["?"] = "HelpPopup", ["A"] = "CherryPickPopup", - ["B"] = "BisectPopup", + ["d"] = "DiffPopup", + ["M"] = "RemotePopup", + ["P"] = "PushPopup", + ["X"] = "ResetPopup", + ["Z"] = "StashPopup", + ["i"] = "IgnorePopup", + ["t"] = "TagPopup", ["b"] = "BranchPopup", + ["B"] = "BisectPopup", + ["w"] = "WorktreePopup", ["c"] = "CommitPopup", - ["d"] = "DiffPopup", ["f"] = "FetchPopup", - ["i"] = "IgnorePopup", ["l"] = "LogPopup", ["m"] = "MergePopup", - ["M"] = "RemotePopup", ["p"] = "PullPopup", - ["P"] = "PushPopup", ["r"] = "RebasePopup", - ["t"] = "TagPopup", ["v"] = "RevertPopup", - ["w"] = "WorktreePopup", - ["X"] = "ResetPopup", - ["Z"] = "StashPopup", } status = { - ["q"] = "Close", - ["Q"] = "Command", - ["I"] = "InitRepo", - ["1"] = "Depth1", - ["2"] = "Depth2", - ["3"] = "Depth3", - ["4"] = "Depth4", - [""] = "Toggle", - ["x"] = "Discard", - ["s"] = "Stage", - ["S"] = "StageUnstaged", - [""] = "StageAll", - ["u"] = "Unstage", - ["U"] = "UnstageStaged", - ["y"] = "ShowRefs", - ["$"] = "CommandHistory", - ["#"] = "Console", - ["Y"] = "YankSelected", - [""] = "RefreshBuffer", - [""] = "GoToFile", - [""] = "PeekFile", - [""] = "VSplitOpen", - [""] = "SplitOpen", - [""] = "TabOpen", - ["{"] = "GoToPreviousHunkHeader", - ["}"] = "GoToNextHunkHeader", - ["[c"] = "OpenOrScrollUp", - ["]c"] = "OpenOrScrollDown", - [""] = "PeekUp", - [""] = "PeekDown", + ["j"] = "MoveDown", + ["k"] = "MoveUp", + ["o"] = "OpenTree", + ["q"] = "Close", + ["I"] = "InitRepo", + ["1"] = "Depth1", + ["2"] = "Depth2", + ["3"] = "Depth3", + ["4"] = "Depth4", + ["Q"] = "Command", + [""] = "Toggle", + ["x"] = "Discard", + ["s"] = "Stage", + ["S"] = "StageUnstaged", + [""] = "StageAll", + ["u"] = "Unstage", + ["K"] = "Untrack", + ["U"] = "UnstageStaged", + ["y"] = "ShowRefs", + ["$"] = "CommandHistory", + ["Y"] = "YankSelected", + [""] = "RefreshBuffer", + [""] = "GoToFile", + [""] = "PeekFile", + [""] = "VSplitOpen", + [""] = "SplitOpen", + [""] = "TabOpen", + ["{"] = "GoToPreviousHunkHeader", + ["}"] = "GoToNextHunkHeader", + ["[c"] = "OpenOrScrollUp", + ["]c"] = "OpenOrScrollDown", + [""] = "PeekUp", + [""] = "PeekDown", } < ============================================================================== @@ -381,11 +506,175 @@ The following mappings can all be customized via the setup function. ============================================================================== 4. Events *neogit_events* -(TODO) +The following events are emitted by Neogit: + +• `NeogitStatusRefreshed` + When: Status has been reloaded + Data: `{}` + +• `NeogitCommitComplete` + When: Commit has been created + Data: `{}` + +• `NeogitPushComplete` + When: Push has finished + Data: `{}` + +• `NeogitPullComplete` + When: Push has finished + Data: `{}` + +• `NeogitFetchComplete` + When: Fetch has finished + Data: `{}` + +• `NeogitBranchCreate` + When: Branch was created, starting from `` + Data: > + { + branch_name: string, + base: string? + } +< +• `NeogitBranchDelete` + When: Branch was deleted + Data: > + { + branch_name: string, + } +< +• `NeogitBranchCheckout` + When: Branch was checked out + Data: > + { + branch_name: string, + } +< +• `NeogitBranchReset` + When: Branch was reset to commit/branch + Data: > + { + branch_name: string, + resetting_to: string + } +< +• `NeogitBranchRename` + When: Branch was renamed + Data: > + { + branch_name: string, + new_name: string + } +< +• `NeogitRebase` + When: A rebase has finished + Data: > + { + commit: string, + status: "ok" | "conflict" + } +< +• `NeogitReset` + When: A reset has been performed + Data: > + { + commit: string, + mode: "soft" | "mixed" | "hard" | "keep" | "index" + } +< +• `NeogitTagCreate` + When: A tag is placed on a commit + Data: > + { + ref: string, + name: string + } +< +• `NeogitTagCreate` + When: A tag is placed on a commit + Data: > + { + ref: string, + name: string + } +< +• `NeogitTagDelete` + When: A tag is removed + Data: > + { + name: string + } +< +• `NeogitCherryPick` + When: One or more commits were cherry picked + Data: > + { + commits: string[] + } +< +• `NeogitMerge` + When: A merge has finished + Data: > + { + branch: string, + args: string[], + status: "ok" | "conflict" + } +< +• `NeogitStash` + When: A stash was performed + Data: > + { + success: boolean + } +< +• `NeogitWorktreeCreate` + When: A worktree was created + Data: > + { + old_cwd: string, + new_cwd: string, + copy_if_present: function(filename: string, callback: function|nil) + } +< ============================================================================== 5. Highlights *neogit_highlights* +To provide a custom color palette directly to the plugin, you can use the +`config.highlight` table with the following signature: >lua + + ---@class HighlightOptions + ---@field italic? boolean + ---@field bold? boolean + ---@field underline? boolean + ---@field bg0? string Darkest background color + ---@field bg1? string Second darkest background color + ---@field bg2? string Second lightest background color + ---@field bg3? string Lightest background color + ---@field grey? string middle grey shade for foreground + ---@field white? string Foreground white (main text) + ---@field red? string Foreground red + ---@field bg_red? string Background red + ---@field line_red? string Cursor line highlight for red regions + ---@field orange? string Foreground orange + ---@field bg_orange? string background orange + ---@field yellow? string Foreground yellow + ---@field bg_yellow? string background yellow + ---@field green? string Foreground green + ---@field bg_green? string Background green + ---@field line_green? string Cursor line highlight for green regions + ---@field cyan? string Foreground cyan + ---@field bg_cyan? string Background cyan + ---@field blue? string Foreground blue + ---@field bg_blue? string Background blue + ---@field purple? string Foreground purple + ---@field bg_purple? string Background purple + ---@field md_purple? string Background medium purple +< + +The following highlight groups will all be derived from this palette. + The following highlight groups are defined by this plugin. If you set any of these yourself before the plugin loads, that will be respected. If they do not exist, they will be created with sensible defaults based on your colorscheme. @@ -440,7 +729,7 @@ whats recommended. However, if you want to control the style on a per-section basis, the _actual_ highlight groups on the labels follow this pattern: `NeogitChange
` -Where `` is one of: (corrospinding to the git mode) +Where `` is one of: (corresponding to the git mode) M A N @@ -472,6 +761,7 @@ NeogitDiffContext NeogitDiffAdd NeogitDiffDelete NeogitDiffHeader +NeogitActiveItem Highlight of current commit-ish open SIGNS FOR LINE HIGHLIGHTING CURRENT CONTEXT These are essentially an accented version of the above highlight groups. Only @@ -497,21 +787,21 @@ NeogitCommitViewHeader Applied to header of Commit View LOG VIEW BUFFER NeogitGraphAuthor Applied to the commit's author in graph view NeogitGraphBlack Used when --colors is enabled for graph -NeogitGraphBlackBold +NeogitGraphBoldBlack NeogitGraphRed -NeogitGraphRedBold +NeogitGraphBoldRed NeogitGraphGreen -NeogitGraphGreenBold +NeogitGraphBoldGreen NeogitGraphYellow -NeogitGraphYellowBold +NeogitGraphBoldYellow NeogitGraphBlue -NeogitGraphBlueBold +NeogitGraphBoldBlue NeogitGraphPurple -NeogitGraphPurpleBold +NeogitGraphBoldPurple NeogitGraphCyan -NeogitGraphCyanBold +NeogitGraphBoldCyan NeogitGraphWhite -NeogitGraphWhiteBold +NeogitGraphBoldWhite NeogitGraphGray NeogitGraphBoldGray NeogitGraphOrange @@ -655,13 +945,27 @@ Actions: *neogit_cherry_pick_popup_actions* Otherwise the user is prompted to select one or more commits. • Harvest *neogit_cherry_pick_harvest* - (Not yet implemented) + This command moves the selected COMMITS that must be located on another + BRANCH onto the current branch instead, removing them from the former. + When this command succeeds, then the same branch is current as before. + + Applying the commits on the current branch or removing them from the other + branch can lead to conflicts. When that happens, then this command stops + and you have to resolve the conflicts and then finish the process manually. • Squash *neogit_cherry_pick_squash* - (Not yet implemented) + See: |neogit_merge_squash| • Donate *neogit_cherry_pick_donate* - (Not yet implemented) + This command moves the selected COMMITS from the current branch onto + another existing BRANCH, removing them from the former. When this command + succeeds, then the same branch is current as before. + + HEAD is allowed to be detached initially. + + Applying the commits on the other branch or removing them from the current + branch can lead to conflicts. When that happens, then this command stops + and you have to resolve the conflicts and then finish the process manually. • Spinout *neogit_cherry_pick_spinout* (Not yet implemented) @@ -775,7 +1079,7 @@ Actions: *neogit_branch_popup_actions* the old branch. • Checkout new worktree *neogit_branch_checkout_worktree* - (Not yet implemented) + see: |neogit_worktree_checkout| • Create new branch *neogit_branch_create_branch* Functionally the same as |neogit_branch_checkout_new|, but does not update @@ -786,7 +1090,7 @@ Actions: *neogit_branch_popup_actions* index has uncommitted changes, will behave exactly the same as spin_off. • Create new worktree *neogit_branch_create_worktree* - (Not yet implemented) + see: |neogit_worktree_create_branch| • Configure *neogit_branch_configure* Opens selector to choose a branch, then offering some configuration @@ -1034,14 +1338,38 @@ Actions: *neogit_commit_popup_actions* Creates a fixup commit. If a commit is selected it will be used, otherwise the user is prompted to pick a commit. + `git commit --fixup=COMMIT --no-edit` + • Squash *neogit_commit_squash* Creates a squash commit. If a commit is selected it will be used, otherwise the user is prompted to pick a commit. + `git commit --squash=COMMIT --no-edit` + • Augment *neogit_commit_augment* Creates a squash commit, editing the squash message. If a commit is selected it will be used, otherwise the user is prompted to pick a commit. + `git commit --squash=COMMIT --edit` + + • Alter *neogit_commit_alter* + Create a squash commit, authoring the final message now. + + During a later rebase, when this commit gets squashed into it's targeted + commit, the original message of the targeted commit is replaced with the + message of this commit, without the user automatically being given a + chance to edit it again. + + `git commit --fixup=amend:COMMIT --edit` + + • Revise *neogit_commit_revise* + Reword the message of an existing commit, without editing it's tree. + Later, when the commit is squashed into it's targeted commit, a combined + commit is created which uses the message of the fixup commit and the tree + of the targeted commit. + + `git commit --fixup=reword:COMMIT --edit` + • Instant Fixup *neogit_commit_instant_fixup* Similar to |neogit_commit_fixup|, but instantly rebases after. @@ -1051,7 +1379,44 @@ Actions: *neogit_commit_popup_actions* ============================================================================== Diff Popup *neogit_diff_popup* -(TODO) +The diff popup actions allow inspection of changes in the index (staged +changes), the working tree (unstaged changes and untracked files), any +ref range. + +For these actions to become available, Neogit needs to be configured to enable +`diffview.nvim` integration. See |neogit_setup_plugin| and +https://github.com/sindrets/diffview.nvim. + + • Diff this *neogit_diff_this* + Show the diff for the file referred to by a hunk under the cursor. + + • Diff range *neogit_diff_range* + View a diff between a specified range of commits. Neogit presents a select + for an `A` and a `B` ref and allows specifying the type of range. + + A two-dot range `A..B` shows all of the commits that `B` has that `A` + doesn't have. A three-dot range `A...B` shows all of the commits that `A` + and `B` have independently, excluding the commits shared by both refs. + + + • Diff paths *neogit_diff_paths* + (Not yet implemented) + + • Diff unstaged *neogit_diff_unstaged* + Show the diff for the working tree, without untracked files. + + • Diff staged *neogit_diff_staged* + Show the diff for the index. + + • Diff worktree *neogit_diff_worktree* + Show the full diff of the working tree, including untracked files. + + • Show commit *neogit_show_commit* + Display the diff of the commits within a branch. + + • Show stash *neogit_show_stash* + Display the diff of a specific stash. + ============================================================================== Fetch Popup *neogit_fetch_popup* @@ -1123,7 +1488,37 @@ Log Popup *neogit_log_popup* ============================================================================== Merge Popup *neogit_merge_popup* -(TODO) +Arguments: *neogit_merge_popup_args* + (TODO) + +Actions: *neogit_merge_popup_actions* + • Merge *neogit_merge_merge* + This command merges another branch or revision into the current branch. + + • Merge and edit message *neogit_merge_editmsg* + Like `Merge` above, but opens editor to modify commit message. + + • Merge but don't commit *neogit_merge_nocommit* + This command merges another branch or revision into the current branch, + but does not actually create the merge commit, allowing the user to make + modifications before committing themselves. + + • Absorb *neogit_merge_absorb* + (Not yet implemented) + + • Preview Merge *neogit_merge_preview* + (Not yet implemented) + + • Squash Merge *neogit_merge_squash* + This command squashes the changes introduced by another branch or revision + into the current branch. This only applies the changes made by the + squashed commits. No information is preserved that would allow creating an + actual merge commit. + + Instead of this command you should probably use a cherry-pick command. + + • Dissolve *neogit_merge_dissolve* + (Not yet implemented) ============================================================================== Remote Popup *neogit_remote_popup* @@ -1178,7 +1573,7 @@ Arguments: *neogit_pull_popup_args* upstream branch and the upstream branch was rebased since last fetched, the rebase uses that information to avoid rebasing non-local changes. - See pull.rebase, branch..rebase and branch.autoSetupRebase if you + See pull.rebase, branch..rebase and branch.autoSetupRebase if you want to make git pull always use --rebase instead of merging. Note: @@ -1287,13 +1682,13 @@ Actions: *neogit_push_popup_actions* the user. • Push explicit refspecs *neogit_push_explicit_refspecs* - (Not yet implemented) + Push a refspec to a remote. • Push matching branches *neogit_push_matching_branches* - (Not yet implemented) + Push all matching branches to another repository. • Push a tag *neogit_push_tag* - (Not yet implemented) + Pushes a single tag to a remote. • Push all tags *neogit_push_all_tags* Pushes all tags to selected remote. If only one remote exists, that will @@ -1532,8 +1927,10 @@ Actions: *neogit_reset_popup_actions* • Hard *neogit_reset_hard* Resets the index and working tree. Any changes to tracked files in the - working tree since are discarded. Any untracked files or - directories in the way of writing any tracked files are simply deleted. + working tree since are discarded, however a reflog entry will be + created with their current state, so changes can be restored if needed. + Any untracked files or directories in the way of writing any tracked files + are simply deleted. • Keep *neogit_reset_keep* Resets index entries and updates files in the working tree that are @@ -1547,12 +1944,80 @@ Actions: *neogit_reset_popup_actions* changes. • Worktree *neogit_reset_worktree* - (Not yet implemented) + Resets current worktree to specified commit. + + • Branch *neogit_reset_branch* + see: |neogit_branch_reset| + + • File *neogit_reset_file* + Attempts to perform a `git checkout` from the specified revision, and if + that fails, tries `git reset` instead. ============================================================================== Stash Popup *neogit_stash_popup* -(TODO) +The stash popup actions will affect the current index (staged changes) and the +working tree (unstaged changes and untracked files). When the cursor is on a +stash in the stash list, actions under the "Use" column (pop, apply and drop) +will affect the stash under the cursor. + +Actions: *neogit_stash_popup_actions* + • Stash both *neogit_stash_both* + Stash both the index and the working tree. + + • Stash index *neogit_stash_index* + Stash the index only, excluding unstaged changes and untracked files. + + • Stash worktree *neogit_stash_worktree* + (Not yet implemented) + + • Stash keeping index *neogit_stash_keeping_index* + Stash both the index and the working tree, but still leave behind the + index. + + • Stash push *neogit_stash_push* + Select a changed file to push it to the stash. + + • Snapshot both *neogit_snapshot_both* + (Not yet implemented) + + • Snapshot index *neogit_snapshot_index* + (Not yet implemented) + + • Snapshot worktree *neogit_snapshot_worktree* + (Not yet implemented) + + • Snapshot to wip ref *neogit_snapshot_to_wip_ref* + (Not yet implemented) + + • Pop *neogit_stash_pop* + Apply a stash to the working tree. If there are conflicts, leave the stash + intact. If there aren't any conflicts, remove the stash from the list. + + • Apply *neogit_stash_apply* + Apply a stash to the working tree without removing it from the stash list. + + • Drop *neogit_stash_drop* + Remove a stash from the stash list. + + • List *neogit_stash_list* + Display the list of all stashes, and show a diff if a stash is selected. + + • Show *neogit_stash_show* + (Not yet implemented) + + • Branch *neogit_stash_branch* + (Not yet implemented) + + • Branch here *neogit_stash_branch_here* + (Not yet implemented) + + • Rename *neogit_stash_rename* + Rename an existing stash. + + • Format patch *neogit_stash_format_patch* + (Not yet implemented) + ============================================================================== Ignore Popup *neogit_ignore_popup* @@ -1644,6 +2109,8 @@ Untracked Files *neogit_status_buffer_untracked* ============================================================================== Editor Buffer *neogit_editor_buffer* +User customizations can be made via `gitcommit` ftplugin. + Commands: *neogit_editor_commands* • Submit *neogit_editor_submit* Default key: `` @@ -1701,6 +2168,8 @@ Refs Buffer *neogit_refs_buffer* ============================================================================== Rebase Todo Buffer *neogit_rebase_todo_buffer* +User customizations can be made via `gitrebase` ftplugin. + The Rebase editor has some extra commands, beyond being a normal vim buffer. The following keys, in normal mode, will act on the commit under the cursor: @@ -1714,6 +2183,92 @@ The following keys, in normal mode, will act on the commit under the cursor: • `b` Insert breakpoint • `` Open current commit in Commit Buffer +============================================================================== +Popup Builder *neogit_popup_builder* + +You can leverage Neogit's infrastructure to create your own popups and +actions. For example, you can define actions as a function which will take the +popup instance as it's argument: +>lua + local function my_action(popup) + -- You can access the popup state (enabled flags) like so: + local cli_args = popup:get_arguments() + + -- You can use Neogit's git abstraction for many common operations + -- local git = require("neogit.lib.git") + + -- The input library provides some helpful interfaces for getting user + -- input + local input = require("neogit.lib.input") + local user_input = input.get_user_input("User-specified free text for the action") + + vim.notify( + "Hello from my custom action!\n" + .. "CLI args: `" .. table.concat(cli_args, " ") .. "`\n" + .. "User input: `" .. user_input .. "`") + end + + function create_custom_popup() + local popup = require("neogit.lib.popup") + local p = popup + .builder() + :name("NeogitMyCustomPopup") + -- A switch is a boolean CLI flag, like `--no-verify` + :switch("s", "my-switch", "My switch") + -- An "_if" variant exists for builder methods, that takes a boolean + -- as it's first argument. + :switch_if(true, "S", "conditional-switch", "This switch is conditional") + -- Options are CLI flags that have a value, like `--strategy=octopus` + :option("o", "my-option", "default_value", "My option", { key_prefix = "-" }) + :new_action_group("My actions") + :action("a", "Some action", my_action) + -- Data can be stored on the popup instance via the `env`, accessible + -- to the action via `popup.state.env.*` + :env({ some_data = { "like this" } }) + :build() + + p:show() + + return p + end + + require("neogit") +< + +Look at the builder APIs in `lua/neogit/lib/popup/builder.lua`, the built-in +popups/actions in `lua/neogit/popups/*`, and the git APIs in +`lua/neogit/lib/git` for more information (and inspiration!). + +To access your custom popup via a keymapping, you can include a mapping when +calling the setup function: +>lua + require("neogit").setup({ + mappings = { + status = { + ["A"] = create_custom_popup, + }, + }, + }) +< + +============================================================================== +Customizing Popups *neogit_custom_popups* + +You can customize existing popups via the Neogit config. + +Below is an example of adding a custom switch, but you can use any function +from the builder API. +>lua + require("neogit").setup({ + builders = { + NeogitPushPopup = function(builder) + builder:switch('m', 'merge_request.create', 'Create merge request', { cli_prefix = '-o ', persisted = false }) + end, + }, + }) + +Keep in mind that builder hooks are executed at the end of the popup +builder, so any switches or options added will be placed at the end. + ------------------------------------------------------------------------------ vim:tw=78:ts=8:ft=help:norl: - diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 000000000..0920c0252 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,49 @@ +skip_output: + - meta +pre-push: + files: "rg --files" + parallel: true + commands: + rubocop: + glob: "*.rb" + run: bundle exec rubocop {files} + selene: + glob: "{lua,plugin}/**/*.lua" + run: selene --config selene/config.toml {files} + stylua: + glob: "*.lua" + run: stylua --check {files} + typos: + run: typos {files} + lua-types: + glob: "*.lua" + run: llscheck lua/ || echo {files} + lua-test: + glob: "tests/specs/**/*_spec.lua" + run: nvim --headless -S "./tests/init.lua" || echo {files} + env: + - CI: 1 + - GIT_CONFIG_GLOBAL: /dev/null + - GIT_CONFIG_SYSTEM: /dev/null + - NVIM_APPNAME: neogit-test + rspec: + only: + - ref: master + run: bin/specs {files} +pre-commit: + parallel: true + commands: + rubocop: + glob: "*.rb" + run: bundle exec rubocop {staged_files} + selene: + glob: "{lua,plugin}/**/*.lua" + run: selene --config selene/config.toml {staged_files} + stylua: + glob: "*.lua" + run: stylua --check {staged_files} + typos: + run: typos {staged_files} + lua-types: + glob: "*.lua" + run: llscheck lua/ diff --git a/lua/neogit.lua b/lua/neogit.lua index ecaac9171..7f66dcce0 100644 --- a/lua/neogit.lua +++ b/lua/neogit.lua @@ -84,7 +84,7 @@ local function construct_opts(opts) if not opts.cwd then local git = require("neogit.lib.git") - opts.cwd = git.cli.git_root(".") + opts.cwd = git.cli.worktree_root(".") if opts.cwd == "" then opts.cwd = vim.uv.cwd() @@ -111,7 +111,7 @@ local function open_status_buffer(opts) -- going to open into. We will use vim.fn.lcd() in the status buffer constructor, so this will eventually be -- correct. local repo = require("neogit.lib.git.repository").instance(opts.cwd) - status.new(config.values, repo.git_root, opts.cwd):open(opts.kind):dispatch_refresh() + status.new(config.values, repo.worktree_root, opts.cwd):open(opts.kind):dispatch_refresh() end ---@alias Popup @@ -186,6 +186,7 @@ end ---@return function function M.action(popup, action, args) local util = require("neogit.lib.util") + local git = require("neogit.lib.git") local a = require("plenary.async") args = args or {} @@ -202,15 +203,20 @@ function M.action(popup, action, args) if ok then local fn = actions[action] if fn then - fn { - state = { env = {} }, - get_arguments = function() - return args - end, - get_internal_arguments = function() - return internal_args - end, - } + local action = function() + fn { + close = function() end, + state = { env = {} }, + get_arguments = function() + return args + end, + get_internal_arguments = function() + return internal_args + end, + } + end + + git.repo:dispatch_refresh { source = "action", callback = action } else M.notification.error( string.format( diff --git a/lua/neogit/autocmds.lua b/lua/neogit/autocmds.lua index bfb4bdb19..edcd7926c 100644 --- a/lua/neogit/autocmds.lua +++ b/lua/neogit/autocmds.lua @@ -46,6 +46,14 @@ function M.setup() autocmd_disabled = args.event == "QuickFixCmdPre" end, }) + + -- Ensure vim buffers are updated + api.nvim_create_autocmd("User", { + pattern = "NeogitStatusRefreshed", + callback = function() + vim.cmd("set autoread | checktime") + end, + }) end return M diff --git a/lua/neogit/buffers/commit_select_view/init.lua b/lua/neogit/buffers/commit_select_view/init.lua index 802a30b66..241b0d894 100644 --- a/lua/neogit/buffers/commit_select_view/init.lua +++ b/lua/neogit/buffers/commit_select_view/init.lua @@ -7,17 +7,20 @@ local status_maps = require("neogit.config").get_reversed_status_maps() ---@class CommitSelectViewBuffer ---@field commits CommitLogEntry[] +---@field remotes string[] ---@field header string|nil local M = {} M.__index = M ---Opens a popup for selecting a commit ---@param commits CommitLogEntry[]|nil +---@param remotes string[] ---@param header? string ---@return CommitSelectViewBuffer -function M.new(commits, header) +function M.new(commits, remotes, header) local instance = { commits = commits, + remotes = remotes, header = header, buffer = nil, } @@ -50,7 +53,7 @@ function M:open(action) M.instance = self - ---@type fun(commit: CommitLogEntry[])|nil + ---@type fun(commit: string[])|nil local action = action self.buffer = Buffer.create { @@ -114,7 +117,7 @@ function M:open(action) end end, render = function() - return ui.View(self.commits) + return ui.View(self.commits, self.remotes) end, } end diff --git a/lua/neogit/buffers/commit_select_view/ui.lua b/lua/neogit/buffers/commit_select_view/ui.lua index 8d5188d38..71841f8e7 100644 --- a/lua/neogit/buffers/commit_select_view/ui.lua +++ b/lua/neogit/buffers/commit_select_view/ui.lua @@ -6,13 +6,14 @@ local Graph = require("neogit.buffers.common").CommitGraph local M = {} ---@param commits CommitLogEntry[] +---@param remotes string[] ---@return table -function M.View(commits) +function M.View(commits, remotes) return util.filter_map(commits, function(commit) if commit.oid then - return Commit(commit, { graph = true, decorate = true }) + return Commit(commit, remotes, { graph = true, decorate = true }) else - return Graph(commit) + return Graph(commit, #commits[1].abbreviated_commit + 1) end end) end diff --git a/lua/neogit/buffers/commit_view/init.lua b/lua/neogit/buffers/commit_view/init.lua index 0a0d32367..c57989942 100644 --- a/lua/neogit/buffers/commit_view/init.lua +++ b/lua/neogit/buffers/commit_view/init.lua @@ -5,6 +5,7 @@ local git = require("neogit.lib.git") local config = require("neogit.config") local popups = require("neogit.popups") local status_maps = require("neogit.config").get_reversed_status_maps() +local notification = require("neogit.lib.notification") local api = vim.api @@ -47,8 +48,11 @@ local M = { ---@param filter? string[] Filter diffs to filepaths in table ---@return CommitViewBuffer function M.new(commit_id, filter) - local commit_info = - git.log.parse(git.cli.show.format("fuller").args(commit_id).call({ trim = false }).stdout)[1] + local cmd = git.cli.show.format("fuller").args(commit_id) + if config.values.commit_date_format ~= nil then + cmd = cmd.args("--date=format:" .. config.values.commit_date_format) + end + local commit_info = git.log.parse(cmd.call({ trim = false }).stdout)[1] commit_info.commit_arg = commit_id @@ -78,6 +82,15 @@ function M:close() M.instance = nil end +---@return string +function M.current_oid() + if M.is_open() then + return M.instance.commit_info.oid + else + return "null-oid" + end +end + ---Opens the CommitViewBuffer if it isn't open or performs the given action ---which is passed the window id of the commit view buffer ---@param commit_id string commit @@ -89,6 +102,7 @@ function M.open_or_run_in_window(commit_id, filter, cmd) if M.is_open() and M.instance.commit_info.commit_arg == commit_id then M.instance.buffer:win_exec(cmd) else + M:close() local cw = api.nvim_get_current_win() M.new(commit_id, filter):open() api.nvim_set_current_win(cw) @@ -133,18 +147,17 @@ function M:update(commit_id, filter) self.buffer.ui:render( unpack(ui.CommitView(self.commit_info, self.commit_overview, self.commit_signature, self.item_filter)) ) + + self.buffer:win_call(vim.cmd, "normal! gg") end ---Opens the CommitViewBuffer ---If already open will close the buffer ---@param kind? string +---@return CommitViewBuffer function M:open(kind) kind = kind or config.values.commit_view.kind - if M.is_open() then - M.instance:close() - end - M.instance = self self.buffer = Buffer.create { @@ -153,8 +166,29 @@ function M:open(kind) kind = kind, status_column = not config.values.disable_signs and "" or nil, context_highlight = not config.values.disable_context_highlighting, + autocmds = { + ["WinLeave"] = function() + if self.buffer and self.buffer.kind == "floating" then + self:close() + end + end, + }, mappings = { n = { + ["o"] = function() + if not vim.ui.open then + notification.warn("Requires Neovim >= 0.10") + return + end + + local uri = git.remote.commit_url(https://github.com/self.commit_info.oid) + if uri then + notification.info(("Opening %q in your browser."):format(uri)) + vim.ui.open(uri) + else + notification.warn("Couldn't determine commit URL to open") + end + end, [""] = function() local c = self.buffer.ui:get_component_under_cursor(function(c) return c.options.highlight == "NeogitFilePath" @@ -192,9 +226,18 @@ function M:open(kind) -- Search for a match and jump if we find it for path, line_nr in pairs(diff_headers) do + local path_norm = path + for _, kind in ipairs { "modified", "renamed", "new file", "deleted file" } do + if vim.startswith(path_norm, kind .. " ") then + path_norm = string.sub(path_norm, string.len(kind) + 2) + break + end + end -- The gsub is to work around the fact that the OverviewFiles use -- => in renames but the diff header uses -> - if path:gsub(" %-> ", " => "):match(selected_path) then + path_norm = path_norm:gsub(" %-> ", " => ") + + if path_norm == selected_path then -- Save position in jumplist vim.cmd("normal! m'") @@ -244,19 +287,38 @@ function M:open(kind) vim.cmd("normal! zt") end end, - [popups.mapping_for("CherryPickPopup")] = popups.open("cherry_pick", function(p) + [popups.mapping_for("BisectPopup")] = popups.open("bisect", function(p) p { commits = { self.commit_info.oid } } end), [popups.mapping_for("BranchPopup")] = popups.open("branch", function(p) p { commits = { self.commit_info.oid } } end), + [popups.mapping_for("CherryPickPopup")] = popups.open("cherry_pick", function(p) + p { commits = { self.commit_info.oid } } + end), [popups.mapping_for("CommitPopup")] = popups.open("commit", function(p) p { commit = self.commit_info.oid } end), + [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) + p { + section = { name = "log" }, + item = { name = self.commit_info.oid }, + } + end), [popups.mapping_for("FetchPopup")] = popups.open("fetch"), + -- help + [popups.mapping_for("IgnorePopup")] = popups.open("ignore", function(p) + local path = self.buffer.ui:get_hunk_or_filename_under_cursor() + p { + paths = { path and path.escaped_path }, + worktree_root = git.repo.worktree_root, + } + end), + [popups.mapping_for("LogPopup")] = popups.open("log"), [popups.mapping_for("MergePopup")] = popups.open("merge", function(p) p { commit = self.buffer.ui:get_commit_under_cursor() } end), + [popups.mapping_for("PullPopup")] = popups.open("pull"), [popups.mapping_for("PushPopup")] = popups.open("push", function(p) p { commit = self.commit_info.oid } end), @@ -264,25 +326,18 @@ function M:open(kind) p { commit = self.commit_info.oid } end), [popups.mapping_for("RemotePopup")] = popups.open("remote"), - [popups.mapping_for("RevertPopup")] = popups.open("revert", function(p) - p { commits = { self.commit_info.oid } } - end), [popups.mapping_for("ResetPopup")] = popups.open("reset", function(p) p { commit = self.commit_info.oid } end), + [popups.mapping_for("RevertPopup")] = popups.open("revert", function(p) + local item = self.buffer.ui:get_hunk_or_filename_under_cursor() or {} + p { commits = { self.commit_info.oid }, hunk = item.hunk } + end), + [popups.mapping_for("StashPopup")] = popups.open("stash"), [popups.mapping_for("TagPopup")] = popups.open("tag", function(p) p { commit = self.commit_info.oid } end), - [popups.mapping_for("PullPopup")] = popups.open("pull"), - [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) - p { - section = { name = "log" }, - item = { name = self.commit_info.oid }, - } - end), - [popups.mapping_for("BisectPopup")] = popups.open("bisect", function(p) - p { commits = { self.commit_info.oid } } - end), + [popups.mapping_for("WorktreePopup")] = popups.open("worktree"), [status_maps["Close"]] = function() self:close() end, @@ -306,6 +361,8 @@ function M:open(kind) vim.cmd("normal! zR") end, } + + return self end return M diff --git a/lua/neogit/buffers/common.lua b/lua/neogit/buffers/common.lua index f13c4e4de..625ffbc40 100644 --- a/lua/neogit/buffers/common.lua +++ b/lua/neogit/buffers/common.lua @@ -1,6 +1,7 @@ local Ui = require("neogit.lib.ui") local Component = require("neogit.lib.ui.component") local util = require("neogit.lib.util") +local config = require("neogit.config") local git = require("neogit.lib.git") local text = Ui.text @@ -108,10 +109,18 @@ M.List = Component.new(function(props) return container.tag("List")(children) end) -local function build_graph(graph) +---@return Component[] +local function build_graph(graph, opts) + opts = opts or { remove_dots = false } + if type(graph) == "table" then return util.map(graph, function(g) - return text(g.text, { highlight = string.format("NeogitGraph%s", g.color) }) + local char = g.text + if opts.remove_dots and vim.tbl_contains({ "", "", "", "", "•" }, char) then + char = "" + end + + return text(char, { highlight = string.format("NeogitGraph%s", g.color) }) end) else return { text(graph, { highlight = "Include" }) } @@ -137,14 +146,18 @@ local highlight_for_signature = { N = "NeogitSignatureNone", } -M.CommitEntry = Component.new(function(commit, args) +---@param commit CommitLogEntry +---@param remotes string[] +---@param args table +M.CommitEntry = Component.new(function(commit, remotes, args) local ref = {} local ref_last = {} - - local info = git.log.branch_info(commit.ref_name, git.remote.list()) + local info = { head = nil, locals = {}, remotes = {}, tags = {} } -- Parse out ref names if args.decorate and commit.ref_name ~= "" then + info = git.log.branch_info(commit.ref_name, remotes) + -- Render local only branches first for name, _ in pairs(info.locals) do if name:match("^refs/") then @@ -185,11 +198,10 @@ M.CommitEntry = Component.new(function(commit, args) commit.rel_date = " " .. commit.rel_date end - local graph = args.graph and build_graph(commit.graph) or { text("") } - local details if args.details then - details = col.padding_left(git.log.abbreviated_size() + 1) { + local graph = args.graph and build_graph(commit.graph, { remove_dots = true }) or { text("") } + details = col.padding_left(#commit.abbreviated_commit + 1) { row(util.merge(graph, { text(" "), text("Author: ", { highlight = "NeogitSubtleText" }), @@ -243,6 +255,9 @@ M.CommitEntry = Component.new(function(commit, args) } end + local date = (config.values.log_date_format == nil and commit.rel_date or commit.log_date) + local graph = args.graph and build_graph(commit.graph) or { text("") } + return col.tag("commit")({ row( util.merge({ @@ -256,19 +271,25 @@ M.CommitEntry = Component.new(function(commit, args) virtual_text = { { " ", "Constant" }, { - util.str_clamp(commit.author_name, 30 - (#commit.rel_date > 10 and #commit.rel_date or 10)), + util.str_clamp(commit.author_name, 30 - (#date > 10 and #date or 10)), "NeogitGraphAuthor", }, - { util.str_min_width(commit.rel_date, 10), "Special" }, + { util.str_min_width(date, 10), "Special" }, }, } ), details, - }, { oid = commit.oid, foldable = args.details == true, folded = true, remote = info.remotes[1] }) + }, { + item = commit, + oid = commit.oid, + foldable = args.details == true, + folded = true, + remote = info.remotes[1], + }) end) -M.CommitGraph = Component.new(function(commit, _) - return col.tag("graph").padding_left(git.log.abbreviated_size() + 1) { row(build_graph(commit.graph)) } +M.CommitGraph = Component.new(function(commit, padding) + return col.tag("graph").padding_left(padding) { row(build_graph(commit.graph)) } end) M.Grid = Component.new(function(props) diff --git a/lua/neogit/buffers/diff/init.lua b/lua/neogit/buffers/diff/init.lua index 987378d7f..53f42f86e 100644 --- a/lua/neogit/buffers/diff/init.lua +++ b/lua/neogit/buffers/diff/init.lua @@ -7,7 +7,7 @@ local api = vim.api ---@class DiffBuffer ---@field buffer Buffer ----@field open fun(self, kind: string) +---@field open fun(self): DiffBuffer ---@field close fun() ---@field stats table ---@field diffs table diff --git a/lua/neogit/buffers/editor/init.lua b/lua/neogit/buffers/editor/init.lua index dbfff356e..e2475ad25 100644 --- a/lua/neogit/buffers/editor/init.lua +++ b/lua/neogit/buffers/editor/init.lua @@ -10,21 +10,12 @@ local DiffViewBuffer = require("neogit.buffers.diff") local pad = util.pad_right -local M = {} - -local filetypes = { - ["COMMIT_EDITMSG"] = "NeogitCommitMessage", - ["MERGE_MSG"] = "NeogitMergeMessage", - ["TAG_EDITMSG"] = "NeogitTagMessage", - ["EDIT_DESCRIPTION"] = "NeogitBranchDescription", -} - ---@class EditorBuffer ---@field filename string filename of buffer ---@field on_unload function callback invoked when buffer is unloaded ---@field show_diff boolean show the diff view or not ---@field buffer Buffer ----@see Buffer +local M = {} --- Creates a new EditorBuffer ---@param filename string the filename of buffer @@ -70,12 +61,9 @@ function M:open(kind) return message end - local filetype = filetypes[self.filename:match("[%u_]+$")] or "NeogitEditor" - logger.debug("[EDITOR] Filetype " .. filetype) - self.buffer = Buffer.create { name = self.filename, - filetype = filetype, + filetype = "gitcommit", load = true, spell_check = config.values.commit_editor.spell_check, buftype = "", @@ -96,10 +84,8 @@ function M:open(kind) end end, }, - on_detach = function(buffer) + on_detach = function() logger.debug("[EDITOR] Cleaning Up") - pcall(vim.treesitter.stop, buffer.handle) - if self.on_unload then logger.debug("[EDITOR] Running on_unload callback") self.on_unload(aborted and 1 or 0) @@ -122,10 +108,7 @@ function M:open(kind) return pad(mapping[name] and mapping[name][1] or "", padding) end - local comment_char = git.config.get("core.commentChar"):read() - or git.config.get_global("core.commentChar"):read() - or "#" - + local comment_char = git.config.get("core.commentChar"):read() or "#" logger.debug("[EDITOR] Using comment character '" .. comment_char .. "'") -- stylua: ignore @@ -171,19 +154,6 @@ function M:open(kind) vim.cmd(":startinsert") end - -- Source runtime ftplugin - vim.cmd.source("$VIMRUNTIME/ftplugin/gitcommit.vim") - - -- Apply syntax highlighting - local ok, _ = pcall(vim.treesitter.language.inspect, "gitcommit") - if ok then - logger.debug("[EDITOR] Loading treesitter for gitcommit") - vim.treesitter.start(buffer.handle, "gitcommit") - else - logger.debug("[EDITOR] Loading syntax for gitcommit") - vim.cmd.source("$VIMRUNTIME/syntax/gitcommit.vim") - end - if git.branch.current() then vim.fn.matchadd("NeogitBranch", git.branch.current(), 100) end diff --git a/lua/neogit/buffers/fuzzy_finder.lua b/lua/neogit/buffers/fuzzy_finder.lua index ae90a5df3..6950e7d7f 100644 --- a/lua/neogit/buffers/fuzzy_finder.lua +++ b/lua/neogit/buffers/fuzzy_finder.lua @@ -1,4 +1,5 @@ local Finder = require("neogit.lib.finder") +local git = require("neogit.lib.git") local function buffer_height(count) if count < (vim.o.lines / 2) then @@ -24,6 +25,17 @@ function M.new(list) list = list, } + -- If the first item in the list is an git OID, decorate it + if type(list[1]) == "string" and list[1]:match("^%x%x%x%x%x%x%x") then + local oid = table.remove(list, 1) + local ok, result = pcall(git.log.decorate, oid) + if ok then + table.insert(list, 1, result) + else + table.insert(list, 1, oid) + end + end + setmetatable(instance, { __index = M }) return instance diff --git a/lua/neogit/buffers/git_command_history.lua b/lua/neogit/buffers/git_command_history.lua index 9229a7b9d..c9a2326a4 100644 --- a/lua/neogit/buffers/git_command_history.lua +++ b/lua/neogit/buffers/git_command_history.lua @@ -11,8 +11,9 @@ local text = Ui.text local col = Ui.col local row = Ui.row -local command_mask = - vim.pesc(" --no-pager --literal-pathspecs --no-optional-locks -c core.preloadindex=true -c color.ui=always") +local command_mask = vim.pesc( + " --no-pager --literal-pathspecs --no-optional-locks -c core.preloadindex=true -c color.ui=always -c diff.noprefix=false" +) local M = {} diff --git a/lua/neogit/buffers/log_view/init.lua b/lua/neogit/buffers/log_view/init.lua index 63ae984fd..d285e0378 100644 --- a/lua/neogit/buffers/log_view/init.lua +++ b/lua/neogit/buffers/log_view/init.lua @@ -6,9 +6,12 @@ local status_maps = require("neogit.config").get_reversed_status_maps() local CommitViewBuffer = require("neogit.buffers.commit_view") local util = require("neogit.lib.util") local a = require("plenary.async") +local notification = require("neogit.lib.notification") +local git = require("neogit.lib.git") ---@class LogViewBuffer ---@field commits CommitLogEntry[] +---@field remotes string[] ---@field internal_args table ---@field files string[] ---@field buffer Buffer @@ -24,11 +27,13 @@ M.__index = M ---@param files string[]|nil list of files to filter by ---@param fetch_func fun(offset: number): CommitLogEntry[] ---@param header string +---@param remotes string[] ---@return LogViewBuffer -function M.new(commits, internal_args, files, fetch_func, header) +function M.new(commits, internal_args, files, fetch_func, header, remotes) local instance = { files = files, commits = commits, + remotes = remotes, internal_args = internal_args, fetch_func = fetch_func, buffer = nil, @@ -78,6 +83,7 @@ function M:open() context_highlight = false, header = self.header, scroll_header = false, + active_item_highlight = true, status_column = not config.values.disable_signs and "" or nil, mappings = { v = { @@ -115,7 +121,7 @@ function M:open() p { commits = self.buffer.ui:get_commits_in_selection() } end), [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) - local items = self.buffer.ui:get_commits_in_selection() + local items = self.buffer.ui:get_ordered_commits_in_selection() p { section = { name = "log" }, item = { name = items }, @@ -123,6 +129,25 @@ function M:open() end), }, n = { + ["o"] = function() + if not vim.ui.open then + notification.warn("Requires Neovim >= 0.10") + return + end + + local oid = self.buffer.ui:get_commit_under_cursor() + if not oid then + return + end + + local uri = git.remote.commit_url(https://github.com/oid) + if uri then + notification.info(("Opening %q in your browser."):format(uri)) + vim.ui.open(uri) + else + notification.warn("Couldn't determine commit URL to open") + end + end, [popups.mapping_for("BisectPopup")] = popups.open("bisect", function(p) p { commits = { self.buffer.ui:get_commit_under_cursor() } } end), @@ -184,7 +209,9 @@ function M:open() [status_maps["PeekFile"]] = function() local commit = self.buffer.ui:get_commit_under_cursor() if commit then - CommitViewBuffer.new(commit, self.files):open() + local buffer = CommitViewBuffer.new(commit, self.files):open() + buffer.buffer:win_call(vim.cmd, "normal! gg") + self.buffer:focus() end end, @@ -249,7 +276,7 @@ function M:open() local permit = self.refresh_lock:acquire() self.commits = util.merge(self.commits, self.fetch_func(self:commit_count())) - self.buffer.ui:render(unpack(ui.View(self.commits, self.internal_args))) + self.buffer.ui:render(unpack(ui.View(self.commits, self.remotes, self.internal_args))) permit:forget() end), @@ -289,7 +316,7 @@ function M:open() }, }, render = function() - return ui.View(self.commits, self.internal_args) + return ui.View(self.commits, self.remotes, self.internal_args) end, after = function(buffer) -- First line is empty, so move cursor to second line. diff --git a/lua/neogit/buffers/log_view/ui.lua b/lua/neogit/buffers/log_view/ui.lua index 131f5b8c8..e4f8e129f 100644 --- a/lua/neogit/buffers/log_view/ui.lua +++ b/lua/neogit/buffers/log_view/ui.lua @@ -11,16 +11,17 @@ local row = Ui.row local M = {} ---@param commits CommitLogEntry[] +---@param remotes string[] ---@param args table ---@return table -function M.View(commits, args) +function M.View(commits, remotes, args) args.details = true local graph = util.filter_map(commits, function(commit) if commit.oid then - return Commit(commit, args) + return Commit(commit, remotes, args) elseif args.graph then - return Graph(commit) + return Graph(commit, #commits[1].abbreviated_commit + 1) end end) diff --git a/lua/neogit/buffers/process/init.lua b/lua/neogit/buffers/process/init.lua index 7b59f8483..1e7308ce0 100644 --- a/lua/neogit/buffers/process/init.lua +++ b/lua/neogit/buffers/process/init.lua @@ -1,32 +1,22 @@ local Buffer = require("neogit.lib.buffer") local config = require("neogit.config") -local status_maps = require("neogit.config").get_reversed_status_maps() ---@class ProcessBuffer ----@field lines integer +---@field content string[] ---@field truncated boolean ---@field buffer Buffer ----@field open fun(self) ----@field hide fun(self) ----@field close fun(self) ----@field focus fun(self) ----@field show fun(self) ----@field is_visible fun(self): boolean ----@field append fun(self, data: string) ----@field new fun(self, table): ProcessBuffer ----@see Buffer ----@see Ui +---@field process Process local M = {} M.__index = M +---@param process Process +---@param mask_fn fun(string):string ---@return ProcessBuffer ----@param process ProcessOpts -function M:new(process) +function M:new(process, mask_fn) local instance = { - content = string.format("> %s\r\n", table.concat(process.cmd, " ")), + content = { string.format("> %s\r\n", mask_fn(table.concat(process.cmd, " "))) }, process = process, buffer = nil, - lines = 0, truncated = false, } @@ -42,6 +32,7 @@ end function M:close() if self.buffer then + self.buffer:close_terminal_channel() self.buffer:close() self.buffer = nil end @@ -58,42 +49,45 @@ function M:show() end self.buffer:show() - self:refresh() + self:flush_content() end +---@return boolean function M:is_visible() return self.buffer and self.buffer:is_valid() and self.buffer:is_visible() end -function M:refresh() - self.buffer:chan_send(self.content) - self.buffer:move_cursor(self.buffer:line_count()) -end - +---@param data string function M:append(data) - self.lines = self.lines + 1 - if self.lines > 300 then - if not self.truncated then - self.content = table.concat({ self.content, "\r\n[Output too long - Truncated]" }, "\r\n") - self.truncated = true - - if self:is_visible() then - self:refresh() - end - end - - return + assert(data, "no data to append") + + if self:is_visible() then + self:flush_content() + self.buffer:chan_send(data .. "\r\n") + else + table.insert(self.content, data) end +end - self.content = table.concat({ self.content, data }, "\r\n") +---@param data string +function M:append_partial(data) + assert(data, "no data to append") if self:is_visible() then - self:refresh() + self.buffer:chan_send(data) end end -local function hide(self) +function M:flush_content() + if #self.content > 0 then + self.buffer:chan_send(table.concat(self.content, "\r\n") .. "\r\n") + self.content = {} + end +end + +local function close(self) return function() + self.process:stop() self:close() end end @@ -104,6 +98,8 @@ function M:open() return self end + local status_maps = config.get_reversed_status_maps() + self.buffer = Buffer.create { name = "NeogitConsole", filetype = "NeogitConsole", @@ -111,22 +107,16 @@ function M:open() open = false, buftype = false, kind = config.values.preview_buffer.kind, - on_detach = function() - self.buffer = nil + after = function(buffer) + buffer:open_terminal_channel() end, - autocmds = { - ["WinLeave"] = function() - pcall(self.close, self) - end, - }, mappings = { - t = { - [status_maps["Close"]] = hide(self), - [""] = hide(self), - }, n = { - [status_maps["Close"]] = hide(self), - [""] = hide(self), + [""] = function() + pcall(self.process.stop, self.process) + end, + [status_maps["Close"]] = close(self), + [""] = close(self), }, }, } diff --git a/lua/neogit/buffers/rebase_editor/init.lua b/lua/neogit/buffers/rebase_editor/init.lua index 671a4b93b..10149c304 100644 --- a/lua/neogit/buffers/rebase_editor/init.lua +++ b/lua/neogit/buffers/rebase_editor/init.lua @@ -61,10 +61,7 @@ function M.new(filename, on_unload) end function M:open(kind) - local comment_char = git.config.get("core.commentChar"):read() - or git.config.get_global("core.commentChar"):read() - or "#" - + local comment_char = git.config.get("core.commentChar"):read() or "#" local mapping = config.get_reversed_rebase_editor_maps() local mapping_I = config.get_reversed_rebase_editor_maps_I() local aborted = false @@ -72,7 +69,7 @@ function M:open(kind) self.buffer = Buffer.create { name = self.filename, load = true, - filetype = "NeogitRebaseTodo", + filetype = "gitrebase", buftype = "", status_column = not config.values.disable_signs and "" or nil, kind = kind, @@ -80,9 +77,7 @@ function M:open(kind) disable_line_numbers = config.values.disable_line_numbers, disable_relative_line_numbers = config.values.disable_relative_line_numbers, readonly = false, - on_detach = function(buffer) - pcall(vim.treesitter.stop, buffer.handle) - + on_detach = function() if self.on_unload then self.on_unload(aborted and 1 or 0) end @@ -130,17 +125,6 @@ function M:open(kind) buffer:set_lines(-1, -1, false, help_lines) buffer:write() buffer:move_cursor(1) - - -- Source runtime ftplugin - vim.cmd.source("$VIMRUNTIME/ftplugin/gitrebase.vim") - - -- Apply syntax highlighting - local ok, _ = pcall(vim.treesitter.language.inspect, "git_rebase") - if ok then - vim.treesitter.start(buffer.handle, "git_rebase") - else - vim.cmd.source("$VIMRUNTIME/syntax/gitrebase.vim") - end end, mappings = { i = { diff --git a/lua/neogit/buffers/reflog_view/init.lua b/lua/neogit/buffers/reflog_view/init.lua index d740c63b4..986fca7d1 100644 --- a/lua/neogit/buffers/reflog_view/init.lua +++ b/lua/neogit/buffers/reflog_view/init.lua @@ -4,6 +4,8 @@ local config = require("neogit.config") local popups = require("neogit.popups") local status_maps = require("neogit.config").get_reversed_status_maps() local CommitViewBuffer = require("neogit.buffers.commit_view") +local notification = require("neogit.lib.notification") +local git = require("neogit.lib.git") ---@class ReflogViewBuffer ---@field entries ReflogEntry[] @@ -56,6 +58,7 @@ function M:open(_) scroll_header = true, status_column = not config.values.disable_signs and "" or nil, context_highlight = true, + active_item_highlight = true, mappings = { v = { [popups.mapping_for("CherryPickPopup")] = popups.open("cherry_pick", function(p) @@ -88,7 +91,7 @@ function M:open(_) end), [popups.mapping_for("PullPopup")] = popups.open("pull"), [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) - local items = self.buffer.ui:get_commits_in_selection() + local items = self.buffer.ui:get_ordered_commits_in_selection() p { section = { name = "log" }, item = { name = items }, @@ -99,6 +102,25 @@ function M:open(_) end), }, n = { + ["o"] = function() + if not vim.ui.open then + notification.warn("Requires Neovim >= 0.10") + return + end + + local oid = self.buffer.ui:get_commit_under_cursor() + if not oid then + return + end + + local uri = git.remote.commit_url(https://github.com/oid) + if uri then + notification.info(("Opening %q in your browser."):format(uri)) + vim.ui.open(uri) + else + notification.warn("Couldn't determine commit URL to open") + end + end, [popups.mapping_for("BisectPopup")] = popups.open("bisect", function(p) p { commits = { self.buffer.ui:get_commit_under_cursor() } } end), diff --git a/lua/neogit/buffers/reflog_view/ui.lua b/lua/neogit/buffers/reflog_view/ui.lua index 8e4c587de..ac777ed06 100644 --- a/lua/neogit/buffers/reflog_view/ui.lua +++ b/lua/neogit/buffers/reflog_view/ui.lua @@ -1,6 +1,7 @@ local Ui = require("neogit.lib.ui") local Component = require("neogit.lib.ui.component") local util = require("neogit.lib.util") +local config = require("neogit.config") local col = Ui.col local row = Ui.row @@ -25,7 +26,13 @@ local function highlight_for_type(type) end M.Entry = Component.new(function(entry, total) - local date_number, date_quantifier = unpack(vim.split(entry.rel_date, " ")) + local date + if config.values.log_date_format == nil then + local date_number, date_quantifier = unpack(vim.split(entry.rel_date, " ")) + date = date_number .. date_quantifier:sub(1, 1) + else + date = entry.commit_date + end return col({ row({ @@ -38,10 +45,13 @@ M.Entry = Component.new(function(entry, total) virtual_text = { { " ", "Constant" }, -- { util.str_clamp(entry.author_name, 20 - #tostring(date_number)), "Constant" }, - { date_number .. date_quantifier:sub(1, 1), "Special" }, + { date, "Special" }, }, }), - }, { oid = entry.oid }) + }, { + oid = entry.oid, + item = entry, + }) end) ---@param entries ReflogEntry[] diff --git a/lua/neogit/buffers/refs_view/init.lua b/lua/neogit/buffers/refs_view/init.lua index 750d143df..cf812c0a1 100644 --- a/lua/neogit/buffers/refs_view/init.lua +++ b/lua/neogit/buffers/refs_view/init.lua @@ -3,11 +3,13 @@ local config = require("neogit.config") local ui = require("neogit.buffers.refs_view.ui") local popups = require("neogit.popups") local status_maps = require("neogit.config").get_reversed_status_maps() +local mapping = config.get_reversed_refs_view_maps() local CommitViewBuffer = require("neogit.buffers.commit_view") local Watcher = require("neogit.watcher") local logger = require("neogit.logger") local a = require("plenary.async") local git = require("neogit.lib.git") +local event = require("neogit.lib.event") ---@class RefsViewBuffer ---@field buffer Buffer @@ -49,6 +51,36 @@ function M.is_open() return (M.instance and M.instance.buffer and M.instance.buffer:is_visible()) == true end +function M._do_delete(ref) + if not ref.remote then + git.branch.delete(ref.unambiguous_name) + else + git.cli.push.remote(ref.remote).delete.to(ref.name).call() + end +end + +function M.delete_branch(ref) + if ref then + local input = require("neogit.lib.input") + local message = ("Delete branch: '%s'?"):format(ref.unambiguous_name) + if input.get_permission(message) then + M._do_delete(ref) + end + end +end + +function M.delete_branches(refs) + if #refs > 0 then + local input = require("neogit.lib.input") + local message = ("Delete %s branch(es)?"):format(#refs) + if input.get_permission(message) then + for _, ref in ipairs(refs) do + M._do_delete(ref) + end + end + end +end + --- Opens the RefsViewBuffer function M:open() if M.is_open() then @@ -102,12 +134,16 @@ function M:open() p { commits = self.buffer.ui:get_commits_in_selection() } end), [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) - local items = self.buffer.ui:get_commits_in_selection() + local items = self.buffer.ui:get_ordered_commits_in_selection() p { section = { name = "log" }, item = { name = items }, } end), + [mapping["DeleteBranch"]] = function() + M.delete_branches(self.buffer.ui:get_refs_under_cursor()) + self:redraw() + end, }, n = { [popups.mapping_for("CherryPickPopup")] = popups.open("cherry_pick", function(p) @@ -121,6 +157,10 @@ function M:open() suggested_branch_name = ref and ref.name, } end), + [mapping["DeleteBranch"]] = function() + M.delete_branch(self.buffer.ui:get_ref_under_cursor()) + self:redraw() + end, [popups.mapping_for("CommitPopup")] = popups.open("commit", function(p) p { commit = self.buffer.ui:get_commits_in_selection()[1] } end), @@ -290,7 +330,7 @@ function M:redraw() logger.debug("[REFS] Beginning redraw") self.buffer.ui:render(unpack(ui.RefsView(git.refs.list_parsed(), self.head))) - vim.api.nvim_exec_autocmds("User", { pattern = "NeogitRefsRefreshed", modeline = false }) + event.send("RefsRefreshed") logger.info("[REFS] Redraw complete") end diff --git a/lua/neogit/buffers/refs_view/ui.lua b/lua/neogit/buffers/refs_view/ui.lua index d69b1e340..084660717 100644 --- a/lua/neogit/buffers/refs_view/ui.lua +++ b/lua/neogit/buffers/refs_view/ui.lua @@ -41,13 +41,18 @@ local function Cherries(ref, head) end local function Ref(ref) - return row { + local ref_content = { text.highlight("NeogitGraphBoldPurple")(ref.head and "@ " or " "), text.highlight(highlights[ref.type])(util.str_truncate(ref.name, 34), { align_right = 35 }), - text.highlight(highlights[ref.upstream_status])(ref.upstream_name), - text(ref.upstream_name ~= "" and " " or ""), text(ref.subject), } + + if ref.upstream_name ~= "" then + table.insert(ref_content, 3, text.highlight(highlights[ref.upstream_status])(ref.upstream_name)) + table.insert(ref_content, 4, text(" ")) + end + + return row(ref_content) end local function section(refs, heading, head) @@ -133,7 +138,7 @@ function M.Remotes(remotes, head) text.highlight("NeogitBranch")("Remote "), text.highlight("NeogitRemote")(name, { align_right = max_len }), text.highlight("NeogitBranch")( - string.format(" (%s)", git.config.get(string.format("remote.%s.url", name)):read()) + string.format(" (%s)", git.config.get_local(string.format("remote.%s.url", name)):read()) ), }, head) ) diff --git a/lua/neogit/buffers/stash_list_view/init.lua b/lua/neogit/buffers/stash_list_view/init.lua index adeb08d05..95eb6b484 100644 --- a/lua/neogit/buffers/stash_list_view/init.lua +++ b/lua/neogit/buffers/stash_list_view/init.lua @@ -39,9 +39,11 @@ function M:open() scroll_header = true, kind = config.values.stash.kind, context_highlight = true, + active_item_highlight = true, mappings = { v = { [popups.mapping_for("CherryPickPopup")] = function() + -- TODO: implement -- local stash = self.buffer.ui:get_commit_under_cursor()[1] -- if stash then -- local stash_item = util.find(self.stashes, function(s) @@ -95,7 +97,7 @@ function M:open() [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) local items = self.buffer.ui:get_commits_in_selection() p { - section = { name = "log" }, + section = { name = "stashes" }, item = { name = items }, } end), @@ -164,7 +166,7 @@ function M:open() [popups.mapping_for("DiffPopup")] = popups.open("diff", function(p) local item = self.buffer.ui:get_commit_under_cursor() p { - section = { name = "log" }, + section = { name = "stashes" }, item = { name = item }, } end), diff --git a/lua/neogit/buffers/stash_list_view/ui.lua b/lua/neogit/buffers/stash_list_view/ui.lua index eb18ae1cd..76c2b2f34 100644 --- a/lua/neogit/buffers/stash_list_view/ui.lua +++ b/lua/neogit/buffers/stash_list_view/ui.lua @@ -1,6 +1,7 @@ local Ui = require("neogit.lib.ui") local Component = require("neogit.lib.ui.component") local util = require("neogit.lib.util") +local config = require("neogit.config") local text = Ui.text local col = Ui.col @@ -19,10 +20,10 @@ M.Stash = Component.new(function(stash) }, { virtual_text = { { " ", "Constant" }, - { stash.rel_date, "Special" }, + { config.values.log_date_format ~= nil and stash.date or stash.rel_date, "Special" }, }, }), - }, { oid = label }) + }, { oid = label, item = stash }) end) ---@param stashes StashItem[] diff --git a/lua/neogit/buffers/status/actions.lua b/lua/neogit/buffers/status/actions.lua index 6dff99b97..a28d2fec6 100644 --- a/lua/neogit/buffers/status/actions.lua +++ b/lua/neogit/buffers/status/actions.lua @@ -7,24 +7,47 @@ local logger = require("neogit.logger") local input = require("neogit.lib.input") local notification = require("neogit.lib.notification") local util = require("neogit.lib.util") +local config = require("neogit.config") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") local fn = vim.fn local api = vim.api -local function cleanup_items(...) +local function cleanup_dir(dir) if vim.in_fast_event() then a.util.scheduler() end - for _, item in ipairs { ... } do - local bufnr = fn.bufexists(item.name) - if bufnr and bufnr > 0 and api.nvim_buf_is_valid(bufnr) then - api.nvim_buf_delete(bufnr, { force = true }) + for name, type in vim.fs.dir(dir, { depth = math.huge }) do + if type == "file" then + local bufnr = fn.bufnr(name) + if bufnr > 0 then + api.nvim_buf_delete(bufnr, { force = false }) + end + end + end + + fn.delete(dir, "rf") +end + +---@param items StatusItem[] +local function cleanup_items(items) + if vim.in_fast_event() then + a.util.scheduler() + end + + for _, item in ipairs(items) do + local path = item.absolute_path or item.name + logger.debug("[cleanup_items()] Cleaning " .. vim.inspect(path)) + assert(path, "cleanup_items() - item must have a name") + + local bufnr = fn.bufnr(path) + if bufnr > 0 then + api.nvim_buf_delete(bufnr, { force = false }) end - fn.delete(item.name) + fn.delete(fn.fnameescape(path)) end end @@ -54,11 +77,13 @@ local function translate_cursor_location(self, item) end local function open(type, path, cursor) - local command = ("silent! %s %s | %s | redraw! | norm! zz"):format( - type, - fn.fnameescape(path), - cursor and cursor[1] or "1" - ) + local command = ("silent! %s %s | %s"):format(type, fn.fnameescape(path), cursor and cursor[1] or "1") + + logger.debug("[Status - Open] '" .. command .. "'") + + vim.cmd(command) + + command = "redraw! | norm! zz" logger.debug("[Status - Open] '" .. command .. "'") @@ -68,6 +93,7 @@ end local M = {} ---@param self StatusBuffer +---@return fun(): nil M.v_discard = function(self) return a.void(function() local selection = self.buffer.ui:get_selection() @@ -102,7 +128,8 @@ M.v_discard = function(self) for _, hunk in ipairs(hunks) do table.insert(invalidated_diffs, "*:" .. item.name) table.insert(patches, function() - local patch = git.index.generate_patch(item, hunk, hunk.from, hunk.to, true) + local patch = + git.index.generate_patch(hunk, { from = hunk.from, to = hunk.to, reverse = true }) logger.debug(("Discarding Patch: %s"):format(patch)) @@ -154,7 +181,7 @@ M.v_discard = function(self) end if #untracked_files > 0 then - cleanup_items(unpack(untracked_files)) + cleanup_items(untracked_files) end if #unstaged_files > 0 then @@ -164,10 +191,10 @@ M.v_discard = function(self) end if #new_files > 0 then - git.index.reset(util.map(unstaged_files, function(item) + git.index.reset(util.map(new_files, function(item) return item.escaped_path end)) - cleanup_items(unpack(new_files)) + cleanup_items(new_files) end if #staged_files_modified > 0 then @@ -191,6 +218,7 @@ M.v_discard = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_stage = function(self) return a.void(function() local selection = self.buffer.ui:get_selection() @@ -213,7 +241,7 @@ M.v_stage = function(self) if #hunks > 0 then for _, hunk in ipairs(hunks) do - table.insert(patches, git.index.generate_patch(item, hunk, hunk.from, hunk.to)) + table.insert(patches, git.index.generate_patch(hunk.hunk, { from = hunk.from, to = hunk.to })) end else if section.name == "unstaged" then @@ -247,6 +275,7 @@ M.v_stage = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_unstage = function(self) return a.void(function() local selection = self.buffer.ui:get_selection() @@ -263,7 +292,10 @@ M.v_unstage = function(self) if #hunks > 0 then for _, hunk in ipairs(hunks) do - table.insert(patches, git.index.generate_patch(item, hunk, hunk.from, hunk.to, true)) + table.insert( + patches, + git.index.generate_patch(hunk, { from = hunk.from, to = hunk.to, reverse = true }) + ) end else table.insert(files, item.escaped_path) @@ -289,6 +321,7 @@ M.v_unstage = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_branch_popup = function(self) return popups.open("branch", function(p) p { commits = self.buffer.ui:get_commits_in_selection() } @@ -296,6 +329,7 @@ M.v_branch_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_cherry_pick_popup = function(self) return popups.open("cherry_pick", function(p) p { commits = self.buffer.ui:get_commits_in_selection() } @@ -303,6 +337,7 @@ M.v_cherry_pick_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_commit_popup = function(self) return popups.open("commit", function(p) local commits = self.buffer.ui:get_commits_in_selection() @@ -313,6 +348,7 @@ M.v_commit_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_merge_popup = function(self) return popups.open("merge", function(p) local commits = self.buffer.ui:get_commits_in_selection() @@ -323,6 +359,7 @@ M.v_merge_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_push_popup = function(self) return popups.open("push", function(p) local commits = self.buffer.ui:get_commits_in_selection() @@ -333,6 +370,7 @@ M.v_push_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_rebase_popup = function(self) return popups.open("rebase", function(p) local commits = self.buffer.ui:get_commits_in_selection() @@ -343,6 +381,7 @@ M.v_rebase_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_revert_popup = function(self) return popups.open("revert", function(p) p { commits = self.buffer.ui:get_commits_in_selection() } @@ -350,6 +389,7 @@ M.v_revert_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_reset_popup = function(self) return popups.open("reset", function(p) local commits = self.buffer.ui:get_commits_in_selection() @@ -360,6 +400,7 @@ M.v_reset_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_tag_popup = function(self) return popups.open("tag", function(p) local commits = self.buffer.ui:get_commits_in_selection() @@ -370,6 +411,7 @@ M.v_tag_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_stash_popup = function(self) return popups.open("stash", function(p) local stash = self.buffer.ui:get_yankable_under_cursor() @@ -378,6 +420,7 @@ M.v_stash_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_diff_popup = function(self) return popups.open("diff", function(p) local section = self.buffer.ui:get_selection().section @@ -387,13 +430,15 @@ M.v_diff_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_ignore_popup = function(self) return popups.open("ignore", function(p) - p { paths = self.buffer.ui:get_filepaths_in_selection(), git_root = git.repo.git_root } + p { paths = self.buffer.ui:get_filepaths_in_selection(), worktree_root = git.repo.worktree_root } end) end ---@param self StatusBuffer +---@return fun(): nil M.v_bisect_popup = function(self) return popups.open("bisect", function(p) p { commits = self.buffer.ui:get_commits_in_selection() } @@ -401,36 +446,51 @@ M.v_bisect_popup = function(self) end ---@param _self StatusBuffer +---@return fun(): nil M.v_remote_popup = function(_self) return popups.open("remote") end ---@param _self StatusBuffer +---@return fun(): nil M.v_fetch_popup = function(_self) return popups.open("fetch") end ---@param _self StatusBuffer +---@return fun(): nil M.v_pull_popup = function(_self) return popups.open("pull") end ---@param _self StatusBuffer +---@return fun(): nil M.v_help_popup = function(_self) return popups.open("help") end ---@param _self StatusBuffer +---@return fun(): nil M.v_log_popup = function(_self) return popups.open("log") end +---@param self StatusBuffer +---@return fun(): nil +M.v_margin_popup = function(self) + return popups.open("margin", function(p) + p { buffer = self } + end) +end + ---@param _self StatusBuffer +---@return fun(): nil M.v_worktree_popup = function(_self) return popups.open("worktree") end ---@param self StatusBuffer +---@return fun(): nil M.n_down = function(self) return function() if vim.v.count > 0 then @@ -446,6 +506,7 @@ M.n_down = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_up = function(self) return function() if vim.v.count > 0 then @@ -461,6 +522,7 @@ M.n_up = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_toggle = function(self) return function() local fold = self.buffer.ui:get_fold_under_cursor() @@ -480,11 +542,49 @@ M.n_toggle = function(self) end ---@param self StatusBuffer +---@return fun(): nil +M.n_open_fold = function(self) + return function() + local fold = self.buffer.ui:get_fold_under_cursor() + if fold then + if fold.options.on_open then + fold.options.on_open(fold, self.buffer.ui) + else + local start, _ = fold:row_range_abs() + local ok, _ = pcall(vim.cmd, "normal! zo") + if ok then + self.buffer:move_cursor(start) + fold.options.folded = false + end + end + end + end +end + +---@param self StatusBuffer +---@return fun(): nil +M.n_close_fold = function(self) + return function() + local fold = self.buffer.ui:get_fold_under_cursor() + if fold then + local start, _ = fold:row_range_abs() + local ok, _ = pcall(vim.cmd, "normal! zc") + if ok then + self.buffer:move_cursor(start) + fold.options.folded = true + end + end + end +end + +---@param self StatusBuffer +---@return fun(): nil M.n_close = function(self) return require("neogit.lib.ui.helpers").close_topmost(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_open_or_scroll_down = function(self) return function() local commit = self.buffer.ui:get_commit_under_cursor() @@ -495,6 +595,7 @@ M.n_open_or_scroll_down = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_open_or_scroll_up = function(self) return function() local commit = self.buffer.ui:get_commit_under_cursor() @@ -505,6 +606,7 @@ M.n_open_or_scroll_up = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_refresh_buffer = function(self) return a.void(function() self:dispatch_refresh({ update_diffs = { "*:*" } }, "n_refresh_buffer") @@ -512,6 +614,7 @@ M.n_refresh_buffer = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_depth1 = function(self) return function() local section = self.buffer.ui:get_current_section() @@ -530,6 +633,7 @@ M.n_depth1 = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_depth2 = function(self) return function() local section = self.buffer.ui:get_current_section() @@ -557,6 +661,7 @@ M.n_depth2 = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_depth3 = function(self) return function() local section = self.buffer.ui:get_current_section() @@ -586,6 +691,7 @@ M.n_depth3 = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_depth4 = function(self) return function() local section = self.buffer.ui:get_current_section() @@ -612,6 +718,7 @@ M.n_depth4 = function(self) end ---@param _self StatusBuffer +---@return fun(): nil M.n_command_history = function(_self) return a.void(function() require("neogit.buffers.git_command_history"):new():show() @@ -619,13 +726,15 @@ M.n_command_history = function(_self) end ---@param _self StatusBuffer +---@return fun(): nil M.n_show_refs = function(_self) return a.void(function() - require("neogit.buffers.refs_view").new(git.refs.list_parsed(), git.repo.git_root):open() + require("neogit.buffers.refs_view").new(git.refs.list_parsed(), git.repo.worktree_root):open() end) end ---@param self StatusBuffer +---@return fun(): nil M.n_yank_selected = function(self) return function() local yank = self.buffer.ui:get_yankable_under_cursor() @@ -644,6 +753,7 @@ M.n_yank_selected = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_discard = function(self) return a.void(function() git.index.update() @@ -659,11 +769,20 @@ M.n_discard = function(self) if selection.item and selection.item.first == fn.line(".") then -- Discard File if section == "untracked" then - message = ("Discard %q?"):format(selection.item.name) - action = function() - cleanup_items(selection.item) - end + local mode = git.config.get("status.showUntrackedFiles"):read() + refresh = { update_diffs = { "untracked:" .. selection.item.name } } + if mode == "all" then + message = ("Discard %q?"):format(selection.item.name) + action = function() + cleanup_items { selection.item } + end + else + message = ("Recursively discard %q?"):format(selection.item.name) + action = function() + cleanup_dir(selection.item.name) + end + end elseif section == "unstaged" then if selection.item.mode:match("^[UAD][UAD]") then choices = { "&ours", "&theirs", "&conflict", "&abort" } @@ -688,7 +807,7 @@ M.n_discard = function(self) action = function() if selection.item.mode == "A" then git.index.reset { selection.item.escaped_path } - cleanup_items(selection.item) + cleanup_items { selection.item } else git.index.checkout { selection.item.name } end @@ -719,14 +838,14 @@ M.n_discard = function(self) action = function() if selection.item.mode == "N" then git.index.reset { selection.item.escaped_path } - cleanup_items(selection.item) + cleanup_items { selection.item } elseif selection.item.mode == "M" then git.index.reset { selection.item.escaped_path } git.index.checkout { selection.item.escaped_path } elseif selection.item.mode == "R" then git.index.reset_HEAD(selection.item.name, selection.item.original_name) git.index.checkout { selection.item.original_name } - cleanup_items(selection.item) + cleanup_items { selection.item } elseif selection.item.mode == "D" then git.index.reset_HEAD(selection.item.escaped_path) git.index.checkout { selection.item.escaped_path } @@ -747,7 +866,6 @@ M.n_discard = function(self) end elseif selection.item then -- Discard Hunk if selection.item.mode == "UU" then - -- TODO: https://github.com/emacs-mirror/emacs/blob/master/lisp/vc/smerge-mode.el notification.warn("Resolve conflicts in file before discarding hunks.") return end @@ -755,17 +873,11 @@ M.n_discard = function(self) local hunk = self.buffer.ui:item_hunks(selection.item, selection.first_line, selection.last_line, false)[1] - local patch = git.index.generate_patch(selection.item, hunk, hunk.from, hunk.to, true) + local patch = git.index.generate_patch(hunk, { reverse = true }) if section == "untracked" then message = "Discard hunk?" action = function() - local hunks = - self.buffer.ui:item_hunks(selection.item, selection.first_line, selection.last_line, false) - - local patch = git.index.generate_patch(selection.item, hunks[1], hunks[1].from, hunks[1].to, true) - - git.index.apply(patch, { reverse = true }) git.index.apply(patch, { reverse = true }) end refresh = { update_diffs = { "untracked:" .. selection.item.name } } @@ -786,7 +898,7 @@ M.n_discard = function(self) if section == "untracked" then message = ("Discard %s files?"):format(#selection.section.items) action = function() - cleanup_items(unpack(selection.section.items)) + cleanup_items(selection.section.items) end refresh = { update_diffs = { "untracked:*" } } elseif section == "unstaged" then @@ -799,7 +911,7 @@ M.n_discard = function(self) end if conflict then - -- TODO: https://github.com/magit/magit/blob/28bcd29db547ab73002fb81b05579e4a2e90f048/lisp/magit-apply.el#Lair + -- TODO: https://github.com/magit/magit/blob/28bcd29db547ab73002fb81b05579e4a2e90f048/lisp/magit-apply.el#L515 notification.warn("Resolve conflicts before discarding section.") return else @@ -819,7 +931,7 @@ M.n_discard = function(self) for _, item in ipairs(selection.section.items) do if item.mode == "N" or item.mode == "A" then - table.insert(new_files, item.escaped_path) + table.insert(new_files, item) elseif item.mode == "M" then table.insert(staged_files_modified, item.escaped_path) elseif item.mode == "R" then @@ -832,9 +944,10 @@ M.n_discard = function(self) end if #new_files > 0 then - -- ensure the file is deleted - git.index.reset(new_files) - cleanup_items(unpack(new_files)) + git.index.reset(util.map(new_files, function(item) + return item.escaped_path + end)) + cleanup_items(new_files) end if #staged_files_modified > 0 then @@ -874,6 +987,7 @@ M.n_discard = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_go_to_next_hunk_header = function(self) return function() local c = self.buffer.ui:get_component_under_cursor(function(c) @@ -905,6 +1019,7 @@ M.n_go_to_next_hunk_header = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_go_to_previous_hunk_header = function(self) return function() local function previous_hunk_header(self, line) @@ -931,6 +1046,7 @@ M.n_go_to_previous_hunk_header = function(self) end ---@param _self StatusBuffer +---@return fun(): nil M.n_init_repo = function(_self) return function() git.init.init_repo() @@ -938,6 +1054,43 @@ M.n_init_repo = function(_self) end ---@param self StatusBuffer +---@return fun(): nil +M.n_rename = function(self) + return a.void(function() + local selection = self.buffer.ui:get_selection() + local paths = git.files.all_tree() + + if + selection.item + and selection.item.escaped_path + and git.files.is_tracked(selection.item.escaped_path) + then + paths = util.deduplicate(util.merge({ selection.item.escaped_path }, paths)) + end + + local selected = FuzzyFinderBuffer.new(paths):open_async { prompt_prefix = "Rename file" } + if (selected or "") == "" then + return + end + + local destination = input.get_user_input("Move to", { completion = "dir", prepend = selected }) + if (destination or "") == "" then + return + end + + assert(destination, "must have a destination") + local success = git.files.move(selected, destination) + + if not success then + notification.warn("Renaming failed") + end + + self:dispatch_refresh({ update_diffs = { "*:*" } }, "n_rename") + end) +end + +---@param self StatusBuffer +---@return fun(): nil M.n_untrack = function(self) return a.void(function() local selection = self.buffer.ui:get_selection() @@ -968,6 +1121,7 @@ M.n_untrack = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.v_untrack = function(self) return a.void(function() local selection = self.buffer.ui:get_selection() @@ -995,51 +1149,88 @@ M.v_untrack = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_stage = function(self) return a.void(function() local stagable = self.buffer.ui:get_hunk_or_filename_under_cursor() local section = self.buffer.ui:get_current_section() + local selection = self.buffer.ui:get_selection() if stagable and section then if section.options.section == "staged" then return end - if stagable.hunk then - local item = self.buffer.ui:get_item_under_cursor() - assert(item, "Item cannot be nil") - - if item.mode == "UU" then - notification.info("Conflicts must be resolved before staging hunks") + if selection.item and selection.item.mode == "UU" then + if config.check_integration("diffview") then + require("neogit.integrations.diffview").open("conflict", selection.item.name, { + on_close = { + handle = self.buffer.handle, + fn = function() + if not git.merge.is_conflicted(selection.item.name) then + git.status.stage { selection.item.name } + self:dispatch_refresh({ update_diffs = { "*:" .. selection.item.name } }, "n_stage") + end + end, + }, + }) + else + if not git.merge.is_conflicted(selection.item.name) then + git.status.stage { selection.item.name } + self:dispatch_refresh({ update_diffs = { "*:" .. selection.item.name } }, "n_stage") + else + notification.info("Conflicts must be resolved before staging") + end return end + elseif selection.item and section.options.section == "untracked" then + git.index.add { selection.item.name } + self:dispatch_refresh({ update_diffs = { "*:" .. selection.item.name } }, "n_stage") + elseif stagable.hunk then + local item = self.buffer.ui:get_item_under_cursor() + assert(item, "Item cannot be nil") - local patch = git.index.generate_patch(item, stagable.hunk, stagable.hunk.from, stagable.hunk.to) - + local patch = git.index.generate_patch(stagable.hunk) git.index.apply(patch, { cached = true }) - self:dispatch_refresh({ update_diffs = { "*:" .. item.escaped_path } }, "n_stage") - elseif stagable.filename then - if section.options.section == "unstaged" then - git.status.stage { stagable.filename } - self:dispatch_refresh({ update_diffs = { "*:" .. stagable.filename } }, "n_stage") - elseif section.options.section == "untracked" then - git.index.add { stagable.filename } - self:dispatch_refresh({ update_diffs = { "*:" .. stagable.filename } }, "n_stage") - end + self:dispatch_refresh({ update_diffs = { "*:" .. item.name } }, "n_stage") + elseif stagable.filename and section.options.section == "unstaged" then + git.status.stage { stagable.filename } + self:dispatch_refresh({ update_diffs = { "*:" .. stagable.filename } }, "n_stage") end elseif section then if section.options.section == "untracked" then git.status.stage_untracked() self:dispatch_refresh({ update_diffs = { "untracked:*" } }, "n_stage") elseif section.options.section == "unstaged" then - git.status.stage_modified() - self:dispatch_refresh({ update_diffs = { "*:*" } }, "n_stage") + if git.status.any_unmerged() then + if config.check_integration("diffview") then + require("neogit.integrations.diffview").open("conflict", nil, { + on_close = { + handle = self.buffer.handle, + fn = function() + if not git.merge.any_conflicted() then + git.status.stage_modified() + self:dispatch_refresh({ update_diffs = { "*:*" } }, "n_stage") + popups.open("merge")() + end + end, + }, + }) + else + notification.info("Conflicts must be resolved before staging") + return + end + else + git.status.stage_modified() + self:dispatch_refresh({ update_diffs = { "*:*" } }, "n_stage") + end end end end) end ---@param self StatusBuffer +---@return fun(): nil M.n_stage_all = function(self) return a.void(function() git.status.stage_all() @@ -1048,6 +1239,7 @@ M.n_stage_all = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_stage_unstaged = function(self) return a.void(function() git.status.stage_modified() @@ -1056,9 +1248,11 @@ M.n_stage_unstaged = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_unstage = function(self) return a.void(function() local unstagable = self.buffer.ui:get_hunk_or_filename_under_cursor() + local selection = self.buffer.ui:get_selection() local section = self.buffer.ui:get_current_section() if section and section.options.section ~= "staged" then @@ -1066,14 +1260,19 @@ M.n_unstage = function(self) end if unstagable then - if unstagable.hunk then + if selection.item and selection.item.mode == "N" then + git.status.unstage { selection.item.name } + self:dispatch_refresh({ update_diffs = { "*:" .. selection.item.name } }, "n_unstage") + elseif unstagable.hunk then local item = self.buffer.ui:get_item_under_cursor() assert(item, "Item cannot be nil") - local patch = - git.index.generate_patch(item, unstagable.hunk, unstagable.hunk.from, unstagable.hunk.to, true) + local patch = git.index.generate_patch( + unstagable.hunk, + { from = unstagable.hunk.from, to = unstagable.hunk.to, reverse = true } + ) git.index.apply(patch, { cached = true, reverse = true }) - self:dispatch_refresh({ update_diffs = { "*:" .. item.escaped_path } }, "n_unstage") + self:dispatch_refresh({ update_diffs = { "*:" .. item.name } }, "n_unstage") elseif unstagable.filename then git.status.unstage { unstagable.filename } self:dispatch_refresh({ update_diffs = { "*:" .. unstagable.filename } }, "n_unstage") @@ -1086,6 +1285,7 @@ M.n_unstage = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_unstage_staged = function(self) return a.void(function() git.status.unstage_all() @@ -1094,6 +1294,7 @@ M.n_unstage_staged = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_goto_file = function(self) return function() local item = self.buffer.ui:get_item_under_cursor() @@ -1115,6 +1316,7 @@ M.n_goto_file = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_tab_open = function(self) return function() local item = self.buffer.ui:get_item_under_cursor() @@ -1126,6 +1328,7 @@ M.n_tab_open = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_split_open = function(self) return function() local item = self.buffer.ui:get_item_under_cursor() @@ -1137,6 +1340,7 @@ M.n_split_open = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_vertical_split_open = function(self) return function() local item = self.buffer.ui:get_item_under_cursor() @@ -1148,6 +1352,7 @@ M.n_vertical_split_open = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_branch_popup = function(self) return popups.open("branch", function(p) p { commits = { self.buffer.ui:get_commit_under_cursor() } } @@ -1155,6 +1360,7 @@ M.n_branch_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_bisect_popup = function(self) return popups.open("bisect", function(p) p { commits = { self.buffer.ui:get_commit_under_cursor() } } @@ -1162,6 +1368,7 @@ M.n_bisect_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_cherry_pick_popup = function(self) return popups.open("cherry_pick", function(p) p { commits = { self.buffer.ui:get_commit_under_cursor() } } @@ -1169,6 +1376,7 @@ M.n_cherry_pick_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_commit_popup = function(self) return popups.open("commit", function(p) p { commit = self.buffer.ui:get_commit_under_cursor() } @@ -1176,6 +1384,7 @@ M.n_commit_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_merge_popup = function(self) return popups.open("merge", function(p) p { commit = self.buffer.ui:get_commit_under_cursor() } @@ -1183,6 +1392,7 @@ M.n_merge_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_push_popup = function(self) return popups.open("push", function(p) p { commit = self.buffer.ui:get_commit_under_cursor() } @@ -1190,6 +1400,7 @@ M.n_push_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_rebase_popup = function(self) return popups.open("rebase", function(p) p { commit = self.buffer.ui:get_commit_under_cursor() } @@ -1197,6 +1408,7 @@ M.n_rebase_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_revert_popup = function(self) return popups.open("revert", function(p) p { commits = { self.buffer.ui:get_commit_under_cursor() } } @@ -1204,6 +1416,7 @@ M.n_revert_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_reset_popup = function(self) return popups.open("reset", function(p) p { commit = self.buffer.ui:get_commit_under_cursor() } @@ -1211,6 +1424,7 @@ M.n_reset_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_tag_popup = function(self) return popups.open("tag", function(p) p { commit = self.buffer.ui:get_commit_under_cursor() } @@ -1218,6 +1432,7 @@ M.n_tag_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_stash_popup = function(self) return popups.open("stash", function(p) local stash = self.buffer.ui:get_yankable_under_cursor() @@ -1226,6 +1441,7 @@ M.n_stash_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_diff_popup = function(self) return popups.open("diff", function(p) local section = self.buffer.ui:get_selection().section @@ -1238,24 +1454,27 @@ M.n_diff_popup = function(self) end ---@param self StatusBuffer +---@return fun(): nil M.n_ignore_popup = function(self) return popups.open("ignore", function(p) local path = self.buffer.ui:get_hunk_or_filename_under_cursor() p { paths = { path and path.escaped_path }, - git_root = git.repo.git_root, + worktree_root = git.repo.worktree_root, } end) end ---@param self StatusBuffer +---@return fun(): nil M.n_help_popup = function(self) return popups.open("help", function(p) -- Since any other popup can be launched from help, build an ENV for any of them. local path = self.buffer.ui:get_hunk_or_filename_under_cursor() local section = self.buffer.ui:get_selection().section + local section_name if section then - section = section.name + section_name = section.name end local item = self.buffer.ui:get_yankable_under_cursor() @@ -1275,14 +1494,15 @@ M.n_help_popup = function(self) bisect = { commits = commits }, reset = { commit = commit }, tag = { commit = commit }, + margin = { buffer = self }, stash = { name = stash and stash:match("^stash@{%d+}") }, diff = { - section = { name = section }, + section = { name = section_name }, item = { name = item }, }, ignore = { paths = { path and path.escaped_path }, - git_root = git.repo.git_root, + worktree_root = git.repo.worktree_root, }, remote = {}, fetch = {}, @@ -1294,50 +1514,80 @@ M.n_help_popup = function(self) end ---@param _self StatusBuffer +---@return fun(): nil M.n_remote_popup = function(_self) return popups.open("remote") end ---@param _self StatusBuffer +---@return fun(): nil M.n_fetch_popup = function(_self) return popups.open("fetch") end ---@param _self StatusBuffer +---@return fun(): nil M.n_pull_popup = function(_self) return popups.open("pull") end ---@param _self StatusBuffer +---@return fun(): nil M.n_log_popup = function(_self) return popups.open("log") end +---@param self StatusBuffer +---@return fun(): nil +M.n_margin_popup = function(self) + return popups.open("margin", function(p) + p { buffer = self } + end) +end + ---@param _self StatusBuffer +---@return fun(): nil M.n_worktree_popup = function(_self) return popups.open("worktree") end ----@param _self StatusBuffer -M.n_open_tree = function(_self) +---@param self StatusBuffer +---@return fun(): nil +M.n_open_tree = function(self) return a.void(function() - local template = "https://${host}/$%7Bowner%7D/$%7Brepository%7D/tree/$%7Bbranch_name%7D" + if not vim.ui.open then + notification.warn("Requires Neovim >= 0.10") + return + end - local url = git.remote.get_url(https://github.com/git.branch.upstream_remote())[1] - local format_values = git.remote.parse(url) - format_values["branch_name"] = git.branch.current() + local commit = self.buffer.ui:get_commit_under_cursor() + local branch = git.branch.current() + local url - vim.ui.open(util.format(template, format_values)) + if commit then + url = git.remote.commit_url(https://github.com/commit) + elseif branch then + url = git.remote.tree_url(https://github.com/branch) + end + + if url then + notification.info(("Opening %q in your browser."):format(url)) + vim.ui.open(url) + else + notification.warn("Couldn't determine commit URL to open") + end end) end ---@param self StatusBuffer|nil +---@return fun(): nil M.n_command = function(self) local process = require("neogit.process") local runner = require("neogit.runner") return a.void(function() - local cmd = input.get_user_input(("Run command in %s"):format(git.repo.git_root), { prepend = "git " }) + local cmd = + input.get_user_input(("Run command in %s"):format(git.repo.worktree_root), { prepend = "git " }) if not cmd then return end @@ -1348,7 +1598,7 @@ M.n_command = function(self) local proc = process.new { cmd = cmd, - cwd = git.repo.git_root, + cwd = git.repo.worktree_root, env = {}, on_error = function() return false @@ -1360,18 +1610,46 @@ M.n_command = function(self) proc:show_console() - runner.call( - proc, - { - pty = true, - callback = function() - if self then - self:dispatch_refresh() - end + runner.call(proc, { + pty = true, + callback = function() + if self then + self:dispatch_refresh() end - } - ) + end, + }) end) end +---@param self StatusBuffer +---@return fun(): nil +M.n_next_section = function(self) + return function() + local section = self.buffer.ui:get_current_section() + if section then + local position = section.position.row_end + 2 + self.buffer:move_cursor(position) + else + self.buffer:move_cursor(self.buffer.ui:first_section().first + 1) + end + end +end + +---@param self StatusBuffer +---@return fun(): nil +M.n_prev_section = function(self) + return function() + local section = self.buffer.ui:get_current_section() + if section then + local prev_section = self.buffer.ui:get_current_section(section.position.row_start - 1) + if prev_section then + self.buffer:move_cursor(prev_section.position.row_start + 1) + return + end + end + + self.buffer:win_exec("norm! gg") + end +end + return M diff --git a/lua/neogit/buffers/status/init.lua b/lua/neogit/buffers/status/init.lua index 983a8db50..387d7d23f 100644 --- a/lua/neogit/buffers/status/init.lua +++ b/lua/neogit/buffers/status/init.lua @@ -6,8 +6,7 @@ local git = require("neogit.lib.git") local Watcher = require("neogit.watcher") local a = require("plenary.async") local logger = require("neogit.logger") -- TODO: Add logging - -local api = vim.api +local event = require("neogit.lib.event") ---@class Semaphore ---@field permits number @@ -79,7 +78,12 @@ function M:_action(name) return action(self) end ----@param kind string<"floating" | "split" | "tab" | "split" | "vsplit">|nil +---@param kind nil|string +---| "'floating'" +---| "'split'" +---| "'tab'" +---| "'split'" +---| "'vsplit'" ---@return StatusBuffer function M:open(kind) if self.buffer and self.buffer:is_visible() then @@ -97,7 +101,9 @@ function M:open(kind) context_highlight = not config.values.disable_context_highlighting, kind = kind or config.values.kind or "tab", disable_line_numbers = config.values.disable_line_numbers, + disable_relative_line_numbers = config.values.disable_relative_line_numbers, foldmarkers = not config.values.disable_signs, + active_item_highlight = true, on_detach = function() Watcher.instance(self.root):unregister(self) @@ -121,6 +127,7 @@ function M:open(kind) [popups.mapping_for("HelpPopup")] = self:_action("v_help_popup"), [popups.mapping_for("IgnorePopup")] = self:_action("v_ignore_popup"), [popups.mapping_for("LogPopup")] = self:_action("v_log_popup"), + [popups.mapping_for("MarginPopup")] = self:_action("v_margin_popup"), [popups.mapping_for("MergePopup")] = self:_action("v_merge_popup"), [popups.mapping_for("PullPopup")] = self:_action("v_pull_popup"), [popups.mapping_for("PushPopup")] = self:_action("v_push_popup"), @@ -138,7 +145,10 @@ function M:open(kind) [mappings["MoveDown"]] = self:_action("n_down"), [mappings["MoveUp"]] = self:_action("n_up"), [mappings["Untrack"]] = self:_action("n_untrack"), + [mappings["Rename"]] = self:_action("n_rename"), [mappings["Toggle"]] = self:_action("n_toggle"), + [mappings["OpenFold"]] = self:_action("n_open_fold"), + [mappings["CloseFold"]] = self:_action("n_close_fold"), [mappings["Close"]] = self:_action("n_close"), [mappings["OpenOrScrollDown"]] = self:_action("n_open_or_scroll_down"), [mappings["OpenOrScrollUp"]] = self:_action("n_open_or_scroll_up"), @@ -163,6 +173,8 @@ function M:open(kind) [mappings["TabOpen"]] = self:_action("n_tab_open"), [mappings["SplitOpen"]] = self:_action("n_split_open"), [mappings["VSplitOpen"]] = self:_action("n_vertical_split_open"), + [mappings["NextSection"]] = self:_action("n_next_section"), + [mappings["PreviousSection"]] = self:_action("n_prev_section"), [popups.mapping_for("BisectPopup")] = self:_action("n_bisect_popup"), [popups.mapping_for("BranchPopup")] = self:_action("n_branch_popup"), [popups.mapping_for("CherryPickPopup")] = self:_action("n_cherry_pick_popup"), @@ -172,6 +184,7 @@ function M:open(kind) [popups.mapping_for("HelpPopup")] = self:_action("n_help_popup"), [popups.mapping_for("IgnorePopup")] = self:_action("n_ignore_popup"), [popups.mapping_for("LogPopup")] = self:_action("n_log_popup"), + [popups.mapping_for("MarginPopup")] = self:_action("n_margin_popup"), [popups.mapping_for("MergePopup")] = self:_action("n_merge_popup"), [popups.mapping_for("PullPopup")] = self:_action("n_pull_popup"), [popups.mapping_for("PushPopup")] = self:_action("n_push_popup"), @@ -203,27 +216,13 @@ function M:open(kind) buffer:move_cursor(buffer.ui:first_section().first) end, user_autocmds = { - ["NeogitPushComplete"] = function() - self:dispatch_refresh(nil, "push_complete") - end, - ["NeogitPullComplete"] = function() - self:dispatch_refresh(nil, "pull_complete") - end, - ["NeogitFetchComplete"] = function() - self:dispatch_refresh(nil, "fetch_complete") - end, - ["NeogitRebase"] = function() - self:dispatch_refresh(nil, "rebase") - end, - ["NeogitMerge"] = function() - self:dispatch_refresh(nil, "merge") - end, - ["NeogitReset"] = function() - self:dispatch_refresh(nil, "reset_complete") - end, - ["NeogitStash"] = function() - self:dispatch_refresh(nil, "stash") - end, + -- Resetting doesn't yield the correct repo state instantly, so we need to re-refresh after a few seconds + -- in order to show the user the correct state. + ["NeogitReset"] = self:deferred_refresh("reset"), + ["NeogitBranchReset"] = self:deferred_refresh("reset_branch"), + }, + autocmds = { + ["FocusGained"] = self:deferred_refresh("focused", 10), }, } @@ -243,15 +242,19 @@ function M:close() end function M:chdir(dir) - local destination = require("plenary.path").new(dir) + local Path = require("plenary.path") + + local destination = Path:new(dir) vim.wait(5000, function() return destination:exists() end) - logger.debug("[STATUS] Changing Dir: " .. dir) - vim.api.nvim_set_current_dir(dir) - self.cwd = dir - self:dispatch_reset() + vim.schedule(function() + logger.debug("[STATUS] Changing Dir: " .. dir) + vim.api.nvim_set_current_dir(dir) + require("neogit.lib.git.repository").instance(dir) + self.new(config.values, git.repo.worktree_root, dir):open("replace"):dispatch_refresh() + end) end function M:focus() @@ -276,7 +279,7 @@ function M:refresh(partial, reason) partial = partial, callback = function() self:redraw(cursor, view) - api.nvim_exec_autocmds("User", { pattern = "NeogitStatusRefreshed", modeline = false }) + event.send("StatusRefreshed") logger.info("[STATUS] Refresh complete") end, } @@ -293,18 +296,18 @@ function M:redraw(cursor, view) logger.debug("[STATUS] Rendering UI") self.buffer.ui:render(unpack(ui.Status(git.repo.state, self.config))) - if self.fold_state then + if self.fold_state and self.buffer then logger.debug("[STATUS] Restoring fold state") self.buffer.ui:set_fold_state(self.fold_state) self.fold_state = nil end - if self.cursor_state and self.view_state then + if self.cursor_state and self.view_state and self.buffer then logger.debug("[STATUS] Restoring cursor and view state") self.buffer:restore_view(self.view_state, self.cursor_state) self.view_state = nil self.cursor_state = nil - elseif cursor and view then + elseif cursor and view and self.buffer then self.buffer:restore_view(view, self.buffer.ui:resolve_cursor_location(cursor)) end end @@ -313,6 +316,17 @@ M.dispatch_refresh = a.void(function(self, partial, reason) self:refresh(partial, reason) end) +---@param reason string +---@param wait number? timeout in ms, or 2 seconds +---@return fun() +function M:deferred_refresh(reason, wait) + return function() + vim.defer_fn(function() + self:dispatch_refresh(nil, reason) + end, wait or 2000) + end +end + function M:reset() logger.debug("[STATUS] Resetting repo and refreshing - CWD: " .. vim.uv.cwd()) git.repo:reset() diff --git a/lua/neogit/buffers/status/ui.lua b/lua/neogit/buffers/status/ui.lua index 92cb04e82..0452bdeea 100755 --- a/lua/neogit/buffers/status/ui.lua +++ b/lua/neogit/buffers/status/ui.lua @@ -2,7 +2,9 @@ local Ui = require("neogit.lib.ui") local Component = require("neogit.lib.ui.component") local util = require("neogit.lib.util") local common = require("neogit.buffers.common") +local config = require("neogit.config") local a = require("plenary.async") +local state = require("neogit.lib.state") local col = Ui.col local row = Ui.row @@ -235,8 +237,10 @@ local SectionItemFile = function(section, config) end end - this:append(DiffHunks(diff)) - ui:update() + ui.buf:with_locked_viewport(function() + this:append(DiffHunks(diff)) + ui:update() + end) end) end @@ -273,12 +277,27 @@ local SectionItemFile = function(section, config) text.highlight("NeogitSubtleText")((" %s -> %s"):format(item.file_mode.head, item.file_mode.worktree)) end + local submodule = text("") + if item.submodule then + local submodule_text + if item.submodule.commit_changed then + submodule_text = " (new commits)" + elseif item.submodule.has_tracked_changes then + submodule_text = " (modified content)" + elseif item.submodule.has_untracked_changes then + submodule_text = " (untracked content)" + end + + submodule = text.highlight("NeogitTagName")(submodule_text) + end + return col.tag("Item")({ row { text.highlight(highlight)(mode_text), text(name), text.highlight("NeogitSubtleText")(unmerged_types[item.mode] or ""), file_mode_change, + submodule, }, }, { foldable = true, @@ -299,14 +318,14 @@ local SectionItemStash = Component.new(function(item) text.highlight("NeogitSubtleText")(name), text.highlight("NeogitSubtleText")(": "), text(item.message), - }, { yankable = name, item = item }) + }, { yankable = item.oid, item = item }) end) local SectionItemCommit = Component.new(function(item) local ref = {} local ref_last = {} - if item.commit.ref_name ~= "" then + if item.commit.ref_name ~= "" and state.get({ "NeogitMarginPopup", "decorate" }, true) then -- Render local only branches first for name, _ in pairs(item.decoration.locals) do if name:match("^refs/") then @@ -342,6 +361,79 @@ local SectionItemCommit = Component.new(function(item) end end + local virtual_text + + -- Render author and date in margin, if visible + if state.get({ "margin", "visibility" }, false) then + local margin_date_style = state.get({ "margin", "date_style" }, 1) + local details = state.get({ "margin", "details" }, false) + + local date + local rel_date + local date_width = 10 + local clamp_width = 30 -- to avoid having too much space when relative date is short + + -- Render date + if item.commit.rel_date:match(" years?,") then + rel_date, _ = item.commit.rel_date:gsub(" years?,", "y") + rel_date = rel_date .. " " + elseif item.commit.rel_date:match("^%d ") then + rel_date = " " .. item.commit.rel_date + else + rel_date = item.commit.rel_date + end + + if margin_date_style == 1 then -- relative date (short) + local unpacked = vim.split(rel_date, " ") + + -- above, we added a space if the rel_date started with a single number + -- we get the last two elements to deal with that + local date_number = unpacked[#unpacked - 1] + local date_quantifier = unpacked[#unpacked] + if date_quantifier:match("months?") then + date_quantifier = date_quantifier:gsub("m", "M") -- to distinguish from minutes + end + + -- add back the space if we have a single number + local left_pad + if #unpacked > 2 then + left_pad = " " + else + left_pad = "" + end + + date = left_pad .. date_number .. date_quantifier:sub(1, 1) + date_width = 3 + clamp_width = 23 + elseif margin_date_style == 2 then -- relative date (long) + date = rel_date + date_width = 10 + else -- local iso date + if config.values.log_date_format == nil then + -- we get the unix date to be able to convert the date to the local timezone + date = os.date("%Y-%m-%d %H:%M", item.commit.unix_date) + date_width = 16 -- TODO: what should the width be here? + else + date = item.commit.log_date + date_width = 16 + end + end + + local author_table = { "" } + if details then + author_table = { + util.str_clamp(item.commit.author_name, clamp_width - (#date > date_width and #date or date_width)), + "NeogitGraphAuthor", + } + end + + virtual_text = { + { " ", "Constant" }, + author_table, + { util.str_min_width(date, date_width), "Special" }, + } + end + return row( util.merge( { text.highlight("NeogitObjectId")(item.commit.abbreviated_commit) }, @@ -350,7 +442,12 @@ local SectionItemCommit = Component.new(function(item) ref_last, { text(item.commit.subject) } ), - { oid = item.commit.oid, yankable = item.commit.oid, item = item } + { + virtual_text = virtual_text, + oid = item.commit.oid, + yankable = item.commit.oid, + item = item, + } ) end) @@ -687,6 +784,7 @@ function M.Status(state, config) }, } end + -- stylua: ignore end return M diff --git a/lua/neogit/client.lua b/lua/neogit/client.lua index 735d8d36b..177bbba0e 100644 --- a/lua/neogit/client.lua +++ b/lua/neogit/client.lua @@ -64,13 +64,7 @@ function M.client(opts) local client = fn.serverstart() logger.debug(("[CLIENT] Client address: %s"):format(client)) - local lua_cmd = - fmt('lua require("neogit.client").editor("%s", "%s", %s)', file_target, client, opts.show_diff) - - if vim.loop.os_uname().sysname == "Windows_NT" then - lua_cmd = lua_cmd:gsub("\\", "/") - end - + local lua_cmd = fmt('lua require("neogit.client").editor(%q, %q, %s)', file_target, client, opts.show_diff) local rpc_server = RPC.create_connection(nvim_server) rpc_server:send_cmd(lua_cmd) end @@ -102,9 +96,7 @@ function M.editor(target, client, show_diff) kind = config.values.commit_editor.kind elseif target:find("MERGE_MSG$") then kind = config.values.merge_editor.kind - elseif target:find("TAG_EDITMSG$") then - kind = "popup" - elseif target:find("EDIT_DESCRIPTION$") then + elseif target:find("TAG_EDITMSG$") or target:find("EDIT_DESCRIPTION$") then kind = "popup" elseif target:find("git%-rebase%-todo$") then kind = config.values.rebase_editor.kind @@ -152,7 +144,7 @@ function M.wrap(cmd, opts) a.util.scheduler() logger.debug("[CLIENT] DONE editor command") - if result.code == 0 then + if result:success() then if opts.msg.success then notification.info(opts.msg.success, { dismiss = true }) end diff --git a/lua/neogit/config.lua b/lua/neogit/config.lua index 01f8d8476..8b3c7fff0 100644 --- a/lua/neogit/config.lua +++ b/lua/neogit/config.lua @@ -62,6 +62,11 @@ function M.get_reversed_commit_editor_maps_I() return get_reversed_maps("commit_editor_I") end +---@return table +function M.get_reversed_refs_view_maps() + return get_reversed_maps("refs_view") +end + ---@param set string ---@return table function M.get_user_mappings(set) @@ -79,10 +84,17 @@ function M.get_user_mappings(set) end ---@alias WindowKind +---| "replace" Like :enew +---| "tab" Open in a new tab ---| "split" Open in a split +---| "split_above" Like :top split +---| "split_above_all" Like :top split +---| "split_below" Like :below split +---| "split_below_all" Like :below split ---| "vsplit" Open in a vertical split ---| "floating" Open in a floating window ----| "tab" Open in a new tab +---| "floating_console" Open in a floating window across the bottom of the screen +---| "auto" vsplit if window would have 80 cols, otherwise split ---@class NeogitCommitBufferConfig Commit buffer options ---@field kind WindowKind The type of window that should be opened @@ -91,6 +103,15 @@ end ---@class NeogitConfigPopup Popup window options ---@field kind WindowKind The type of window that should be opened +---@class NeogitConfigFloating +---@field relative? string +---@field width? number +---@field height? number +---@field col? number +---@field row? number +---@field style? string +---@field border? string + ---@alias StagedDiffSplitKind ---| "split" Open in a split ---| "vsplit" Open in a vertical split @@ -165,10 +186,14 @@ end ---| "Close" ---| "Next" ---| "Previous" +---| "CopySelection" ---| "MultiselectToggleNext" ---| "MultiselectTogglePrevious" ---| "InsertCompletion" ---| "NOP" +---| "ScrollWheelDown" +---| "ScrollWheelUp" +---| "MouseClick" ---| false ---@alias NeogitConfigMappingsStatus @@ -176,6 +201,7 @@ end ---| "MoveDown" ---| "MoveUp" ---| "OpenTree" +---| "OpenFold" ---| "Command" ---| "Depth1" ---| "Depth2" @@ -191,6 +217,7 @@ end ---| "Untrack" ---| "RefreshBuffer" ---| "GoToFile" +---| "PeekFile" ---| "VSplitOpen" ---| "SplitOpen" ---| "TabOpen" @@ -202,6 +229,10 @@ end ---| "YankSelected" ---| "OpenOrScrollUp" ---| "OpenOrScrollDown" +---| "PeekUp" +---| "PeekDown" +---| "NextSection" +---| "PreviousSection" ---| false ---| fun() @@ -214,6 +245,7 @@ end ---| "PushPopup" ---| "CommitPopup" ---| "LogPopup" +---| "MarginPopup" ---| "RevertPopup" ---| "StashPopup" ---| "IgnorePopup" @@ -268,10 +300,22 @@ end ---| "Abort" ---| false ---| fun() +--- +---@alias NeogitConfigMappingsRefsView +---| "DeleteBranch" +---| false +---| fun() ---@alias NeogitGraphStyle ---| "ascii" ---| "unicode" +---| "kitty" +--- +---@alias NeogitCommitOrder +---| "" +---| "topo" +---| "author-date" +---| "date" ---@class NeogitConfigStatusOptions ---@field recent_commit_count? integer The number of recent commits to display @@ -289,47 +333,61 @@ end ---@field rebase_editor_I? { [string]: NeogitConfigMappingsRebaseEditor_I } A dictionary that uses Rebase editor commands to set a single keybind ---@field commit_editor? { [string]: NeogitConfigMappingsCommitEditor } A dictionary that uses Commit editor commands to set a single keybind ---@field commit_editor_I? { [string]: NeogitConfigMappingsCommitEditor_I } A dictionary that uses Commit editor commands to set a single keybind +---@field refs_view? { [string]: NeogitConfigMappingsRefsView } A dictionary that uses Refs view editor commands to set a single keybind + +---@class NeogitConfigGitService +---@field pull_request? string +---@field commit? string +---@field tree? string ---@class NeogitConfig Neogit configuration settings ---@field filewatcher? NeogitFilewatcherConfig Values for filewatcher ---@field graph_style? NeogitGraphStyle Style for graph +---@field commit_date_format? string Commit date format +---@field log_date_format? string Log date format ---@field disable_hint? boolean Remove the top hint in the Status buffer ---@field disable_context_highlighting? boolean Disable context highlights based on cursor position ---@field disable_signs? boolean Special signs to draw for sections etc. in Neogit ----@field git_services? table Templartes to use when opening a pull request for a branch +---@field prompt_force_push? boolean Offer to force push when branches diverge +---@field git_services? NeogitConfigGitService[] Templates to use when opening a pull request for a branch, or commit ---@field fetch_after_checkout? boolean Perform a fetch if the newly checked out branch has an upstream or pushRemote set ---@field telescope_sorter? function The sorter telescope will use +---@field process_spinner? boolean Hide/Show the process spinner ---@field disable_insert_on_commit? boolean|"auto" Disable automatically entering insert mode in commit dialogues ---@field use_per_project_settings? boolean Scope persisted settings on a per-project basis ---@field remember_settings? boolean Whether neogit should persist flags from popups, e.g. git push flags ---@field sort_branches? string Value used for `--sort` for the `git branch` command +---@field commit_order? NeogitCommitOrder Value used for `---order` for the `git log` command +---@field initial_branch_name? string Default for new branch name prompts ---@field kind? WindowKind The default type of window neogit should open in +---@field floating? NeogitConfigFloating The floating window style ---@field disable_line_numbers? boolean Whether to disable line numbers ---@field disable_relative_line_numbers? boolean Whether to disable line numbers ---@field console_timeout? integer Time in milliseconds after a console is created for long running commands ---@field auto_show_console? boolean Automatically show the console if a command takes longer than console_timeout +---@field auto_show_console_on? string Specify "output" (show always; default) or "error" if `auto_show_console` enabled ---@field auto_close_console? boolean Automatically hide the console if the process exits with a 0 status ---@field status? NeogitConfigStatusOptions Status buffer options ---@field commit_editor? NeogitCommitEditorConfigPopup Commit editor options ---@field commit_select_view? NeogitConfigPopup Commit select view options +---@field stash? NeogitConfigPopup Commit select view options ---@field commit_view? NeogitCommitBufferConfig Commit buffer options ---@field log_view? NeogitConfigPopup Log view options ---@field rebase_editor? NeogitConfigPopup Rebase editor options ---@field reflog_view? NeogitConfigPopup Reflog view options ---@field refs_view? NeogitConfigPopup Refs view options ---@field merge_editor? NeogitConfigPopup Merge editor options ----@field description_editor? NeogitConfigPopup Merge editor options ----@field tag_editor? NeogitConfigPopup Tag editor options ---@field preview_buffer? NeogitConfigPopup Preview options ---@field popup? NeogitConfigPopup Set the default way of opening popups ---@field signs? NeogitConfigSigns Signs used for toggled regions ----@field integrations? { diffview: boolean, telescope: boolean, fzf_lua: boolean, mini_pick: boolean } Which integrations to enable +---@field integrations? { diffview: boolean, telescope: boolean, fzf_lua: boolean, mini_pick: boolean, snacks: boolean } Which integrations to enable ---@field sections? NeogitConfigSections ---@field ignored_settings? string[] Settings to never persist, format: "Filetype--cli-value", i.e. "NeogitCommitPopup--author" ---@field mappings? NeogitConfigMappings ---@field notification_icon? string ---@field use_default_keymaps? boolean ---@field highlight? HighlightOptions +---@field builders? { [string]: fun(builder: PopupBuilder) } ---Returns the default Neogit configuration ---@return NeogitConfig @@ -339,7 +397,11 @@ function M.get_default_values() disable_hint = false, disable_context_highlighting = false, disable_signs = false, + prompt_force_push = true, graph_style = "ascii", + commit_date_format = nil, + log_date_format = nil, + process_spinner = false, filewatcher = { enabled = true, }, @@ -347,10 +409,26 @@ function M.get_default_values() return nil end, git_services = { - ["github.com"] = "https://github.com/$%7Bowner%7D/$%7Brepository%7D/compare/$%7Bbranch_name%7D?expand=1", - ["bitbucket.org"] = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/pull-requests/new?source=${branch_name}&t=1", - ["gitlab.com"] = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/merge_requests/new?merge_request[source_branch]=${branch_name}", - ["azure.com"] = "https://dev.azure.com/$%7Bowner%7D/_git/$%7Brepository%7D/pullrequestcreate?sourceRef=${branch_name}&targetRef=${target}", + ["github.com"] = { + pull_request = "https://github.com/$%7Bowner%7D/$%7Brepository%7D/compare/$%7Bbranch_name%7D?expand=1", + commit = "https://github.com/$%7Bowner%7D/$%7Brepository%7D/commit/$%7Boid%7D", + tree = "https://${host}/$%7Bowner%7D/$%7Brepository%7D/tree/$%7Bbranch_name%7D", + }, + ["bitbucket.org"] = { + pull_request = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/pull-requests/new?source=${branch_name}&t=1", + commit = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/commits/$%7Boid%7D", + tree = "https://bitbucket.org/$%7Bowner%7D/$%7Brepository%7D/branch/$%7Bbranch_name%7D", + }, + ["gitlab.com"] = { + pull_request = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/merge_requests/new?merge_request[source_branch]=${branch_name}", + commit = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/-/commit/$%7Boid%7D", + tree = "https://gitlab.com/$%7Bowner%7D/$%7Brepository%7D/-/tree/$%7Bbranch_name%7D?ref_type=heads", + }, + ["azure.com"] = { + pull_request = "https://dev.azure.com/$%7Bowner%7D/_git/$%7Brepository%7D/pullrequestcreate?sourceRef=${branch_name}&targetRef=${target}", + commit = "", + tree = "", + }, }, highlight = {}, disable_insert_on_commit = "auto", @@ -358,13 +436,25 @@ function M.get_default_values() remember_settings = true, fetch_after_checkout = false, sort_branches = "-committerdate", + commit_order = "topo", kind = "tab", + floating = { + relative = "editor", + width = 0.8, + height = 0.7, + style = "minimal", + border = "rounded", + }, + initial_branch_name = "", disable_line_numbers = true, disable_relative_line_numbers = true, -- The time after which an output console is shown for slow running commands console_timeout = 2000, -- Automatically show console if a command takes more than console_timeout milliseconds auto_show_console = true, + -- If `auto_show_console` is enabled, specify "output" (default) to show + -- the console always, or "error" to auto-show the console only on error + auto_show_console_on = "output", auto_close_console = true, notification_icon = "󰊢", status = { @@ -381,6 +471,7 @@ function M.get_default_values() C = "copied", U = "updated", R = "renamed", + T = "changed", DD = "unmerged", AU = "unmerged", UD = "unmerged", @@ -416,12 +507,6 @@ function M.get_default_values() merge_editor = { kind = "auto", }, - description_editor = { - kind = "auto", - }, - tag_editor = { - kind = "auto", - }, preview_buffer = { kind = "floating_console", }, @@ -444,6 +529,7 @@ function M.get_default_values() diffview = nil, fzf_lua = nil, mini_pick = nil, + snacks = nil, }, sections = { sequencer = { @@ -495,12 +581,7 @@ function M.get_default_values() hidden = false, }, }, - ignored_settings = { - "NeogitPushPopup--force-with-lease", - "NeogitPushPopup--force", - "NeogitPullPopup--rebase", - "NeogitCommitPopup--allow-empty", - }, + ignored_settings = {}, mappings = { commit_editor = { ["q"] = "Close", @@ -545,6 +626,7 @@ function M.get_default_values() [""] = "Next", [""] = "Previous", [""] = "InsertCompletion", + [""] = "CopySelection", [""] = "MultiselectToggleNext", [""] = "MultiselectTogglePrevious", [""] = "NOP", @@ -555,6 +637,9 @@ function M.get_default_values() [""] = "MouseClick", ["<2-LeftMouse>"] = "NOP", }, + refs_view = { + ["x"] = "DeleteBranch", + }, popup = { ["?"] = "HelpPopup", ["A"] = "CherryPickPopup", @@ -571,6 +656,7 @@ function M.get_default_values() ["c"] = "CommitPopup", ["f"] = "FetchPopup", ["l"] = "LogPopup", + ["L"] = "MarginPopup", ["m"] = "MergePopup", ["p"] = "PullPopup", ["r"] = "RebasePopup", @@ -588,12 +674,18 @@ function M.get_default_values() ["4"] = "Depth4", ["Q"] = "Command", [""] = "Toggle", + ["za"] = "Toggle", + ["zo"] = "OpenFold", + ["zc"] = "CloseFold", + ["zC"] = "Depth1", + ["zO"] = "Depth4", ["x"] = "Discard", ["s"] = "Stage", ["S"] = "StageUnstaged", [""] = "StageAll", ["u"] = "Unstage", ["K"] = "Untrack", + ["R"] = "Rename", ["U"] = "UnstageStaged", ["y"] = "ShowRefs", ["$"] = "CommandHistory", @@ -610,6 +702,8 @@ function M.get_default_values() ["]c"] = "OpenOrScrollDown", [""] = "PeekUp", [""] = "PeekDown", + [""] = "NextSection", + [""] = "PreviousSection", }, }, } @@ -746,7 +840,7 @@ function M.validate_config() end local function validate_integrations() - local valid_integrations = { "diffview", "telescope", "fzf_lua", "mini_pick" } + local valid_integrations = { "diffview", "telescope", "fzf_lua", "mini_pick", "snacks" } if not validate_type(config.integrations, "integrations", "table") or #config.integrations == 0 then return end @@ -1087,12 +1181,21 @@ function M.validate_config() validate_type(config.use_per_project_settings, "use_per_project_settings", "boolean") validate_type(config.remember_settings, "remember_settings", "boolean") validate_type(config.sort_branches, "sort_branches", "string") + validate_type(config.initial_branch_name, "initial_branch_name", "string") validate_type(config.notification_icon, "notification_icon", "string") validate_type(config.console_timeout, "console_timeout", "number") validate_kind(config.kind, "kind") + if validate_type(config.floating, "floating", "table") then + validate_type(config.floating.relative, "relative", "string") + validate_type(config.floating.width, "width", "number") + validate_type(config.floating.height, "height", "number") + validate_type(config.floating.style, "style", "string") + validate_type(config.floating.border, "border", "string") + end validate_type(config.disable_line_numbers, "disable_line_numbers", "boolean") validate_type(config.disable_relative_line_numbers, "disable_relative_line_numbers", "boolean") validate_type(config.auto_show_console, "auto_show_console", "boolean") + validate_type(config.auto_show_console_on, "auto_show_console_on", "string") validate_type(config.auto_close_console, "auto_close_console", "boolean") if validate_type(config.status, "status", "table") then validate_type(config.status.show_head_commit_hash, "status.show_head_commit_hash", "boolean") @@ -1146,6 +1249,15 @@ function M.validate_config() validate_kind(config.popup.kind, "popup.kind") end + if validate_type(config.git_services, "git_services", "table") then + for k, v in pairs(config.git_services) do + validate_type(v, "git_services." .. k, "table") + validate_type(v.pull_request, "git_services." .. k .. ".pull_request", "string") + validate_type(v.commit, "git_services." .. k .. ".commit", "string") + validate_type(v.tree, "git_services." .. k .. ".tree", "string") + end + end + validate_integrations() validate_sections() validate_ignored_settings() @@ -1178,7 +1290,14 @@ function M.setup(opts) end if opts.use_default_keymaps == false then - M.values.mappings = { status = {}, popup = {}, finder = {}, commit_editor = {}, rebase_editor = {} } + M.values.mappings = { + status = {}, + popup = {}, + finder = {}, + commit_editor = {}, + rebase_editor = {}, + refs_view = {}, + } else -- Clear our any "false" user mappings from defaults for section, maps in pairs(opts.mappings or {}) do diff --git a/lua/neogit/integrations/diffview.lua b/lua/neogit/integrations/diffview.lua index 1fc7dfe58..d586f6a95 100644 --- a/lua/neogit/integrations/diffview.lua +++ b/lua/neogit/integrations/diffview.lua @@ -1,7 +1,5 @@ local M = {} -local dv = require("diffview") -local dv_config = require("diffview.config") local Rev = require("diffview.vcs.adapters.git.rev").GitRev local RevType = require("diffview.vcs.rev").RevType local CDiffView = require("diffview.api.views.diff.diff_view").CDiffView @@ -12,34 +10,35 @@ local Watcher = require("neogit.watcher") local git = require("neogit.lib.git") local a = require("plenary.async") -local old_config - -local function close() - vim.cmd("tabclose") - Watcher.instance():dispatch_refresh() - dv.setup(old_config) -end - local function get_local_diff_view(section_name, item_name, opts) local left = Rev(RevType.STAGE) local right = Rev(RevType.LOCAL) - if section_name == "unstaged" then - section_name = "working" - end - local function update_files() local files = {} - local sections = { - conflicting = { - items = vim.tbl_filter(function(o) - return o.mode and o.mode:sub(2, 2) == "U" - end, git.repo.state.untracked.items), - }, - working = git.repo.state.unstaged, - staged = git.repo.state.staged, - } + local sections = {} + + -- all conflict modes (but I don't know how to generate UA/AU) + local conflict_modes = { "UU", "UD", "DU", "AA", "UA", "AU" } + + -- merge section gets both + if section_name == "unstaged" or section_name == "merge" then + sections.conflicting = { + items = vim.tbl_filter(function(item) + return vim.tbl_contains(conflict_modes, item.mode) and item + end, git.repo.state.unstaged.items), + } + sections.working = { + items = vim.tbl_filter(function(item) + return not vim.tbl_contains(conflict_modes, item.mode) and item + end, git.repo.state.unstaged.items), + } + end + + if section_name == "staged" or section_name == "merge" then + sections.staged = git.repo.state.staged + end for kind, section in pairs(sections) do files[kind] = {} @@ -58,7 +57,7 @@ local function get_local_diff_view(section_name, item_name, opts) } if opts.only then - if (item_name and file.selected) or (not item_name and section_name == kind) then + if not item_name or (item_name and file.selected) then table.insert(files[kind], file) end else @@ -73,7 +72,7 @@ local function get_local_diff_view(section_name, item_name, opts) local files = update_files() local view = CDiffView { - git_root = git.repo.git_root, + git_root = git.repo.worktree_root, left = left, right = right, files = files, @@ -103,60 +102,50 @@ local function get_local_diff_view(section_name, item_name, opts) return view end +---@param section_name string +---@param item_name string|string[]|nil +---@param opts table|nil function M.open(section_name, item_name, opts) opts = opts or {} - old_config = vim.deepcopy(dv_config.get_config()) - - local config = dv_config.get_config() - - local keymaps = { - view = { - ["q"] = close, - [""] = close, - }, - file_panel = { - ["q"] = close, - [""] = close, - }, - } - for key, keymap in pairs(keymaps) do - config.keymaps[key] = dv_config.extend_keymaps(keymap, config.keymaps[key] or {}) + -- Hack way to do an on-close callback + if opts.on_close then + vim.api.nvim_create_autocmd({ "BufEnter" }, { + buffer = opts.on_close.handle, + once = true, + callback = opts.on_close.fn, + }) end - dv.setup(config) - local view - - if section_name == "recent" or section_name == "unmerged" or section_name == "log" then + -- selene: allow(if_same_then_else) + if + (section_name == "recent" or section_name == "log" or (section_name and section_name:match("unmerged$"))) + and item_name + then local range if type(item_name) == "table" then range = string.format("%s..%s", item_name[1], item_name[#item_name]) - elseif item_name ~= nil then - range = string.format("%s^!", item_name:match("[a-f0-9]+")) else - return + range = string.format("%s^!", item_name:match("[a-f0-9]+")) end view = dv_lib.diffview_open(dv_utils.tbl_pack(range)) - elseif section_name == "range" then - local range = item_name - view = dv_lib.diffview_open(dv_utils.tbl_pack(range)) - elseif section_name == "stashes" then - -- TODO: Fix when no item name - local stash_id = item_name:match("stash@{%d+}") - view = dv_lib.diffview_open(dv_utils.tbl_pack(stash_id .. "^!")) - elseif section_name == "commit" then + elseif section_name == "range" and item_name then + view = dv_lib.diffview_open(dv_utils.tbl_pack(item_name)) + elseif (section_name == "stashes" or section_name == "commit") and item_name then view = dv_lib.diffview_open(dv_utils.tbl_pack(item_name .. "^!")) + elseif section_name == "conflict" and item_name then + view = dv_lib.diffview_open(dv_utils.tbl_pack("--selected-file=" .. item_name)) + elseif (section_name == "conflict" or section_name == "worktree") and not item_name then + view = dv_lib.diffview_open() elseif section_name ~= nil then + -- for staged, unstaged, merge view = get_local_diff_view(section_name, item_name, opts) + elseif section_name == nil and item_name ~= nil then + view = dv_lib.diffview_open(dv_utils.tbl_pack(item_name .. "^!")) else - -- selene: allow(if_same_then_else) - if item_name ~= nil then - view = dv_lib.diffview_open(dv_utils.tbl_pack(item_name .. "^!")) - else - view = dv_lib.diffview_open() - end + view = dv_lib.diffview_open() end if view then diff --git a/lua/neogit/lib/buffer.lua b/lua/neogit/lib/buffer.lua index e902869c9..2c567d25d 100644 --- a/lua/neogit/lib/buffer.lua +++ b/lua/neogit/lib/buffer.lua @@ -5,23 +5,21 @@ local util = require("neogit.lib.util") local signs = require("neogit.lib.signs") local Ui = require("neogit.lib.ui") +local config = require("neogit.config") local Path = require("plenary.path") ---@class Buffer ---@field handle number ---@field win_handle number +---@field header_win_handle number? ---@field namespaces table ---@field autocmd_group number ---@field ui Ui ---@field kind string ---@field name string ----@field disable_line_numbers boolean ----@field disable_relative_line_numbers boolean local Buffer = { kind = "split", - disable_line_numbers = true, - disable_relative_line_numbers = true, } Buffer.__index = Buffer @@ -79,6 +77,13 @@ function Buffer:clear() api.nvim_buf_set_lines(self.handle, 0, -1, false, {}) end +---@param fn fun() +function Buffer:with_locked_viewport(fn) + local view = self:save_view() + self:call(fn) + self:restore_view(view) +end + ---@return table function Buffer:save_view() local view = fn.winsaveview() @@ -91,11 +96,13 @@ end ---@param view table output of Buffer:save_view() ---@param cursor? number function Buffer:restore_view(view, cursor) - if cursor then - view.lnum = math.min(fn.line("$"), cursor) - end + self:win_call(function() + if cursor then + view.lnum = math.min(fn.line("$"), cursor) + end - fn.winrestview(view) + fn.winrestview(view) + end) end function Buffer:write() @@ -158,7 +165,7 @@ function Buffer:set_text(first_line, last_line, first_col, last_col, lines) api.nvim_buf_set_text(self.handle, first_line, first_col, last_line, last_col, lines) end ----@param line nil|number|number[] +---@param line nil|integer|integer[] function Buffer:move_cursor(line) if not line or not self:is_focused() then return @@ -180,7 +187,7 @@ function Buffer:move_top_line(line) return end - if vim.o.lines < vim.fn.line("$") then + if vim.o.lines < fn.line("$") then return end @@ -203,13 +210,25 @@ function Buffer:close(force) force = false end + if self.header_win_handle ~= nil then + api.nvim_win_close(self.header_win_handle, true) + end + if self.kind == "replace" then + if self.old_cwd then + api.nvim_set_current_dir(self.old_cwd) + self.old_cwd = nil + end + api.nvim_buf_delete(self.handle, { force = force }) return end if self.kind == "tab" then local ok, _ = pcall(vim.cmd, "tabclose") + if not ok and #api.nvim_list_tabpages() == 1 then + ok, _ = pcall(vim.cmd, "bd! " .. self.handle) + end if not ok then vim.cmd("tab sb " .. self.handle) vim.cmd("tabclose #") @@ -239,6 +258,11 @@ function Buffer:hide() vim.cmd("silent! 1only") vim.cmd("try | tabn # | catch /.*/ | tabp | endtry") elseif self.kind == "replace" then + if self.old_cwd then + api.nvim_set_current_dir(self.old_cwd) + self.old_cwd = nil + end + if self.old_buf and api.nvim_buf_is_loaded(self.old_buf) then api.nvim_set_current_buf(self.old_buf) self.old_buf = nil @@ -273,101 +297,105 @@ function Buffer:show() end end - local win - local kind = self.kind - - if kind == "replace" then - self.old_buf = api.nvim_get_current_buf() - api.nvim_set_current_buf(self.handle) - win = api.nvim_get_current_win() - elseif kind == "tab" then - vim.cmd("tab sb " .. self.handle) - win = api.nvim_get_current_win() - elseif kind == "split" or kind == "split_below" then - win = api.nvim_open_win(self.handle, true, { split = "below" }) - elseif kind == "split_above" then - win = api.nvim_open_win(self.handle, true, { split = "above" }) - elseif kind == "split_above_all" then - win = api.nvim_open_win(self.handle, true, { split = "above", win = -1 }) - elseif kind == "split_below_all" then - win = api.nvim_open_win(self.handle, true, { split = "below", win = -1 }) - elseif kind == "vsplit" then - win = api.nvim_open_win(self.handle, true, { split = "right", vertical = true }) - elseif kind == "vsplit_left" then - win = api.nvim_open_win(self.handle, true, { split = "left", vertical = true }) - elseif kind == "floating" then - -- Creates the border window - local vim_height = vim.o.lines - local vim_width = vim.o.columns - - local width = math.floor(vim_width * 0.8) + 3 - local height = math.floor(vim_height * 0.7) - local col = vim_width * 0.1 - 1 - local row = vim_height * 0.15 - - local content_window = api.nvim_open_win(self.handle, true, { - relative = "editor", - width = width, - height = height, - col = col, - row = row, - style = "minimal", - focusable = false, - border = "rounded", - }) - - api.nvim_win_set_cursor(content_window, { 1, 0 }) - win = content_window - elseif kind == "floating_console" then - local content_window = api.nvim_open_win(self.handle, true, { - anchor = "SW", - relative = "editor", - width = vim.o.columns, - height = math.floor(vim.o.lines * 0.3), - col = 0, - row = vim.o.lines - 2, - style = "minimal", - focusable = false, - border = { "─", "─", "─", "", "", "", "", "" }, - title = " Git Console ", - }) - - api.nvim_win_set_cursor(content_window, { 1, 0 }) - win = content_window - elseif kind == "popup" then - -- local title, _ = self.name:gsub("^Neogit", ""):gsub("Popup$", "") - - local content_window = api.nvim_open_win(self.handle, true, { - anchor = "SW", - relative = "editor", - width = vim.o.columns, - height = math.floor(vim.o.lines * 0.3), - col = 0, - row = vim.o.lines - 2, - style = "minimal", - border = { "─", "─", "─", "", "", "", "", "" }, - -- title = (" %s Actions "):format(title), - -- title_pos = "center", - }) + ---@return integer window handle + local function open() + local win + if self.kind == "replace" then + self.old_buf = api.nvim_get_current_buf() + self.old_cwd = vim.uv.cwd() + api.nvim_set_current_buf(self.handle) + win = api.nvim_get_current_win() + elseif self.kind == "tab" then + vim.cmd("tab sb " .. self.handle) + win = api.nvim_get_current_win() + elseif self.kind == "split" or self.kind == "split_below" then + win = api.nvim_open_win(self.handle, true, { split = "below" }) + elseif self.kind == "split_above" then + win = api.nvim_open_win(self.handle, true, { split = "above" }) + elseif self.kind == "split_above_all" then + win = api.nvim_open_win(self.handle, true, { split = "above", win = -1 }) + elseif self.kind == "split_below_all" then + win = api.nvim_open_win(self.handle, true, { split = "below", win = -1 }) + elseif self.kind == "vsplit" then + win = api.nvim_open_win(self.handle, true, { split = "right", vertical = true }) + elseif self.kind == "vsplit_left" then + win = api.nvim_open_win(self.handle, true, { split = "left", vertical = true }) + elseif self.kind == "floating" then + local width = config.values.floating.width + local height = config.values.floating.height + local vim_height = vim.o.lines + local vim_width = vim.o.columns + width = width > 1 and width or math.floor(vim_width * width) + height = height > 1 and height or math.floor(vim_height * height) + + local content_window = api.nvim_open_win(self.handle, true, { + width = width, + height = height, + relative = config.values.floating.relative, + border = config.values.floating.border, + style = config.values.floating.style, + col = config.values.floating.col or (vim_width - width) / 2, + row = config.values.floating.row or (vim_height - height) / 2, + focusable = true, + }) + + api.nvim_win_set_cursor(content_window, { 1, 0 }) + win = content_window + elseif self.kind == "floating_console" then + local content_window = api.nvim_open_win(self.handle, true, { + anchor = "SW", + relative = "editor", + width = vim.o.columns, + height = math.floor(vim.o.lines * 0.3), + col = 0, + -- buffer_height - cmdline - statusline + row = vim.o.lines - vim.o.cmdheight - (vim.o.laststatus > 0 and 1 or 0), + style = "minimal", + focusable = true, + border = { "─", "─", "─", "", "", "", "", "" }, + title = " Git Console ", + }) + + api.nvim_win_set_cursor(content_window, { 1, 0 }) + win = content_window + elseif self.kind == "popup" then + -- local title, _ = self.name:gsub("^Neogit", ""):gsub("Popup$", "") + + local content_window = api.nvim_open_win(self.handle, true, { + anchor = "SW", + relative = "editor", + width = vim.o.columns, + height = math.floor(vim.o.lines * 0.3), + col = 0, + -- buffer_height - cmdline - statusline + row = vim.o.lines - vim.o.cmdheight - (vim.o.laststatus > 0 and 1 or 0), + style = "minimal", + border = { "─", "─", "─", "", "", "", "", "" }, + -- title = (" %s Actions "):format(title), + -- title_pos = "center", + }) + + api.nvim_win_set_cursor(content_window, { 1, 0 }) + win = content_window + end - api.nvim_win_set_cursor(content_window, { 1, 0 }) - win = content_window + return win end - api.nvim_win_call(win, function() - if self.disable_line_numbers then - vim.cmd("setlocal nonu") - end - - if self.disable_relative_line_numbers then - vim.cmd("setlocal nornu") - end - end) + -- With focus on a popup window, any kind of "split" buffer will crash. Floating windows cannot be split. + local ok, win = pcall(open) + if not ok then + self.kind = "floating" + win = open() + end -- Workaround UFO getting folds wrong. - local ufo, _ = pcall(require, "ufo") - if ufo then - require("ufo").detach(self.handle) + if package.loaded["ufo"] then + local ok, ufo = pcall(require, "ufo") + if ok and type(ufo.detach) == "function" then + logger.debug("[BUFFER:" .. self.handle .. "] Disabling UFO for buffer") + ufo.detach(self.handle) + end end self.win_handle = win @@ -405,13 +433,18 @@ end function Buffer:set_buffer_option(name, value) if self.handle ~= nil then - api.nvim_set_option_value(name, value, { buf = self.handle }) + -- TODO: Remove this at some point. Nvim 0.10 throws an error if using both buf and scope + if vim.fn.has("nvim-0.11") == 1 then + api.nvim_set_option_value(name, value, { scope = "local", buf = self.handle }) + else + api.nvim_set_option_value(name, value, { buf = self.handle }) + end end end function Buffer:set_window_option(name, value) if self.win_handle ~= nil then - api.nvim_set_option_value(name, value, { win = self.win_handle }) + api.nvim_set_option_value(name, value, { scope = "local", win = self.win_handle }) end end @@ -508,14 +541,36 @@ function Buffer:call(f, ...) end function Buffer:win_call(f, ...) - local args = { ... } - api.nvim_win_call(self.win_handle, function() - f(unpack(args)) - end) + if self.win_handle and api.nvim_win_is_valid(self.win_handle) then + local args = { ... } + api.nvim_win_call(self.win_handle, function() + f(unpack(args)) + end) + end end function Buffer:chan_send(data) - api.nvim_chan_send(api.nvim_open_term(self.handle, {}), data) + assert(self.chan, "Terminal channel not open") + assert(data, "data cannot be nil") + api.nvim_chan_send(self.chan, data) +end + +function Buffer:open_terminal_channel() + assert(self.chan == nil, "Terminal channel already open") + + self.chan = api.nvim_open_term(self.handle, {}) + assert(self.chan > 0, "Failed to open terminal channel") + + self:unlock() + self:set_lines(0, -1, false, {}) + self:lock() +end + +function Buffer:close_terminal_channel() + assert(self.chan, "No terminal channel to close") + + fn.chanclose(self.chan) + self.chan = nil end function Buffer:win_exec(cmd) @@ -541,6 +596,14 @@ function Buffer:line_count() return api.nvim_buf_line_count(self.handle) end +function Buffer:resize_header() + if not self.header_win_handle then + return + end + + api.nvim_win_set_width(self.header_win_handle, fn.winwidth(self.win_handle)) +end + ---@param text string ---@param scroll boolean function Buffer:set_header(text, scroll) @@ -561,18 +624,21 @@ function Buffer:set_header(text, scroll) -- Display the buffer in a floating window local winid = api.nvim_open_win(buf, false, { relative = "win", - width = vim.o.columns, + win = self.win_handle, + width = fn.winwidth(self.win_handle), height = 1, row = 0, col = 0, focusable = false, style = "minimal", noautocmd = true, + border = "none", }) vim.wo[winid].wrap = false vim.wo[winid].winhl = "NormalFloat:NeogitFloatHeader" fn.matchadd("NeogitFloatHeaderHighlight", [[\v\|\]], 100, -1, { window = winid }) + self.header_win_handle = winid if scroll then -- Log view doesn't need scroll because the top line is blank... Because it can't be a fold or the view doesn't work. @@ -581,6 +647,15 @@ function Buffer:set_header(text, scroll) vim.api.nvim_feedkeys(keys, "n", false) end) end + + -- Ensure the header only covers the intended window. + api.nvim_create_autocmd("WinResized", { + callback = function() + self:resize_header() + end, + buffer = self.handle, + group = self.autocmd_group, + }) end ---@class BufferConfig @@ -595,6 +670,7 @@ end ---@field status_column string|nil ---@field load boolean|nil ---@field context_highlight boolean|nil +---@field active_item_highlight boolean|nil ---@field open boolean|nil ---@field disable_line_numbers boolean|nil ---@field disable_relative_line_numbers boolean|nil @@ -622,9 +698,6 @@ function Buffer.create(config) buffer.name = config.name buffer.kind = config.kind or "split" - buffer.disable_line_numbers = (config.disable_line_numbers == nil) or config.disable_line_numbers - buffer.disable_relative_line_numbers = (config.disable_relative_line_numbers == nil) - or config.disable_relative_line_numbers if config.load then logger.debug("[BUFFER:" .. buffer.handle .. "] Loading content from file: " .. config.name) @@ -634,11 +707,12 @@ function Buffer.create(config) local win if config.open ~= false then win = buffer:show() - logger.debug("[BUFFER:" .. buffer.handle .. "] Showing buffer in window " .. win) + logger.debug("[BUFFER:" .. buffer.handle .. "] Showing buffer in window " .. win .. " as " .. buffer.kind) end logger.debug("[BUFFER:" .. buffer.handle .. "] Setting buffer options") buffer:set_buffer_option("swapfile", false) + buffer:set_buffer_option("modeline", false) buffer:set_buffer_option("bufhidden", config.bufhidden or "wipe") buffer:set_buffer_option("modifiable", config.modifiable or false) buffer:set_buffer_option("modified", config.modifiable or false) @@ -653,6 +727,11 @@ function Buffer.create(config) buffer:set_filetype(config.filetype) end + if config.status_column then + buffer:set_window_option("statuscolumn", config.status_column) + buffer:set_window_option("signcolumn", "no") + end + if config.user_mappings then logger.debug("[BUFFER:" .. buffer.handle .. "] Building user key-mappings") @@ -695,6 +774,7 @@ function Buffer.create(config) buffer:set_window_option("foldlevel", 99) buffer:set_window_option("foldminlines", 0) buffer:set_window_option("foldtext", "") + buffer:set_window_option("foldcolumn", "0") buffer:set_window_option("listchars", "") buffer:set_window_option("list", false) buffer:call(function() @@ -705,6 +785,14 @@ function Buffer.create(config) vim.opt_local.fillchars:append("fold: ") end) + if (config.disable_line_numbers == nil) or config.disable_line_numbers then + buffer:set_window_option("number", false) + end + + if (config.disable_relative_line_numbers == nil) or config.disable_relative_line_numbers then + buffer:set_window_option("relativenumber", false) + end + buffer:set_window_option("spell", config.spell_check or false) buffer:set_window_option("wrap", false) buffer:set_window_option("foldmethod", "manual") @@ -795,14 +883,41 @@ function Buffer.create(config) }) end - if config.status_column then - vim.opt_local.statuscolumn = config.status_column - vim.opt_local.signcolumn = "no" + if config.active_item_highlight then + logger.debug("[BUFFER:" .. buffer.handle .. "] Setting up active item decorations") + buffer:create_namespace("ActiveItem") + buffer:set_decorations("ActiveItem", { + on_start = function() + return buffer:exists() and buffer:is_valid() + end, + on_win = function() + buffer:clear_namespace("ActiveItem") + + local active_oid = require("neogit.buffers.commit_view").current_oid() + local item = buffer.ui:find_component_by_oid(active_oid) + if item and item.first and item.last then + for line = item.first, item.last do + buffer:add_line_highlight(line - 1, "NeogitActiveItem", { + priority = 200, + namespace = "ActiveItem", + }) + end + end + end, + }) + + -- The decoration provider will not quite update in time, leaving two lines highlighted unless we use an autocmd too + api.nvim_create_autocmd("WinLeave", { + buffer = buffer.handle, + group = buffer.autocmd_group, + callback = function() + buffer:clear_namespace("ActiveItem") + end, + }) end if config.foldmarkers then - vim.opt_local.foldcolumn = "0" - vim.opt_local.signcolumn = "auto" + buffer:set_window_option("signcolumn", "auto") logger.debug("[BUFFER:" .. buffer.handle .. "] Setting up foldmarkers") buffer:create_namespace("FoldSigns") diff --git a/lua/neogit/lib/color.lua b/lua/neogit/lib/color.lua index 6b7790541..c80fed2c4 100644 --- a/lua/neogit/lib/color.lua +++ b/lua/neogit/lib/color.lua @@ -97,6 +97,8 @@ function Color.from_hex(c) end end + assert(type(n) == "number", "must be a number") + return Color( bit.rshift(n, 24) / 0xff, bit.band(bit.rshift(n, 16), 0xff) / 0xff, diff --git a/lua/neogit/lib/event.lua b/lua/neogit/lib/event.lua new file mode 100644 index 000000000..95ae65481 --- /dev/null +++ b/lua/neogit/lib/event.lua @@ -0,0 +1,15 @@ +local M = {} + +---@param name string +---@param data table? +function M.send(name, data) + assert(name, "event must have name") + + vim.api.nvim_exec_autocmds("User", { + pattern = "Neogit" .. name, + modeline = false, + data = data, + }) +end + +return M diff --git a/lua/neogit/lib/finder.lua b/lua/neogit/lib/finder.lua index 03f3fedd1..8f7c31b17 100644 --- a/lua/neogit/lib/finder.lua +++ b/lua/neogit/lib/finder.lua @@ -9,6 +9,13 @@ local function refocus_status_buffer() end end +local copy_selection = function() + local selection = require("telescope.actions.state").get_selected_entry() + if selection ~= nil then + vim.cmd.let(("@+=%q"):format(selection[1])) + end +end + local function telescope_mappings(on_select, allow_multi, refocus_status) local action_state = require("telescope.actions.state") local actions = require("telescope.actions") @@ -85,6 +92,7 @@ local function telescope_mappings(on_select, allow_multi, refocus_status) ["InsertCompletion"] = completion_action, ["Next"] = actions.move_selection_next, ["Previous"] = actions.move_selection_previous, + ["CopySelection"] = copy_selection, ["NOP"] = actions.nop, ["MultiselectToggleNext"] = actions.toggle_selection + actions.move_selection_worse, ["MultiselectTogglePrevious"] = actions.toggle_selection + actions.move_selection_better, @@ -117,6 +125,7 @@ end --- Utility function to map actions ---@param on_select fun(item: any|nil) ---@param allow_multi boolean +---@param refocus_status boolean local function fzf_actions(on_select, allow_multi, refocus_status) local function refresh() if refocus_status then @@ -144,13 +153,80 @@ local function fzf_actions(on_select, allow_multi, refocus_status) } end +---Convert entries to snack picker items +---@param entries any[] +---@return any[] +local function entries_to_snack_items(entries) + local items = {} + for idx, entry in ipairs(entries) do + table.insert(items, { idx = idx, score = 0, text = entry }) + end + return items +end + +--- Utility function to map actions +---@param on_select fun(item: any|nil) +---@param allow_multi boolean +---@param refocus_status boolean +local function snacks_confirm(on_select, allow_multi, refocus_status) + local completed = false + local function complete(selection) + if completed then + return + end + on_select(selection) + completed = true + if refocus_status then + refocus_status_buffer() + end + end + local function confirm(picker, item) + local selection = {} + local picker_selected = picker:selected { fallback = true } + + if #picker_selected == 0 then + local prompt = picker:filter().pattern + table.insert(selection, prompt) + elseif #picker_selected > 1 then + for _, item in ipairs(picker_selected) do + table.insert(selection, item.text) + end + else + local entry = item.text + local prompt = picker:filter().pattern + + local navigate_up_level = entry == ".." and #prompt > 0 + local input_git_refspec = prompt:match("%^") + or prompt:match("~") + or prompt:match("@") + or prompt:match(":") + + table.insert(selection, (navigate_up_level or input_git_refspec) and prompt or entry) + end + + if selection and selection[1] and selection[1] ~= "" then + complete(allow_multi and selection or selection[1]) + picker:close() + end + end + + local function on_close() + complete(nil) + end + + return confirm, on_close +end + --- Utility function to map finder opts to fzf ---@param opts FinderOpts ---@return table local function fzf_opts(opts) local fzf_opts = {} - if not opts.allow_multi then + -- Allow multi by default + if opts.allow_multi then + fzf_opts["--multi"] = "" + else fzf_opts["--no-multi"] = "" end @@ -264,12 +340,31 @@ function Finder:find(on_select) fzf_opts = fzf_opts(self.opts), winopts = { height = self.opts.layout_config.height, + border = self.opts.border, + preview = { border = self.opts.border }, }, actions = fzf_actions(on_select, self.opts.allow_multi, self.opts.refocus_status), }) elseif config.check_integration("mini_pick") then local mini_pick = require("mini.pick") mini_pick.start { source = { items = self.entries, choose = on_select } } + elseif config.check_integration("snacks") then + local snacks_picker = require("snacks.picker") + local confirm, on_close = snacks_confirm(on_select, self.opts.allow_multi, self.opts.refocus_status) + snacks_picker.pick(nil, { + title = "Neogit", + prompt = string.format("%s > ", self.opts.prompt_prefix), + items = entries_to_snack_items(self.entries), + format = "text", + layout = { + preset = self.opts.theme, + preview = self.opts.previewer, + height = self.opts.layout_config.height, + border = self.opts.border and "rounded" or "none", + }, + confirm = confirm, + on_close = on_close, + }) else vim.ui.select(self.entries, { prompt = string.format("%s: ", self.opts.prompt_prefix), diff --git a/lua/neogit/lib/git/bisect.lua b/lua/neogit/lib/git/bisect.lua index 3542079d9..9aa9f8f50 100644 --- a/lua/neogit/lib/git/bisect.lua +++ b/lua/neogit/lib/git/bisect.lua @@ -1,23 +1,20 @@ local git = require("neogit.lib.git") +local event = require("neogit.lib.event") ---@class NeogitGitBisect local M = {} -local function fire_bisect_event(data) - vim.api.nvim_exec_autocmds("User", { pattern = "NeogitBisect", modeline = false, data = data }) -end - ---@param cmd string local function bisect(cmd) local result = git.cli.bisect.args(cmd).call { long = true } - if result.code == 0 then - fire_bisect_event { type = cmd } + if result:success() then + event.send("Bisect", { type = cmd }) end end function M.in_progress() - return git.repo:git_path("BISECT_LOG"):exists() + return git.repo:worktree_git_path("BISECT_LOG"):exists() end function M.is_finished() @@ -31,8 +28,8 @@ function M.start(bad_revision, good_revision, args) local result = git.cli.bisect.args("start").arg_list(args).args(bad_revision, good_revision).call { long = true } - if result.code == 0 then - fire_bisect_event { type = "start" } + if result:success() then + event.send("Bisect", { type = "start" }) end end @@ -74,13 +71,13 @@ M.register = function(meta) local finished - for line in git.repo:git_path("BISECT_LOG"):iter() do + for line in git.repo:worktree_git_path("BISECT_LOG"):iter() do if line:match("^#") and line ~= "" then local action, oid, subject = line:match("^# ([^:]+): %[(.+)%] (.+)") finished = action == "first bad commit" if finished then - fire_bisect_event { type = "finished", oid = oid } + event.send("Bisect", { type = "finished", oid = oid }) end ---@type BisectItem @@ -96,7 +93,7 @@ M.register = function(meta) end end - local expected = vim.trim(git.repo:git_path("BISECT_EXPECTED_REV"):read()) + local expected = vim.trim(git.repo:worktree_git_path("BISECT_EXPECTED_REV"):read()) state.bisect.current = git.log.parse(git.cli.show.format("fuller").args(expected).call({ trim = false }).stdout)[1] diff --git a/lua/neogit/lib/git/branch.lua b/lua/neogit/lib/git/branch.lua index 8dcd5920e..007d17ec7 100644 --- a/lua/neogit/lib/git/branch.lua +++ b/lua/neogit/lib/git/branch.lua @@ -7,6 +7,9 @@ local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") ---@class NeogitGitBranch local M = {} +---@param branches string[] +---@param include_current? boolean +---@return string[] local function parse_branches(branches, include_current) include_current = include_current or false local other_branches = {} @@ -37,6 +40,7 @@ local function parse_branches(branches, include_current) return other_branches end +---@return string[] function M.get_recent_local_branches() local valid_branches = M.get_local_branches() @@ -53,37 +57,76 @@ function M.get_recent_local_branches() return util.deduplicate(branches) end +---@param relation? string +---@param commit? string +---@param ... any +---@return string[] +function M.list_related_branches(relation, commit, ...) + local result = git.cli.branch.args(relation or "", commit or "", ...).call { hidden = true } + + local branches = {} + for _, branch in ipairs(result.stdout) do + branch = branch:match("^%s*(.-)%s*$") + if branch and not branch:match("^%(HEAD") and not branch:match("^HEAD ->") and branch ~= "" then + table.insert(branches, branch) + end + end + + return branches +end + +---@param commit string +---@return string[] +function M.list_containing_branches(commit, ...) + return M.list_related_branches("--contains", commit, ...) +end + +---@param name string +---@param args? string[] ---@return ProcessResult function M.checkout(name, args) return git.cli.checkout.branch(name).arg_list(args or {}).call { await = true } end +---@param name string +---@param args? string[] +---@return ProcessResult function M.track(name, args) - git.cli.checkout.track(name).arg_list(args or {}).call { await = true } + return git.cli.checkout.track(name).arg_list(args or {}).call { await = true } end +---@param include_current? boolean +---@return string[] function M.get_local_branches(include_current) - local branches = git.cli.branch.list(config.values.sort_branches).call({ hidden = true }).stdout + local branches = git.cli.branch.sort(config.values.sort_branches).call({ hidden = true }).stdout return parse_branches(branches, include_current) end +---@param include_current? boolean +---@return string[] function M.get_remote_branches(include_current) - local branches = git.cli.branch.remotes.list(config.values.sort_branches).call({ hidden = true }).stdout + local branches = git.cli.branch.remotes.sort(config.values.sort_branches).call({ hidden = true }).stdout return parse_branches(branches, include_current) end +---@param include_current? boolean +---@return string[] function M.get_all_branches(include_current) return util.merge(M.get_local_branches(include_current), M.get_remote_branches(include_current)) end +---@param branch string +---@param base? string +---@return boolean function M.is_unmerged(branch, base) return git.cli.cherry.arg_list({ base or M.base_branch(), branch }).call({ hidden = true }).stdout[1] ~= nil end +---@return string|nil function M.base_branch() local value = git.config.get("neogit.baseBranch") if value:is_set() then - return value:read() + return value:read() ---@type string else if M.exists("master") then return "master" @@ -101,17 +144,17 @@ function M.exists(branch) .args(string.format("refs/heads/%s", branch)) .call { hidden = true, ignore_error = true } - return result.code == 0 + return result:success() end ---Determine if a branch name ("origin/master", "fix/bug-1000", etc) ---is a remote branch or a local branch ---@param ref string ----@return nil|string remote +---@return string remote ---@return string branch function M.parse_remote_branch(ref) if M.exists(ref) then - return nil, ref + return ".", ref end return ref:match("^([^/]*)/(.*)$") @@ -119,10 +162,13 @@ end ---@param name string ---@param base_branch? string +---@return boolean function M.create(name, base_branch) - git.cli.branch.args(name, base_branch).call { await = true } + return git.cli.branch.args(name, base_branch).call({ await = true }):success() end +---@param name string +---@return boolean function M.delete(name) local input = require("neogit.lib.input") @@ -136,7 +182,7 @@ function M.delete(name) result = git.cli.branch.delete.name(name).call { await = true } end - return result and result.code == 0 or false + return result and result:success() or false end ---Returns current branch name, or nil if detached HEAD @@ -155,6 +201,7 @@ function M.current() end end +---@return string|nil function M.current_full_name() local current = M.current() if current then @@ -162,17 +209,21 @@ function M.current_full_name() end end +---@param branch? string +---@return string|nil function M.pushRemote(branch) branch = branch or M.current() if branch then - local remote = git.config.get("branch." .. branch .. ".pushRemote") + local remote = git.config.get_local("branch." .. branch .. ".pushRemote") if remote:is_set() then return remote.value end end end +---@param branch? string +---@return string|nil function M.pushRemote_ref(branch) branch = branch or M.current() local pushRemote = M.pushRemote(branch) @@ -182,14 +233,51 @@ function M.pushRemote_ref(branch) end end +---@return string|nil +function M.pushDefault() + local pushDefault = git.config.get("remote.pushDefault") + if pushDefault:is_set() then + return pushDefault:read() ---@type string + end +end + +---@param branch? string +---@return string|nil +function M.pushDefault_ref(branch) + branch = branch or M.current() + local pushDefault = M.pushDefault() + + if branch and pushDefault then + return string.format("%s/%s", pushDefault, branch) + end +end + +---@return string +function M.pushRemote_or_pushDefault_label() + local ref = M.pushRemote_ref() + if ref then + return ref + end + + local pushDefault = M.pushDefault() + if pushDefault then + return ("%s, creating it"):format(M.pushDefault_ref()) + end + + return "pushRemote, setting that" +end + +---@return string function M.pushRemote_label() return M.pushRemote_ref() or "pushRemote, setting that" end +---@return string function M.pushRemote_remote_label() return M.pushRemote() or "pushRemote, setting that" end +---@return boolean function M.is_detached() return git.repo.state.head.branch == "(detached)" end @@ -208,6 +296,8 @@ function M.set_pushRemote() pushRemote = FuzzyFinderBuffer.new(remotes):open_async { prompt_prefix = "set pushRemote" } end + assert(type(pushRemote) == "nil" or type(pushRemote) == "string", "pushRemote is not a string or nil?") + if pushRemote then git.config.set(string.format("branch.%s.pushRemote", M.current()), pushRemote) end @@ -221,12 +311,10 @@ end ---@return string|nil function M.upstream(name) if name then - local result = git.cli["rev-parse"].symbolic_full_name - .abbrev_ref() - .args(name .. "@{upstream}") - .call { ignore_error = true } + local result = + git.cli["rev-parse"].symbolic_full_name.abbrev_ref(name .. "@{upstream}").call { ignore_error = true } - if result.code == 0 then + if result:success() then return result.stdout[1] end else @@ -234,27 +322,34 @@ function M.upstream(name) end end +---@param name string +---@param destination string? +function M.set_upstream(name, destination) + git.cli.branch.set_upstream_to(name).args(destination or M.current()) +end + +---@return string function M.upstream_label() return M.upstream() or "@{upstream}, creating it" end +---@return string function M.upstream_remote_label() return M.upstream_remote() or "@{upstream}, setting it" end +---@return string|nil function M.upstream_remote() - local remote = git.repo.state.upstream.remote - - if not remote then - local remotes = git.remote.list() - if #remotes == 1 then - remote = remotes[1] - elseif vim.tbl_contains(remotes, "origin") then - remote = "origin" - end + if git.repo.state.upstream.remote then + return git.repo.state.upstream.remote end - return remote + local remotes = git.remote.list() + if #remotes == 1 then + return remotes[1] + elseif vim.tbl_contains(remotes, "origin") then + return "origin" + end end ---@return string[] @@ -344,7 +439,7 @@ local function update_branch_information(state) state.head.oid = status.oid state.head.detached = status.detached - if status.oid ~= INITIAL_COMMIT then + if status.oid and status.oid ~= INITIAL_COMMIT then state.head.abbrev = git.rev_parse.abbreviate_commit(status.oid) state.head.commit_message = git.log.message(status.oid) @@ -364,7 +459,7 @@ local function update_branch_information(state) local pushRemote = git.branch.pushRemote_ref() if pushRemote and not status.detached then - local remote, branch = unpack(vim.split(pushRemote, "/")) + local remote, branch = pushRemote:match("([^/]+)/(.+)") state.pushRemote.ref = pushRemote state.pushRemote.remote = remote state.pushRemote.branch = branch diff --git a/lua/neogit/lib/git/cherry_pick.lua b/lua/neogit/lib/git/cherry_pick.lua index f02c4405d..3d48e307e 100644 --- a/lua/neogit/lib/git/cherry_pick.lua +++ b/lua/neogit/lib/git/cherry_pick.lua @@ -2,14 +2,14 @@ local git = require("neogit.lib.git") local notification = require("neogit.lib.notification") local util = require("neogit.lib.util") local client = require("neogit.client") +local event = require("neogit.lib.event") ---@class NeogitGitCherryPick local M = {} -local function fire_cherrypick_event(data) - vim.api.nvim_exec_autocmds("User", { pattern = "NeogitCherryPick", modeline = false, data = data }) -end - +---@param commits string[] +---@param args string[] +---@return boolean function M.pick(commits, args) local cmd = git.cli["cherry-pick"].arg_list(util.merge(args, commits)) @@ -20,10 +20,12 @@ function M.pick(commits, args) result = cmd.call { await = true } end - if result.code ~= 0 then + if result:failure() then notification.error("Cherry Pick failed. Resolve conflicts before continuing") + return false else - fire_cherrypick_event { commits = commits } + event.send("CherryPick", { commits = commits }) + return true end end @@ -35,10 +37,67 @@ function M.apply(commits, args) end) local result = git.cli["cherry-pick"].no_commit.arg_list(util.merge(args, commits)).call { await = true } - if result.code ~= 0 then + if result:failure() then notification.error("Cherry Pick failed. Resolve conflicts before continuing") else - fire_cherrypick_event { commits = commits } + event.send("CherryPick", { commits = commits }) + end +end + +---@param commits string[] +---@param src? string +---@param dst string +---@param start? string +---@param checkout_dst? boolean +function M.move(commits, src, dst, args, start, checkout_dst) + local current = git.branch.current() + + if not git.branch.exists(dst) then + git.cli.branch.args(start or "", dst).call { hidden = true } + local upstream = git.branch.upstream(start) + if upstream then + git.branch.set_upstream(upstream, dst) + end + end + + if dst ~= current then + git.branch.checkout(dst) + end + + if not src then + return git.cherry_pick.pick(commits, args) + end + + local tip = commits[#commits] + local keep = commits[1] .. "^" + + if not git.cherry_pick.pick(commits, args) then + return + end + + if git.log.is_ancestor(src, tip) then + git.cli["update-ref"] + .message(string.format("reset: moving to %s", keep)) + .args(git.rev_parse.full_name(src), keep, tip) + .call() + + if not checkout_dst then + git.branch.checkout(src) + end + else + git.branch.checkout(src) + + local editor = "nvim -c '%g/^pick \\(" .. table.concat(commits, ".*|") .. ".*\\)/norm! dd/' -c 'wq'" + local result = + git.cli.rebase.interactive.args(keep).in_pty(true).env({ GIT_SEQUENCE_EDITOR = editor }).call() + + if result:failure() then + return notification.error("Picking failed - Fix things manually before continuing.") + end + + if checkout_dst then + git.branch.checkout(dst) + end end end diff --git a/lua/neogit/lib/git/cli.lua b/lua/neogit/lib/git/cli.lua index 6d34b502c..958f42c98 100644 --- a/lua/neogit/lib/git/cli.lua +++ b/lua/neogit/lib/git/cli.lua @@ -1,73 +1,395 @@ -local logger = require("neogit.logger") local git = require("neogit.lib.git") local process = require("neogit.process") local util = require("neogit.lib.util") local Path = require("plenary.path") local runner = require("neogit.runner") +---@class GitCommandSetup +---@field flags table|nil +---@field options table|nil +---@field aliases table|nil +---@field short_opts table|nil + ---@class GitCommand ---@field flags table ---@field options table ---@field aliases table ---@field short_opts table ----@field args fun(...): table ----@field arg_list fun(table): table + +---@class GitCommandBuilder +---@field args fun(...): self appends all params to cli as argument +---@field arguments fun(...): self alias for `args` +---@field arg_list fun(table): self unpacks table and uses items as cli arguments +---@field files fun(...): self any filepaths to append to the cli call +---@field paths fun(...): self alias for `files` +---@field input fun(string): self string to send to process via STDIN +---@field stdin fun(string): self alias for `input` +---@field prefix fun(string): self prefix for CLI call +---@field env fun(table): self key/value pairs to set as ENV variables for process +---@field in_pty fun(boolean): self should this be run in a PTY or not? +---@field call fun(CliCallOptions): ProcessResult + +---@class CliCallOptions +---@field hidden boolean Is the command hidden from user? +---@field trim boolean remove blank lines from output? +---@field remove_ansi boolean remove ansi escape-characters from output? +---@field await boolean run synchronously if true +---@field long boolean is the command expected to be long running? (like git bisect, commit, rebase, etc) +---@field pty boolean run command in PTY? +---@field on_error fun(res: ProcessResult): boolean function to call if the process exits with status > 0. Used to +--- determine how to handle the error, if user should be alerted or not + +---@class GitCommandShow: GitCommandBuilder +---@field stat self +---@field oneline self +---@field no_patch self +---@field format fun(string): self +---@field file fun(name: string, rev: string|nil): self + +---@class GitCommandNameRev: GitCommandBuilder +---@field name_only self +---@field no_undefined self +---@field refs fun(string): self +---@field exclude fun(string): self + +---@class GitCommandInit: GitCommandBuilder + +---@class GitCommandCheckoutIndex: GitCommandBuilder +---@field all self +---@field force self + +---@class GitCommandWorktree: GitCommandBuilder +---@field add self +---@field list self +---@field move self +---@field remove self + +---@class GitCommandRm: GitCommandBuilder +---@field cached self + +---@class GitCommandMove: GitCommandBuilder + +---@class GitCommandStatus: GitCommandBuilder +---@field short self +---@field branch self +---@field verbose self +---@field null_separated self +---@field porcelain fun(string): self + +---@class GitCommandLog: GitCommandBuilder +---@field oneline self +---@field branches self +---@field remotes self +---@field all self +---@field graph self +---@field color self +---@field pretty fun(string): self +---@field max_count fun(string): self +---@field format fun(string): self + +---@class GitCommandConfig: GitCommandBuilder +---@field _local self +---@field global self +---@field list self +---@field _get self PRIVATE - use alias +---@field _add self PRIVATE - use alias +---@field _unset self PRIVATE - use alias +---@field null self +---@field set fun(key: string, value: string): self +---@field unset fun(key: string): self +---@field get fun(path: string): self + +---@class GitCommandDescribe: GitCommandBuilder +---@field long self +---@field tags self + +---@class GitCommandDiff: GitCommandBuilder +---@field cached self +---@field stat self +---@field shortstat self +---@field patch self +---@field name_only self +---@field no_ext_diff self +---@field no_index self +---@field index self +---@field check self + +---@class GitCommandStash: GitCommandBuilder +---@field apply self +---@field drop self +---@field push self +---@field store self +---@field index self +---@field staged self +---@field keep_index self +---@field message fun(text: string): self + +---@class GitCommandTag: GitCommandBuilder +---@field n self +---@field list self +---@field delete self + +---@class GitCommandRebase: GitCommandBuilder +---@field interactive self +---@field onto self +---@field edit_todo self +---@field continue self +---@field abort self +---@field skip self +---@field autosquash self +---@field autostash self +---@field commit fun(rev: string): self + +---@class GitCommandMerge: GitCommandBuilder +---@field continue self +---@field abort self + +---@class GitCommandMergeBase: GitCommandBuilder +---@field is_ancestor self + +---@class GitCommandReset: GitCommandBuilder +---@field hard self +---@field mixed self +---@field soft self +---@field keep self +---@field merge self + +---@class GitCommandCheckout: GitCommandBuilder +---@field b fun(): self +---@field _track self PRIVATE - use alias +---@field detach self +---@field ours self +---@field theirs self +---@field merge self +---@field track fun(branch: string): self +---@field rev fun(rev: string): self +---@field branch fun(branch: string): self +---@field commit fun(commit: string): self +---@field new_branch fun(new_branch: string): self +---@field new_branch_with_start_point fun(branch: string, start_point: string): self + +---@class GitCommandRemote: GitCommandBuilder +---@field push self +---@field add self +---@field rm self +---@field rename self +---@field prune self +---@field get_url fun(remote: string): self + +---@class GitCommandRevert: GitCommandBuilder +---@field no_commit self +---@field no_edit self +---@field continue self +---@field skip self +---@field abort self + +---@class GitCommandApply: GitCommandBuilder +---@field ignore_space_change self +---@field cached self +---@field reverse self +---@field index self +---@field with_patch fun(string): self alias for input + +---@class GitCommandAdd: GitCommandBuilder +---@field update self +---@field all self + +---@class GitCommandAbsorb: GitCommandBuilder +---@field verbose self +---@field and_rebase self +---@field base fun(commit: string): self + +---@class GitCommandCommit: GitCommandBuilder +---@field all self +---@field no_verify self +---@field amend self +---@field only self +---@field dry_run self +---@field no_edit self +---@field edit self +---@field allow_empty self +---@field with_message fun(message: string): self Passes message via STDIN +---@field message fun(message: string): self Passes message via CLI + +---@class GitCommandPush: GitCommandBuilder +---@field delete self +---@field remote fun(remote: string): self +---@field to fun(to: string): self + +---@class GitCommandPull: GitCommandBuilder +---@field no_commit self + +---@class GitCommandCherry: GitCommandBuilder +---@field verbose self + +---@class GitCommandBranch: GitCommandBuilder +---@field all self +---@field delete self +---@field remotes self +---@field force self +---@field current self +---@field edit_description self +---@field very_verbose self +---@field move self +---@field sort fun(sort: string): self +---@field set_upstream_to fun(name: string): self +---@field name fun(name: string): self + +---@class GitCommandFetch: GitCommandBuilder +---@field recurse_submodules self +---@field verbose self +---@field jobs fun(n: number): self + +---@class GitCommandReadTree: GitCommandBuilder +---@field merge self +---@field index_output fun(path: string): self +---@field tree fun(tree: string): self + +---@class GitCommandWriteTree: GitCommandBuilder + +---@class GitCommandCommitTree: GitCommandBuilder +---@field no_gpg_sign self +---@field parent fun(parent: string): self +---@field message fun(message: string): self +---@field parents fun(...): self +---@field tree fun(tree: string): self + +---@class GitCommandUpdateIndex: GitCommandBuilder +---@field add self +---@field remove self +---@field refresh self + +---@class GitCommandShowRef: GitCommandBuilder +---@field verify self + +---@class GitCommandShowBranch: GitCommandBuilder +---@field all self + +---@class GitCommandReflog: GitCommandBuilder +---@field show self +---@field format fun(format: string): self +---@field date fun(mode: string): self + +---@class GitCommandUpdateRef: GitCommandBuilder +---@field create_reflog self +---@field message fun(text: string): self + +---@class GitCommandLsFiles: GitCommandBuilder +---@field others self +---@field deleted self +---@field modified self +---@field cached self +---@field deduplicate self +---@field exclude_standard self +---@field full_name self +---@field error_unmatch self + +---@class GitCommandLsTree: GitCommandBuilder +---@field full_tree self +---@field name_only self +---@field recursive self + +---@class GitCommandLsRemote: GitCommandBuilder +---@field tags self +---@field remote fun(remote: string): self + +---@class GitCommandForEachRef: GitCommandBuilder +---@field format self +---@field sort self + +---@class GitCommandRevList: GitCommandBuilder +---@field merges self +---@field parents self +---@field max_count fun(n: number): self + +---@class GitCommandRevParse: GitCommandBuilder +---@field verify self +---@field quiet self +---@field short self +---@field revs_only self +---@field no_revs self +---@field flags self +---@field no_flags self +---@field symbolic self +---@field symbolic_full_name self +---@field abbrev_ref fun(ref: string): self + +---@class GitCommandCherryPick: GitCommandBuilder +---@field no_commit self +---@field continue self +---@field skip self +---@field abort self + +---@class GitCommandVerifyCommit: GitCommandBuilder + +---@class GitCommandBisect: GitCommandBuilder ---@class NeogitGitCLI ----@field show GitCommand ----@field name-rev GitCommand ----@field init GitCommand ----@field checkout-index GitCommand ----@field worktree GitCommand ----@field rm GitCommand ----@field status GitCommand ----@field log GitCommand ----@field config GitCommand ----@field describe GitCommand ----@field diff GitCommand ----@field stash GitCommand ----@field tag GitCommand ----@field rebase GitCommand ----@field merge GitCommand ----@field merge-base GitCommand ----@field reset GitCommand ----@field checkout GitCommand ----@field remote GitCommand ----@field apply GitCommand ----@field add GitCommand ----@field absorb GitCommand ----@field commit GitCommand ----@field push GitCommand ----@field pull GitCommand ----@field cherry GitCommand ----@field branch GitCommand ----@field fetch GitCommand ----@field read-tree GitCommand ----@field write-tree GitCommand ----@field commit-tree GitCommand ----@field update-index GitCommand ----@field show-ref GitCommand ----@field show-branch GitCommand ----@field update-ref GitCommand ----@field ls-files GitCommand ----@field ls-tree GitCommand ----@field ls-remote GitCommand ----@field for-each-ref GitCommand ----@field rev-list GitCommand ----@field rev-parse GitCommand ----@field cherry-pick GitCommand ----@field verify-commit GitCommand ----@field bisect GitCommand ----@field git_root fun(dir: string):string +---@field absorb GitCommandAbsorb +---@field add GitCommandAdd +---@field apply GitCommandApply +---@field bisect GitCommandBisect +---@field branch GitCommandBranch +---@field checkout GitCommandCheckout +---@field checkout-index GitCommandCheckoutIndex +---@field cherry GitCommandCherry +---@field cherry-pick GitCommandCherryPick +---@field commit GitCommandCommit +---@field commit-tree GitCommandCommitTree +---@field config GitCommandConfig +---@field describe GitCommandDescribe +---@field diff GitCommandDiff +---@field fetch GitCommandFetch +---@field for-each-ref GitCommandForEachRef +---@field init GitCommandInit +---@field log GitCommandLog +---@field ls-files GitCommandLsFiles +---@field ls-remote GitCommandLsRemote +---@field ls-tree GitCommandLsTree +---@field merge GitCommandMerge +---@field merge-base GitCommandMergeBase +---@field name-rev GitCommandNameRev +---@field pull GitCommandPull +---@field push GitCommandPush +---@field read-tree GitCommandReadTree +---@field rebase GitCommandRebase +---@field reflog GitCommandReflog +---@field remote GitCommandRemote +---@field revert GitCommandRevert +---@field reset GitCommandReset +---@field rev-list GitCommandRevList +---@field rev-parse GitCommandRevParse +---@field rm GitCommandRm +---@field show GitCommandShow +---@field show-branch GitCommandShowBranch +---@field show-ref GitCommandShowRef +---@field stash GitCommandStash +---@field status GitCommandStatus +---@field tag GitCommandTag +---@field update-index GitCommandUpdateIndex +---@field update-ref GitCommandUpdateRef +---@field verify-commit GitCommandVerifyCommit +---@field worktree GitCommandWorktree +---@field write-tree GitCommandWriteTree +---@field mv GitCommandMove +---@field worktree_root fun(dir: string):string +---@field git_dir fun(dir: string):string +---@field worktree_git_dir fun(dir: string):string ---@field is_inside_worktree fun(dir: string):boolean +---@field history ProcessResult[] +---@param setup GitCommandSetup|nil +---@return GitCommand local function config(setup) setup = setup or {} - setup.flags = setup.flags or {} - setup.options = setup.options or {} - setup.aliases = setup.aliases or {} - setup.short_opts = setup.short_opts or {} - return setup + + local command = {} + command.flags = setup.flags or {} + command.options = setup.options or {} + command.aliases = setup.aliases or {} + command.short_opts = setup.short_opts or {} + + return command end local configurations = { @@ -150,13 +472,6 @@ local configurations = { max_count = "--max-count", format = "--format", }, - aliases = { - for_range = function(tbl) - return function(range) - return tbl.args(range) - end - end, - }, }, config = config { @@ -289,11 +604,14 @@ local configurations = { flags = { no_commit = "--no-commit", continue = "--continue", + no_edit = "--no-edit", skip = "--skip", abort = "--abort", }, }, + mv = config {}, + checkout = config { short_opts = { b = "-b", @@ -409,14 +727,11 @@ local configurations = { end end, message = function(tbl) - return function(text) - return tbl.args("-m", text) + return function(message) + return tbl.args("-m", message) end end, }, - options = { - commit_message_file = "--file", - }, }, push = config { @@ -441,9 +756,6 @@ local configurations = { flags = { no_commit = "--no-commit", }, - pull = config { - flags = {}, - }, }, cherry = config { @@ -463,12 +775,11 @@ local configurations = { very_verbose = "-vv", move = "-m", }, + options = { + sort = "--sort", + set_upstream_to = "--set-upstream-to", + }, aliases = { - list = function(tbl) - return function(sort) - return tbl.args("--sort=" .. sort) - end - end, name = function(tbl) return function(name) return tbl.args(name) @@ -478,16 +789,12 @@ local configurations = { }, fetch = config { - options = { + flags = { recurse_submodules = "--recurse-submodules", verbose = "--verbose", }, - aliases = { - jobs = function(tbl) - return function(n) - return tbl.args("--jobs=" .. tostring(n)) - end - end, + options = { + jobs = "--jobs", }, }, @@ -577,6 +884,7 @@ local configurations = { aliases = { message = function(tbl) return function(text) + -- TODO: Is this escapement needed? local escaped_text, _ = text:gsub([["]], [[\"]]) return tbl.args("-m", string.format([["%s"]], escaped_text)) end @@ -666,15 +974,34 @@ local configurations = { ["bisect"] = config {}, } ---- NOTE: Use require("neogit.lib.git").repo.git_root instead of calling this function. ---- repository.git_root is used by all other library functions, so it's most likely the one you want to use. ---- git_root_of_cwd() returns the git repo of the cwd, which can change anytime ---- after git_root_of_cwd() has been called. +--- NOTE: Use require("neogit.lib.git").repo.worktree_root instead of calling this function. +--- repository.worktree_root is used by all other library functions, so it's most likely the one you want to use. +--- worktree_root_of_cwd() returns the git repo of the cwd, which can change anytime +--- after worktree_root_of_cwd() has been called. ---@param dir string ----@return string -local function git_root(dir) +---@return string Absolute path of current worktree +local function worktree_root(dir) local cmd = { "git", "-C", dir, "rev-parse", "--show-toplevel" } local result = vim.system(cmd, { text = true }):wait() + + return Path:new(vim.trim(result.stdout)):absolute() +end + +---@param dir string +---@return string Absolute path of `.git/` directory +local function git_dir(dir) + local cmd = { "git", "-C", dir, "rev-parse", "--git-common-dir" } + local result = vim.system(cmd, { text = true }):wait() + + return Path:new(vim.trim(result.stdout)):absolute() +end + +---@param dir string +---@return string Absolute path of `.git/` directory +local function worktree_git_dir(dir) + local cmd = { "git", "-C", dir, "rev-parse", "--git-dir" } + local result = vim.system(cmd, { text = true }):wait() + return Path:new(vim.trim(result.stdout)):absolute() end @@ -683,6 +1010,7 @@ end local function is_inside_worktree(dir) local cmd = { "git", "-C", dir, "rev-parse", "--is-inside-work-tree" } local result = vim.system(cmd):wait() + return result.code == 0 end @@ -846,6 +1174,7 @@ local function new_builder(subcommand) "--no-optional-locks", "-c", "core.preloadindex=true", "-c", "color.ui=always", + "-c", "diff.noprefix=false", subcommand }, cmd @@ -853,7 +1182,7 @@ local function new_builder(subcommand) return process.new { cmd = cmd, - cwd = git.repo.git_root, + cwd = git.repo.worktree_root, env = state.env, input = state.input, on_error = opts.on_error, @@ -864,6 +1193,7 @@ local function new_builder(subcommand) } end + ---@return CliCallOptions local function make_options(options) local opts = vim.tbl_extend("keep", (options or {}), { hidden = false, @@ -929,7 +1259,9 @@ local meta = { local cli = setmetatable({ history = runner.history, - git_root = git_root, + worktree_root = worktree_root, + worktree_git_dir = worktree_git_dir, + git_dir = git_dir, is_inside_worktree = is_inside_worktree, }, meta) diff --git a/lua/neogit/lib/git/config.lua b/lua/neogit/lib/git/config.lua index bb73b1d1b..f6645305f 100644 --- a/lua/neogit/lib/git/config.lua +++ b/lua/neogit/lib/git/config.lua @@ -68,12 +68,23 @@ function ConfigEntry:update(value) end end +---@return self +function ConfigEntry:refresh() + if self.scope == "local" then + self.value = M.get_local(self.name).value + elseif self.scope == "global" then + self.value = M.get_global(self.name).value + end + + return self +end + ---@type table local config_cache = {} local cache_key = nil local function make_cache_key() - local stat = vim.loop.fs_stat(git.repo:git_path("config"):absolute()) + local stat = vim.uv.fs_stat(git.repo:git_path("config"):absolute()) if stat then return stat.mtime.sec end @@ -109,7 +120,13 @@ end ---@return ConfigEntry function M.get(key) - return config()[key:lower()] or ConfigEntry.new(key, "", "local") + if M.get_local(key):is_set() then + return M.get_local(key) + elseif M.get_global(key):is_set() then + return M.get_global(key) + else + return ConfigEntry.new(key, "", "local") + end end ---@return ConfigEntry @@ -118,6 +135,11 @@ function M.get_global(key) return ConfigEntry.new(key, result, "global") end +---@return ConfigEntry +function M.get_local(key) + return config()[key:lower()] or ConfigEntry.new(key, "", "local") +end + function M.get_matching(pattern) local matches = {} for key, value in pairs(config()) do diff --git a/lua/neogit/lib/git/diff.lua b/lua/neogit/lib/git/diff.lua index 7bfaec2f0..64d3be1fa 100644 --- a/lua/neogit/lib/git/diff.lua +++ b/lua/neogit/lib/git/diff.lua @@ -6,6 +6,45 @@ local logger = require("neogit.logger") local insert = table.insert local sha256 = vim.fn.sha256 +---@class NeogitGitDiff +---@field parse fun(raw_diff: string[], raw_stats: string[]): Diff +---@field build fun(section: string, file: StatusItem) +---@field staged_stats fun(): DiffStagedStats +--- +---@class Diff +---@field kind string +---@field lines string[] +---@field file string +---@field info table +---@field stats table +---@field hunks Hunk +--- +---@class DiffStats +---@field additions number +---@field deletions number +--- +---@class Hunk +---@field file string +---@field index_from number +---@field index_len number +---@field diff_from number +---@field diff_to number +---@field first number First line number in buffer +---@field last number Last line number in buffer +---@field lines string[] +--- +---@class DiffStagedStats +---@field summary string +---@field files DiffStagedStatsFile +--- +---@class DiffStagedStatsFile +---@field path string|nil +---@field changes string|nil +---@field insertions string|nil +---@field deletions string|nil + +---@param raw string|string[] +---@return DiffStats local function parse_diff_stats(raw) if type(raw) == "string" then raw = vim.split(raw, ", ") @@ -33,6 +72,8 @@ local function parse_diff_stats(raw) return stats end +---@param output string[] +---@return string[], number local function build_diff_header(output) local header = {} local start_idx = 1 @@ -50,9 +91,12 @@ local function build_diff_header(output) return header, start_idx end +---@param header string[] +---@param kind string +---@return string local function build_file(header, kind) if kind == "modified" then - return header[3]:match("%-%-%- a/(.*)") + return header[3]:match("%-%-%- ./(.*)") elseif kind == "renamed" then return ("%s -> %s"):format(header[3]:match("rename from (.*)"), header[4]:match("rename to (.*)")) elseif kind == "new file" then @@ -64,6 +108,8 @@ local function build_file(header, kind) end end +---@param header string[] +---@return string, string[] local function build_kind(header) local kind = "" local info = {} @@ -83,6 +129,9 @@ local function build_kind(header) return kind, info end +---@param output string[] +---@param start_idx number +---@return string[] local function build_lines(output, start_idx) local lines = {} @@ -97,18 +146,13 @@ local function build_lines(output, start_idx) return lines end +---@param content string[] +---@return string local function hunk_hash(content) return sha256(table.concat(content, "\n")) end ----@class Hunk ----@field index_from number ----@field index_len number ----@field diff_from number ----@field diff_to number ----@field first number First line number in buffer ----@field last number Last line number in buffer - +---@param lines string[] ---@return Hunk local function build_hunks(lines) local hunks = {} @@ -171,6 +215,9 @@ local function build_hunks(lines) return hunks end +---@param raw_diff string[] +---@param raw_stats string[] +---@return Diff local function parse_diff(raw_diff, raw_stats) local header, start_idx = build_diff_header(raw_diff) local lines = build_lines(raw_diff, start_idx) @@ -179,7 +226,12 @@ local function parse_diff(raw_diff, raw_stats) local file = build_file(header, kind) local stats = parse_diff_stats(raw_stats or {}) - return { + util.map(hunks, function(hunk) + hunk.file = file + return hunk + end) + + return { ---@type Diff kind = kind, lines = lines, file = file, @@ -205,6 +257,8 @@ local function build_metatable(f, raw_output_fn) end -- Doing a git-diff with untracked files will exit(1) if a difference is observed, which we can ignore. +---@param name string +---@return fun(): table local function raw_untracked(name) return function() local diff = git.cli.diff.no_ext_diff.no_index @@ -216,6 +270,8 @@ local function raw_untracked(name) end end +---@param name string +---@return fun(): table local function raw_unstaged(name) return function() local diff = git.cli.diff.no_ext_diff.files(name).call({ hidden = true }).stdout @@ -225,6 +281,8 @@ local function raw_unstaged(name) end end +---@param name string +---@return fun(): table local function raw_staged_unmerged(name) return function() local diff = git.cli.diff.no_ext_diff.files(name).call({ hidden = true }).stdout @@ -234,6 +292,8 @@ local function raw_staged_unmerged(name) end end +---@param name string +---@return fun(): table local function raw_staged(name) return function() local diff = git.cli.diff.no_ext_diff.cached.files(name).call({ hidden = true }).stdout @@ -243,6 +303,8 @@ local function raw_staged(name) end end +---@param name string +---@return fun(): table local function raw_staged_renamed(name, original) return function() local diff = git.cli.diff.no_ext_diff.cached.files(name, original).call({ hidden = true }).stdout @@ -253,6 +315,8 @@ local function raw_staged_renamed(name, original) end end +---@param section string +---@param file StatusItem local function build(section, file) if section == "untracked" then build_metatable(file, raw_untracked(file.name)) @@ -269,48 +333,51 @@ local function build(section, file) end end ----@class NeogitGitDiff -return { - parse = parse_diff, - staged_stats = function() - local raw = git.cli.diff.no_ext_diff.cached.stat.call({ hidden = true }).stdout - local files = {} - local summary - - local idx = 1 - local function advance() - idx = idx + 1 - end +---@return DiffStagedStats +local function staged_stats() + local raw = git.cli.diff.no_ext_diff.cached.stat.call({ hidden = true }).stdout + local files = {} + local summary - local function peek() - return raw[idx] - end + local idx = 1 + local function advance() + idx = idx + 1 + end - while true do - local line = peek() - if not line then - break - end + local function peek() + return raw[idx] + end - if line:match("^ %d+ file[s ]+changed,") then - summary = vim.trim(line) - break - else - table.insert(files, { - path = vim.trim(line:match("^ ([^ ]+)")), - changes = line:match("|%s+(%d+)"), - insertions = line:match("|%s+%d+ (%+*)"), - deletions = line:match("|%s+%d+ %+*(%-*)$"), - }) - - advance() - end + while true do + local line = peek() + if not line then + break end - return { - summary = summary, - files = files, - } - end, + if line:match("^ %d+ file[s ]+changed,") then + summary = vim.trim(line) + break + else + local file = { ---@type DiffStagedStatsFile + path = vim.trim(line:match("^ ([^ ]+)")), + changes = line:match("|%s+(%d+)"), + insertions = line:match("|%s+%d+ (%+*)"), + deletions = line:match("|%s+%d+ %+*(%-*)$"), + } + + insert(files, file) + advance() + end + end + + return { + summary = summary, + files = files, + } +end + +return { ---@type NeogitGitDiff + parse = parse_diff, + staged_stats = staged_stats, build = build, } diff --git a/lua/neogit/lib/git/files.lua b/lua/neogit/lib/git/files.lua index 36a880ad2..af652f7d9 100644 --- a/lua/neogit/lib/git/files.lua +++ b/lua/neogit/lib/git/files.lua @@ -1,26 +1,49 @@ local git = require("neogit.lib.git") +local util = require("neogit.lib.util") +local Path = require("plenary.path") ---@class NeogitGitFiles local M = {} +---@return string[] function M.all() return git.cli["ls-files"].full_name.deleted.modified.exclude_standard.deduplicate.call({ hidden = true, }).stdout end +---@return string[] function M.untracked() return git.cli["ls-files"].others.exclude_standard.call({ hidden = true }).stdout end -function M.all_tree() - return git.cli["ls-tree"].full_tree.name_only.recursive.args("HEAD").call({ hidden = true }).stdout +---@param opts? { with_dir: boolean } +---@return string[] +function M.all_tree(opts) + opts = opts or {} + local files = git.cli["ls-tree"].full_tree.name_only.recursive.args("HEAD").call({ hidden = true }).stdout + + if opts.with_dir then + local dirs = {} + + for _, path in ipairs(files) do + local dir = vim.fs.dirname(path) .. Path.path.sep + dirs[dir] = true + end + + files = util.merge(files, vim.tbl_keys(dirs)) + table.sort(files) + end + + return files end +---@return string[] function M.diff(commit) return git.cli.diff.name_only.args(commit .. "...").call({ hidden = true }).stdout end +---@return string function M.relpath_from_repository(path) local result = git.cli["ls-files"].others.cached.modified.deleted.full_name .args(path) @@ -29,12 +52,23 @@ function M.relpath_from_repository(path) return result.stdout[1] end +---@param path string +---@return boolean function M.is_tracked(path) - return git.cli["ls-files"].error_unmatch.files(path).call({ hidden = true, ignore_error = true }).code == 0 + return git.cli["ls-files"].error_unmatch.files(path).call({ hidden = true, ignore_error = true }):success() end +---@param paths string[] +---@return boolean function M.untrack(paths) - return git.cli.rm.cached.files(unpack(paths)).call({ hidden = true }).code == 0 + return git.cli.rm.cached.files(unpack(paths)).call({ hidden = true }):success() +end + +---@param from string +---@param to string +---@return boolean +function M.move(from, to) + return git.cli.mv.args(from, to).call():success() end return M diff --git a/lua/neogit/lib/git/hooks.lua b/lua/neogit/lib/git/hooks.lua index 0ee07e6b5..991a2ab07 100644 --- a/lua/neogit/lib/git/hooks.lua +++ b/lua/neogit/lib/git/hooks.lua @@ -1,4 +1,4 @@ -local Path = require("plenary.path") ---@class Path +local Path = require("plenary.path") local git = require("neogit.lib.git") local M = {} ---@class NeogitGitHooks @@ -47,13 +47,13 @@ function M.register(meta) meta.update_hooks = function(state) state.hooks = {} - if not Path:new(state.git_root):joinpath(".git", "hooks"):is_dir() then + if not Path:new(state.git_dir):joinpath("hooks"):is_dir() then return end - for file in vim.fs.dir(vim.fs.joinpath(state.git_root, ".git", "hooks")) do + for file in vim.fs.dir(vim.fs.joinpath(state.git_dir, "hooks")) do if not file:match("%.sample$") then - local path = vim.fs.joinpath(state.git_root, ".git", "hooks", file) + local path = vim.fs.joinpath(state.git_dir, "hooks", file) local stat = vim.uv.fs_stat(path) if stat and stat.mode and is_executable(stat.mode) then diff --git a/lua/neogit/lib/git/index.lua b/lua/neogit/lib/git/index.lua index d929d08b2..354eceee1 100644 --- a/lua/neogit/lib/git/index.lua +++ b/lua/neogit/lib/git/index.lua @@ -1,64 +1,53 @@ local git = require("neogit.lib.git") local Path = require("plenary.path") local util = require("neogit.lib.util") -local uv = vim.uv or vim.loop ---@class NeogitGitIndex local M = {} ---Generates a patch that can be applied to index ----@param item any ---@param hunk Hunk ----@param from number ----@param to number ----@param reverse boolean|nil +---@param opts table|nil ---@return string -function M.generate_patch(item, hunk, from, to, reverse) - reverse = reverse or false +function M.generate_patch(hunk, opts) + opts = opts or { reverse = false } - if not from and not to then - from = hunk.diff_from + 1 - to = hunk.diff_to - end + local reverse = opts.reverse + + local from = opts.from or 1 + local to = opts.to or (hunk.diff_to - hunk.diff_from) assert(from <= to, string.format("from must be less than or equal to to %d %d", from, to)) - if from > to then - from, to = to, from - end local diff_content = {} local len_start = hunk.index_len local len_offset = 0 - -- + 1 skips the hunk header, since we construct that manually afterwards - -- TODO: could use `hunk.lines` instead if this is only called with the `SelectedHunk` type - for k = hunk.diff_from + 1, hunk.diff_to do - local v = item.diff.lines[k] - local operand, line = v:match("^([+ -])(.*)") - + for k, line in pairs(hunk.lines) do + local operand, l = line:match("^([+ -])(.*)") if operand == "+" or operand == "-" then if from <= k and k <= to then len_offset = len_offset + (operand == "+" and 1 or -1) - table.insert(diff_content, v) + table.insert(diff_content, line) else -- If we want to apply the patch normally, we need to include every `-` line we skip as a normal line, -- since we want to keep that line. if not reverse then if operand == "-" then - table.insert(diff_content, " " .. line) + table.insert(diff_content, " " .. l) end -- If we want to apply the patch in reverse, we need to include every `+` line we skip as a normal line, since -- it's unchanged as far as the diff is concerned and should not be reversed. -- We also need to adapt the original line offset based on if we skip or not elseif reverse then if operand == "+" then - table.insert(diff_content, " " .. line) + table.insert(diff_content, " " .. l) end len_start = len_start + (operand == "-" and -1 or 1) end end else - table.insert(diff_content, v) + table.insert(diff_content, line) end end @@ -68,10 +57,10 @@ function M.generate_patch(item, hunk, from, to, reverse) string.format("@@ -%d,%d +%d,%d @@", hunk.index_from, len_start, hunk.index_from, len_start + len_offset) ) - local git_root = git.repo.git_root + local worktree_root = git.repo.worktree_root + assert(hunk.file, "hunk has no filepath") - assert(item.absolute_path, "Item is not a path") - local path = Path:new(item.absolute_path):make_relative(git_root) + local path = Path:new(hunk.file):make_relative(worktree_root) table.insert(diff_content, 1, string.format("+++ b/%s", path)) table.insert(diff_content, 1, string.format("--- a/%s", path)) @@ -134,8 +123,8 @@ function M.with_temp_index(revision, fn) assert(revision, "temp index requires a revision") assert(fn, "Pass a function to call with temp index") - local tmp_index = Path:new(uv.os_tmpdir(), ("index.neogit.%s"):format(revision)) - git.cli["read-tree"].args(revision).index_output(tmp_index:absolute()).call { hidden = true } + local tmp_index = Path:new(vim.uv.os_tmpdir(), ("index.neogit.%s"):format(revision)) + git.cli["read-tree"].index_output(tmp_index:absolute()).args(revision).call { hidden = true } assert(tmp_index:exists(), "Failed to create temp index") fn(tmp_index:absolute()) @@ -153,8 +142,28 @@ function M.update() on_error = function(_) return false end, + suppress_console = true, + git_hook = false, + user_command = false, }) :spawn_async() end +local function timestamp() + local now = os.date("!*t") + return string.format("%s-%s-%sT%s.%s.%s", now.year, now.month, now.day, now.hour, now.min, now.sec) +end + +-- https://gist.github.com/chx/3a694c2a077451e3d446f85546bb9278 +-- Capture state of index as reflog entry +function M.create_backup() + git.cli.add.update.call { hidden = true, await = true } + local result = + git.cli.commit.allow_empty.message("Hard reset backup").call { hidden = true, await = true, pty = true } + if result:success() then + git.cli["update-ref"].args("refs/backups/" .. timestamp(), "HEAD").call { hidden = true, await = true } + git.cli.reset.hard.args("HEAD~1").call { hidden = true, await = true } + end +end + return M diff --git a/lua/neogit/lib/git/init.lua b/lua/neogit/lib/git/init.lua index 1b5365c47..8b6f7fed0 100644 --- a/lua/neogit/lib/git/init.lua +++ b/lua/neogit/lib/git/init.lua @@ -28,7 +28,8 @@ M.init_repo = function() status.instance():chdir(directory) end - if git.cli.is_inside_worktree() then + if git.cli.is_inside_worktree(directory) then + vim.cmd.redraw() if not input.get_permission(("Reinitialize existing repository %s?"):format(directory)) then return end diff --git a/lua/neogit/lib/git/log.lua b/lua/neogit/lib/git/log.lua index 4b3c61857..4958f433b 100644 --- a/lua/neogit/lib/git/log.lua +++ b/lua/neogit/lib/git/log.lua @@ -2,6 +2,7 @@ local git = require("neogit.lib.git") local util = require("neogit.lib.util") local config = require("neogit.config") local record = require("neogit.lib.record") +local state = require("neogit.lib.state") ---@class NeogitGitLog local M = {} @@ -21,7 +22,16 @@ local commit_header_pat = "([| ]*)(%*?)([| ]*)commit (%w+)" ---@field committer_date string when the committer committed ---@field description string a list of lines ---@field commit_arg string the passed argument of the git command +---@field subject string +---@field parent string ---@field diffs any[] +---@field ref_name string +---@field abbreviated_commit string +---@field body string +---@field verification_flag string? +---@field rel_date string +---@field log_date string +---@field unix_date string ---Parses the provided list of lines into a CommitLogEntry ---@param raw string[] @@ -134,7 +144,7 @@ function M.parse(raw) if not line or vim.startswith(line, "diff") then -- There was a previous diff, parse it if in_diff then - table.insert(commit.diffs, git.diff.parse(current_diff)) + table.insert(commit.diffs, git.diff.parse(current_diff, {})) current_diff = {} end @@ -142,7 +152,7 @@ function M.parse(raw) elseif line == "" then -- A blank line signifies end of diffs -- Parse the last diff, consume the blankline, and exit if in_diff then - table.insert(commit.diffs, git.diff.parse(current_diff)) + table.insert(commit.diffs, git.diff.parse(current_diff, {})) current_diff = {} end @@ -280,6 +290,17 @@ local function determine_order(options, graph) return options end +--- Specifies date format when not using relative dates +--- @param options table +--- @return table, string|nil +local function set_date_format(options) + if config.values.log_date_format ~= nil then + table.insert(options, "--date=format:" .. config.values.log_date_format) + end + + return options +end + ---@param options table|nil ---@param files? table ---@param color? boolean @@ -320,6 +341,8 @@ local function format(show_signature) committer_email = "%cE", committer_date = "%cD", rel_date = "%cr", + log_date = "%cd", + unix_date = "%ct", } if show_signature then @@ -345,6 +368,7 @@ M.list = util.memoize(function(options, graph, files, hidden, graph_color) options = ensure_max(options or {}) options = determine_order(options, graph) options, signature = show_signature(options) + options = set_date_format(options) local output = git.cli.log .format(format(signature)) @@ -353,7 +377,7 @@ M.list = util.memoize(function(options, graph, files, hidden, graph_color) .files(unpack(files)) .call({ hidden = hidden, ignore_error = hidden }).stdout - local commits = record.decode(output) + local commits = record.decode(output) ---@type CommitLogEntry[] if vim.tbl_isempty(commits) then return {} end @@ -361,7 +385,9 @@ M.list = util.memoize(function(options, graph, files, hidden, graph_color) local graph_output if graph then if config.values.graph_style == "unicode" then - graph_output = require("neogit.lib.graph").build(commits) + graph_output = require("neogit.lib.graph.unicode").build(commits) + elseif config.values.graph_style == "kitty" then + graph_output = require("neogit.lib.graph.kitty").build(commits, graph_color) elseif config.values.graph_style == "ascii" then util.remove_item_from_table(options, "--show-signature") graph_output = M.graph(options, files, graph_color) @@ -380,7 +406,8 @@ end) function M.is_ancestor(ancestor, descendant) return git.cli["merge-base"].is_ancestor .args(ancestor, descendant) - .call({ ignore_error = true, hidden = true }).code == 0 + .call({ ignore_error = true, hidden = true }) + :success() end ---Finds parent commit of a commit. If no parent exists, will return nil @@ -394,17 +421,27 @@ function M.parent(commit) end function M.register(meta) - meta.update_recent = function(state) - state.recent = { items = {} } + meta.update_recent = function(repo_state) + repo_state.recent = { items = {} } local count = config.values.status.recent_commit_count + local order = state.get({ "NeogitMarginPopup", "-order" }, config.values.commit_order) + if count > 0 then - state.recent.items = - util.filter_map(M.list({ "--max-count=" .. tostring(count) }, nil, {}, true), M.present_commit) + local args = { "--max-count=" .. tostring(count) } + local graph = nil + if order and order ~= "" then + table.insert(args, "--" .. order .. "-order") + graph = {} + end + + repo_state.recent.items = util.filter_map(M.list(args, graph, {}, false), M.present_commit) end end end +---@param from string +---@param to string function M.update_ref(from, to) git.cli["update-ref"].message(string.format("reset: moving to %s", to)).args(from, to).call() end @@ -414,7 +451,7 @@ function M.message(commit) end function M.full_message(commit) - return git.cli.log.max_count(1).format("%B").args(commit).call({ hidden = true }).stdout + return git.cli.log.max_count(1).format("%B").args(commit).call({ hidden = true, trim = false }).stdout end ---@class CommitItem @@ -439,7 +476,7 @@ end --- Runs `git verify-commit` ---@param commit string Hash of commit ----@return string The stderr output of the command +---@return string[] The stderr output of the command function M.verify_commit(commit) return git.cli["verify-commit"].args(commit).call({ ignore_error = true }).stderr end @@ -520,7 +557,7 @@ function M.reflog_message(skip) end M.abbreviated_size = util.memoize(function() - local commits = M.list({ "HEAD", "--max-count=1" }, {}, {}, true) + local commits = M.list({ "HEAD", "--max-count=1" }, nil, {}, true) if vim.tbl_isempty(commits) then return 7 else @@ -528,4 +565,19 @@ M.abbreviated_size = util.memoize(function() end end, { timeout = math.huge }) +function M.decorate(oid) + local result = git.cli.log.format("%D").max_count(1).args(oid).call().stdout + + if result[1] == nil then + return oid + else + local decorated_ref = vim.split(result[1], ",")[1] + if decorated_ref:match("%->") or decorated_ref:match("tag: ") then + return oid + else + return decorated_ref + end + end +end + return M diff --git a/lua/neogit/lib/git/merge.lua b/lua/neogit/lib/git/merge.lua index 77d431484..b018bfde1 100644 --- a/lua/neogit/lib/git/merge.lua +++ b/lua/neogit/lib/git/merge.lua @@ -1,6 +1,7 @@ local client = require("neogit.client") local git = require("neogit.lib.git") local notification = require("neogit.lib.notification") +local event = require("neogit.lib.event") ---@class NeogitGitMerge local M = {} @@ -9,18 +10,14 @@ local function merge_command(cmd) return cmd.env(client.get_envs_git_editor()).call { pty = true } end -local function fire_merge_event(data) - vim.api.nvim_exec_autocmds("User", { pattern = "NeogitMerge", modeline = false, data = data }) -end - function M.merge(branch, args) local result = merge_command(git.cli.merge.args(branch).arg_list(args)) - if result.code ~= 0 then + if result:failure() then notification.error("Merging failed. Resolve conflicts before continuing") - fire_merge_event { branch = branch, args = args, status = "conflict" } + event.send("Merge", { branch = branch, args = args, status = "conflict" }) else notification.info("Merged '" .. branch .. "' into '" .. git.branch.current() .. "'") - fire_merge_event { branch = branch, args = args, status = "ok" } + event.send("Merge", { branch = branch, args = args, status = "ok" }) end end @@ -37,6 +34,17 @@ function M.in_progress() return git.repo.state.merge.head ~= nil end +---@param path string filepath to check for conflict markers +---@return boolean +function M.is_conflicted(path) + return git.cli.diff.check.files(path).call():failure() +end + +---@return boolean +function M.any_conflicted() + return git.cli.diff.check.call():failure() +end + ---@class MergeItem ---Not used, just for a consistent interface @@ -44,7 +52,7 @@ M.register = function(meta) meta.update_merge_status = function(state) state.merge = { head = nil, branch = nil, msg = "", items = {} } - local merge_head = git.repo:git_path("MERGE_HEAD") + local merge_head = git.repo:worktree_git_path("MERGE_HEAD") if not merge_head:exists() then return end @@ -52,7 +60,7 @@ M.register = function(meta) state.merge.head = merge_head:read():match("([^\r\n]+)") state.merge.subject = git.log.message(state.merge.head) - local message = git.repo:git_path("MERGE_MSG") + local message = git.repo:worktree_git_path("MERGE_MSG") if message:exists() then state.merge.msg = message:read():match("([^\r\n]+)") -- we need \r? to support windows state.merge.branch = state.merge.msg:match("Merge branch '(.*)'$") diff --git a/lua/neogit/lib/git/push.lua b/lua/neogit/lib/git/push.lua index db142273b..2041add09 100644 --- a/lua/neogit/lib/git/push.lua +++ b/lua/neogit/lib/git/push.lua @@ -5,14 +5,32 @@ local util = require("neogit.lib.util") local M = {} ---Pushes to the remote and handles password questions ----@param remote string ----@param branch string +---@param remote string? +---@param branch string? ---@param args string[] ---@return ProcessResult function M.push_interactive(remote, branch, args) return git.cli.push.args(remote or "", branch or "").arg_list(args).call { pty = true } end +---@param branch string|nil +---@return boolean +function M.auto_setup_remote(branch) + if not branch then + return false + end + + local push_autoSetupRemote = git.config.get("push.autoSetupRemote"):read() + local push_default = git.config.get("push.default"):read() + local branch_remote = git.config.get_local("branch." .. branch .. ".remote"):read() + + return ( + push_autoSetupRemote + and (push_default == "current" or push_default == "simple" or push_default == "upstream") + and not branch_remote + ) == true +end + local function update_unmerged(state) local status = git.branch.status() diff --git a/lua/neogit/lib/git/rebase.lua b/lua/neogit/lib/git/rebase.lua index 2493597c4..ed8aa0015 100644 --- a/lua/neogit/lib/git/rebase.lua +++ b/lua/neogit/lib/git/rebase.lua @@ -2,14 +2,11 @@ local logger = require("neogit.logger") local git = require("neogit.lib.git") local client = require("neogit.client") local notification = require("neogit.lib.notification") +local event = require("neogit.lib.event") ---@class NeogitGitRebase local M = {} -local function fire_rebase_event(data) - vim.api.nvim_exec_autocmds("User", { pattern = "NeogitRebase", modeline = false, data = data }) -end - local function rebase_command(cmd) return cmd.env(client.get_envs_git_editor()).call { long = true, pty = true } end @@ -19,16 +16,16 @@ end ---@param args? string[] list of arguments to pass to git rebase ---@return ProcessResult function M.instantly(commit, args) - local result = git.cli.rebase - .env({ GIT_SEQUENCE_EDITOR = ":" }).interactive.autostash.autosquash - .arg_list(args or {}) + local result = git.cli.rebase.interactive.autostash.autosquash .commit(commit) + .env({ GIT_SEQUENCE_EDITOR = ":", GIT_EDITOR = ":" }) + .arg_list(args or {}) .call { long = true, pty = true } - if result.code ~= 0 then - fire_rebase_event { commit = commit, status = "failed" } + if result:failure() then + event.send("Rebase", { commit = commit, status = "failed" }) else - fire_rebase_event { commit = commit, status = "ok" } + event.send("Rebase", { commit = commit, status = "ok" }) end return result @@ -40,39 +37,43 @@ function M.rebase_interactive(commit, args) end local result = rebase_command(git.cli.rebase.interactive.arg_list(args).args(commit)) - if result.code ~= 0 then + if result:failure() then if result.stdout[1]:match("^hint: Waiting for your editor to close the file%.%.%. error") then notification.info("Rebase aborted") - fire_rebase_event { commit = commit, status = "aborted" } + event.send("Rebase", { commit = commit, status = "aborted" }) else notification.error("Rebasing failed. Resolve conflicts before continuing") - fire_rebase_event { commit = commit, status = "conflict" } + event.send("Rebase", { commit = commit, status = "conflict" }) end else notification.info("Rebased successfully") - fire_rebase_event { commit = commit, status = "ok" } + event.send("Rebase", { commit = commit, status = "ok" }) end end function M.onto_branch(branch, args) local result = rebase_command(git.cli.rebase.args(branch).arg_list(args)) - if result.code ~= 0 then + if result:failure() then notification.error("Rebasing failed. Resolve conflicts before continuing") - fire_rebase_event("conflict") + event.send("Rebase", { commit = branch, status = "conflict" }) else notification.info("Rebased onto '" .. branch .. "'") - fire_rebase_event("ok") + event.send("Rebase", { commit = branch, status = "ok" }) end end function M.onto(start, newbase, args) + if vim.tbl_contains(args, "--root") then + start = "" + end + local result = rebase_command(git.cli.rebase.onto.args(newbase, start).arg_list(args)) - if result.code ~= 0 then + if result:failure() then notification.error("Rebasing failed. Resolve conflicts before continuing") - fire_rebase_event("conflict") + event.send("Rebase", { status = "conflict" }) else notification.info("Rebased onto '" .. newbase .. "'") - fire_rebase_event("ok") + event.send("Rebase", { commit = newbase, status = "ok" }) end end @@ -98,29 +99,29 @@ end function M.modify(commit) local short_commit = git.rev_parse.abbreviate_commit(commit) local editor = "nvim -c '%s/^pick \\(" .. short_commit .. ".*\\)/edit \\1/' -c 'wq'" - local result = git.cli.rebase - .env({ GIT_SEQUENCE_EDITOR = editor }).interactive.autosquash.autostash - .in_pty(true) + local result = git.cli.rebase.interactive.autosquash.autostash .commit(commit) + .in_pty(true) + .env({ GIT_SEQUENCE_EDITOR = editor }) .call() - if result.code ~= 0 then - return + + if result:success() then + event.send("Rebase", { commit = commit, status = "ok" }) end - fire_rebase_event { commit = commit, status = "ok" } end function M.drop(commit) local short_commit = git.rev_parse.abbreviate_commit(commit) local editor = "nvim -c '%s/^pick \\(" .. short_commit .. ".*\\)/drop \\1/' -c 'wq'" - local result = git.cli.rebase - .env({ GIT_SEQUENCE_EDITOR = editor }).interactive.autosquash.autostash - .in_pty(true) + local result = git.cli.rebase.interactive.autosquash.autostash .commit(commit) + .in_pty(true) + .env({ GIT_SEQUENCE_EDITOR = editor }) .call() - if result.code ~= 0 then - return + + if result:success() then + event.send("Rebase", { commit = commit, status = "ok" }) end - fire_rebase_event { commit = commit, status = "ok" } end function M.continue() @@ -144,7 +145,7 @@ end function M.merge_base_HEAD() local result = git.cli["merge-base"].args("HEAD", "HEAD@{upstream}").call { ignore_error = true, hidden = true } - if result.code == 0 then + if result:success() then return result.stdout[1] end end @@ -171,7 +172,7 @@ local function rev_name(oid) .args(oid) .call { hidden = true, ignore_error = true } - if result.code == 0 then + if result:success() then return result.stdout[1] else return oid @@ -192,8 +193,8 @@ function M.update_rebase_status(state) state.rebase = { items = {}, onto = {}, head_oid = nil, head = nil, current = nil } local rebase_file - local rebase_merge = git.repo:git_path("rebase-merge") - local rebase_apply = git.repo:git_path("rebase-apply") + local rebase_merge = git.repo:worktree_git_path("rebase-merge") + local rebase_apply = git.repo:worktree_git_path("rebase-apply") if rebase_merge:exists() then rebase_file = rebase_merge @@ -224,14 +225,18 @@ function M.update_rebase_status(state) if done:exists() then for line in done:iter() do if line:match("^[^#]") and line ~= "" then - local oid = line:match("^%w+ (%x+)") - table.insert(state.rebase.items, { - action = line:match("^(%w+) "), - oid = oid, - abbreviated_commit = oid:sub(1, git.log.abbreviated_size()), - subject = line:match("^%w+ %x+ (.+)$"), - done = true, - }) + local oid = line:match("^%w+ (%x+)") or line:match("^fixup %-C (%x+)") + if oid then + table.insert(state.rebase.items, { + action = line:match("^(%w+) "), + oid = oid, + abbreviated_commit = oid:sub(1, git.log.abbreviated_size()), + subject = line:match("^%w+ %x+ (.+)$"), + done = true, + }) + else + logger.debug("[rebase status] No OID found on line '" .. line .. "'") + end end end end @@ -248,13 +253,15 @@ function M.update_rebase_status(state) for line in todo:iter() do if line:match("^[^#]") and line ~= "" then local oid = line:match("^%w+ (%x+)") - table.insert(state.rebase.items, { - done = false, - action = line:match("^(%w+) "), - oid = oid, - abbreviated_commit = oid:sub(1, git.log.abbreviated_size()), - subject = line:match("^%w+ %x+ (.+)$"), - }) + if oid then + table.insert(state.rebase.items, { + done = false, + action = line:match("^(%w+) "), + oid = oid, + abbreviated_commit = oid:sub(1, git.log.abbreviated_size()), + subject = line:match("^%w+ %x+ (.+)$"), + }) + end end end end diff --git a/lua/neogit/lib/git/reflog.lua b/lua/neogit/lib/git/reflog.lua index 26f5d4740..ca3c1650c 100644 --- a/lua/neogit/lib/git/reflog.lua +++ b/lua/neogit/lib/git/reflog.lua @@ -1,5 +1,6 @@ local git = require("neogit.lib.git") local util = require("neogit.lib.util") +local config = require("neogit.config") ---@class NeogitGitReflog local M = {} @@ -15,7 +16,7 @@ local function parse(entries) return util.filter_map(entries, function(entry) index = index + 1 - local hash, author, name, subject, date = unpack(vim.split(entry, "\30")) + local hash, author, name, subject, rel_date, commit_date = unpack(vim.split(entry, "\30")) local command, message = subject:match([[^(.-): (.*)]]) if not command then command = subject:match([[^(.-):]]) @@ -42,7 +43,8 @@ local function parse(entries) author_name = author, ref_name = name, ref_subject = message, - rel_date = date, + rel_date = rel_date, + commit_date = commit_date, type = command, } end) @@ -50,17 +52,28 @@ end function M.list(refname, options) local format = table.concat({ - "%h", -- Full Hash + "%H", -- Full Hash "%aN", -- Author Name "%gd", -- Reflog Name "%gs", -- Reflog Subject "%cr", -- Commit Date (Relative) + "%cd", -- Commit Date }, "%x1E") + util.remove_item_from_table(options, "--simplify-by-decoration") + util.remove_item_from_table(options, "--follow") + + local date_format + if config.values.log_date_format ~= nil then + date_format = "format:" .. config.values.log_date_format + else + date_format = "raw" + end + return parse( git.cli.reflog.show .format(format) - .date("raw") + .date(date_format) .arg_list(options or {}) .args(refname, "--") .call({ hidden = true }).stdout diff --git a/lua/neogit/lib/git/refs.lua b/lua/neogit/lib/git/refs.lua index 0c25a2289..b7ae41cba 100644 --- a/lua/neogit/lib/git/refs.lua +++ b/lua/neogit/lib/git/refs.lua @@ -75,7 +75,7 @@ local RECORD_TEMPLATE = record.encode({ local insert = table.insert local format = string.format local match = string.match -local substring = string.sub +local split = vim.split local LOCAL_BRANCH = "local_branch" local REMOTE_BRANCH = "remote_branch" @@ -84,9 +84,9 @@ local TAG_TEMPLATE = "tags/%s" local BRANCH_TEMPLATE = "%s/%s" local REMOTE_BRANCH_PATTERN = "^refs/remotes/([^/]*)/(.*)$" local HEAD = "*" -local head = "h" -local remote = "r" -local tag = "t" +local head = "heads" +local remote = "remotes" +local tag = "tags" function M.list_parsed() local result = record.decode(refs(RECORD_TEMPLATE)) @@ -100,7 +100,7 @@ function M.list_parsed() for _, ref in ipairs(result) do ref.head = ref.head == HEAD - local ref_type = substring(ref.ref, 6, 6) + local ref_type = split(ref.ref, "/")[2] if ref_type == head then ref.type = LOCAL_BRANCH ref.unambiguous_name = ref.name @@ -132,7 +132,7 @@ M.heads = util.memoize(function() local heads = { "HEAD", "ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD", "CHERRY_PICK_HEAD" } local present = {} for _, head in ipairs(heads) do - if git.repo:git_path(head):exists() then + if git.repo:worktree_git_path(head):exists() then table.insert(present, head) end end diff --git a/lua/neogit/lib/git/remote.lua b/lua/neogit/lib/git/remote.lua index c7ed74b3e..2d28ebcc8 100644 --- a/lua/neogit/lib/git/remote.lua +++ b/lua/neogit/lib/git/remote.lua @@ -5,8 +5,10 @@ local util = require("neogit.lib.util") local M = {} -- https://github.com/magit/magit/blob/main/lisp/magit-remote.el#LL141C32-L141C32 +---@param remote string +---@param new_name string|nil local function cleanup_push_variables(remote, new_name) - if remote == git.config.get("remote.pushDefault").value then + if remote == git.config.get("remote.pushDefault"):read() then git.config.set("remote.pushDefault", new_name) end @@ -21,36 +23,50 @@ local function cleanup_push_variables(remote, new_name) end end +---@param name string +---@param url string +---@param args string[] +---@return boolean function M.add(name, url, args) - return git.cli.remote.add.arg_list(args).args(name, url).call({ await = true }).code == 0 + return git.cli.remote.add.arg_list(args).args(name, url).call():success() end +---@param from string +---@param to string +---@return boolean function M.rename(from, to) - local result = git.cli.remote.rename.arg_list({ from, to }).call { await = true } - if result.code == 0 then + local result = git.cli.remote.rename.arg_list({ from, to }).call() + if result:success() then cleanup_push_variables(from, to) end - return result.code == 0 + return result:success() end +---@param name string +---@return boolean function M.remove(name) - local result = git.cli.remote.rm.args(name).call { await = true } - if result.code == 0 then + local result = git.cli.remote.rm.args(name).call() + if result:success() then cleanup_push_variables(name) end - return result.code == 0 + return result:success() end +---@param name string +---@return boolean function M.prune(name) - return git.cli.remote.prune.args(name).call().code == 0 + return git.cli.remote.prune.args(name).call():success() end +---@return string[] M.list = util.memoize(function() return git.cli.remote.call({ hidden = true }).stdout end) +---@param name string +---@return string[] function M.get_url(https://github.com/name) return git.cli.remote.get_url(https://github.com/name).call({ hidden = true }).stdout end @@ -105,7 +121,7 @@ function M.parse(url) repository = url:match([[/([^/]+)%.git]]) or url:match([[/([^/]+)$]]) end - return { + return { ---@type RemoteInfo url = url, protocol = protocol, user = user, @@ -118,4 +134,58 @@ function M.parse(url) } end +---@param oid string object-id for commit +---@return string|nil +function M.commit_url(https://github.com/oid) + local upstream = git.branch.upstream_remote() + if not upstream then + return + end + + local template + local url = M.get_url(https://github.com/upstream)[1] + + for s, v in pairs(require("neogit.config").values.git_services) do + if url:match(util.pattern_escape(s)) then + template = v.commit + break + end + end + + if template and template ~= "" then + local format_values = M.parse(url) + format_values["oid"] = oid + local uri = util.format(template, format_values) + + return uri + end +end + +---@param branch string +---@return string|nil +function M.tree_url(https://github.com/branch) + local upstream = git.branch.upstream_remote() + if not upstream then + return + end + + local template + local url = M.get_url(https://github.com/upstream)[1] + + for s, v in pairs(require("neogit.config").values.git_services) do + if url:match(util.pattern_escape(s)) then + template = v.tree + break + end + end + + if template and template ~= "" then + local format_values = M.parse(url) + format_values["branch_name"] = branch + local uri = util.format(template, format_values) + + return uri + end +end + return M diff --git a/lua/neogit/lib/git/repository.lua b/lua/neogit/lib/git/repository.lua index b1e3afb85..a8e55fcc4 100644 --- a/lua/neogit/lib/git/repository.lua +++ b/lua/neogit/lib/git/repository.lua @@ -1,6 +1,6 @@ local a = require("plenary.async") local logger = require("neogit.logger") -local Path = require("plenary.path") ---@class Path +local Path = require("plenary.path") local git = require("neogit.lib.git") local ItemFilter = require("neogit.lib.item_filter") local util = require("neogit.lib.util") @@ -21,22 +21,25 @@ local modules = { } ---@class NeogitRepoState ----@field git_path fun(self, ...):Path ----@field refresh fun(self, table) ----@field git_root string ----@field head NeogitRepoHead ----@field upstream NeogitRepoRemote ----@field pushRemote NeogitRepoRemote ----@field untracked NeogitRepoIndex ----@field unstaged NeogitRepoIndex ----@field staged NeogitRepoIndex ----@field stashes NeogitRepoStash ----@field recent NeogitRepoRecent ----@field sequencer NeogitRepoSequencer ----@field rebase NeogitRepoRebase ----@field merge NeogitRepoMerge ----@field bisect NeogitRepoBisect ----@field hooks string[] +---@field git_path fun(self, ...): Path +---@field worktree_git_path fun(self, ...): Path +---@field refresh fun(self, table) +---@field worktree_root string Absolute path to the root of the current worktree +---@field worktree_git_dir string Absolute path to the .git/ dir of the current worktree +---@field git_dir string Absolute path of the .git/ dir for the repository +---@field head NeogitRepoHead +---@field upstream NeogitRepoRemote +---@field pushRemote NeogitRepoRemote +---@field untracked NeogitRepoIndex +---@field unstaged NeogitRepoIndex +---@field staged NeogitRepoIndex +---@field stashes NeogitRepoStash +---@field recent NeogitRepoRecent +---@field sequencer NeogitRepoSequencer +---@field rebase NeogitRepoRebase +---@field merge NeogitRepoMerge +---@field bisect NeogitRepoBisect +---@field hooks string[] --- ---@class NeogitRepoHead ---@field branch string|nil @@ -98,7 +101,9 @@ local modules = { ---@return NeogitRepoState local function empty_state() return { - git_root = "", + worktree_root = "", + worktree_git_dir = "", + git_dir = "", head = { branch = nil, detached = false, @@ -165,12 +170,14 @@ local function empty_state() end ---@class NeogitRepo ----@field lib table ----@field state NeogitRepoState ----@field git_root string ----@field running table ----@field interrupt table ----@field tmp_state table +---@field lib table +---@field state NeogitRepoState +---@field worktree_root string Project root, or worktree +---@field worktree_git_dir string Dir to watch for changes in worktree +---@field git_dir string '.git/' directory for repo +---@field running table +---@field interrupt table +---@field tmp_state table ---@field refresh_callbacks function[] local Repo = {} Repo.__index = Repo @@ -205,14 +212,18 @@ function Repo.new(dir) local instance = { lib = {}, state = empty_state(), - git_root = git.cli.git_root(dir), + worktree_root = git.cli.worktree_root(dir), + worktree_git_dir = git.cli.worktree_git_dir(dir), + git_dir = git.cli.git_dir(dir), refresh_callbacks = {}, running = util.weak_table(), interrupt = util.weak_table(), tmp_state = util.weak_table("v"), } - instance.state.git_root = instance.git_root + instance.state.worktree_root = instance.worktree_root + instance.state.worktree_git_dir = instance.worktree_git_dir + instance.state.git_dir = instance.git_dir setmetatable(instance, Repo) @@ -227,8 +238,14 @@ function Repo:reset() self.state = empty_state() end +---@return Path +function Repo:worktree_git_path(...) + return Path:new(self.worktree_git_dir):joinpath(...) +end + +---@return Path function Repo:git_path(...) - return Path:new(self.git_root):joinpath(".git", ...) + return Path:new(self.git_dir):joinpath(...) end function Repo:tasks(filter, state) @@ -277,7 +294,7 @@ function Repo:set_state(id) end function Repo:refresh(opts) - if self.git_root == "" then + if self.worktree_root == "" then logger.debug("[REPO] No git root found - skipping refresh") return end diff --git a/lua/neogit/lib/git/reset.lua b/lua/neogit/lib/git/reset.lua index 259ada4bf..c5dbd9017 100644 --- a/lua/neogit/lib/git/reset.lua +++ b/lua/neogit/lib/git/reset.lua @@ -1,85 +1,67 @@ -local notification = require("neogit.lib.notification") local git = require("neogit.lib.git") ---@class NeogitGitReset local M = {} -local function fire_reset_event(data) - vim.api.nvim_exec_autocmds("User", { pattern = "NeogitReset", modeline = false, data = data }) +---@param target string +---@return boolean +function M.mixed(target) + local result = git.cli.reset.mixed.args(target).call() + return result:success() end -function M.mixed(commit) - local result = git.cli.reset.mixed.args(commit).call { await = true } - if result.code ~= 0 then - notification.error("Reset Failed") - else - notification.info("Reset to " .. commit) - fire_reset_event { commit = commit, mode = "mixed" } - end +---@param target string +---@return boolean +function M.soft(target) + local result = git.cli.reset.soft.args(target).call() + return result:success() end -function M.soft(commit) - local result = git.cli.reset.soft.args(commit).call { await = true } - if result.code ~= 0 then - notification.error("Reset Failed") - else - notification.info("Reset to " .. commit) - fire_reset_event { commit = commit, mode = "soft" } - end -end +---@param target string +---@return boolean +function M.hard(target) + git.index.create_backup() -function M.hard(commit) - local result = git.cli.reset.hard.args(commit).call { await = true } - if result.code ~= 0 then - notification.error("Reset Failed") - else - notification.info("Reset to " .. commit) - fire_reset_event { commit = commit, mode = "hard" } - end + local result = git.cli.reset.hard.args(target).call() + return result:success() end -function M.keep(commit) - local result = git.cli.reset.keep.args(commit).call { await = true } - if result.code ~= 0 then - notification.error("Reset Failed") - else - notification.info("Reset to " .. commit) - fire_reset_event { commit = commit, mode = "keep" } - end +---@param target string +---@return boolean +function M.keep(target) + local result = git.cli.reset.keep.args(target).call() + return result:success() end -function M.index(commit) - local result = git.cli.reset.args(commit).files(".").call { await = true } - if result.code ~= 0 then - notification.error("Reset Failed") - else - notification.info("Reset to " .. commit) - fire_reset_event { commit = commit, mode = "index" } - end +---@param target string +---@return boolean +function M.index(target) + local result = git.cli.reset.args(target).files(".").call() + return result:success() end --- TODO: Worktree support --- "Reset the worktree to COMMIT. Keep the `HEAD' and index as-is." --- --- (magit-wip-commit-before-change nil " before reset") --- (magit-with-temp-index commit nil (magit-call-git "checkout-index" "--all" "--force")) --- (magit-wip-commit-after-apply nil " after reset") --- --- function M.worktree(commit) --- end +---@param target string revision to reset to +---@return boolean +function M.worktree(target) + local success = false + git.index.with_temp_index(target, function(index) + local result = git.cli["checkout-index"].all.force.env({ GIT_INDEX_FILE = index }).call() + success = result:success() + end) + + return success +end -function M.file(commit, files) - local result = git.cli.checkout.rev(commit).files(unpack(files)).call { await = true } - if result.code ~= 0 then - notification.error("Reset Failed") - else - fire_reset_event { commit = commit, mode = "files" } - if #files > 1 then - notification.info("Reset " .. #files .. " files") - else - notification.info("Reset " .. files[1]) - end +---@param target string +---@param files string[] +---@return boolean +function M.file(target, files) + local result = git.cli.checkout.rev(target).files(unpack(files)).call() + if result:failure() then + result = git.cli.reset.args(target).files(unpack(files)).call() end + + return result:success() end return M diff --git a/lua/neogit/lib/git/rev_parse.lua b/lua/neogit/lib/git/rev_parse.lua index 18c05a3e3..b44d98196 100644 --- a/lua/neogit/lib/git/rev_parse.lua +++ b/lua/neogit/lib/git/rev_parse.lua @@ -28,8 +28,13 @@ end ---@return string ---@async function M.verify(rev) - return git.cli["rev-parse"].verify - .abbrev_ref() + return git.cli["rev-parse"].verify.abbrev_ref(rev).call({ hidden = true, ignore_error = true }).stdout[1] +end + +---@param rev string +---@return string +function M.full_name(rev) + return git.cli["rev-parse"].verify.symbolic_full_name .args(rev) .call({ hidden = true, ignore_error = true }).stdout[1] end diff --git a/lua/neogit/lib/git/revert.lua b/lua/neogit/lib/git/revert.lua index 54064b3b7..a6a3e608f 100644 --- a/lua/neogit/lib/git/revert.lua +++ b/lua/neogit/lib/git/revert.lua @@ -4,12 +4,25 @@ local util = require("neogit.lib.util") ---@class NeogitGitRevert local M = {} +---@param commits string[] +---@param args string[] +---@return boolean, string|nil function M.commits(commits, args) - return git.cli.revert.no_commit.arg_list(util.merge(args, commits)).call({ pty = true }).code == 0 + local result = git.cli.revert.no_commit.arg_list(util.merge(args, commits)).call { pty = true } + if result:success() then + return true, "" + else + return false, result.stdout[1] + end +end + +function M.hunk(hunk, _) + local patch = git.index.generate_patch(hunk, { reverse = true }) + git.index.apply(patch, { reverse = true }) end function M.continue() - git.cli.revert.continue.call() + git.cli.revert.continue.no_edit.call { pty = true } end function M.skip() diff --git a/lua/neogit/lib/git/sequencer.lua b/lua/neogit/lib/git/sequencer.lua index c5fc385e8..a961a7e0e 100644 --- a/lua/neogit/lib/git/sequencer.lua +++ b/lua/neogit/lib/git/sequencer.lua @@ -8,6 +8,7 @@ local M = {} -- And CHERRY_PICK_HEAD does not exist when a conflict happens while picking a series of commits with --no-commit. -- And REVERT_HEAD does not exist when a conflict happens while reverting a series of commits with --no-commit. -- +---@return boolean function M.pick_or_revert_in_progress() local pick_or_revert_todo = false @@ -18,7 +19,7 @@ function M.pick_or_revert_in_progress() end end - return git.repo.state.sequencer.head or pick_or_revert_todo + return git.repo.state.sequencer.head ~= nil or pick_or_revert_todo end ---@class SequencerItem @@ -30,28 +31,30 @@ end function M.update_sequencer_status(state) state.sequencer = { items = {}, head = nil, head_oid = nil, revert = false, cherry_pick = false } - local revert_head = git.repo:git_path("REVERT_HEAD") - local cherry_head = git.repo:git_path("CHERRY_PICK_HEAD") + local revert_head = git.repo:worktree_git_path("REVERT_HEAD") + local cherry_head = git.repo:worktree_git_path("CHERRY_PICK_HEAD") if cherry_head:exists() then state.sequencer.head = "CHERRY_PICK_HEAD" - state.sequencer.head_oid = vim.trim(git.repo:git_path("CHERRY_PICK_HEAD"):read()) + state.sequencer.head_oid = vim.trim(git.repo:worktree_git_path("CHERRY_PICK_HEAD"):read()) state.sequencer.cherry_pick = true elseif revert_head:exists() then state.sequencer.head = "REVERT_HEAD" - state.sequencer.head_oid = vim.trim(git.repo:git_path("REVERT_HEAD"):read()) + state.sequencer.head_oid = vim.trim(git.repo:worktree_git_path("REVERT_HEAD"):read()) state.sequencer.revert = true end local HEAD_oid = git.rev_parse.oid("HEAD") - table.insert(state.sequencer.items, { - action = "onto", - oid = HEAD_oid, - abbreviated_commit = HEAD_oid:sub(1, git.log.abbreviated_size()), - subject = git.log.message(HEAD_oid), - }) + if HEAD_oid then + table.insert(state.sequencer.items, { + action = "onto", + oid = HEAD_oid, + abbreviated_commit = HEAD_oid:sub(1, git.log.abbreviated_size()), + subject = git.log.message(HEAD_oid), + }) + end - local todo = git.repo:git_path("sequencer/todo") + local todo = git.repo:worktree_git_path("sequencer/todo") if todo:exists() then for line in todo:iter() do if line:match("^[^#]") and line ~= "" then @@ -68,7 +71,7 @@ function M.update_sequencer_status(state) table.insert(state.sequencer.items, { action = "join", oid = state.sequencer.head_oid, - abbreviated_commit = state.sequencer.head_oid:sub(1, git.log.abbreviated_size()), + abbreviated_commit = string.sub(state.sequencer.head_oid, 1, git.log.abbreviated_size()), subject = git.log.message(state.sequencer.head_oid), }) end diff --git a/lua/neogit/lib/git/stash.lua b/lua/neogit/lib/git/stash.lua index e07972729..d82793476 100644 --- a/lua/neogit/lib/git/stash.lua +++ b/lua/neogit/lib/git/stash.lua @@ -1,76 +1,69 @@ local git = require("neogit.lib.git") local input = require("neogit.lib.input") local util = require("neogit.lib.util") +local config = require("neogit.config") +local event = require("neogit.lib.event") ---@class NeogitGitStash local M = {} ----@param pattern string -local function fire_stash_event(pattern) - vim.api.nvim_exec_autocmds("User", { pattern = pattern, modeline = false }) -end - function M.list_refs() local result = git.cli.reflog.show.format("%h").args("stash").call { ignore_error = true } - if result.code > 0 then + if result:failure() then return {} else return result.stdout end end +---@param args string[] function M.stash_all(args) - git.cli.stash.arg_list(args).call { await = true } - fire_stash_event("NeogitStash") - -- this should work, but for some reason doesn't. - --return perform_stash({ worktree = true, index = true }) + local result = git.cli.stash.push.files(".").arg_list(args).call() + event.send("Stash", { success = result:success() }) end function M.stash_index() - git.cli.stash.staged.call { await = true } - fire_stash_event("NeogitStash") + local result = git.cli.stash.staged.call() + event.send("Stash", { success = result:success() }) end function M.stash_keep_index() - local files = git.cli["ls-files"].call({ hidden = true }).stdout - -- for some reason complains if not passed files, - -- but this seems to be a git cli error; running: - -- git --literal-pathspecs stash --keep-index - -- fails with a bizarre error: - -- error: pathspec ':/' did not match any file(s) known to git - git.cli.stash.keep_index.files(unpack(files)).call { await = true } - fire_stash_event("NeogitStash") + local result = git.cli.stash.keep_index.files(".").call() + event.send("Stash", { success = result:success() }) end +---@param args string[] +---@param files string[] function M.push(args, files) - git.cli.stash.push.arg_list(args).files(unpack(files)).call { await = true } + local result = git.cli.stash.push.arg_list(args).files(unpack(files)).call() + event.send("Stash", { success = result:success() }) end function M.pop(stash) - local result = git.cli.stash.apply.index.args(stash).call { await = true } + local result = git.cli.stash.apply.index.args(stash).call() - if result.code == 0 then - git.cli.stash.drop.args(stash).call { await = true } + if result:success() then + git.cli.stash.drop.args(stash).call() else - git.cli.stash.apply.args(stash).call { await = true } + git.cli.stash.apply.args(stash).call() end - fire_stash_event("NeogitStash") + event.send("Stash", { success = result:success() }) end function M.apply(stash) - local result = git.cli.stash.apply.index.args(stash).call { await = true } + local result = git.cli.stash.apply.index.args(stash).call() - if result.code ~= 0 then - git.cli.stash.apply.args(stash).call { await = true } + if result:failure() then + git.cli.stash.apply.args(stash).call() end - fire_stash_event("NeogitStash") + event.send("Stash", { success = result:success() }) end function M.drop(stash) - git.cli.stash.drop.args(stash).call { await = true } - fire_stash_event("NeogitStash") + local result = git.cli.stash.drop.args(stash).call() + event.send("Stash", { success = result:success() }) end function M.list() @@ -78,17 +71,19 @@ function M.list() end function M.rename(stash) - local message = input.get_user_input("New name") + local current = git.log.message(stash) + local message = input.get_user_input("rename", { prepend = current }) if message then local oid = git.rev_parse.abbreviate_commit(stash) - git.cli.stash.drop.args(stash).call { await = true } - git.cli.stash.store.message(message).args(oid).call { await = true } + git.cli.stash.drop.args(stash).call() + git.cli.stash.store.message(message).args(oid).call() end end ---@class StashItem ---@field idx number string the id of the stash i.e. stash@{7} ---@field name string +---@field date string timestamp ---@field rel_date string relative timestamp ---@field message string the message associated with each stash. @@ -98,7 +93,7 @@ function M.register(meta) local idx, message = line:match("stash@{(%d*)}: (.*)") idx = tonumber(idx) - assert(idx, "indx cannot be nil") + assert(idx, "index cannot be nil") ---@class StashItem local item = { @@ -118,6 +113,18 @@ function M.register(meta) .call({ hidden = true }).stdout[1] return self.rel_date + elseif key == "date" then + self.date = git.cli.log + .max_count(1) + .format("%cd") + .args("--date=format:" .. config.values.log_date_format) + .args(("stash@{%s}"):format(idx)) + .call({ hidden = true }).stdout[1] + + return self.date + elseif key == "oid" then + self.oid = git.rev_parse.oid("stash@{" .. idx .. "}") + return self.oid end end, }) diff --git a/lua/neogit/lib/git/status.lua b/lua/neogit/lib/git/status.lua index dfad7f7af..6383393cb 100644 --- a/lua/neogit/lib/git/status.lua +++ b/lua/neogit/lib/git/status.lua @@ -6,14 +6,47 @@ local logger = require("neogit.logger") ---@class StatusItem ---@field mode string ----@field diff string[] +---@field diff Diff ---@field absolute_path string ---@field escaped_path string ---@field original_name string|nil ---@field file_mode {head: number, index: number, worktree: number}|nil +---@field submodule SubmoduleStatus|nil +---@field name string +---@field first number +---@field last number +---@field oid string|nil optional object id +---@field commit CommitLogEntry|nil optional object id +---@field folded boolean|nil +---@field hunks Hunk[]|nil + +---@class SubmoduleStatus +---@field commit_changed boolean C +---@field has_tracked_changes boolean M +---@field has_untracked_changes boolean U + +---@param status string +-- A 4 character field describing the submodule state. +-- "N..." when the entry is not a submodule. +-- "S" when the entry is a submodule. +-- is "C" if the commit changed; otherwise ".". +-- is "M" if it has tracked changes; otherwise ".". +-- is "U" if there are untracked changes; otherwise ".". +local function parse_submodule_status(status) + local a, b, c, d = status:match("(.)(.)(.)(.)") + if a == "N" then + return nil + else + return { + commit_changed = b == "C", + has_tracked_changes = c == "M", + has_untracked_changes = d == "U", + } + end +end ---@return StatusItem -local function update_file(section, cwd, file, mode, name, original_name, file_mode) +local function update_file(section, cwd, file, mode, name, original_name, file_mode, submodule) local absolute_path = Path:new(cwd, name):absolute() local escaped_path = vim.fn.fnameescape(vim.fn.fnamemodify(absolute_path, ":~:.")) @@ -24,6 +57,7 @@ local function update_file(section, cwd, file, mode, name, original_name, file_m absolute_path = absolute_path, escaped_path = escaped_path, file_mode = file_mode, + submodule = submodule, } if file and rawget(file, "diff") then @@ -90,16 +124,17 @@ local function update_status(state, filter) local mode, _, _, _, _, _, _, _, _, name = rest:match(match_u) table.insert( state.unstaged.items, - update_file("unstaged", state.git_root, old_files.unstaged_files[name], mode, name) + update_file("unstaged", state.worktree_root, old_files.unstaged_files[name], mode, name) ) elseif kind == "?" then table.insert( state.untracked.items, - update_file("untracked", state.git_root, old_files.untracked_files[rest], "?", rest) + update_file("untracked", state.worktree_root, old_files.untracked_files[rest], "?", rest) ) elseif kind == "1" then - local mode_staged, mode_unstaged, _, mH, mI, mW, hH, _, name = rest:match(match_1) + local mode_staged, mode_unstaged, submodule, mH, mI, mW, hH, _, name = rest:match(match_1) local file_mode = { head = mH, index = mI, worktree = mW } + local submodule = parse_submodule_status(submodule) if mode_staged ~= "." then if hH:match("^0+$") then @@ -110,12 +145,13 @@ local function update_status(state, filter) state.staged.items, update_file( "staged", - state.git_root, + state.worktree_root, old_files.staged_files[name], mode_staged, name, nil, - file_mode + file_mode, + submodule ) ) end @@ -125,30 +161,33 @@ local function update_status(state, filter) state.unstaged.items, update_file( "unstaged", - state.git_root, + state.worktree_root, old_files.unstaged_files[name], mode_unstaged, name, nil, - file_mode + file_mode, + submodule ) ) end elseif kind == "2" then - local mode_staged, mode_unstaged, _, mH, mI, mW, _, _, _, name, orig_name = rest:match(match_2) + local mode_staged, mode_unstaged, submodule, mH, mI, mW, _, _, _, name, orig_name = rest:match(match_2) local file_mode = { head = mH, index = mI, worktree = mW } + local submodule = parse_submodule_status(submodule) if mode_staged ~= "." then table.insert( state.staged.items, update_file( "staged", - state.git_root, + state.worktree_root, old_files.staged_files[name], mode_staged, name, orig_name, - file_mode + file_mode, + submodule ) ) end @@ -158,12 +197,13 @@ local function update_status(state, filter) state.unstaged.items, update_file( "unstaged", - state.git_root, + state.worktree_root, old_files.unstaged_files[name], mode_unstaged, name, orig_name, - file_mode + file_mode, + submodule ) ) end @@ -172,42 +212,68 @@ local function update_status(state, filter) end ---@class NeogitGitStatus -local status = { - stage = function(files) - git.cli.add.files(unpack(files)).call { await = true } - end, - stage_modified = function() - git.cli.add.update.call { await = true } - end, - stage_untracked = function() - local paths = util.map(git.repo.state.untracked.items, function(item) - return item.escaped_path - end) - - git.cli.add.files(unpack(paths)).call { await = true } - end, - stage_all = function() - git.cli.add.all.call { await = true } - end, - unstage = function(files) - git.cli.reset.files(unpack(files)).call { await = true } - end, - unstage_all = function() - git.cli.reset.call { await = true } - end, - is_dirty = function() - return #git.repo.state.staged.items > 0 or #git.repo.state.unstaged.items > 0 - end, - anything_staged = function() - return #git.repo.state.staged.items > 0 - end, - anything_unstaged = function() - return #git.repo.state.unstaged.items > 0 - end, -} - -status.register = function(meta) +local M = {} + +---@param files string[] +function M.stage(files) + git.cli.add.files(unpack(files)).call { await = true } +end + +function M.stage_modified() + git.cli.add.update.call { await = true } +end + +function M.stage_untracked() + local paths = util.map(git.repo.state.untracked.items, function(item) + return item.escaped_path + end) + + git.cli.add.files(unpack(paths)).call { await = true } +end + +function M.stage_all() + git.cli.add.all.call { await = true } +end + +---@param files string[] +function M.unstage(files) + git.cli.reset.files(unpack(files)).call { await = true } +end + +function M.unstage_all() + git.cli.reset.call { await = true } +end + +---@return boolean +function M.is_dirty() + return M.anything_unstaged() or M.anything_staged() +end + +---@return boolean +function M.anything_staged() + local output = git.cli.status.porcelain(2).call({ hidden = true }).stdout + return vim.iter(output):any(function(line) + return line:match("^%d [^%.]") + end) +end + +---@return boolean +function M.anything_unstaged() + local output = git.cli.status.porcelain(2).call({ hidden = true }).stdout + return vim.iter(output):any(function(line) + return line:match("^%d %..") + end) +end + +---@return boolean +function M.any_unmerged() + return vim.iter(git.repo.state.unstaged.items):any(function(item) + return vim.tbl_contains({ "UU", "AA", "DU", "UD", "AU", "UA", "DD" }, item.mode) + end) +end + +M.register = function(meta) meta.update_status = update_status end -return status +return M diff --git a/lua/neogit/lib/git/tag.lua b/lua/neogit/lib/git/tag.lua index 2b025a919..0bc1983e9 100644 --- a/lua/neogit/lib/git/tag.lua +++ b/lua/neogit/lib/git/tag.lua @@ -14,7 +14,7 @@ end ---@return boolean Successfully deleted function M.delete(tags) local result = git.cli.tag.delete.arg_list(tags).call { await = true } - return result.code == 0 + return result:success() end --- Show a list of tags under a selected ref diff --git a/lua/neogit/lib/git/worktree.lua b/lua/neogit/lib/git/worktree.lua index 9fb46002e..1095eb3b5 100644 --- a/lua/neogit/lib/git/worktree.lua +++ b/lua/neogit/lib/git/worktree.lua @@ -8,10 +8,14 @@ local M = {} ---Creates new worktree at path for ref ---@param ref string branch name, tag name, HEAD, etc. ---@param path string absolute path ----@return boolean +---@return boolean, string function M.add(ref, path, params) - local result = git.cli.worktree.add.arg_list(params or {}).args(path, ref).call { await = true } - return result.code == 0 + local result = git.cli.worktree.add.arg_list(params or {}).args(path, ref).call() + if result:success() then + return true, "" + else + return false, result.stderr[#result.stderr] + end end ---Moves an existing worktree @@ -19,8 +23,8 @@ end ---@param destination string absolute path for where to move worktree ---@return boolean function M.move(worktree, destination) - local result = git.cli.worktree.move.args(worktree, destination).call { await = true } - return result.code == 0 + local result = git.cli.worktree.move.args(worktree, destination).call() + return result:success() end ---Removes a worktree @@ -28,9 +32,8 @@ end ---@param args? table ---@return boolean function M.remove(worktree, args) - local result = - git.cli.worktree.remove.args(worktree).arg_list(args or {}).call { ignore_error = true, await = true } - return result.code == 0 + local result = git.cli.worktree.remove.args(worktree).arg_list(args or {}).call { ignore_error = true } + return result:success() end ---@class Worktree @@ -48,20 +51,22 @@ function M.list(opts) local list = git.cli.worktree.list.args("--porcelain").call({ hidden = true }).stdout local worktrees = {} - for i = 1, #list, 3 do - local path = list[i]:match("^worktree (.-)$") - local head = list[i]:match("^HEAD (.-)$") - local type, ref = list[i + 2]:match("^([^ ]+) (.+)$") + for i = 1, #list, 1 do + if list[i]:match("^branch.*$") then + local path = list[i - 2]:match("^worktree (.-)$") + local head = list[i - 1]:match("^HEAD (.-)$") + local type, ref = list[i]:match("^([^ ]+) (.+)$") - if path then - local main = Path.new(path, ".git"):is_dir() - table.insert(worktrees, { - head = head, - type = type, - ref = ref, - main = main, - path = path, - }) + if path then + local main = Path.new(path, ".git"):is_dir() + table.insert(worktrees, { + head = head, + type = type, + ref = ref, + main = main, + path = path, + }) + end end end diff --git a/lua/neogit/lib/graph/kitty.lua b/lua/neogit/lib/graph/kitty.lua new file mode 100644 index 000000000..d9a36f2fa --- /dev/null +++ b/lua/neogit/lib/graph/kitty.lua @@ -0,0 +1,1206 @@ +-- Modified version of graphing algorithm from https://github.com/isakbm/gitgraph.nvim +-- +-- MIT License +-- +-- Copyright (c) 2024 Isak Buhl-Mortensen +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to deal +-- in the Software without restriction, including without limitation the rights +-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +-- copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. + +local M = {} + +-- heuristic to check if this row contains a "bi-crossing" of branches +-- +-- a bi-crossing is when we have more than one branch "propagating" horizontally +-- on a connector row +-- +-- this can only happen when the commit on the row +-- above the connector row is a merge commit +-- but it doesn't always happen +-- +-- in addition to needing a merge commit on the row above +-- we need the span (interval) of the "emphasized" connector cells +-- (they correspond to connectors to the parents of the merge commit) +-- we need that span to overlap with at least one connector cell that +-- is destined for the commit on the next row +-- (the commit before the merge commit) +-- in addition, we need there to be more than one connector cell +-- destined to the next commit +-- +-- here is an example +-- +-- +-- j i i ⓮ │ │ j -> g h +-- g i i h ?─?─?─╮ +-- g i h │ ⓚ │ i +-- +-- +-- overlap: +-- +-- g-----h 1 4 +-- i-i 2 3 +-- +-- NOTE how `i` is the commit that the `i` cells are destined for +-- notice how there is more than on `i` in the connector row +-- and that it lies in the span of g-h +-- +-- some more examples +-- +-- ------------------------------------- +-- +-- S T S │ ⓮ │ T -> R S +-- S R S ?─?─? +-- S R ⓚ │ S +-- +-- +-- overlap: +-- +-- S-R 1 2 +-- S---S 1 3 +-- +-- ------------------------------------- +-- +-- +-- c b a b ⓮ │ │ │ c -> Z a +-- Z b a b ?─?─?─? +-- Z b a │ ⓚ │ b +-- +-- overlap: +-- +-- Z---a 1 3 +-- b---b 2 4 +-- +-- ------------------------------------- +-- +-- finally a negative example where there is no problem +-- +-- +-- W V V ⓮ │ │ W -> S V +-- S V V ⓸─⓵─╯ +-- S V │ ⓚ V +-- +-- no overlap: +-- +-- S-V 1 2 +-- V-V 2 3 +-- +-- the reason why there is no problem (bi-crossing) above +-- follows from the fact that the span from V <- V only +-- touches the span S -> V it does not overlap it, so +-- figuratively we have S -> V <- V which is fine +-- +-- TODO: +-- FIXME: need to test if we handle two bi-connectors in succession +-- correctly +-- +---@param commit_row I.Row +---@param connector_row I.Row +---@param next_commit I.Commit? +---@return boolean -- whether or not this is a bi crossing +---@return boolean -- whether or not it can be resolved safely by edge lifting +local function get_is_bi_crossing(commit_row, connector_row, next_commit) + if not next_commit then + return false, false + end + + local prev = commit_row.commit + assert(prev, "expected a prev commit") + + if #prev.parents < 2 then + return false, false -- bi-crossings only happen when prev is a merge commit + end + + local row = connector_row + + ---@param k integer + local function interval_upd(x, k) + if k < x.start then + x.start = k + end + if k > x.stop then + x.stop = k + end + end + + -- compute the emphasized interval (merge commit parent interval) + local emi = { start = #row.cells, stop = 1 } + for k, cell in ipairs(row.cells) do + if cell.commit and cell.emphasis then + interval_upd(emi, k) + end + end + + -- compute connector interval + local coi = { start = #row.cells, stop = 1 } + for k, cell in ipairs(row.cells) do + if cell.commit and cell.commit.hash == next_commit.hash then + interval_upd(coi, k) + end + end + + -- unsafe if starts of intervals overlap and are equal to direct parent location + local safe = not (emi.start == coi.start and prev.j == emi.start) + + -- return early when connector interval is trivial + if coi.start == coi.stop then + return false, safe + end + + -- print('emi:', vim.inspect(emi)) + -- print('coi:', vim.inspect(coi)) + + -- check overlap + do + -- are intervals identical, then that counts as overlap + if coi.start == emi.start and coi.stop == emi.stop then + return true, safe + end + end + for _, k in pairs(emi) do + -- emi endpoints inside coi ? + if coi.start < k and k < coi.stop then + return true, safe + end + end + for _, k in pairs(coi) do + -- coi endpoints inside emi ? + if emi.start < k and k < emi.stop then + return true, safe + end + end + + return false, safe +end + +---@param next I.Commit +---@param prev_commit_row I.Row +---@param prev_connector_row I.Row +---@param commit_row I.Row +---@param connector_row I.Row +local function resolve_bi_crossing(prev_commit_row, prev_connector_row, commit_row, connector_row, next) + -- if false then + -- if false then -- get_is_bi_crossing(graph, next_commit, #graph) then + -- print 'we have a bi crossing' + -- void all repeated reservations of `next` from + -- this and the previous row + local prev_row = commit_row + local this_row = connector_row + assert(prev_row and this_row, "expecting two prior rows due to bi-connector") + + --- example of what this does + --- + --- input: + --- + --- j i i │ │ │ + --- j i i ⓮ │ │ <- prev + --- g i i h ⓸─⓵─ⓥ─╮ <- bi connector + --- + --- output: + --- + --- j i i │ ⓶─╯ + --- j i ⓮ │ <- prev + --- g i h ⓸─│───╮ <- bi connector + --- + ---@param row I.Row + ---@return integer + local function void_repeats(row) + local start_voiding = false + local ctr = 0 + for k, cell in ipairs(row.cells) do + if cell.commit and cell.commit.hash == next.hash then + if not start_voiding then + start_voiding = true + elseif not row.cells[k].emphasis then + -- else + + row.cells[k] = { connector = " " } -- void it + ctr = ctr + 1 + end + end + end + return ctr + end + + void_repeats(prev_row) + void_repeats(this_row) + + -- we must also take care when the prev prev has a repeat where + -- the repeat is not the direct parent of its child + -- + -- G ⓯ + -- e d c ⓸─ⓢ─╮ + -- E D C F │ │ │ ⓯ + -- e D C c b a d ⓶─⓵─│─⓴─ⓢ─ⓢ─? <--- to resolve this + -- E D C C B A ⓮ │ │ │ │ │ + -- c D C C b A ⓸─│─ⓥ─ⓥ─⓷ │ + -- C D B A │ ⓮ │ │ + -- C c b a ⓶─ⓥ─────⓵─⓷ + -- C B A ⓮ │ │ + -- b B a ⓸───────ⓥ─⓷ + -- B A ⓚ │ + -- a A ⓶─────────╯ + -- A ⓚ + local prev_prev_row = prev_connector_row -- graph[#graph - 2] + local prev_prev_prev_row = prev_commit_row -- graph[#graph - 3] + assert(prev_prev_row and prev_prev_prev_row, "assertion failed") + do + local start_voiding = false + local ctr = 0 + ---@type I.Cell? + local replacer = nil + for k, cell in ipairs(prev_prev_row.cells) do + if cell.commit and cell.commit.hash == next.hash then + if not start_voiding then + start_voiding = true + replacer = cell + elseif k ~= prev_prev_prev_row.commit.j then + local ppcell = prev_prev_prev_row.cells[k] + if (not ppcell) or (ppcell and ppcell.connector == " ") then + prev_prev_row.cells[k] = { connector = " " } -- void it + replacer.emphasis = true + ctr = ctr + 1 + end + end + end + end + end + + -- assert(prev_rep_ctr == this_rep_ctr) + + -- newly introduced tracking cells can be squeezed in + -- + -- before: + -- + -- j i i │ ⓶─╯ + -- j i ⓮ │ + -- g i h ⓸─│───╮ + -- + -- after: + -- + -- j i i │ ⓶─╯ + -- j i ⓮ │ + -- g i h ⓸─│─╮ + -- + -- can think of this as scooting the cell to the left + -- when the cell was just introduced + -- TODO: implement this at some point + -- for k, cell in ipairs(this_row.cells) do + -- if cell.commit and not prev_row.cells[k].commit and not this_row.cells[k - 2] then + -- end + -- end +end + +---@class I.Row +---@field cells I.Cell[] +---@field commit I.Commit? -- there's a single commit for every even row + +---@class I.Cell +---@field is_commit boolean? -- when true this cell is a real commit +---@field commit I.Commit? -- a cell is associated with a commit, but the empty column gaps don't have them +---@field symbol string? +---@field connector string? -- a cell is eventually given a connector +---@field emphasis boolean? -- when true indicates that this is a direct parent of cell on previous row + +---@class I.Commit +---@field hash string +---@field msg string +---@field branch_names string[] +---@field tags string[] +---@field debug string? +---@field author_date string +---@field author_name string +---@field i integer +---@field j integer +---@field parents string[] +---@field children string[] + +---@class I.Highlight +---@field hg string +---@field row integer +---@field start integer +---@field stop integer + +local sym = { + merge_commit = "", + commit = "", + merge_commit_end = "", + commit_end = "", + GVER = "", + GHOR = "", + GCLD = "", + GCRD = "╭", + GCLU = "", + GCRU = "", + GLRU = "", + GLRD = "", + GLUD = "", + GRUD = "", + GFORKU = "", + GFORKD = "", + GRUDCD = "", + GRUDCU = "", + GLUDCD = "", + GLUDCU = "", + GLRDCL = "", + GLRDCR = "", + GLRUCL = "", + GLRUCR = "", +} + +local BRANCH_COLORS = { + "Red", + "Yellow", + "Blue", + "Purple", + "Cyan", +} + +local NUM_BRANCH_COLORS = #BRANCH_COLORS + +local util = require("neogit.lib.util") + +---@param commits CommitLogEntry[] +---@param color boolean? +function M.build(commits, color) + local GVER = sym.GVER + local GHOR = sym.GHOR + local GCLD = sym.GCLD + local GCRD = sym.GCRD + local GCLU = sym.GCLU + local GCRU = sym.GCRU + local GLRU = sym.GLRU + local GLRD = sym.GLRD + local GLUD = sym.GLUD + local GRUD = sym.GRUD + + local GFORKU = sym.GFORKU + local GFORKD = sym.GFORKD + + local GRUDCD = sym.GRUDCD + local GRUDCU = sym.GRUDCU + local GLUDCD = sym.GLUDCD + local GLUDCU = sym.GLUDCU + + local GLRDCL = sym.GLRDCL + local GLRDCR = sym.GLRDCR + local GLRUCL = sym.GLRUCL + -- local GLRUCR = sym.GLRUCR + + local GRCM = sym.commit + local GMCM = sym.merge_commit + local GRCME = sym.commit_end + local GMCME = sym.merge_commit_end + + local raw_commits = util.filter_map(commits, function(item) + if item.oid then + return { + msg = item.subject, + branch_names = {}, + tags = {}, + author_date = item.author_date, + hash = item.oid, + parents = vim.split(item.parent, " "), + } + end + end) + + local commits = {} ---@type table + local sorted_commits = {} ---@type string[] + + for _, rc in ipairs(raw_commits) do + local commit = { + msg = rc.msg, + branch_names = rc.branch_names, + tags = rc.tags, + author_date = rc.author_date, + author_name = rc.author_name, + hash = rc.hash, + i = -1, + j = -1, + parents = rc.parents, + children = {}, + } + + sorted_commits[#sorted_commits + 1] = commit.hash + commits[rc.hash] = commit + end + + do + for _, c_hash in ipairs(sorted_commits) do + local c = commits[c_hash] + + for _, h in ipairs(c.parents) do + local p = commits[h] + if p then + p.children[#p.children + 1] = c.hash + else + -- create a virtual parent, it is not added to the list of commit hashes + commits[h] = { + hash = h, + author_name = "virtual", + msg = "virtual parent", + author_date = "unknown", + parents = {}, + children = { c.hash }, + branch_names = {}, + tags = {}, + i = -1, + j = -1, + } + end + end + end + end + + ---@param cells I.Cell[] + ---@return I.Cell[] + local function propagate(cells) + local new_cells = {} + for _, cell in ipairs(cells) do + if cell.connector then + -- new_cells[#new_cells + 1] = { connector = " " } + new_cells[#new_cells + 1] = { connector = cell.connector } + elseif cell.commit then + assert(cell.commit, "assertion failed") + new_cells[#new_cells + 1] = { commit = cell.commit } + else + new_cells[#new_cells + 1] = { connector = " " } + end + end + return new_cells + end + + ---@param cells I.Cell[] + ---@param hash string + ---@param start integer? + ---@return integer? + local function find(cells, hash, start) + local start = start or 1 + for idx = start, #cells, 2 do + local c = cells[idx] + if c.commit and c.commit.hash == hash then + return idx + end + end + return nil + end + + ---@param cells I.Cell[] + ---@param start integer? + ---@return integer + local function next_vacant_j(cells, start) + local start = start or 1 + for i = start, #cells, 2 do + local cell = cells[i] + if cell.connector == " " then + return i + end + end + return #cells + 1 + end + + --- returns the generated row and the integer (j) location of the commit + ---@param c I.Commit + ---@param prev_row I.Row? + ---@return I.Row, integer + local function generate_commit_row(c, prev_row) + local j = nil ---@type integer? + + local rowc = {} ---@type I.Cell[] + + if prev_row then + rowc = propagate(prev_row.cells) + j = find(prev_row.cells, c.hash) + end + + -- if reserved location use it + if j then + c.j = j + rowc[j] = { commit = c, is_commit = true } + + -- clear any supurfluous reservations + for k = j + 1, #rowc do + local v = rowc[k] + if v.commit and v.commit.hash == c.hash then + rowc[k] = { connector = " " } + end + end + else + j = next_vacant_j(rowc) + c.j = j + rowc[j] = { commit = c, is_commit = true } + rowc[j + 1] = { connector = " " } + end + + return { cells = rowc, commit = c }, j + end + + ---@param prev_commit_row I.Row + ---@param prev_connector_row I.Row + ---@param commit_row I.Row + ---@param commit_loc integer + ---@param curr_commit I.Commit + ---@param next_commit I.Commit? + ---@return I.Row + local function generate_connector_row( + prev_commit_row, + prev_connector_row, + commit_row, + commit_loc, + curr_commit, + next_commit + ) + -- connector row (reservation row) + -- + -- first we propagate + local connector_cells = propagate(commit_row.cells) + + -- connector row + -- + -- now we proceed to add the parents of the commit we just added + if #curr_commit.parents > 0 then + ---@param rem_parents string[] + local function reserve_remainder(rem_parents) + -- + -- reserve the rest of the parents in slots to the right of us + -- + -- ... another alternative is to reserve rest of the parents of c if they have not already been reserved + -- for i = 2, #c.parents do + for _, h in ipairs(rem_parents) do + local j = find(commit_row.cells, h, commit_loc) + if not j then + local j = next_vacant_j(connector_cells, commit_loc) + connector_cells[j] = { commit = commits[h], emphasis = true } + connector_cells[j + 1] = { connector = " " } + else + connector_cells[j].emphasis = true + end + end + end + + -- we start by peeking at next commit and seeing if it is one of our parents + -- we only do this if one of our propagating branches is already destined for this commit + ---@type I.Cell? + local tracker = nil + if next_commit then + for _, cell in ipairs(connector_cells) do + if cell.commit and cell.commit.hash == next_commit.hash then + tracker = cell + break + end + end + end + + local next_p_idx = nil -- default to picking first parent + if tracker and next_commit then + -- this loop updates next_p_idx to the next commit if they are identical + for k, h in ipairs(curr_commit.parents) do + if h == next_commit.hash then + next_p_idx = k + break + end + end + end + + -- next_p_idx = nil + + -- add parents + if next_p_idx then + assert(tracker, "assertion failed") + -- if next commit is our parent then we do some complex logic + if #curr_commit.parents == 1 then + -- simply place parent at our location + connector_cells[commit_loc].commit = commits[curr_commit.parents[1]] + connector_cells[commit_loc].emphasis = true + else + -- void the cell at our location (will be replaced by our parents in a moment) + connector_cells[commit_loc] = { connector = " " } + + -- put emphasis on tracker for the special parent + tracker.emphasis = true + + -- only reserve parents that are different from next commit + ---@type string[] + local rem_parents = {} + for k, h in ipairs(curr_commit.parents) do + if k ~= next_p_idx then + rem_parents[#rem_parents + 1] = h + end + end + + assert(#rem_parents == #curr_commit.parents - 1, "unexpected amount of rem parents") + reserve_remainder(rem_parents) + + -- we fill this with the next commit if it is empty, a bit hacky + if connector_cells[commit_loc].connector == " " then + connector_cells[commit_loc].commit = tracker.commit + connector_cells[commit_loc].emphasis = true + connector_cells[commit_loc].connector = nil + tracker.emphasis = false + end + end + else + -- simply add first parent at our location and then reserve the rest + connector_cells[commit_loc].commit = commits[curr_commit.parents[1]] + connector_cells[commit_loc].emphasis = true + + local rem_parents = {} + for k = 2, #curr_commit.parents do + rem_parents[#rem_parents + 1] = curr_commit.parents[k] + end + + reserve_remainder(rem_parents) + end + + local connector_row = { cells = connector_cells } ---@type I.Row + + -- handle bi-connector rows + local is_bi_crossing, bi_crossing_safely_resolvable = + get_is_bi_crossing(commit_row, connector_row, next_commit) + + if is_bi_crossing and bi_crossing_safely_resolvable and next_commit then + resolve_bi_crossing(prev_commit_row, prev_connector_row, commit_row, connector_row, next_commit) + end + + return connector_row + else + -- if we're here then it means that this commit has no common ancestors with other commits + -- ... a different family ... see test `different family` + + -- we must remove the already propagated connector for the current commit since it has no parents + for i = 1, #connector_cells, 2 do + local cell = connector_cells[i] + if cell.commit and cell.commit.hash == curr_commit.hash then + connector_cells[i] = { connector = " " } + end + end + + local connector_row = { cells = connector_cells } + + return connector_row + end + end + + ---@param commits table + ---@param sorted_commits string[] + ---@return I.Row[] + local function straight_j(commits, sorted_commits) + local graph = {} ---@type I.Row[] + + for i, c_hash in ipairs(sorted_commits) do + -- get the input parameters + local curr_commit = commits[c_hash] + local next_commit = commits[sorted_commits[i + 1]] + local prev_commit_row = graph[#graph - 1] + local prev_connector_row = graph[#graph] + + -- generate commit and connector row for the current commit + local commit_row, commit_loc = generate_commit_row(curr_commit, prev_connector_row) + local connector_row = nil ---@type I.Row + if i < #sorted_commits then + connector_row = generate_connector_row( + prev_commit_row, + prev_connector_row, + commit_row, + commit_loc, + curr_commit, + next_commit + ) + end + + -- write the result + graph[#graph + 1] = commit_row + if connector_row then + graph[#graph + 1] = connector_row + end + end + + return graph + end + + local graph = straight_j(commits, sorted_commits) + + ---@param graph I.Row[] + ---@return string[] + ---@return I.Highlight[] + local function graph_to_lines(graph) + ---@type table[] + local lines = {} + + ---@type I.Highlight[] + local highlights = {} + + ---@param cell I.Cell + ---@return string + local function commit_cell_symb(cell) + assert(cell.is_commit, "assertion failed") + + if #cell.commit.parents > 1 then + -- merge commit + return #cell.commit.children == 0 and GMCME or GMCM + else + -- regular commit + return #cell.commit.children == 0 and GRCME or GRCM + end + end + + ---@param row I.Row + ---@return table + local function row_to_str(row) + local row_strs = {} + for j = 1, #row.cells do + local cell = row.cells[j] + if cell.connector then + cell.symbol = cell.connector -- TODO: connector and symbol should not be duplicating data? + else + assert(cell.commit, "assertion failed") + cell.symbol = commit_cell_symb(cell) + end + row_strs[#row_strs + 1] = cell.symbol + end + -- return table.concat(row_strs) + return row_strs + end + + ---@param row I.Row + ---@param row_idx integer + ---@return I.Highlight[] + local function row_to_highlights(row, row_idx) + local row_hls = {} + local offset = 1 -- WAS 0 + + for j = 1, #row.cells do + local cell = row.cells[j] + + local width = cell.symbol and vim.fn.strdisplaywidth(cell.symbol) or 1 + local start = offset + local stop = start + width + + offset = offset + width + + if cell.commit then + local hg = (cell.emphasis and "Bold" or "") .. BRANCH_COLORS[(j % NUM_BRANCH_COLORS + 1)] + row_hls[#row_hls + 1] = { + hg = hg, + row = row_idx, + start = start, + stop = stop, + } + elseif cell.symbol == GHOR then + -- take color from first right cell that attaches to this connector + for k = j + 1, #row.cells do + local rcell = row.cells[k] + + -- TODO: would be nice with a better way than this hacky method of + -- to figure out where our vertical branch is + local continuations = { + GCLD, + GCLU, + -- + GFORKD, + GFORKU, + -- + GLUDCD, + GLUDCU, + -- + GLRDCL, + GLRUCL, + } + + if rcell.commit and vim.tbl_contains(continuations, rcell.symbol) then + local hg = (cell.emphasis and "Bold" or "") + .. BRANCH_COLORS[(rcell.commit.j % NUM_BRANCH_COLORS + 1)] + row_hls[#row_hls + 1] = { + hg = hg, + row = row_idx, + start = start, + stop = stop, + } + + break + end + end + end + end + + return row_hls + end + + local width = 0 + for _, row in ipairs(graph) do + if #row.cells > width then + width = #row.cells + end + end + + for idx = 1, #graph do + local proper_row = graph[idx] + + local row_str_arr = {} + + ---@param stuff table|string + local function add_to_row(stuff) + row_str_arr[#row_str_arr + 1] = stuff + end + + local c = proper_row.commit + if c then + add_to_row(c.hash) -- Commit row + add_to_row(row_to_str(proper_row)) + else + local c = graph[idx - 1].commit + assert(c, "assertion failed") + + local row = row_to_str(proper_row) + local valid = false + for _, char in ipairs(row) do + if char ~= " " and char ~= GVER then + valid = true + break + end + end + + if valid then + add_to_row("") -- Connection Row + else + add_to_row("strip") -- Useless Connection Row + end + + add_to_row(row) + end + + for _, hl in ipairs(row_to_highlights(proper_row, idx)) do + highlights[#highlights + 1] = hl + end + + lines[#lines + 1] = row_str_arr + end + + return lines, highlights + end + + -- store stage 1 graph + -- + ---@param c I.Cell? + ---@return string? + local function hash(c) + return c and c.commit and c.commit.hash + end + + -- inserts vertical and horizontal pipes + for i = 2, #graph - 1 do + local row = graph[i] + + ---@param cells I.Cell[] + local function count_emph(cells) + local n = 0 + for _, c in ipairs(cells) do + if c.commit and c.emphasis then + n = n + 1 + end + end + return n + end + + local num_emphasized = count_emph(graph[i].cells) + + -- vertical connections + for j = 1, #row.cells, 2 do + local this = graph[i].cells[j] + local below = graph[i + 1].cells[j] + + local tch, bch = hash(this), hash(below) + + if not this.is_commit and not this.connector then + -- local ch = row.commit and row.commit.hash + -- local row_commit_is_child = ch and vim.tbl_contains(this.commit.children, ch) + -- local trivial_continuation = (not row_commit_is_child) and (new_columns < 1 or ach == tch or acc == GVER) + -- local trivial_continuation = (new_columns < 1 or ach == tch or acc == GVER) + local ignore_this = (num_emphasized > 1 and (this.emphasis or false)) + + if not ignore_this and bch == tch then -- and trivial_continuation then + local has_repeats = false + local first_repeat = nil + for k = 1, #row.cells, 2 do + local cell_k, cell_j = row.cells[k], row.cells[j] + local rkc, rjc = + (not cell_k.connector and cell_k.commit), (not cell_j.connector and cell_j.commit) + + -- local rkc, rjc = row.cells[k].commit, row.cells[j].commit + + if k ~= j and (rkc and rjc) and rkc.hash == rjc.hash then + has_repeats = true + first_repeat = k + break + end + end + + if not has_repeats then + local cell = graph[i].cells[j] + cell.connector = GVER + else + local k = first_repeat + local this_k = graph[i].cells[k] + local below_k = graph[i + 1].cells[k] + + local bkc, tkc = + (not below_k.connector and below_k.commit), (not this_k.connector and this_k.commit) + + -- local bkc, tkc = below_k.commit, this_k.commit + if (bkc and tkc) and bkc.hash == tkc.hash then + local cell = graph[i].cells[j] + cell.connector = GVER + end + end + end + end + end + + do + -- we expect number of rows to be odd always !! since the last + -- row is a commit row without a connector row following it + assert(#graph % 2 == 1, "assertion failed") + local last_row = graph[#graph] + for j = 1, #last_row.cells, 2 do + local cell = last_row.cells[j] + if cell.commit and not cell.is_commit then + cell.connector = GVER + end + end + end + + -- horizontal connections + -- + -- a stopped connector is one that has a void cell below it + -- + local stopped = {} + for j = 1, #row.cells, 2 do + local this = graph[i].cells[j] + local below = graph[i + 1].cells[j] + if not this.connector and (not below or below.connector == " ") then + assert(this.commit, "assertion failed") + stopped[#stopped + 1] = j + end + end + + -- now lets get the intervals between the stopped connectors + -- and other connectors of the same commit hash + local intervals = {} + for _, j in ipairs(stopped) do + local curr = 1 + for k = curr, j do + local cell_k, cell_j = row.cells[k], row.cells[j] + local rkc, rjc = (not cell_k.connector and cell_k.commit), (not cell_j.connector and cell_j.commit) + if (rkc and rjc) and (rkc.hash == rjc.hash) then + if k < j then + intervals[#intervals + 1] = { start = k, stop = j } + end + curr = j + break + end + end + end + + -- add intervals for the connectors of merge children + -- these are where we have multiple connector commit hashes + -- for a single merge child, that is, more than one connector + -- + -- TODO: this method presented here is probably universal and covers + -- also for the previously computed intervals ... two birds one stone? + do + local low = #row.cells + local high = 1 + for j = 1, #row.cells, 2 do + local c = row.cells[j] + if c.emphasis then + if j > high then + high = j + end + if j < low then + low = j + end + end + end + + if high > low then + intervals[#intervals + 1] = { start = low, stop = high } + end + end + + if i % 2 == 0 then + for _, interval in ipairs(intervals) do + local a, b = interval.start, interval.stop + for j = a + 1, b - 1 do + local this = graph[i].cells[j] + if this.connector == " " then + this.connector = GHOR + end + end + end + end + end + + -- print '---- stage 2 -------' + + -- insert symbols on connector rows + -- + -- note that there are 8 possible connections + -- under the assumption that any connector cell + -- has at least 2 neighbors but no more than 3 + -- + -- there are 4 ways to make the connections of three neighbors + -- there are 6 ways to make the connections of two neighbors + -- however two of them are the vertical and horizontal connections + -- that have already been taken care of + -- + + local symb_map = { + -- two neighbors (no straights) + -- - 8421 + [10] = GCLU, -- '1010' + [9] = GCLD, -- '1001' + [6] = GCRU, -- '0110' + [5] = GCRD, -- '0101' + -- three neighbors + [14] = GLRU, -- '1110' + [13] = GLRD, -- '1101' + [11] = GLUD, -- '1011' + [7] = GRUD, -- '0111' + } + + for i = 2, #graph, 2 do + local row = graph[i] + local above = graph[i - 1] + local below = graph[i + 1] + + for j = 1, #row.cells, 2 do + local this = row.cells[j] + + if this.connector ~= GVER then + local lc = row.cells[j - 1] + local rc = row.cells[j + 1] + local uc = above and above.cells[j] + local dc = below and below.cells[j] + + local l = lc and (lc.connector ~= " " or lc.commit) or false + local r = rc and (rc.connector ~= " " or rc.commit) or false + local u = uc and (uc.connector ~= " " or uc.commit) or false + local d = dc and (dc.connector ~= " " or dc.commit) or false + + -- number of neighbors + local nn = 0 + + local symb_n = 0 + for i, b in ipairs { l, r, u, d } do + if b then + nn = nn + 1 + symb_n = symb_n + bit.lshift(1, 4 - i) + end + end + + local symbol = symb_map[symb_n] or "?" + + if (i == #graph or i == #graph - 1) and symbol == "?" then + symbol = GVER + end + + local commit_dir_above = above.commit and above.commit.j == j + + ---@type 'l' | 'r' | nil -- placement of commit horizontally, only relevant if this is a connector row and if the cell is not immediately above or below the commit + local clh_above = nil + local commit_above = above.commit and above.commit.j ~= j + if commit_above then + clh_above = above.commit.j < j and "l" or "r" + end + + if clh_above and symbol == GLRD then + if clh_above == "l" then + symbol = GLRDCL -- '<' + elseif clh_above == "r" then + symbol = GLRDCR -- '>' + end + elseif symbol == GLRU then + -- because nothing else is possible with our + -- current implicit graph building rules? + symbol = GLRUCL -- '<' + end + + local merge_dir_above = commit_dir_above and #above.commit.parents > 1 + + if symbol == GLUD then + symbol = merge_dir_above and GLUDCU or GLUDCD + end + + if symbol == GRUD then + symbol = merge_dir_above and GRUDCU or GRUDCD + end + + if nn == 4 then + symbol = merge_dir_above and GFORKD or GFORKU + end + + if row.cells[j].commit then + row.cells[j].connector = symbol + end + end + end + end + + local lines, highlights = graph_to_lines(graph) + + -- + -- BEGIN NEOGIT COMPATIBILITY CODE + -- Transform graph into what neogit needs to render + -- + local result = {} + local hl = {} + for _, highlight in ipairs(highlights) do + local row = highlight.row + if not hl[row] then + hl[row] = {} + end + + for i = highlight.start, highlight.stop do + hl[row][i] = highlight + end + end + + for row, line in ipairs(lines) do + local graph_row = {} + local oid = line[1] + local parts = line[2] + + for i, part in ipairs(parts) do + local current_highlight = hl[row][i] or {} + + table.insert(graph_row, { + oid = oid ~= "" and oid, + text = part, + color = not color and "Purple" or current_highlight.hg, + }) + end + + if oid ~= "strip" then + table.insert(result, graph_row) + end + end + + return result +end + +return M diff --git a/lua/neogit/lib/graph.lua b/lua/neogit/lib/graph/unicode.lua similarity index 99% rename from lua/neogit/lib/graph.lua rename to lua/neogit/lib/graph/unicode.lua index b2309b72a..65fdda2eb 100644 --- a/lua/neogit/lib/graph.lua +++ b/lua/neogit/lib/graph/unicode.lua @@ -477,6 +477,7 @@ function M.build(commits) if is_missing_parent and branch_index ~= moved_parent_branch_index then -- Remove branch branch_hashes[branch_index] = nil + assert(branch_hash, "no branch hash") branch_indexes[branch_hash] = nil -- Trim trailing empty branches diff --git a/lua/neogit/lib/hl.lua b/lua/neogit/lib/hl.lua index bd9620563..658e32773 100644 --- a/lua/neogit/lib/hl.lua +++ b/lua/neogit/lib/hl.lua @@ -89,13 +89,13 @@ end local function make_palette(config) local bg = Color.from_hex(get_bg("Normal") or (vim.o.bg == "dark" and "#22252A" or "#eeeeee")) local fg = Color.from_hex((vim.o.bg == "dark" and "#fcfcfc" or "#22252A")) - local red = Color.from_hex(get_fg("Error") or "#E06C75") - local orange = Color.from_hex(get_fg("SpecialChar") or "#ffcb6b") - local yellow = Color.from_hex(get_fg("PreProc") or "#FFE082") - local green = Color.from_hex(get_fg("String") or "#C3E88D") - local cyan = Color.from_hex(get_fg("Operator") or "#89ddff") - local blue = Color.from_hex(get_fg("Macro") or "#82AAFF") - local purple = Color.from_hex(get_fg("Include") or "#C792EA") + local red = Color.from_hex(config.highlight.red or get_fg("Error") or "#E06C75") + local orange = Color.from_hex(config.highlight.orange or get_fg("SpecialChar") or "#ffcb6b") + local yellow = Color.from_hex(config.highlight.yellow or get_fg("PreProc") or "#FFE082") + local green = Color.from_hex(config.highlight.green or get_fg("String") or "#C3E88D") + local cyan = Color.from_hex(config.highlight.cyan or get_fg("Operator") or "#89ddff") + local blue = Color.from_hex(config.highlight.blue or get_fg("Macro") or "#82AAFF") + local purple = Color.from_hex(config.highlight.purple or get_fg("Include") or "#C792EA") local bg_factor = vim.o.bg == "dark" and 1 or -1 @@ -178,7 +178,9 @@ function M.setup(config) NeogitSignatureGoodExpired = { link = "NeogitGraphOrange" }, NeogitSignatureGoodExpiredKey = { link = "NeogitGraphYellow" }, NeogitSignatureGoodRevokedKey = { link = "NeogitGraphRed" }, + NeogitNormal = { link = "Normal" }, NeogitCursorLine = { link = "CursorLine" }, + NeogitCursorLineNr = { link = "CursorLineNr" }, NeogitHunkMergeHeader = { fg = palette.bg2, bg = palette.grey, bold = palette.bold }, NeogitHunkMergeHeaderHighlight = { fg = palette.bg0, bg = palette.bg_cyan, bold = palette.bold }, NeogitHunkMergeHeaderCursor = { fg = palette.bg0, bg = palette.bg_cyan, bold = palette.bold }, @@ -230,6 +232,7 @@ function M.setup(config) NeogitStash = { link = "NeogitSubtleText" }, NeogitRebaseDone = { link = "NeogitSubtleText" }, NeogitFold = { fg = "None", bg = "None" }, + NeogitWinSeparator = { link = "WinSeparator" }, NeogitChangeMuntracked = { link = "NeogitChangeModified" }, NeogitChangeAuntracked = { link = "NeogitChangeAdded" }, NeogitChangeNuntracked = { link = "NeogitChangeNewFile" }, @@ -252,6 +255,7 @@ function M.setup(config) NeogitChangeCunstaged = { link = "NeogitChangeCopied" }, NeogitChangeUunstaged = { link = "NeogitChangeUpdated" }, NeogitChangeRunstaged = { link = "NeogitChangeRenamed" }, + NeogitChangeTunstaged = { link = "NeogitChangeUpdated" }, NeogitChangeDDunstaged = { link = "NeogitChangeUnmerged" }, NeogitChangeUUunstaged = { link = "NeogitChangeUnmerged" }, NeogitChangeAAunstaged = { link = "NeogitChangeUnmerged" }, @@ -267,6 +271,7 @@ function M.setup(config) NeogitChangeCstaged = { link = "NeogitChangeCopied" }, NeogitChangeUstaged = { link = "NeogitChangeUpdated" }, NeogitChangeRstaged = { link = "NeogitChangeRenamed" }, + NeogitChangeTstaged = { link = "NeogitChangeUpdated" }, NeogitChangeDDstaged = { link = "NeogitChangeUnmerged" }, NeogitChangeUUstaged = { link = "NeogitChangeUnmerged" }, NeogitChangeAAstaged = { link = "NeogitChangeUnmerged" }, @@ -302,6 +307,7 @@ function M.setup(config) NeogitTagDistance = { fg = palette.cyan }, NeogitFloatHeader = { bg = palette.bg0, bold = palette.bold }, NeogitFloatHeaderHighlight = { bg = palette.bg2, fg = palette.cyan, bold = palette.bold }, + NeogitActiveItem = { bg = palette.bg_orange, fg = palette.bg0, bold = palette.bold }, } for group, hl in pairs(hl_store) do diff --git a/lua/neogit/lib/input.lua b/lua/neogit/lib/input.lua index b0308478c..26a336180 100644 --- a/lua/neogit/lib/input.lua +++ b/lua/neogit/lib/input.lua @@ -49,6 +49,7 @@ end ---@field completion string? ---@field separator string? ---@field cancel string? +---@field prepend string? ---@param prompt string Prompt to use for user input ---@param opts GetUserInputOpts? Options table diff --git a/lua/neogit/lib/popup/builder.lua b/lua/neogit/lib/popup/builder.lua index 5036697e2..bfc1c7fa5 100644 --- a/lua/neogit/lib/popup/builder.lua +++ b/lua/neogit/lib/popup/builder.lua @@ -2,11 +2,12 @@ local git = require("neogit.lib.git") local state = require("neogit.lib.state") local util = require("neogit.lib.util") local notification = require("neogit.lib.notification") +local config = require("neogit.config") -local M = {} - ----@class PopupData +---@class PopupBuilder ---@field state PopupState +---@field builder_fn PopupData +local M = {} ---@class PopupState ---@field name string @@ -25,69 +26,98 @@ local M = {} ---@field cli string ---@field cli_prefix string ---@field default string|integer|boolean +---@field dependent table ---@field description string ---@field fn function ---@field id string +---@field incompatible table ---@field key string ---@field key_prefix string ---@field separator string ---@field type string ----@field value string +---@field value string? ---@class PopupSwitch ---@field cli string ---@field cli_base string ---@field cli_prefix string ---@field cli_suffix string ----@field dependant table +---@field dependent table ---@field description string ---@field enabled boolean ---@field fn function ---@field id string ----@field incompatible table +---@field incompatible string[] ---@field internal boolean ---@field key string ---@field key_prefix string ---@field options table ---@field type string ---@field user_input boolean +---@field value string? +---@field persisted? boolean ---@class PopupConfig ---@field id string ---@field key string ---@field name string ----@field entry string ----@field value string +---@field entry ConfigEntry +---@field value string? ---@field type string +---@field passive boolean? +---@field heading string? +---@field options PopupConfigOption[]? +---@field callback fun(popup: PopupData, config: self)? Called after the config is set +---@field fn fun(popup: PopupData, config: self)? If set, overrides the actual config setting behavior + +---@class PopupConfigOption An option that can be selected as a value for a config +---@field display string The display name for the option +---@field value string The value to set in git config +---@field condition? fun(): boolean An option predicate to determine if the option should appear ---@class PopupAction ----@field keys table +---@field keys string[] ---@field description string ---@field callback function +---@field heading string? +---@field persist_popup boolean? set to true to prevent closing the popup when invoking ----@class PopupSwitchOpts ----@field enabled boolean Controls if the switch should default to 'on' state ----@field internal boolean Whether the switch is internal to neogit or should be included in the cli command. If `true` we don't include it in the cli command. ----@field incompatible table A table of strings that represent other cli flags that this one cannot be used with ----@field key_prefix string Allows overwriting the default '-' to toggle switch ----@field cli_prefix string Allows overwriting the default '--' thats used to create the cli flag. Sometimes you may want to use '++' or '-'. ----@field cli_suffix string ----@field options table ----@field value string Allows for pre-building cli flags that can be customised by user input ----@field user_input boolean If true, allows user to customise the value of the cli flag ----@field dependant string[] other switches with a state dependency on this one +---@class PopupActionOptions +---@field persist_popup boolean Controls if the action should close the popup (false/nil) or keep it open (true) ----@class PopupOptionsOpts ----@field key_prefix string Allows overwriting the default '=' to set option ----@field cli_prefix string Allows overwriting the default '--' cli prefix ----@field choices table Table of predefined choices that a user can select for option ----@field default string|integer|boolean Default value for option, if the user attempts to unset value +---@class PopupSwitchOpts +---@field enabled? boolean Controls if the switch should default to 'on' state +---@field internal? boolean Whether the switch is internal to neogit or should be included in the cli command. If `true` we don't include it in the cli command. +---@field incompatible? string[] A table of strings that represent other cli switches/options that this one cannot be used with +---@field key_prefix? string Allows overwriting the default '-' to toggle switch +---@field cli_prefix? string Allows overwriting the default '--' that's used to create the cli flag. Sometimes you may want to use '++' or '-'. +---@field cli_suffix? string +---@field options? table +---@field value? string Allows for pre-building cli flags that can be customized by user input +---@field user_input? boolean If true, allows user to customize the value of the cli flag +---@field dependent? string[] other switches/options with a state dependency on this one +---@field persisted? boolean Allows overwriting the default 'true' to decide if this switch should be persisted + +---@class PopupOptionOpts +---@field key_prefix? string Allows overwriting the default '=' to set option +---@field cli_prefix? string Allows overwriting the default '--' cli prefix +---@field choices? table Table of predefined choices that a user can select for option +---@field default? string|integer|boolean Default value for option, if the user attempts to unset value +---@field dependent? string[] other switches/options with a state dependency on this one +---@field incompatible? string[] A table of strings that represent other cli switches/options that this one cannot be used with +---@field separator? string Defaults to `=`, separating the key from the value. Some CLI options are weird. +---@field setup? fun(PopupBuilder) function called before rendering +---@field fn? fun() function called - like an action. Used to launch a popup from a popup. ---@class PopupConfigOpts ----@field options { display: string, value: string, config: function? } ----@field passive boolean Controls if this config setting can be manipulated directly, or if it is managed by git, and should just be shown in UI --- A 'condition' key with function value can also be present in the option, which controls if the option gets shown by returning boolean. - +---@field options? PopupConfigOption[] +---@field fn? fun(popup: PopupData, config: self) If set, overrides the actual config setting behavior +---@field callback? fun(popup: PopupData, config: PopupConfig)? A callback that will be invoked after the config is set +---@field passive? boolean? Controls if this config setting can be manipulated directly, or if it is managed by git, and should just be shown in UI +--- A 'condition' key with function value can also be present in the option, which controls if the option gets shown by returning boolean. + +---@param builder_fn fun(): PopupData +---@return PopupBuilder function M.new(builder_fn) local instance = { state = { @@ -106,17 +136,23 @@ function M.new(builder_fn) return instance end -function M:name(x) - self.state.name = x +-- Set the popup's name. This must be set for all popups. +---@param name string The name +---@return self +function M:name(name) + self.state.name = name return self end -function M:env(x) - self.state.env = x or {} +-- Set initial context for the popup +---@param env table The initial context +---@return self +function M:env(env) + self.state.env = env or {} return self end ----Adds new column to actions section of popup +-- adds a new column to the actions section of the popup ---@param heading string? ---@return self function M:new_action_group(heading) @@ -124,7 +160,7 @@ function M:new_action_group(heading) return self end ----Conditionally adds new column to actions section of popup +-- Conditionally adds a new column to the actions section of the popup ---@param cond boolean ---@param heading string? ---@return self @@ -136,7 +172,7 @@ function M:new_action_group_if(cond, heading) return self end ----Adds new heading to current column within actions section of popup +-- adds a new heading to current column within the actions section of the popup ---@param heading string ---@return self function M:group_heading(heading) @@ -144,7 +180,7 @@ function M:group_heading(heading) return self end ----Conditionally adds new heading to current column within actions section of popup +-- Conditionally adds a new heading to current column within the actions section of the popup ---@param cond boolean ---@param heading string ---@return self @@ -156,10 +192,11 @@ function M:group_heading_if(cond, heading) return self end +-- Adds a switch to the popup ---@param key string Which key triggers switch ---@param cli string Git cli flag to use ---@param description string Description text to show user ----@param opts PopupSwitchOpts? +---@param opts PopupSwitchOpts? Additional options ---@return self function M:switch(key, cli, description, opts) opts = opts or {} @@ -176,8 +213,8 @@ function M:switch(key, cli, description, opts) opts.incompatible = {} end - if opts.dependant == nil then - opts.dependant = {} + if opts.dependent == nil then + opts.dependent = {} end if opts.key_prefix == nil then @@ -192,6 +229,10 @@ function M:switch(key, cli, description, opts) opts.cli_suffix = "" end + if opts.persisted == nil then + opts.persisted = true + end + local value if opts.enabled and opts.value then value = cli .. opts.value @@ -223,21 +264,22 @@ function M:switch(key, cli, description, opts) cli_prefix = opts.cli_prefix, user_input = opts.user_input, cli_suffix = opts.cli_suffix, + persisted = opts.persisted, options = opts.options, incompatible = util.build_reverse_lookup(opts.incompatible), - dependant = util.build_reverse_lookup(opts.dependant), + dependent = util.build_reverse_lookup(opts.dependent), }) return self end --- Conditionally adds a switch. +-- Conditionally adds a switch to the popup ---@see M:switch ----@param cond boolean +---@param cond boolean The condition under which to add the config ---@param key string Which key triggers switch ---@param cli string Git cli flag to use ---@param description string Description text to show user ----@param opts PopupSwitchOpts? +---@param opts PopupSwitchOpts? Additional options ---@return self function M:switch_if(cond, key, cli, description, opts) if cond then @@ -247,10 +289,12 @@ function M:switch_if(cond, key, cli, description, opts) return self end +-- Adds an option to the popup ---@param key string Key for the user to engage option ---@param cli string CLI value used ---@param value string Current value of option ---@param description string Description of option, presented to user +---@param opts PopupOptionOpts? Additional options function M:option(key, cli, value, description, opts) opts = opts or {} @@ -266,6 +310,14 @@ function M:option(key, cli, value, description, opts) opts.separator = "=" end + if opts.dependent == nil then + opts.dependent = {} + end + + if opts.incompatible == nil then + opts.incompatible = {} + end + if opts.setup then opts.setup(self) end @@ -283,13 +335,15 @@ function M:option(key, cli, value, description, opts) choices = opts.choices, default = opts.default, separator = opts.separator, + dependent = util.build_reverse_lookup(opts.dependent), + incompatible = util.build_reverse_lookup(opts.incompatible), fn = opts.fn, }) return self end --- Adds heading text within Arguments (options/switches) section of popup +-- adds a heading text within Arguments (options/switches) section of the popup ---@param heading string Heading to show ---@return self function M:arg_heading(heading) @@ -297,8 +351,13 @@ function M:arg_heading(heading) return self end +-- Conditionally adds an option to the popup ---@see M:option ----@param cond boolean +---@param cond boolean The condition under which to add the config +---@param key string Which key triggers switch +---@param cli string Git cli flag to use +---@param description string Description text to show user +---@param opts PopupOptionOpts? Additional options ---@return self function M:option_if(cond, key, cli, value, description, opts) if cond then @@ -308,16 +367,30 @@ function M:option_if(cond, key, cli, value, description, opts) return self end ----@param heading string Heading to render within config section of popup +-- adds a heading text with the config section of the popup +---@param heading string Heading to render ---@return self function M:config_heading(heading) table.insert(self.state.config, { heading = heading }) return self end +-- adds a heading text with the config section of the popup +---@param cond boolean +---@param heading string Heading to render +---@return self +function M:config_heading_if(cond, heading) + if cond then + table.insert(self.state.config, { heading = heading }) + end + + return self +end + +-- Adds config to the popup ---@param key string Key for user to use that engages config ---@param name string Name of config ----@param options PopupConfigOpts? +---@param options PopupConfigOpts? Additional options ---@return self function M:config(key, name, options) local entry = git.config.get(name) @@ -341,9 +414,12 @@ function M:config(key, name, options) return self end --- Conditionally adds config to popup +-- Conditionally adds config to the popup ---@see M:config ----@param cond boolean +---@param cond boolean The condition under which to add the config +---@param key string Key for user to use that engages config +---@param name string Name of config +---@param options PopupConfigOpts? Additional options ---@return self function M:config_if(cond, key, name, options) if cond then @@ -353,11 +429,26 @@ function M:config_if(cond, key, name, options) return self end +---Inserts a blank slot +---@return self +function M:spacer() + table.insert(self.state.actions[#self.state.actions], { + keys = "", + description = "", + heading = "", + }) + return self +end + +-- Adds an action to the popup ---@param keys string|string[] Key or list of keys for the user to press that runs the action ---@param description string Description of action in UI ----@param callback function Function that gets run in async context +---@param callback? fun(popup: PopupData) Function that gets run in async context +---@param opts? PopupActionOptions ---@return self -function M:action(keys, description, callback) +function M:action(keys, description, callback, opts) + opts = opts or {} + if type(keys) == "string" then keys = { keys } end @@ -375,28 +466,39 @@ function M:action(keys, description, callback) keys = keys, description = description, callback = callback, + persist_popup = opts.persist_popup or false, }) return self end --- Conditionally adds action to popup ----@param cond boolean +-- Conditionally adds an action to the popup ---@see M:action +---@param cond boolean The condition under which to add the action +---@param keys string|string[] Key or list of keys for the user to press that runs the action +---@param description string Description of action in UI +---@param callback? fun(popup: PopupData) Function that gets run in async context +---@param opts? PopupActionOptions ---@return self -function M:action_if(cond, key, description, callback) +function M:action_if(cond, keys, description, callback, opts) if cond then - return self:action(key, description, callback) + return self:action(keys, description, callback, opts) end return self end +-- Builds the popup +---@return PopupData # The popup function M:build() if self.state.name == nil then error("A popup needs to have a name!") end + if config.values.builders ~= nil and type(config.values.builders[self.state.name]) == "function" then + config.values.builders[self.state.name](self) + end + return self.builder_fn(self.state) end diff --git a/lua/neogit/lib/popup/init.lua b/lua/neogit/lib/popup/init.lua index 909fb5c43..dc11c4143 100644 --- a/lua/neogit/lib/popup/init.lua +++ b/lua/neogit/lib/popup/init.lua @@ -2,7 +2,6 @@ local PopupBuilder = require("neogit.lib.popup.builder") local Buffer = require("neogit.lib.buffer") local logger = require("neogit.logger") local util = require("neogit.lib.util") -local config = require("neogit.config") local state = require("neogit.lib.state") local input = require("neogit.lib.input") local notification = require("neogit.lib.notification") @@ -26,6 +25,8 @@ local ui = require("neogit.lib.popup.ui") ---@field buffer Buffer local M = {} +-- Create a new popup builder +---@return PopupBuilder function M.builder() return PopupBuilder.new(M.new) end @@ -63,6 +64,16 @@ function M:get_arguments() return flags end +---@param key string +---@return any|nil +function M:get_env(key) + if not self.state.env then + return nil + end + + return self.state.env[key] +end + -- Returns a table of key/value pairs, where the key is the name of the switch, and value is `true`, for all -- enabled arguments that are NOT for cli consumption (internal use only). ---@return table @@ -91,7 +102,7 @@ function M:close() end -- Toggle a switch on/off ----@param switch table +---@param switch PopupSwitch ---@return nil function M:toggle_switch(switch) if switch.options then @@ -107,7 +118,10 @@ function M:toggle_switch(switch) switch.cli = options[(index + 1)] or options[1] switch.value = switch.cli switch.enabled = switch.cli ~= "" - state.set({ self.state.name, switch.cli_suffix }, switch.cli) + + if switch.persisted ~= false then + state.set({ self.state.name, switch.cli_suffix }, switch.cli) + end return end @@ -126,70 +140,117 @@ function M:toggle_switch(switch) end end - state.set({ self.state.name, switch.cli }, switch.enabled) + if switch.persisted ~= false then + state.set({ self.state.name, switch.cli }, switch.enabled) + end - -- Ensure that other switches that are incompatible with this one are disabled + -- Ensure that other switches/options that are incompatible with this one are disabled if switch.enabled and #switch.incompatible > 0 then for _, var in ipairs(self.state.args) do - if var.type == "switch" and var.enabled and switch.incompatible[var.cli] then - var.enabled = false - state.set({ self.state.name, var.cli }, var.enabled) + if switch.incompatible[var.cli] then + if var.type == "switch" then + ---@cast var PopupSwitch + self:disable_switch(var) + elseif var.type == "option" then + ---@cast var PopupOption + self:disable_option(var) + end end end end - -- Ensure that switches that depend on this one are also disabled - if not switch.enabled and #switch.dependant > 0 then + -- Ensure that switches/options that depend on this one are also disabled + if not switch.enabled and #switch.dependent > 0 then for _, var in ipairs(self.state.args) do - if var.type == "switch" and var.enabled and switch.dependant[var.cli] then - var.enabled = false - state.set({ self.state.name, var.cli }, var.enabled) + if switch.dependent[var.cli] then + if var.type == "switch" then + ---@cast var PopupSwitch + self:disable_switch(var) + elseif var.type == "option" then + ---@cast var PopupOption + self:disable_option(var) + end end end end end -- Toggle an option on/off and set it's value ----@param option table +---@param option PopupOption +---@param value? string ---@return nil -function M:set_option(option) - -- Prompt user to select from predetermined choices - if option.choices then - if not option.value or option.value == "" then - local choice = FuzzyFinderBuffer.new(option.choices):open_async { - prompt_prefix = option.description, - } - if choice then - option.value = choice - else - option.value = "" - end - else - option.value = "" - end +function M:set_option(option, value) + if option.value and option.value ~= "" then -- Toggle option off when it's currently set + option.value = "" + elseif value then + option.value = value + elseif option.choices then + local eventignore = vim.o.eventignore + vim.o.eventignore = "WinLeave" + option.value = FuzzyFinderBuffer.new(option.choices):open_async { + prompt_prefix = option.description, + refocus_status = false, + } + vim.o.eventignore = eventignore elseif option.fn then option.value = option.fn(self, option) else - local input = input.get_user_input(option.cli, { + option.value = input.get_user_input(option.cli, { separator = "=", default = option.value, cancel = option.value, }) + end - -- If the option specifies a default value, and the user set the value to be empty, defer to default value. - -- This is handy to prevent the user from accidentally loading thousands of log entries by accident. - if option.default and input == "" then - option.value = option.default - else - option.value = input + state.set({ self.state.name, option.cli }, option.value) + + -- Ensure that other switches/options that are incompatible with this one are disabled + if option.value and option.value ~= "" and #option.incompatible > 0 then + for _, var in ipairs(self.state.args) do + if option.incompatible[var.cli] then + if var.type == "switch" then + self:disable_switch(var --[[@as PopupSwitch]]) + elseif var.type == "option" then + self:disable_option(var --[[@as PopupOption]]) + end + end end end - state.set({ self.state.name, option.cli }, option.value) + -- Ensure that switches/options that depend on this one are also disabled + if option.value and option.value ~= "" and #option.dependent > 0 then + for _, var in ipairs(self.state.args) do + if option.dependent[var.cli] then + if var.type == "switch" then + self:disable_switch(var --[[@as PopupSwitch]]) + elseif var.type == "option" then + self:disable_option(var --[[@as PopupOption]]) + end + end + end + end +end + +---Disables a switch. +---@param switch PopupSwitch +function M:disable_switch(switch) + if switch.enabled then + self:toggle_switch(switch) + end +end + +---Disables an option, setting its value to "". Doesn't use the default, which +---is important to ensure that we don't use incompatible switches/options +---together. +---@param option PopupOption +function M:disable_option(option) + if option.value and option.value ~= "" then + self:set_option(option, "") + end end -- Set a config value ----@param config table +---@param config PopupConfig ---@return nil function M:set_config(config) if config.options then @@ -209,6 +270,7 @@ function M:set_config(config) else local result = input.get_user_input(config.name, { default = config.value, cancel = config.value }) + assert(result, "no input from user - what happened to the default?") config.value = result git.config.set(config.name, config.value) end @@ -236,7 +298,7 @@ function M:mappings() [""] = function() self:close() end, - [""] = function() + [""] = a.void(function() local component = self.buffer.ui:get_interactive_component_under_cursor() if not component then return @@ -251,7 +313,7 @@ function M:mappings() end self:refresh() - end, + end), }, } @@ -261,8 +323,10 @@ function M:mappings() arg_prefixes[arg.key_prefix] = true mappings.n[arg.id] = a.void(function() if arg.type == "switch" then + ---@cast arg PopupSwitch self:toggle_switch(arg) elseif arg.type == "option" then + ---@cast arg PopupOption self:set_option(arg) end @@ -288,6 +352,7 @@ function M:mappings() mappings.n[config.id] = a.void(function() self:set_config(config) self:refresh() + Watcher.instance():dispatch_refresh() end) end end @@ -301,7 +366,11 @@ function M:mappings() for _, key in ipairs(action.keys) do mappings.n[key] = a.void(function() logger.debug(string.format("[POPUP]: Invoking action %q of %s", key, self.state.name)) - self:close() + if not action.persist_popup then + logger.debug("[POPUP]: Closing popup") + self:close() + end + action.callback(self) Watcher.instance():dispatch_refresh() end) @@ -321,7 +390,6 @@ end function M:refresh() if self.buffer then - self.buffer:focus() self.buffer.ui:render(unpack(ui.Popup(self.state))) end end @@ -350,7 +418,7 @@ function M:show() pcall(self.close, self) end, }, - after = function(buf, _win) + after = function(buf) buf:set_window_option("cursorline", false) buf:set_window_option("list", false) @@ -368,25 +436,18 @@ function M:show() end end - if - config.values.popup.kind == "split" - or config.values.popup.kind == "split_above" - or config.values.popup.kind == "split_above_all" - or config.values.popup.kind == "split_below" - or config.values.popup.kind == "split_below_all" - then - vim.cmd.resize(vim.fn.line("$") + 1) - - -- We do it again because things like the BranchConfigPopup come from an async context, - -- but if we only do it schedule wrapped, then you can see it load at one size, and - -- resize a few ms later - vim.schedule(function() - if buf:is_focused() then - vim.cmd.resize(vim.fn.line("$") + 1) - buf:set_window_option("winfixheight", true) - end - end) - end + local height = vim.fn.line("$") + 1 + vim.cmd.resize(height) + + -- We do it again because things like the BranchConfigPopup come from an async context, + -- but if we only do it schedule wrapped, then you can see it load at one size, and + -- resize a few ms later + vim.schedule(function() + if buf:is_focused() then + vim.cmd.resize(height) + buf:set_window_option("winfixheight", true) + end + end) end, render = function() return ui.Popup(self.state) diff --git a/lua/neogit/lib/popup/ui.lua b/lua/neogit/lib/popup/ui.lua index 29f758bf6..b8f78e39a 100644 --- a/lua/neogit/lib/popup/ui.lua +++ b/lua/neogit/lib/popup/ui.lua @@ -196,6 +196,8 @@ local function render_action(action) -- selene: allow(empty_if) if action.keys == nil then -- Action group heading + elseif action.keys == "" then + table.insert(items, text("")) -- spacer elseif #action.keys == 0 then table.insert(items, text.highlight("NeogitPopupActionDisabled")("_")) else diff --git a/lua/neogit/lib/state.lua b/lua/neogit/lib/state.lua index 246701809..5b6335492 100644 --- a/lua/neogit/lib/state.lua +++ b/lua/neogit/lib/state.lua @@ -17,7 +17,7 @@ end ---@return Path function M.filepath(config) - local state_path = Path.new(vim.fn.stdpath("state")):joinpath("neogit") + local state_path = Path:new(vim.fn.stdpath("state")):joinpath("neogit") local filename = "state" if config.use_per_project_settings then @@ -60,7 +60,12 @@ function M.read() end log("Reading file") - return vim.mpack.decode(M.path:read()) + local content = M.path:read() + if content then + return vim.mpack.decode(content) + else + return {} + end end ---Writes state to disk diff --git a/lua/neogit/lib/ui/component.lua b/lua/neogit/lib/ui/component.lua index 82a55d2cc..ae737977c 100644 --- a/lua/neogit/lib/ui/component.lua +++ b/lua/neogit/lib/ui/component.lua @@ -25,6 +25,13 @@ local default_component_options = { ---@field section string|nil ---@field item table|nil ---@field id string|nil +---@field oid string|nil +---@field ref ParsedRef +---@field yankable string? +---@field on_open fun(fold, Ui) +---@field hunk Hunk +---@field filename string? +---@field value any ---@class Component ---@field position ComponentPosition @@ -35,8 +42,15 @@ local default_component_options = { ---@field index number|nil ---@field value string|nil ---@field id string|nil +---@field highlight fun(hl_group:string): self +---@field line_hl fun(hl_group:string): self +---@field padding_left fun(string): self +---@field first integer|nil first line component appears rendered in buffer +---@field last integer|nil last line component appears rendered in buffer +---@operator call: Component local Component = {} +---@return integer, integer function Component:row_range_abs() return self.position.row_start, self.position.row_end end @@ -140,6 +154,8 @@ function Component:close_all_folds(ui) end end +---@param f fun(...): table +---@return Component function Component.new(f) local instance = {} diff --git a/lua/neogit/lib/ui/debug.lua b/lua/neogit/lib/ui/debug.lua index 4e5df63b9..c09bda914 100644 --- a/lua/neogit/lib/ui/debug.lua +++ b/lua/neogit/lib/ui/debug.lua @@ -33,13 +33,13 @@ function Ui._visualize_tree(indent, components, tree) end end -function Ui.visualize_component(c, options) - Ui._print_component(0, c, options or {}) - - if c.tag == "col" or c.tag == "row" then - Ui._visualize_tree(1, c.children, options or {}) - end -end +-- function Ui.visualize_component(c, options) +-- Ui._print_component(0, c, options or {}) +-- +-- if c.tag == "col" or c.tag == "row" then +-- Ui._visualize_tree(1, c.children, options or {}) +-- end +-- end function Ui._draw_component(indent, c, _) local output = string.rep(" ", indent) diff --git a/lua/neogit/lib/ui/init.lua b/lua/neogit/lib/ui/init.lua index 92f9814d2..225cc5af5 100644 --- a/lua/neogit/lib/ui/init.lua +++ b/lua/neogit/lib/ui/init.lua @@ -5,8 +5,9 @@ local Collection = require("neogit.lib.collection") local logger = require("neogit.logger") -- TODO: Add logging ---@class Section ----@field items StatusItem[] +---@field items StatusItem[] ---@field name string +---@field first number ---@class Selection ---@field sections Section[] @@ -15,8 +16,8 @@ local logger = require("neogit.logger") -- TODO: Add logging ---@field section Section|nil ---@field item StatusItem|nil ---@field commit CommitLogEntry|nil ----@field commits CommitLogEntry[] ----@field items StatusItem[] +---@field commits CommitLogEntry[] +---@field items StatusItem[] local Selection = {} Selection.__index = Selection @@ -39,6 +40,7 @@ function Ui.new(buf) return setmetatable({ buf = buf, layout = {} }, Ui) end +---@return Component|nil function Ui._find_component(components, f, options) for _, c in ipairs(components) do if c.tag == "col" or c.tag == "row" then @@ -63,24 +65,6 @@ function Ui:find_component(f, options) return Ui._find_component(self.layout, f, options or {}) end -function Ui._find_components(components, f, result, options) - for _, c in ipairs(components) do - if c.tag == "col" or c.tag == "row" then - Ui._find_components(c.children, f, result, options) - end - - if f(c) then - table.insert(result, c) - end - end -end - -function Ui:find_components(f, options) - local result = {} - Ui._find_components(self.layout, f, result, options or {}) - return result -end - ---@param fn? fun(c: Component): boolean ---@return Component|nil function Ui:get_component_under_cursor(fn) @@ -113,6 +97,12 @@ function Ui:_find_component_by_index(line, f) end end +---@param oid string +---@return Component|nil +function Ui:find_component_by_oid(oid) + return self.node_index:find_by_oid(oid) +end + ---@return Component|nil function Ui:get_cursor_context(line) local cursor = line or vim.api.nvim_win_get_cursor(0)[1] @@ -148,15 +138,6 @@ function Ui:get_fold_under_cursor() end) end ----@class StatusItem ----@field name string ----@field first number ----@field last number ----@field oid string|nil optional object id ----@field commit CommitLogEntry|nil optional object id ----@field folded boolean|nil ----@field hunks Hunk[]|nil - ---@class SelectedHunk: Hunk ---@field from number start offset from the first line of the hunk ---@field to number end offset from the first line of the hunk @@ -184,25 +165,19 @@ function Ui:item_hunks(item, first_line, last_line, partial) if not item.folded and item.diff.hunks then for _, h in ipairs(item.diff.hunks) do - if h.first <= last_line and h.last >= first_line then + if h.first <= first_line and h.last >= last_line then local from, to if partial then - local cursor_offset = first_line - h.first local length = last_line - first_line - from = h.diff_from + cursor_offset + from = first_line - h.first to = from + length else from = h.diff_from + 1 to = h.diff_to end - local hunk_lines = {} - for i = from, to do - table.insert(hunk_lines, item.diff.lines[i]) - end - -- local conflict = false -- for _, n in ipairs(conflict_markers) do -- if from <= n and n <= to then @@ -216,7 +191,6 @@ function Ui:item_hunks(item, first_line, last_line, partial) to = to, __index = h, hunk = h, - lines = hunk_lines, -- conflict = conflict, } @@ -230,6 +204,7 @@ function Ui:item_hunks(item, first_line, last_line, partial) return hunks end +---@return Selection function Ui:get_selection() local visual_pos = vim.fn.line("v") local cursor_pos = vim.fn.line(".") @@ -292,6 +267,7 @@ function Ui:get_selection() return setmetatable(res, Selection) end +--- returns commits in selection in a constant order ---@return string[] function Ui:get_commits_in_selection() local range = { vim.fn.getpos("v")[2], vim.fn.getpos(".")[2] } @@ -301,7 +277,7 @@ function Ui:get_commits_in_selection() local commits = {} for i = start, stop do local component = self:_find_component_by_index(i, function(node) - return node.options.oid + return node.options.oid ~= nil end) if component then @@ -312,6 +288,33 @@ function Ui:get_commits_in_selection() return util.deduplicate(commits) end +--- returns commits in selection ordered according to the direction of the selection the user has made +---@return string[] +function Ui:get_ordered_commits_in_selection() + local start = vim.fn.getpos("v")[2] + local stop = vim.fn.getpos(".")[2] + + local increment + if start <= stop then + increment = 1 + else + increment = -1 + end + + local commits = {} + for i = start, stop, increment do + local component = self:_find_component_by_index(i, function(node) + return node.options.oid ~= nil + end) + + if component then + table.insert(commits, component.options.oid) + end + end + + return util.deduplicate(commits) +end + ---@return string[] function Ui:get_filepaths_in_selection() local range = { vim.fn.getpos("v")[2], vim.fn.getpos(".")[2] } @@ -321,7 +324,7 @@ function Ui:get_filepaths_in_selection() local paths = {} for i = start, stop do local component = self:_find_component_by_index(i, function(node) - return node.options.item and node.options.item.escaped_path + return node.options.item ~= nil and node.options.item.escaped_path end) if component then @@ -352,6 +355,27 @@ function Ui:get_ref_under_cursor() return component and component.options.ref end +--- +---@return ParsedRef[] +function Ui:get_refs_under_cursor() + local range = { vim.fn.getpos("v")[2], vim.fn.getpos(".")[2] } + table.sort(range) + local start, stop = unpack(range) + + local refs = {} + for i = start, stop do + local component = self:_find_component_by_index(i, function(node) + return node.options.ref ~= nil + end) + + if component then + table.insert(refs, 1, component.options.ref) + end + end + + return util.deduplicate(refs) +end + ---@return string|nil function Ui:get_yankable_under_cursor() local cursor = vim.api.nvim_win_get_cursor(0) @@ -504,7 +528,7 @@ function Ui:resolve_cursor_location(cursor) if cursor.hunk.index_from == hunk.index_from then logger.debug(("[UI] Using hunk.first with offset %q"):format(cursor.hunk.name)) - return hunk.first + cursor.hunk_offset - (cursor.last - hunk.last) + return hunk.first + (cursor.hunk_offset or 0) - (cursor.last - hunk.last) else logger.debug(("[UI] Using hunk.first %q"):format(cursor.hunk.name)) return hunk.first @@ -515,7 +539,7 @@ end function Ui:get_hunk_or_filename_under_cursor() local cursor = vim.api.nvim_win_get_cursor(0) local component = self:_find_component_by_index(cursor[1], function(node) - return node.options.hunk or node.options.filename + return node.options.hunk ~= nil or node.options.filename ~= nil end) return component and { @@ -528,7 +552,7 @@ end function Ui:get_item_under_cursor() local cursor = vim.api.nvim_win_get_cursor(0) local component = self:_find_component_by_index(cursor[1], function(node) - return node.options.item + return node.options.item ~= nil end) return component and component.options.item @@ -740,10 +764,6 @@ Ui.text = Component.new(function(value, options, ...) error("Too many arguments") end - vim.validate { - options = { options, "table", true }, - } - return { tag = "text", value = value or "", diff --git a/lua/neogit/lib/ui/renderer.lua b/lua/neogit/lib/ui/renderer.lua index 2c187bcdb..721278565 100644 --- a/lua/neogit/lib/ui/renderer.lua +++ b/lua/neogit/lib/ui/renderer.lua @@ -1,8 +1,11 @@ ---@source component.lua +local strdisplaywidth = vim.fn.strdisplaywidth + ---@class RendererIndex ---@field index table ---@field items table +---@field oid_index table local RendererIndex = {} RendererIndex.__index = RendererIndex @@ -12,6 +15,12 @@ function RendererIndex:find_by_line(line) return self.index[line] or {} end +---@param oid string +---@return Component|nil +function RendererIndex:find_by_oid(oid) + return self.oid_index[oid] +end + ---@param node Component function RendererIndex:add(node) if not self.index[node.position.row_start] then @@ -32,17 +41,25 @@ function RendererIndex:add_section(name, first, last) table.insert(self.items, { items = {} }) end +---@param item table +---@param first number +---@param last number function RendererIndex:add_item(item, first, last) self.items[#self.items].last = last item.first = first item.last = last table.insert(self.items[#self.items].items, item) + + if item.oid then + self.oid_index[item.oid] = item + end end function RendererIndex.new() return setmetatable({ index = {}, + oid_index = {}, items = { { items = {} }, -- First section }, @@ -60,6 +77,11 @@ end ---@field in_row boolean ---@field in_nested_row boolean +---@class RendererHighlight +---@field from integer +---@field to integer +---@field name string + ---@class Renderer ---@field buffer RendererBuffer ---@field flags RendererFlags @@ -113,6 +135,9 @@ function Renderer:item_index() return self.index.items end +---@param child Component +---@param parent Component +---@param index integer function Renderer:_build_child(child, parent, index) child.parent = parent child.index = index @@ -240,6 +265,10 @@ end ---@param child Component ---@param i integer index of child in parent.children +---@param col_start integer +---@param col_end integer|nil +---@param highlights RendererHighlight[] +---@param text string[] function Renderer:_render_child_in_row(child, i, col_start, col_end, highlights, text) if child.tag == "text" then return self:_render_in_row_text(child, i, col_start, highlights, text) @@ -252,6 +281,9 @@ end ---@param child Component ---@param index integer index of child in parent.children +---@param col_start integer +---@param highlights RendererHighlight[] +---@param text string[] function Renderer:_render_in_row_text(child, index, col_start, highlights, text) local padding_left = self.flags.in_nested_row and "" or child:get_padding_left(index == 1) table.insert(text, 1, padding_left) @@ -279,6 +311,10 @@ function Renderer:_render_in_row_text(child, index, col_start, highlights, text) end ---@param child Component +---@param highlights RendererHighlight[] +---@param text string[] +---@param col_start integer +---@param col_end integer|nil function Renderer:_render_in_row_row(child, highlights, text, col_start, col_end) self.flags.in_nested_row = true local res = self:_render(child, child.children, col_start) @@ -290,7 +326,7 @@ function Renderer:_render_in_row_row(child, highlights, text, col_start, col_end table.insert(highlights, h) end - col_end = col_start + vim.fn.strdisplaywidth(res.text) + col_end = col_start + strdisplaywidth(res.text) child.position.col_start = col_start child.position.col_end = col_end diff --git a/lua/neogit/lib/util.lua b/lua/neogit/lib/util.lua index 532c8d419..46c9942f6 100644 --- a/lua/neogit/lib/util.lua +++ b/lua/neogit/lib/util.lua @@ -3,7 +3,7 @@ local M = {} ---@generic T: any ---@generic U: any ---@param tbl T[] ----@param f fun(v: T): U +---@param f Component|fun(v: T): U ---@return U[] function M.map(tbl, f) local t = {} @@ -455,7 +455,7 @@ end ---@param callback function ---@return uv_timer_t local function set_timeout(timeout, callback) - local timer = vim.loop.new_timer() + local timer = vim.uv.new_timer() timer:start(timeout, 0, function() timer:stop() @@ -522,7 +522,7 @@ function M.debounce_trailing(ms, fn, hash) return function(...) local id = hash and hash(...) or true if running[id] == nil then - running[id] = assert(vim.loop.new_timer()) + running[id] = assert(vim.uv.new_timer()) end local timer = running[id] @@ -600,9 +600,8 @@ end ---@param winid integer ---@param force boolean function M.safe_win_close(winid, force) - local ok, _ = pcall(vim.api.nvim_win_close, winid, force) - - if not ok then + local success = M.try(vim.api.nvim_win_close, winid, force) + if not success then pcall(vim.cmd, "b#") end end @@ -611,4 +610,17 @@ function M.weak_table(mode) return setmetatable({}, { __mode = mode or "k" }) end +---@param fn fun(...): any +---@param ...any +---@return boolean|any +function M.try(fn, ...) + local ok, result = pcall(fn, ...) + if not ok then + require("neogit.logger").error(result) + return false + else + return result or true + end +end + return M diff --git a/lua/neogit/logger.lua b/lua/neogit/logger.lua index a07a21916..b8d725a56 100644 --- a/lua/neogit/logger.lua +++ b/lua/neogit/logger.lua @@ -41,6 +41,8 @@ log.new = function(config, standalone) obj = {} end + obj.config = config + local levels = {} for i, v in ipairs(config.modes) do levels[v.name] = i diff --git a/lua/neogit/popups/branch/actions.lua b/lua/neogit/popups/branch/actions.lua index 190c5230f..d733e396b 100644 --- a/lua/neogit/popups/branch/actions.lua +++ b/lua/neogit/popups/branch/actions.lua @@ -5,27 +5,30 @@ local config = require("neogit.config") local input = require("neogit.lib.input") local util = require("neogit.lib.util") local notification = require("neogit.lib.notification") +local event = require("neogit.lib.event") local a = require("plenary.async") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") local BranchConfigPopup = require("neogit.popups.branch_config") -local function fire_branch_event(pattern, data) - vim.api.nvim_exec_autocmds("User", { pattern = pattern, modeline = false, data = data }) -end - local function fetch_remote_branch(target) local remote, branch = git.branch.parse_remote_branch(target) if remote then notification.info("Fetching from " .. remote .. "/" .. branch) git.fetch.fetch(remote, branch) - fire_branch_event("NeogitFetchComplete", { branch = branch, remote = remote }) + event.send("FetchComplete", { branch = branch, remote = remote }) end end local function checkout_branch(target, args) - git.branch.checkout(target, args) - fire_branch_event("NeogitBranchCheckout", { branch_name = target }) + local result = git.branch.checkout(target, args) + if result:failure() then + notification.error(table.concat(result.stderr, "\n")) + return + end + + event.send("BranchCheckout", { branch_name = target }) + notification.info("Checked out branch " .. target) if config.values.fetch_after_checkout then a.void(function() @@ -43,35 +46,45 @@ local function checkout_branch(target, args) end end +local function get_branch_name_user_input(prompt, default) + default = default or config.values.initial_branch_name + return input.get_user_input(prompt, { strip_spaces = true, default = default }) +end + +---@param checkout boolean local function spin_off_branch(checkout) if git.status.is_dirty() and not checkout then notification.info("Staying on HEAD due to uncommitted changes") checkout = true end - local name = - input.get_user_input(("%s branch"):format(checkout and "Spin-off" or "Spin-out"), { strip_spaces = true }) + local name = get_branch_name_user_input(("%s branch"):format(checkout and "Spin-off" or "Spin-out")) if not name then return end - fire_branch_event("NeogitBranchCreate", { branch_name = name }) - git.branch.create(name) + if not git.branch.create(name) then + notification.warn("Branch " .. name .. " already exists.") + return + end + + event.send("BranchCreate", { branch_name = name }) local current_branch_name = git.branch.current_full_name() if checkout then git.cli.checkout.branch(name).call() - fire_branch_event("NeogitBranchCheckout", { branch_name = name }) + event.send("BranchCheckout", { branch_name = name }) end local upstream = git.branch.upstream() if upstream then if checkout then + assert(current_branch_name, "No current branch") git.log.update_ref(current_branch_name, upstream) else git.cli.reset.hard.args(upstream).call() - fire_branch_event("NeogitReset", { commit = name, mode = "hard" }) + event.send("Reset", { commit = name, mode = "hard" }) end end end @@ -110,19 +123,25 @@ local function create_branch(popup, prompt, checkout, name) end local name = name - or input.get_user_input("Create branch", { - strip_spaces = true, - default = popup.state.env.suggested_branch_name or suggested_branch_name, - }) + or get_branch_name_user_input( + "Create branch", + popup.state.env.suggested_branch_name or suggested_branch_name + ) if not name then return end - git.branch.create(name, base_branch) - fire_branch_event("NeogitBranchCreate", { branch_name = name, base = base_branch }) + local success = git.branch.create(name, base_branch) + if success then + event.send("BranchCreate", { branch_name = name, base = base_branch }) - if checkout then - checkout_branch(name, popup:get_arguments()) + if checkout then + checkout_branch(name, popup:get_arguments()) + else + notification.info("Created branch " .. name) + end + else + notification.warn("Branch " .. name .. " already exists.") end end @@ -170,8 +189,14 @@ function M.checkout_local_branch(popup) if target then if vim.tbl_contains(remote_branches, target) then - git.branch.track(target, popup:get_arguments()) - fire_branch_event("NeogitBranchCheckout", { branch_name = target }) + local result = git.branch.track(target, popup:get_arguments()) + if result:failure() then + notification.error(table.concat(result.stderr, "\n")) + return + end + + notification.info("Created local branch " .. target .. " tracking remote") + event.send("BranchCheckout", { branch_name = target }) elseif not vim.tbl_contains(options, target) then target, _ = target:gsub("%s", "-") create_branch(popup, "Create " .. target .. " starting at", true, target) @@ -205,7 +230,7 @@ function M.configure_branch() return end - BranchConfigPopup.create(branch_name) + BranchConfigPopup.create { branch = branch_name } end function M.rename_branch() @@ -215,15 +240,18 @@ function M.rename_branch() return end - local new_name = input.get_user_input(("Rename '%s' to"):format(selected_branch), { strip_spaces = true }) + local new_name = get_branch_name_user_input(("Rename '%s' to"):format(selected_branch)) if not new_name then return end - git.cli.branch.move.args(selected_branch, new_name).call { await = true } - - notification.info(string.format("Renamed '%s' to '%s'", selected_branch, new_name)) - fire_branch_event("NeogitBranchRename", { branch_name = selected_branch, new_name = new_name }) + local result = git.cli.branch.move.args(selected_branch, new_name).call { await = true } + if result:success() then + notification.info(string.format("Renamed '%s' to '%s'", selected_branch, new_name)) + event.send("BranchRename", { branch_name = selected_branch, new_name = new_name }) + else + notification.warn(string.format("Couldn't rename '%s' to '%s'", selected_branch, new_name)) + end end function M.reset_branch(popup) @@ -263,30 +291,38 @@ function M.reset_branch(popup) end -- Reset the current branch to the desired state & update reflog - git.cli.reset.hard.args(to).call() - git.log.update_ref(git.branch.current_full_name(), to) - - notification.info(string.format("Reset '%s' to '%s'", current, to)) - fire_branch_event("NeogitBranchReset", { branch_name = current, resetting_to = to }) + local result = git.cli.reset.hard.args(to).call() + if result:success() then + local current = git.branch.current_full_name() + assert(current, "no current branch") + git.log.update_ref(current, to) + + notification.info(string.format("Reset '%s' to '%s'", current, to)) + event.send("BranchReset", { branch_name = current, resetting_to = to }) + else + notification.error("Couldn't reset branch.") + end end function M.delete_branch(popup) local options = util.deduplicate(util.merge({ popup.state.env.ref_name }, git.refs.list_branches())) - local selected_branch = FuzzyFinderBuffer.new(options):open_async { refocus_status = false } + local selected_branch = FuzzyFinderBuffer.new(options) + :open_async { prompt_prefix = "Delete branch", refocus_status = false } if not selected_branch then return end local remote, branch_name = git.branch.parse_remote_branch(selected_branch) + local is_remote = remote and remote ~= "." local success = false if - remote + is_remote and branch_name and input.get_permission(("Delete remote branch '%s/%s'?"):format(remote, branch_name)) then - success = git.cli.push.remote(remote).delete.to(branch_name).call().code == 0 - elseif not remote and branch_name == git.branch.current() then + success = git.cli.push.remote(remote).delete.to(branch_name).call():success() + elseif not is_remote and branch_name == git.branch.current() then local choices = { "&detach HEAD and delete", "&abort", @@ -305,6 +341,7 @@ function M.delete_branch(popup) if choice == "d" then git.cli.checkout.detach.call() elseif choice == "c" then + assert(upstream, "there should be an upstream by this point") git.cli.checkout.branch(upstream).call() else return @@ -314,29 +351,34 @@ function M.delete_branch(popup) if not success then -- Reset HEAD if unsuccessful git.cli.checkout.branch(branch_name).call() end - elseif not remote and branch_name then + elseif not is_remote and branch_name then success = git.branch.delete(branch_name) end if success then - if remote then + if is_remote then notification.info(string.format("Deleted remote branch '%s/%s'", remote, branch_name)) else notification.info(string.format("Deleted branch '%s'", branch_name)) end - fire_branch_event("NeogitBranchDelete", { branch_name = branch_name }) + event.send("BranchDelete", { branch_name = branch_name }) end end function M.open_pull_request() local template local service - local url = git.remote.get_url(https://github.com/git.branch.upstream_remote())[1] + local upstream = git.branch.upstream_remote() + if not upstream then + return + end + + local url = git.remote.get_url(https://github.com/upstream)[1] for s, v in pairs(config.values.git_services) do if url:match(util.pattern_escape(s)) then service = s - template = v + template = v.pull_request break end end @@ -363,9 +405,11 @@ function M.open_pull_request() format_values["target"] = target end - vim.ui.open(util.format(template, format_values)) + local uri = util.format(template, format_values) + notification.info(("Opening %q in your browser."):format(uri)) + vim.ui.open(uri) else - notification.warn("Requires Neovim 0.10") + notification.warn("Requires Neovim >= 0.10") end else notification.warn("Pull request URL template not found for this branch's upstream") diff --git a/lua/neogit/popups/branch/init.lua b/lua/neogit/popups/branch/init.lua index 34a8ad011..3a4177f4f 100644 --- a/lua/neogit/popups/branch/init.lua +++ b/lua/neogit/popups/branch/init.lua @@ -11,16 +11,24 @@ function M.create(env) local show_config = current_branch ~= "" local pull_rebase_entry = git.config.get("pull.rebase") local pull_rebase = pull_rebase_entry:is_set() and pull_rebase_entry.value or "false" + local has_upstream = git.branch.upstream() ~= nil local p = popup .builder() :name("NeogitBranchPopup") - :switch("r", "recurse-submodules", "Recurse submodules when checking out an existing branch") + :config_heading_if(show_config, "Configure branch") :config_if(show_config, "d", "branch." .. current_branch .. ".description", { fn = config_actions.description_config(current_branch), }) :config_if(show_config, "u", "branch." .. current_branch .. ".merge", { fn = config_actions.merge_config(current_branch), + callback = function(popup) + for _, config in ipairs(popup.state.config) do + if config.name == "branch." .. current_branch .. ".remote" then + config.value = tostring(config.entry:refresh():read() or "") + end + end + end, }) :config_if(show_config, "m", "branch." .. current_branch .. ".remote", { passive = true }) :config_if(show_config, "R", "branch." .. current_branch .. ".rebase", { @@ -33,6 +41,7 @@ function M.create(env) :config_if(show_config, "p", "branch." .. current_branch .. ".pushRemote", { options = config_actions.remotes_for_config(), }) + :switch("r", "recurse-submodules", "Recurse submodules when checking out an existing branch") :group_heading("Checkout") :action("b", "branch/revision", actions.checkout_branch_revision) :action("l", "local branch", actions.checkout_local_branch) @@ -50,7 +59,7 @@ function M.create(env) :action("m", "rename", actions.rename_branch) :action("X", "reset", actions.reset_branch) :action("D", "delete", actions.delete_branch) - :action_if(git.branch.upstream(), "o", "pull request", actions.open_pull_request) + :action_if(has_upstream, "o", "pull request", actions.open_pull_request) :env(env) :build() diff --git a/lua/neogit/popups/branch_config/actions.lua b/lua/neogit/popups/branch_config/actions.lua index 73444cc92..ecb9b8cc9 100644 --- a/lua/neogit/popups/branch_config/actions.lua +++ b/lua/neogit/popups/branch_config/actions.lua @@ -24,7 +24,22 @@ end function M.merge_config(branch) local fn = function() - local target = FuzzyFinderBuffer.new(git.refs.list_branches()):open_async { prompt_prefix = "upstream" } + -- When the values are set, clear them and return + if git.config.get_local("branch." .. branch .. ".merge"):is_set() then + git.config.set("branch." .. branch .. ".merge", nil) + git.config.set("branch." .. branch .. ".remote", nil) + + return + end + + local eventignore = vim.o.eventignore + vim.o.eventignore = "WinLeave" + local target = FuzzyFinderBuffer.new(git.refs.list_branches()):open_async { + prompt_prefix = "upstream", + refocus_status = false, + } + vim.o.eventignore = eventignore + if not target then return end @@ -59,7 +74,7 @@ function M.description_config(branch) }) vim.o.eventignore = "" - return git.config.get("branch." .. branch .. ".description"):read() + return git.config.get_local("branch." .. branch .. ".description"):read() end return a.wrap(fn, 2) diff --git a/lua/neogit/popups/branch_config/init.lua b/lua/neogit/popups/branch_config/init.lua index 6c953b42c..2cd1e8171 100644 --- a/lua/neogit/popups/branch_config/init.lua +++ b/lua/neogit/popups/branch_config/init.lua @@ -3,20 +3,41 @@ local M = {} local popup = require("neogit.lib.popup") local git = require("neogit.lib.git") local actions = require("neogit.popups.branch_config.actions") +local notification = require("neogit.lib.notification") + +---@param env table +function M.create(env) + local branch = env.branch or git.branch.current() + + if not branch then + notification.error("Cannot infer branch.") + return + end -function M.create(branch) - branch = branch or git.branch.current() local g_pull_rebase = git.config.get_global("pull.rebase") - local pull_rebase_entry = git.config.get("pull.rebase") + local pull_rebase_entry = git.config.get_local("pull.rebase") local pull_rebase = pull_rebase_entry:is_set() and pull_rebase_entry.value or "false" local p = popup .builder() :name("NeogitBranchConfigPopup") :config_heading("Configure branch") - :config("d", "branch." .. branch .. ".description", { fn = actions.description_config(branch) }) - :config("u", "branch." .. branch .. ".merge", { fn = actions.merge_config(branch) }) - :config("m", "branch." .. branch .. ".remote", { passive = true }) + :config("d", "branch." .. branch .. ".description", { + fn = actions.description_config(branch), + }) + :config("u", "branch." .. branch .. ".merge", { + fn = actions.merge_config(branch), + callback = function(popup) + for _, config in ipairs(popup.state.config) do + if config.name == "branch." .. branch .. ".remote" then + config.value = tostring(config.entry:refresh():read() or "") + end + end + end, + }) + :config("m", "branch." .. branch .. ".remote", { + passive = true, + }) :config("r", "branch." .. branch .. ".rebase", { options = { { display = "true", value = "true" }, @@ -24,7 +45,9 @@ function M.create(branch) { display = "pull.rebase:" .. pull_rebase, value = "" }, }, }) - :config("p", "branch." .. branch .. ".pushRemote", { options = actions.remotes_for_config() }) + :config("p", "branch." .. branch .. ".pushRemote", { + options = actions.remotes_for_config(), + }) :config_heading("") :config_heading("Configure repository defaults") :config("R", "pull.rebase", { @@ -40,7 +63,9 @@ function M.create(branch) }, }, }) - :config("P", "remote.pushDefault", { options = actions.remotes_for_config() }) + :config("P", "remote.pushDefault", { + options = actions.remotes_for_config(), + }) :config("b", "neogit.baseBranch") :config("A", "neogit.askSetPushDefault", { options = { diff --git a/lua/neogit/popups/cherry_pick/actions.lua b/lua/neogit/popups/cherry_pick/actions.lua index 28a7ea833..764e81934 100644 --- a/lua/neogit/popups/cherry_pick/actions.lua +++ b/lua/neogit/popups/cherry_pick/actions.lua @@ -1,8 +1,10 @@ local M = {} - +local util = require("neogit.lib.util") local git = require("neogit.lib.git") +local notification = require("neogit.lib.notification") local CommitSelectViewBuffer = require("neogit.buffers.commit_select_view") +local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") ---@param popup any ---@return table @@ -13,6 +15,7 @@ local function get_commits(popup) else commits = CommitSelectViewBuffer.new( git.log.list { "--max-count=256" }, + git.remote.list(), "Select one or more commits to cherry pick with , or to abort" ):open_async() end @@ -38,6 +41,93 @@ function M.apply(popup) git.cherry_pick.apply(commits, popup:get_arguments()) end +function M.squash(popup) + local refs = util.merge(popup.state.env.commits, git.refs.list_branches(), git.refs.list_tags()) + local ref = FuzzyFinderBuffer.new(refs):open_async { prompt_prefix = "Squash" } + if ref then + local args = popup:get_arguments() + table.insert(args, "--squash") + git.merge.merge(ref, args) + end +end + +---@param popup PopupData +---@param verb string +---@return string[] +local function get_cherries(popup, verb) + local commits + if #popup.state.env.commits > 1 then + commits = popup.state.env.commits + else + local refs = util.merge(popup.state.env.commits, git.refs.list_branches()) + local ref = FuzzyFinderBuffer.new(refs):open_async { prompt_prefix = verb .. " cherry" } + + if ref == popup.state.env.commits[1] then + commits = popup.state.env.commits + else + commits = util.map(git.cherry.list(git.rev_parse.oid("HEAD"), ref), function(cherry) + return cherry.oid or cherry + end) + end + + if not commits[1] then + commits = { git.rev_parse.oid(ref) } + end + end + + return commits +end + +---@param popup PopupData +function M.donate(popup) + local commits = get_cherries(popup, "Donate") + local src = git.branch.current() or git.rev_parse.oid("HEAD") + + if not git.log.is_ancestor(commits[1], git.rev_parse.oid(src)) then + return notification.error("Cannot donate cherries that are not reachable from HEAD") + end + + local prefix = string.format("Move %d cherr%s to branch", #commits, #commits > 1 and "ies" or "y") + local options = git.refs.list_branches() + util.remove_item_from_table(options, src) + + local dst = FuzzyFinderBuffer.new(options):open_async { prompt_prefix = prefix } + if dst then + notification.info( + ("Moved %d cherr%s from %q to %q"):format(#commits, #commits > 1 and "ies" or "y", src, dst) + ) + git.cherry_pick.move(commits, src, dst, popup:get_arguments()) + end +end + +---@param popup PopupData +function M.harvest(popup) + local current = git.branch.current() + if not current then + return + end + + local commits = get_cherries(popup, "Harvest") + + if git.log.is_ancestor(commits[1], git.rev_parse.oid("HEAD")) then + return notification.error("Cannot harvest cherries that are reachable from HEAD") + end + + local branch + local containing_branches = git.branch.list_containing_branches(commits[1]) + if #containing_branches > 1 then + local prefix = string.format("Remove %d cherr%s from branch", #commits, #commits > 1 and "ies" or "y") + branch = FuzzyFinderBuffer.new(containing_branches):open_async { prompt_prefix = prefix } + else + branch = containing_branches[1] + end + + if branch then + notification.info(("Harvested %d cherr%s"):format(#commits, #commits > 1 and "ies" or "y")) + git.cherry_pick.move(commits, branch, current, popup:get_arguments(), nil, true) + end +end + function M.continue() git.cherry_pick.continue() end diff --git a/lua/neogit/popups/cherry_pick/init.lua b/lua/neogit/popups/cherry_pick/init.lua index 180d476a8..2f48714b0 100644 --- a/lua/neogit/popups/cherry_pick/init.lua +++ b/lua/neogit/popups/cherry_pick/init.lua @@ -10,29 +10,34 @@ function M.create(env) local p = popup .builder() :name("NeogitCherryPickPopup") - :option_if(not in_progress, "m", "mainline", "", "Replay merge relative to parent", { key_prefix = "-" }) + :option_if(not in_progress, "m", "mainline", "", "Replay merge relative to parent", { + key_prefix = "-", + }) :option_if(not in_progress, "s", "strategy", "", "Strategy", { key_prefix = "=", choices = { "octopus", "ours", "resolve", "subtree", "recursive" }, }) - :switch_if( - not in_progress, - "F", - "ff", - "Attempt fast-forward", - { enabled = true, incompatible = { "edit" } } - ) - :switch_if(not in_progress, "x", "x", "Reference cherry in commit message", { cli_prefix = "-" }) - :switch_if(not in_progress, "e", "edit", "Edit commit messages", { incompatible = { "ff" } }) + :switch_if(not in_progress, "F", "ff", "Attempt fast-forward", { + enabled = true, + incompatible = { "edit" }, + }) + :switch_if(not in_progress, "x", "x", "Reference cherry in commit message", { + cli_prefix = "-", + }) + :switch_if(not in_progress, "e", "edit", "Edit commit messages", { + incompatible = { "ff" }, + }) :switch_if(not in_progress, "s", "signoff", "Add Signed-off-by lines") - :option_if(not in_progress, "S", "gpg-sign", "", "Sign using gpg", { key_prefix = "-" }) + :option_if(not in_progress, "S", "gpg-sign", "", "Sign using gpg", { + key_prefix = "-", + }) :group_heading_if(not in_progress, "Apply here") :action_if(not in_progress, "A", "Pick", actions.pick) :action_if(not in_progress, "a", "Apply", actions.apply) - :action_if(not in_progress, "h", "Harvest") - :action_if(not in_progress, "m", "Squash") + :action_if(not in_progress, "h", "Harvest", actions.harvest) + :action_if(not in_progress, "m", "Squash", actions.squash) :new_action_group_if(not in_progress, "Apply elsewhere") - :action_if(not in_progress, "d", "Donate") + :action_if(not in_progress, "d", "Donate", actions.donate) :action_if(not in_progress, "n", "Spinout") :action_if(not in_progress, "s", "Spinoff") :group_heading_if(in_progress, "Cherry Pick") diff --git a/lua/neogit/popups/commit/actions.lua b/lua/neogit/popups/commit/actions.lua index 9d188830e..252922a3e 100644 --- a/lua/neogit/popups/commit/actions.lua +++ b/lua/neogit/popups/commit/actions.lua @@ -8,6 +8,13 @@ local notification = require("neogit.lib.notification") local config = require("neogit.config") local a = require("plenary.async") +---@param popup PopupData +---@return boolean +local function allow_empty(popup) + return vim.tbl_contains(popup:get_arguments(), "--allow-empty") + or vim.tbl_contains(popup:get_arguments(), "--all") +end + local function confirm_modifications() if git.branch.upstream() @@ -30,6 +37,7 @@ local function do_commit(popup, cmd) autocmd = "NeogitCommitComplete", msg = { success = "Committed", + fail = "Commit failed", }, interactive = true, show_diff = config.values.commit_editor.show_staged_diff, @@ -37,7 +45,7 @@ local function do_commit(popup, cmd) end local function commit_special(popup, method, opts) - if not git.status.anything_staged() then + if not git.status.anything_staged() and not allow_empty(popup) then if git.status.anything_unstaged() then if input.get_permission("Nothing is staged. Commit all uncommitted changed?") then opts.all = true @@ -50,7 +58,8 @@ local function commit_special(popup, method, opts) end end - local commit = popup.state.env.commit or CommitSelectViewBuffer.new(git.log.list()):open_async()[1] + local commit = popup.state.env.commit + or CommitSelectViewBuffer.new(git.log.list(), git.remote.list()):open_async()[1] if not commit then return end @@ -69,13 +78,13 @@ local function commit_special(popup, method, opts) if choice == "c" then opts.rebase = false elseif choice == "s" then - commit = CommitSelectViewBuffer.new(git.log.list()):open_async()[1] + commit = CommitSelectViewBuffer.new(git.log.list(), git.remote.list()):open_async()[1] else return end end - local cmd = git.cli.commit.args(string.format("--%s=%s", method, commit)) + local cmd = git.cli.commit if opts.edit then cmd = cmd.edit else @@ -87,7 +96,7 @@ local function commit_special(popup, method, opts) end a.util.scheduler() - do_commit(popup, cmd) + do_commit(popup, cmd.args(method:format(commit))) if opts.rebase then a.util.scheduler() @@ -96,10 +105,27 @@ local function commit_special(popup, method, opts) end function M.commit(popup) + if not git.status.anything_staged() and not allow_empty(popup) then + notification.warn("No changes to commit.") + return + end + do_commit(popup, git.cli.commit) end function M.extend(popup) + if not git.status.anything_staged() and not allow_empty(popup) then + if git.status.anything_unstaged() then + if input.get_permission("Nothing is staged. Commit all uncommitted changes?") then + git.status.stage_modified() + else + return + end + else + return notification.warn("No changes to commit.") + end + end + if not confirm_modifications() then return end @@ -124,15 +150,23 @@ function M.amend(popup) end function M.fixup(popup) - commit_special(popup, "fixup", { edit = false }) + commit_special(popup, "--fixup=%s", { edit = false }) end function M.squash(popup) - commit_special(popup, "squash", { edit = false }) + commit_special(popup, "--squash=%s", { edit = false }) end function M.augment(popup) - commit_special(popup, "squash", { edit = true }) + commit_special(popup, "--squash=%s", { edit = true }) +end + +function M.alter(popup) + commit_special(popup, "--fixup=amend:%s", { edit = true }) +end + +function M.revise(popup) + commit_special(popup, "--fixup=reword:%s", { edit = true }) end function M.instant_fixup(popup) @@ -140,7 +174,7 @@ function M.instant_fixup(popup) return end - commit_special(popup, "fixup", { rebase = true, edit = false }) + commit_special(popup, "--fixup=%s", { rebase = true, edit = false }) end function M.instant_squash(popup) @@ -148,7 +182,7 @@ function M.instant_squash(popup) return end - commit_special(popup, "squash", { rebase = true, edit = false }) + commit_special(popup, "--squash=%s", { rebase = true, edit = false }) end function M.absorb(popup) @@ -157,9 +191,9 @@ function M.absorb(popup) return end - if not git.status.anything_staged() then + if not git.status.anything_staged() and not allow_empty(popup) then if git.status.anything_unstaged() then - if input.get_permission("Nothing is staged. Absorb all unstaged changed?") then + if input.get_permission("Nothing is staged. Absorb all unstaged changes?") then git.status.stage_modified() else return @@ -173,6 +207,7 @@ function M.absorb(popup) local commit = popup.state.env.commit or CommitSelectViewBuffer.new( git.log.list { "HEAD" }, + git.remote.list(), "Select a base commit for the absorb stack with , or to abort" ) :open_async()[1] @@ -180,7 +215,7 @@ function M.absorb(popup) return end - git.cli.absorb.verbose.base(commit).and_rebase.call() + git.cli.absorb.verbose.base(commit .. "^").and_rebase.env({ GIT_SEQUENCE_EDITOR = ":" }).call() end return M diff --git a/lua/neogit/popups/commit/init.lua b/lua/neogit/popups/commit/init.lua index 6b9223519..fa5fd8a9f 100644 --- a/lua/neogit/popups/commit/init.lua +++ b/lua/neogit/popups/commit/init.lua @@ -8,7 +8,7 @@ function M.create(env) .builder() :name("NeogitCommitPopup") :switch("a", "all", "Stage all modified and deleted files") - :switch("e", "allow-empty", "Allow empty commit") + :switch("e", "allow-empty", "Allow empty commit", { persisted = false }) :switch("v", "verbose", "Show diff of changes to be committed") :switch("h", "no-verify", "Disable hooks") :switch("R", "reset-author", "Claim authorship and reset author date") @@ -18,18 +18,23 @@ function M.create(env) :option("C", "reuse-message", "", "Reuse commit message", { key_prefix = "-" }) :group_heading("Create") :action("c", "Commit", actions.commit) - :action("x", "Absorb", actions.absorb) :new_action_group("Edit HEAD") :action("e", "Extend", actions.extend) - :action("w", "Reword", actions.reword) + :spacer() :action("a", "Amend", actions.amend) + :spacer() + :action("w", "Reword", actions.reword) :new_action_group("Edit") :action("f", "Fixup", actions.fixup) :action("s", "Squash", actions.squash) - :action("A", "Augment", actions.augment) - :new_action_group() + :action("A", "Alter", actions.alter) + :action("n", "Augment", actions.augment) + :action("W", "Revise", actions.revise) + :new_action_group("Edit and rebase") :action("F", "Instant Fixup", actions.instant_fixup) :action("S", "Instant Squash", actions.instant_squash) + :new_action_group("Spread across commits") + :action("x", "Absorb", actions.absorb) :env({ highlight = { "HEAD" }, commit = env.commit }) :build() diff --git a/lua/neogit/popups/diff/actions.lua b/lua/neogit/popups/diff/actions.lua index 39d3df266..015b84214 100644 --- a/lua/neogit/popups/diff/actions.lua +++ b/lua/neogit/popups/diff/actions.lua @@ -9,42 +9,67 @@ local input = require("neogit.lib.input") function M.this(popup) popup:close() - if popup.state.env.section and popup.state.env.item then - diffview.open(popup.state.env.section.name, popup.state.env.item.name, { - only = true, - }) - elseif popup.state.env.section then - diffview.open(popup.state.env.section.name, nil, { only = true }) + local item = popup:get_env("item") + local section = popup:get_env("section") + + if section and section.name and item and item.name then + diffview.open(section.name, item.name, { only = true }) + elseif section.name then + diffview.open(section.name, nil, { only = true }) + elseif item.name then + diffview.open("range", item.name .. "..HEAD") + end +end + +function M.this_to_HEAD(popup) + popup:close() + + local item = popup:get_env("item") + if item then + if item.name then + diffview.open("range", item.name .. "..HEAD") + end end end function M.range(popup) + local commit + local item = popup:get_env("item") + local section = popup:get_env("section") + if section and (section.name == "log" or section.name == "recent") then + commit = item and item.name + end + local options = util.deduplicate( util.merge( - { git.branch.current() or "HEAD" }, + { commit, git.branch.current() or "HEAD" }, git.branch.get_all_branches(false), git.tag.list(), git.refs.heads() ) ) - local range_from = FuzzyFinderBuffer.new(options):open_async { prompt_prefix = "Diff for range from" } + local range_from = FuzzyFinderBuffer.new(options):open_async { + prompt_prefix = "Diff for range from", + refocus_status = false, + } + if not range_from then return end local range_to = FuzzyFinderBuffer.new(options) - :open_async { prompt_prefix = "Diff from " .. range_from .. " to" } + :open_async { prompt_prefix = "Diff from " .. range_from .. " to", refocus_status = false } if not range_to then return end local choices = { - "&1. " .. range_from .. ".." .. range_to, - "&2. " .. range_from .. "..." .. range_to, + "&1. Range (a..b)", + "&2. Symmetric Difference (a...b)", "&3. Cancel", } - local choice = input.get_choice("Select range", { values = choices, default = #choices }) + local choice = input.get_choice("Select type", { values = choices, default = #choices }) popup:close() if choice == "1" then @@ -56,7 +81,7 @@ end function M.worktree(popup) popup:close() - diffview.open() + diffview.open("worktree") end function M.staged(popup) @@ -72,7 +97,7 @@ end function M.stash(popup) popup:close() - local selected = FuzzyFinderBuffer.new(git.stash.list()):open_async() + local selected = FuzzyFinderBuffer.new(git.stash.list()):open_async { refocus_status = false } if selected then diffview.open("stashes", selected) end @@ -81,10 +106,9 @@ end function M.commit(popup) popup:close() - local options = - util.merge(git.branch.get_all_branches(), git.tag.list(), { "HEAD", "ORIG_HEAD", "FETCH_HEAD" }) + local options = util.merge(git.refs.list_branches(), git.refs.list_tags(), git.refs.heads()) - local selected = FuzzyFinderBuffer.new(options):open_async() + local selected = FuzzyFinderBuffer.new(options):open_async { refocus_status = false } if selected then diffview.open("commit", selected) end diff --git a/lua/neogit/popups/diff/init.lua b/lua/neogit/popups/diff/init.lua index ed8b849c0..34b32ef83 100644 --- a/lua/neogit/popups/diff/init.lua +++ b/lua/neogit/popups/diff/init.lua @@ -6,12 +6,14 @@ local actions = require("neogit.popups.diff.actions") function M.create(env) local diffview = config.check_integration("diffview") + local commit_selected = (env.section and env.section.name == "log") and type(env.item.name) == "string" local p = popup .builder() :name("NeogitDiffPopup") :group_heading("Diff") - :action_if(diffview, "d", "this", actions.this) + :action_if(diffview and env.item, "d", "this", actions.this) + :action_if(diffview and commit_selected, "h", "this..HEAD", actions.this_to_HEAD) :action_if(diffview, "r", "range", actions.range) :action("p", "paths") :new_action_group() diff --git a/lua/neogit/popups/fetch/actions.lua b/lua/neogit/popups/fetch/actions.lua index 17d1c8746..67991cdf6 100644 --- a/lua/neogit/popups/fetch/actions.lua +++ b/lua/neogit/popups/fetch/actions.lua @@ -5,6 +5,7 @@ local git = require("neogit.lib.git") local logger = require("neogit.logger") local notification = require("neogit.lib.notification") local util = require("neogit.lib.util") +local event = require("neogit.lib.event") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") @@ -16,15 +17,11 @@ local function fetch_from(name, remote, branch, args) notification.info("Fetching from " .. name) local res = git.fetch.fetch_interactive(remote, branch, args) - if res and res.code == 0 then + if res and res:success() then a.util.scheduler() notification.info("Fetched from " .. name, { dismiss = true }) logger.debug("Fetched from " .. name) - vim.api.nvim_exec_autocmds("User", { - pattern = "NeogitFetchComplete", - modeline = false, - data = { remote = remote, branch = branch }, - }) + event.send("FetchComplete", { remote = remote, branch = branch }) else logger.error("Failed to fetch from " .. name) end @@ -121,11 +118,11 @@ end function M.fetch_submodules(_) notification.info("Fetching submodules") - git.cli.fetch.recurse_submodules().verbose().jobs(4).call() + git.cli.fetch.recurse_submodules.verbose.jobs(4).call() end function M.set_variables() - require("neogit.popups.branch_config").create() + require("neogit.popups.branch_config").create {} end return M diff --git a/lua/neogit/popups/fetch/init.lua b/lua/neogit/popups/fetch/init.lua index 2a3371eb6..8fa17f70e 100644 --- a/lua/neogit/popups/fetch/init.lua +++ b/lua/neogit/popups/fetch/init.lua @@ -10,7 +10,7 @@ function M.create() :name("NeogitFetchPopup") :switch("p", "prune", "Prune deleted branches") :switch("t", "tags", "Fetch all tags") - :switch("F", "force", "force") + :switch("F", "force", "force", { persisted = false }) :group_heading("Fetch from") :action("p", git.branch.pushRemote_remote_label(), actions.fetch_pushremote) :action("u", git.branch.upstream_remote_label(), actions.fetch_upstream) diff --git a/lua/neogit/popups/help/actions.lua b/lua/neogit/popups/help/actions.lua index 0249d03df..b46ffe0c5 100644 --- a/lua/neogit/popups/help/actions.lua +++ b/lua/neogit/popups/help/actions.lua @@ -21,6 +21,12 @@ local function present(commands) end if type(keymap) == "table" and next(keymap) then + -- HACK: Remove "za" as listed keymap for toggle action. + table.sort(keymap) + if name == "Toggle" and keymap[2] == "za" then + table.remove(keymap, 2) + end + return { { name = name, keys = keymap, cmp = table.concat(keymap):lower(), fn = fn } } else return { { name = name, keys = {}, cmp = "", fn = fn } } @@ -75,6 +81,9 @@ M.popups = function(env) { "LogPopup", "Log", popups.open("log", function(p) p(env.log) end) }, + { "MarginPopup", "Margin", popups.open("margin", function(p) + p(env.margin) + end) }, { "CherryPickPopup", "Cherry Pick", @@ -115,10 +124,10 @@ end M.actions = function() return present { { "Stage", "Stage", NONE }, - { "StageUnstaged", "Stage-Unstaged", NONE }, + { "StageUnstaged", "Stage unstaged", NONE }, { "StageAll", "Stage all", NONE }, { "Unstage", "Unstage", NONE }, - { "UnstageStaged", "Unstage-Staged", NONE }, + { "UnstageStaged", "Unstage all", NONE }, { "Discard", "Discard", NONE }, { "Untrack", "Untrack", NONE }, } diff --git a/lua/neogit/popups/ignore/actions.lua b/lua/neogit/popups/ignore/actions.lua index bdcbca280..ebbd3b3f3 100644 --- a/lua/neogit/popups/ignore/actions.lua +++ b/lua/neogit/popups/ignore/actions.lua @@ -33,17 +33,16 @@ local function add_rules(path, rules) end function M.shared_toplevel(popup) - local ignore_file = Path:new(git.repo.git_root, ".gitignore") - local rules = make_rules(popup, git.repo.git_root) + local ignore_file = Path:new(git.repo.worktree_root, ".gitignore") + local rules = make_rules(popup, git.repo.worktree_root) add_rules(ignore_file, rules) end function M.shared_subdirectory(popup) - local subdirectory = input.get_user_input("Ignore sub-directory", { completion = "dir" }) - if subdirectory then - subdirectory = Path:new(vim.uv.cwd(), subdirectory) - + local choice = input.get_user_input("Ignore sub-directory", { completion = "dir" }) + if choice then + local subdirectory = Path:new(vim.uv.cwd(), choice) local ignore_file = subdirectory:joinpath(".gitignore") local rules = make_rules(popup, tostring(subdirectory)) @@ -53,14 +52,14 @@ end function M.private_local(popup) local ignore_file = git.repo:git_path("info", "exclude") - local rules = make_rules(popup, git.repo.git_root) + local rules = make_rules(popup, git.repo.worktree_root) add_rules(ignore_file, rules) end function M.private_global(popup) local ignore_file = Path:new(git.config.get_global("core.excludesfile"):read()) - local rules = make_rules(popup, git.repo.git_root) + local rules = make_rules(popup, git.repo.worktree_root) add_rules(ignore_file, rules) end diff --git a/lua/neogit/popups/ignore/init.lua b/lua/neogit/popups/ignore/init.lua index 7b624a461..d359334f8 100644 --- a/lua/neogit/popups/ignore/init.lua +++ b/lua/neogit/popups/ignore/init.lua @@ -22,7 +22,7 @@ function M.create(env) "g", string.format( "privately for all repositories (%s)", - "~/" .. Path:new(excludesFile:read()):make_relative(vim.loop.os_homedir()) + "~/" .. Path:new(excludesFile:read()):make_relative(vim.uv.os_homedir()) ), actions.private_global ) diff --git a/lua/neogit/popups/log/actions.lua b/lua/neogit/popups/log/actions.lua index 8bf49a835..ebc5c836d 100644 --- a/lua/neogit/popups/log/actions.lua +++ b/lua/neogit/popups/log/actions.lua @@ -32,14 +32,14 @@ local function fetch_more_commits(popup, flags) end end --- TODO: Handle when head is detached function M.log_current(popup) LogViewBuffer.new( commits(popup, {}), popup:get_internal_arguments(), popup.state.env.files, fetch_more_commits(popup, {}), - "Commits in " .. git.branch.current() + "Commits in " .. (git.branch.current() or ("(detached) " .. git.log.message("HEAD"))), + git.remote.list() ):open() end @@ -50,7 +50,8 @@ function M.log_related(popup) popup:get_internal_arguments(), popup.state.env.files, fetch_more_commits(popup, flags), - "Commits in " .. table.concat(flags, ", ") + "Commits in " .. table.concat(flags, ", "), + git.remote.list() ):open() end @@ -61,7 +62,8 @@ function M.log_head(popup) popup:get_internal_arguments(), popup.state.env.files, fetch_more_commits(popup, flags), - "Commits in HEAD" + "Commits in HEAD", + git.remote.list() ):open() end @@ -72,7 +74,8 @@ function M.log_local_branches(popup) popup:get_internal_arguments(), popup.state.env.files, fetch_more_commits(popup, flags), - "Commits in --branches" + "Commits in --branches", + git.remote.list() ):open() end @@ -86,7 +89,8 @@ function M.log_other(popup) popup:get_internal_arguments(), popup.state.env.files, fetch_more_commits(popup, flags), - "Commits in " .. branch + "Commits in " .. branch, + git.remote.list() ):open() end end @@ -98,7 +102,8 @@ function M.log_all_branches(popup) popup:get_internal_arguments(), popup.state.env.files, fetch_more_commits(popup, flags), - "Commits in --branches --remotes" + "Commits in --branches --remotes", + git.remote.list() ):open() end @@ -109,7 +114,8 @@ function M.log_all_references(popup) popup:get_internal_arguments(), popup.state.env.files, fetch_more_commits(popup, flags), - "Commits in --all" + "Commits in --all", + git.remote.list() ):open() end @@ -141,10 +147,13 @@ function M.limit_to_files() return "" end - local files = FuzzyFinderBuffer.new(git.files.all_tree()):open_async { + local eventignore = vim.o.eventignore + vim.o.eventignore = "WinLeave" + local files = FuzzyFinderBuffer.new(git.files.all_tree { with_dir = true }):open_async { allow_multi = true, refocus_status = false, } + vim.o.eventignore = eventignore if not files or vim.tbl_isempty(files) then popup.state.env.files = nil diff --git a/lua/neogit/popups/log/init.lua b/lua/neogit/popups/log/init.lua index 64ffa55ab..700cb2495 100644 --- a/lua/neogit/popups/log/init.lua +++ b/lua/neogit/popups/log/init.lua @@ -62,10 +62,10 @@ function M.create() enabled = true, internal = true, incompatible = { "reverse" }, - dependant = { "color" }, + dependent = { "color" }, }) :switch_if( - config.values.graph_style == "ascii", + config.values.graph_style == "ascii" or config.values.graph_style == "kitty", "c", "color", "Show graph in color", diff --git a/lua/neogit/popups/margin/actions.lua b/lua/neogit/popups/margin/actions.lua new file mode 100644 index 000000000..8ab733736 --- /dev/null +++ b/lua/neogit/popups/margin/actions.lua @@ -0,0 +1,32 @@ +local M = {} + +local state = require("neogit.lib.state") +local a = require("plenary.async") + +function M.refresh_buffer(buffer) + return a.void(function() + buffer:dispatch_refresh({ update_diffs = { "*:*" } }, "margin_refresh_buffer") + end) +end + +function M.toggle_visibility() + local visibility = state.get({ "margin", "visibility" }, false) + local new_visibility = not visibility + state.set({ "margin", "visibility" }, new_visibility) +end + +function M.cycle_date_style() + local styles = { "relative_short", "relative_long", "local_datetime" } + local current_index = state.get({ "margin", "date_style" }, #styles) + local next_index = (current_index % #styles) + 1 -- wrap around to the first style + + state.set({ "margin", "date_style" }, next_index) +end + +function M.toggle_details() + local details = state.get({ "margin", "details" }, false) + local new_details = not details + state.set({ "margin", "details" }, new_details) +end + +return M diff --git a/lua/neogit/popups/margin/init.lua b/lua/neogit/popups/margin/init.lua new file mode 100644 index 000000000..272838737 --- /dev/null +++ b/lua/neogit/popups/margin/init.lua @@ -0,0 +1,61 @@ +local popup = require("neogit.lib.popup") +local config = require("neogit.config") +local actions = require("neogit.popups.margin.actions") + +local M = {} + +-- TODO: Implement various flags/switches + +function M.create(env) + local p = popup + .builder() + :name("NeogitMarginPopup") + -- :option("n", "max-count", "256", "Limit number of commits", { default = "256", key_prefix = "-" }) + :switch( + "o", + config.values.commit_order, + "Order commits by", + { + cli_suffix = "-order", + options = { + { display = "", value = "" }, + { display = "topo", value = "topo" }, + { display = "author-date", value = "author-date" }, + { display = "date", value = "date" }, + }, + } + ) + -- :switch("g", "graph", "Show graph", { + -- enabled = true, + -- internal = true, + -- incompatible = { "reverse" }, + -- dependent = { "color" }, + -- }) + -- :switch_if( + -- config.values.graph_style == "ascii" or config.values.graph_style == "kitty", + -- "c", + -- "color", + -- "Show graph in color", + -- { internal = true, incompatible = { "reverse" } } + -- ) + :switch( + "d", + "decorate", + "Show refnames", + { enabled = true, internal = true } + ) + :group_heading("Refresh") + :action_if(env.buffer, "g", "buffer", actions.refresh_buffer(env.buffer), { persist_popup = true }) + :new_action_group("Margin") + :action("L", "toggle visibility", actions.toggle_visibility, { persist_popup = true }) + :action("l", "cycle style", actions.cycle_date_style, { persist_popup = true }) + :action("d", "toggle details", actions.toggle_details, { persist_popup = true }) + :action("x", "toggle shortstat", actions.log_current, { persist_popup = true }) + :build() + + p:show() + + return p +end + +return M diff --git a/lua/neogit/popups/merge/actions.lua b/lua/neogit/popups/merge/actions.lua index b9b94b208..41dd9e58a 100644 --- a/lua/neogit/popups/merge/actions.lua +++ b/lua/neogit/popups/merge/actions.lua @@ -16,10 +16,17 @@ function M.abort() end end -function M.merge(popup) +---@param popup PopupData +---@return string[] +local function get_refs(popup) local refs = util.merge({ popup.state.env.commit }, git.refs.list_branches(), git.refs.list_tags()) + util.remove_item_from_table(refs, git.branch.current()) + + return refs +end - local ref = FuzzyFinderBuffer.new(refs):open_async() +function M.merge(popup) + local ref = FuzzyFinderBuffer.new(get_refs(popup)):open_async { prompt_prefix = "Merge" } if ref then local args = popup:get_arguments() table.insert(args, "--no-edit") @@ -28,9 +35,7 @@ function M.merge(popup) end function M.squash(popup) - local refs = util.merge({ popup.state.env.commit }, git.refs.list_branches(), git.refs.list_tags()) - - local ref = FuzzyFinderBuffer.new(refs):open_async() + local ref = FuzzyFinderBuffer.new(get_refs(popup)):open_async { prompt_prefix = "Squash" } if ref then local args = popup:get_arguments() table.insert(args, "--squash") @@ -39,9 +44,7 @@ function M.squash(popup) end function M.merge_edit(popup) - local refs = util.merge({ popup.state.env.commit }, git.refs.list_branches(), git.refs.list_tags()) - - local ref = FuzzyFinderBuffer.new(refs):open_async() + local ref = FuzzyFinderBuffer.new(get_refs(popup)):open_async { prompt_prefix = "Merge" } if ref then local args = popup:get_arguments() table.insert(args, "--edit") @@ -55,9 +58,7 @@ function M.merge_edit(popup) end function M.merge_nocommit(popup) - local refs = util.merge({ popup.state.env.commit }, git.refs.list_branches(), git.refs.list_tags()) - - local ref = FuzzyFinderBuffer.new(refs):open_async() + local ref = FuzzyFinderBuffer.new(get_refs(popup)):open_async { prompt_prefix = "Merge" } if ref then local args = popup:get_arguments() table.insert(args, "--no-commit") @@ -69,4 +70,5 @@ function M.merge_nocommit(popup) git.merge.merge(ref, args) end end + return M diff --git a/lua/neogit/popups/pull/actions.lua b/lua/neogit/popups/pull/actions.lua index b2060e13a..d311d6f0d 100644 --- a/lua/neogit/popups/pull/actions.lua +++ b/lua/neogit/popups/pull/actions.lua @@ -2,6 +2,7 @@ local a = require("plenary.async") local git = require("neogit.lib.git") local logger = require("neogit.logger") local notification = require("neogit.lib.notification") +local event = require("neogit.lib.event") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") @@ -25,13 +26,18 @@ local function pull_from(args, remote, branch, opts) local res = git.pull.pull_interactive(remote, branch, args) - if res and res.code == 0 then + if res and res:success() then a.util.scheduler() notification.info("Pulled from " .. name, { dismiss = true }) logger.debug("Pulled from " .. name) - vim.api.nvim_exec_autocmds("User", { pattern = "NeogitPullComplete", modeline = false }) + event.send("PullComplete") else logger.error("Failed to pull from " .. name) + notification.error("Failed to pull from " .. name, { dismiss = true }) + if res.code == 128 then + notification.info(table.concat(res.stdout, "\n")) + return + end end end @@ -81,7 +87,7 @@ function M.from_elsewhere(popup) end function M.configure() - require("neogit.popups.branch_config").create() + require("neogit.popups.branch_config").create {} end return M diff --git a/lua/neogit/popups/pull/init.lua b/lua/neogit/popups/pull/init.lua index dcba8a281..070dd91ce 100755 --- a/lua/neogit/popups/pull/init.lua +++ b/lua/neogit/popups/pull/init.lua @@ -5,15 +5,15 @@ local popup = require("neogit.lib.popup") local M = {} function M.create() - local current = git.branch.current() - local show_config = current ~= "" and current ~= "(detached)" + local current = git.branch.current() or "" local pull_rebase_entry = git.config.get("pull.rebase") local pull_rebase = pull_rebase_entry:is_set() and pull_rebase_entry.value or "false" + local is_detached = git.branch.is_detached() local p = popup .builder() :name("NeogitPullPopup") - :config_if(show_config, "r", "branch." .. (current or "") .. ".rebase", { + :config_if(not is_detached, "r", "branch." .. current .. ".rebase", { options = { { display = "true", value = "true" }, { display = "false", value = "false" }, @@ -21,13 +21,14 @@ function M.create() }, }) :switch("f", "ff-only", "Fast-forward only") - :switch("r", "rebase", "Rebase local commits") + :switch("r", "rebase", "Rebase local commits", { persisted = false }) :switch("a", "autostash", "Autostash") :switch("t", "tags", "Fetch tags") - :group_heading_if(current, "Pull into " .. current .. " from") - :group_heading_if(not current, "Pull from") - :action_if(current, "p", git.branch.pushRemote_label(), actions.from_pushremote) - :action_if(current, "u", git.branch.upstream_label(), actions.from_upstream) + :switch("F", "force", "Force", { persisted = false }) + :group_heading_if(not is_detached, "Pull into " .. current .. " from") + :group_heading_if(is_detached, "Pull from") + :action_if(not is_detached, "p", git.branch.pushRemote_label(), actions.from_pushremote) + :action_if(not is_detached, "u", git.branch.upstream_label(), actions.from_upstream) :action("e", "elsewhere", actions.from_elsewhere) :new_action_group("Configure") :action("C", "Set variables...", actions.configure) diff --git a/lua/neogit/popups/push/actions.lua b/lua/neogit/popups/push/actions.lua index 03e782a12..26fd6df56 100644 --- a/lua/neogit/popups/push/actions.lua +++ b/lua/neogit/popups/push/actions.lua @@ -3,15 +3,22 @@ local git = require("neogit.lib.git") local logger = require("neogit.logger") local notification = require("neogit.lib.notification") local input = require("neogit.lib.input") +local util = require("neogit.lib.util") +local config = require("neogit.config") +local event = require("neogit.lib.event") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") local M = {} +---@param args string[] +---@param remote string +---@param branch string|nil +---@param opts table|nil local function push_to(args, remote, branch, opts) opts = opts or {} - if opts.set_upstream then + if opts.set_upstream or git.push.auto_setup_remote(branch) then table.insert(args, "--set-upstream") end @@ -40,22 +47,22 @@ local function push_to(args, remote, branch, opts) local using_force = vim.tbl_contains(args, "--force") or vim.tbl_contains(args, "--force-with-lease") local updates_rejected = string.find(table.concat(res.stdout), "Updates were rejected") ~= nil - -- Only ask the user whether to force push if not already specified - if res and res.code ~= 0 and not using_force and updates_rejected then + -- Only ask the user whether to force push if not already specified and feature enabled + if res and res:failure() and not using_force and updates_rejected and config.values.prompt_force_push then logger.error("Attempting force push to " .. name) - local message = "Your branch has diverged from the remote branch. Do you want to force push?" + local message = "Your branch has diverged from the remote branch. Do you want to force push with lease?" if input.get_permission(message) then - table.insert(args, "--force") + table.insert(args, "--force-with-lease") res = git.push.push_interactive(remote, branch, args) end end - if res and res.code == 0 then + if res and res:success() then a.util.scheduler() logger.debug("Pushed to " .. name) notification.info("Pushed to " .. name, { dismiss = true }) - vim.api.nvim_exec_autocmds("User", { pattern = "NeogitPushComplete", modeline = false }) + event.send("PushComplete") else logger.debug("Failed to push to " .. name) notification.error("Failed to push to " .. name, { dismiss = true }) @@ -105,14 +112,7 @@ function M.to_elsewhere(popup) end function M.push_other(popup) - local sources = git.branch.get_local_branches() - table.insert(sources, "HEAD") - table.insert(sources, "ORIG_HEAD") - table.insert(sources, "FETCH_HEAD") - if popup.state.env.commit then - table.insert(sources, 1, popup.state.env.commit) - end - + local sources = util.merge({ popup.state.env.commit }, git.refs.list_local_branches(), git.refs.heads()) local source = FuzzyFinderBuffer.new(sources):open_async { prompt_prefix = "push" } if not source then return @@ -123,7 +123,7 @@ function M.push_other(popup) table.insert(destinations, 1, remote .. "/" .. source) end - local destination = FuzzyFinderBuffer.new(destinations) + local destination = FuzzyFinderBuffer.new(util.deduplicate(destinations)) :open_async { prompt_prefix = "push " .. source .. " to" } if not destination then return @@ -133,23 +133,67 @@ function M.push_other(popup) push_to(popup:get_arguments(), remote, source .. ":" .. destination) end -function M.push_tags(popup) +---@param prompt string +---@return string|nil +local function choose_remote(prompt) local remotes = git.remote.list() - local remote if #remotes == 1 then remote = remotes[1] else - remote = FuzzyFinderBuffer.new(remotes):open_async { prompt_prefix = "push tags to" } + remote = FuzzyFinderBuffer.new(remotes):open_async { prompt_prefix = prompt } + end + + return remote +end + +---@param popup PopupData +function M.push_a_tag(popup) + local tags = git.tag.list() + + local tag = FuzzyFinderBuffer.new(tags):open_async { prompt_prefix = "Push tag" } + if not tag then + return end + local remote = choose_remote(("Push %s to remote"):format(tag)) + if remote then + push_to({ tag, unpack(popup:get_arguments()) }, remote) + end +end + +---@param popup PopupData +function M.push_all_tags(popup) + local remote = choose_remote("Push tags to remote") if remote then push_to({ "--tags", unpack(popup:get_arguments()) }, remote) end end +---@param popup PopupData +function M.matching_branches(popup) + local remote = choose_remote("Push matching branches to") + if remote then + push_to({ "-v", unpack(popup:get_arguments()) }, remote, ":") + end +end + +---@param popup PopupData +function M.explicit_refspec(popup) + local remote = choose_remote("Push to remote") + if not remote then + return + end + + local options = util.merge({ "HEAD" }, git.refs.list_local_branches()) + local refspec = FuzzyFinderBuffer.new(options):open_async { prompt_prefix = "Push refspec" } + if refspec then + push_to({ "-v", unpack(popup:get_arguments()) }, remote, refspec) + end +end + function M.configure() - require("neogit.popups.branch_config").create() + require("neogit.popups.branch_config").create {} end return M diff --git a/lua/neogit/popups/push/init.lua b/lua/neogit/popups/push/init.lua index b5644a4ee..b4357a2f8 100644 --- a/lua/neogit/popups/push/init.lua +++ b/lua/neogit/popups/push/init.lua @@ -5,30 +5,39 @@ local git = require("neogit.lib.git") local M = {} function M.create(env) - local current = git.branch.current() + local current = git.branch.current() or "" + local is_detached = git.branch.is_detached() local p = popup .builder() :name("NeogitPushPopup") - :switch("f", "force-with-lease", "Force with lease") - :switch("F", "force", "Force") - :switch("u", "set-upstream", "Set the upstream before pushing") + :switch("f", "force-with-lease", "Force with lease", { persisted = false }) + :switch("F", "force", "Force", { persisted = false }) :switch("h", "no-verify", "Disable hooks") :switch("d", "dry-run", "Dry run") - :group_heading("Push " .. ((current and (current .. " ")) or "") .. "to") - :action("p", git.branch.pushRemote_label(), actions.to_pushremote) - :action("u", git.branch.upstream_label(), actions.to_upstream) - :action("e", "elsewhere", actions.to_elsewhere) - :new_action_group("Push") + :switch("u", "set-upstream", "Set the upstream before pushing") + :switch("T", "tags", "Include all tags") + :switch("t", "follow-tags", "Include related annotated tags") + :group_heading_if(not is_detached, "Push " .. current .. " to") + :action_if(not is_detached, "p", git.branch.pushRemote_or_pushDefault_label(), actions.to_pushremote) + :action_if(not is_detached, "u", git.branch.upstream_label(), actions.to_upstream) + :action_if(not is_detached, "e", "elsewhere", actions.to_elsewhere) + :group_heading_if(is_detached, "Push") + :new_action_group_if(not is_detached, "Push") :action("o", "another branch", actions.push_other) - :action("r", "explicit refspecs") - :action("m", "matching branches") - :action("T", "a tag") - :action("t", "all tags", actions.push_tags) + :action("r", "explicit refspec", actions.explicit_refspec) + :action("m", "matching branches", actions.matching_branches) + :action("T", "a tag", actions.push_a_tag) + :action("t", "all tags", actions.push_all_tags) :new_action_group("Configure") :action("C", "Set variables...", actions.configure) :env({ - highlight = { current, git.branch.upstream(), git.branch.pushRemote_ref() }, + highlight = { + current, + git.branch.upstream(), + git.branch.pushRemote_ref(), + git.branch.pushDefault_ref(), + }, bold = { "pushRemote", "@{upstream}" }, commit = env.commit, }) diff --git a/lua/neogit/popups/rebase/actions.lua b/lua/neogit/popups/rebase/actions.lua index dd1fcb0b2..4076dd0dc 100644 --- a/lua/neogit/popups/rebase/actions.lua +++ b/lua/neogit/popups/rebase/actions.lua @@ -9,7 +9,7 @@ local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") local M = {} local function base_commit(popup, list, header) - return popup.state.env.commit or CommitSelectViewBuffer.new(list, header):open_async()[1] + return popup.state.env.commit or CommitSelectViewBuffer.new(list, git.remote.list(), header):open_async()[1] end function M.onto_base(popup) @@ -31,19 +31,14 @@ function M.onto_pushRemote(popup) end function M.onto_upstream(popup) - local upstream - if git.repo.state.upstream.ref then - upstream = string.format("refs/remotes/%s", git.repo.state.upstream.ref) - else - local target = FuzzyFinderBuffer.new(git.refs.list_remote_branches()):open_async() - if not target then - return - end - - upstream = string.format("refs/remotes/%s", target) + local upstream = git.branch.upstream(git.branch.current()) + if not upstream then + upstream = FuzzyFinderBuffer.new(git.refs.list_branches()):open_async() end - git.rebase.onto_branch(upstream, popup:get_arguments()) + if upstream then + git.rebase.onto_branch(upstream, popup:get_arguments()) + end end function M.onto_elsewhere(popup) @@ -80,6 +75,7 @@ function M.interactively(popup) elseif choice == "s" then popup.state.env.commit = nil M.interactively(popup) + return else return end @@ -136,14 +132,23 @@ function M.subset(popup) else start = CommitSelectViewBuffer.new( git.log.list { "HEAD" }, + git.remote.list(), "Select a commit with to rebase it and commits above it onto " .. newbase .. ", or to abort" ) :open_async()[1] end + if not start then + return + end - if start then - git.rebase.onto(start, newbase, popup:get_arguments()) + local args = popup:get_arguments() + local parent = git.log.parent(start) + if parent then + start = start .. "^" + else + table.insert(args, "--root") end + git.rebase.onto(start, newbase, args) end function M.continue() diff --git a/lua/neogit/popups/rebase/init.lua b/lua/neogit/popups/rebase/init.lua index 63125f9ce..e704f52bf 100644 --- a/lua/neogit/popups/rebase/init.lua +++ b/lua/neogit/popups/rebase/init.lua @@ -35,7 +35,7 @@ function M.create(env) :action_if(not in_rebase, "p", git.branch.pushRemote_label(), actions.onto_pushRemote) :action_if(not in_rebase, "u", git.branch.upstream_label(), actions.onto_upstream) :action_if(not in_rebase, "e", "elsewhere", actions.onto_elsewhere) - :action_if(not in_rebase and show_base_branch, "b", base_branch, actions.onto_base) + :action_if(not in_rebase and show_base_branch, "b", base_branch or "", actions.onto_base) :new_action_group_if(not in_rebase, "Rebase") :action_if(not in_rebase, "i", "interactively", actions.interactively) :action_if(not in_rebase, "s", "a subset", actions.subset) diff --git a/lua/neogit/popups/remote/actions.lua b/lua/neogit/popups/remote/actions.lua index 7e82f6cad..06609bec7 100644 --- a/lua/neogit/popups/remote/actions.lua +++ b/lua/neogit/popups/remote/actions.lua @@ -26,14 +26,16 @@ function M.add(popup) return end - local origin = git.config.get("remote.origin.url").value - local host, _, remote = origin:match("([^:/]+)[:/]([^/]+)/(.+)") - - remote = remote and remote:gsub("%.git$", "") - local msg - if host and remote then - msg = string.format("%s:%s/%s.git", host, name, remote) + local origin = git.config.get("remote.origin.url"):read() + if origin then + assert(type(origin) == "string", "remote.origin.url isn't a string") + local host, _, remote = origin:match("([^:/]+)[:/]([^/]+)/(.+)") + remote = remote and remote:gsub("%.git$", "") + + if host and remote then + msg = string.format("%s:%s/%s.git", host, name, remote) + end end local remote_url = input.get_user_input("URL for " .. name, { default = msg }) @@ -52,12 +54,21 @@ function M.add(popup) else notification.info("Added remote " .. name) end + + if input.get_permission("Fetch refs from " .. name .. "?") then + git.fetch.fetch_interactive(name, "", { "--tags" }) + end end end function M.rename(_) - local selected_remote = FuzzyFinderBuffer.new(git.remote.list()) - :open_async { prompt_prefix = "Rename remote" } + local options = git.remote.list() + if #options == 0 then + notification.info("No remotes found") + return + end + + local selected_remote = FuzzyFinderBuffer.new(options):open_async { prompt_prefix = "Rename remote" } if not selected_remote then return end @@ -74,7 +85,13 @@ function M.rename(_) end function M.remove(_) - local selected_remote = FuzzyFinderBuffer.new(git.remote.list()):open_async() + local options = git.remote.list() + if #options == 0 then + notification.info("No remotes found") + return + end + + local selected_remote = FuzzyFinderBuffer.new(options):open_async { prompt_prefix = "Remove remote" } if not selected_remote then return end @@ -86,22 +103,34 @@ function M.remove(_) end function M.configure(_) - local remote_name = FuzzyFinderBuffer.new(git.remote.list()):open_async() + local options = git.remote.list() + if #options == 0 then + notification.info("No remotes found") + return + end + + local remote_name = FuzzyFinderBuffer.new(options):open_async() if not remote_name then return end - RemoteConfigPopup.create(remote_name) + RemoteConfigPopup.create { remote = remote_name } end function M.prune_branches(_) - local selected_remote = FuzzyFinderBuffer.new(git.remote.list()):open_async() + local options = git.remote.list() + if #options == 0 then + notification.info("No remotes found") + return + end + + local selected_remote = FuzzyFinderBuffer.new(options):open_async() if not selected_remote then return end - notification.info("Pruning remote " .. selected_remote) git.remote.prune(selected_remote) + notification.info("Pruned remote " .. selected_remote) end -- https://github.com/magit/magit/blob/main/lisp/magit-remote.el#L159 diff --git a/lua/neogit/popups/remote_config/init.lua b/lua/neogit/popups/remote_config/init.lua index 0a2d9538a..728aca14b 100644 --- a/lua/neogit/popups/remote_config/init.lua +++ b/lua/neogit/popups/remote_config/init.lua @@ -1,7 +1,29 @@ local M = {} local popup = require("neogit.lib.popup") +local notification = require("neogit.lib.notification") +local git = require("neogit.lib.git") + +---@param env table +function M.create(env) + local remotes = git.remote.list() + if vim.tbl_isempty(remotes) then + notification.warn("Repo has no configured remotes.") + return + end + + local remote = env.remote + + if not remote then + if vim.tbl_contains(remotes, "origin") then + remote = "origin" + elseif #remotes == 1 then + remote = remotes[1] + else + notification.error("Cannot infer remote.") + return + end + end -function M.create(remote) local p = popup .builder() :name("NeogitRemoteConfigPopup") diff --git a/lua/neogit/popups/reset/actions.lua b/lua/neogit/popups/reset/actions.lua index 0b7d79885..248874d93 100644 --- a/lua/neogit/popups/reset/actions.lua +++ b/lua/neogit/popups/reset/actions.lua @@ -2,6 +2,7 @@ local git = require("neogit.lib.git") local util = require("neogit.lib.util") local notification = require("neogit.lib.notification") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") +local event = require("neogit.lib.event") local M = {} @@ -18,50 +19,51 @@ local function target(popup, prompt) return FuzzyFinderBuffer.new(refs):open_async { prompt_prefix = prompt } end ----@param type string +---@param fn fun(target: string): boolean ---@param popup PopupData ---@param prompt string -local function reset(type, popup, prompt) +---@param mode string +local function reset(fn, popup, prompt, mode) local target = target(popup, prompt) if target then - git.reset[type](target) + local success = fn(target) + if success then + notification.info("Reset to " .. target) + event.send("Reset", { commit = target, mode = mode }) + else + notification.error("Reset Failed") + end end end ---@param popup PopupData function M.mixed(popup) - reset("mixed", popup, ("Reset %s to"):format(git.branch.current())) + reset(git.reset.mixed, popup, ("Reset %s to"):format(git.branch.current()), "mixed") end ---@param popup PopupData function M.soft(popup) - reset("soft", popup, ("Soft reset %s to"):format(git.branch.current())) + reset(git.reset.soft, popup, ("Soft reset %s to"):format(git.branch.current()), "soft") end ---@param popup PopupData function M.hard(popup) - reset("hard", popup, ("Hard reset %s to"):format(git.branch.current())) + reset(git.reset.hard, popup, ("Hard reset %s to"):format(git.branch.current()), "hard") end ---@param popup PopupData function M.keep(popup) - reset("keep", popup, ("Reset %s to"):format(git.branch.current())) + reset(git.reset.keep, popup, ("Reset %s to"):format(git.branch.current()), "keep") end ---@param popup PopupData function M.index(popup) - reset("index", popup, "Reset index to") + reset(git.reset.index, popup, "Reset index to", "index") end ---@param popup PopupData function M.worktree(popup) - local target = target(popup, "Reset worktree to") - if target then - git.index.with_temp_index(target, function(index) - git.cli["checkout-index"].all.force.env({ GIT_INDEX_FILE = index }).call() - notification.info(("Reset worktree to %s"):format(target)) - end) - end + reset(git.reset.worktree, popup, "Reset worktree to", "worktree") end ---@param popup PopupData @@ -78,11 +80,31 @@ function M.a_file(popup) end local files = FuzzyFinderBuffer.new(files):open_async { allow_multi = true } - if not files[1] then - return + if files and files[1] then + if git.reset.file(target, files) then + if #files > 1 then + notification.info("Reset " .. #files .. " files") + else + notification.info("Reset " .. files[1]) + end + + event.send("Reset", { commit = target, mode = "files", files = files }) + else + notification.error("Reset Failed") + end + end +end + +---@param popup PopupData +function M.a_branch(popup) + -- branch reset expects commits to be set, not commit + if popup.state.env.commit then + popup.state.env.commits = { popup.state.env.commit } + popup.state.env.commit = nil end - git.reset.file(target, files) + local branch_actions = require("neogit.popups.branch.actions") + branch_actions.reset_branch(popup) end return M diff --git a/lua/neogit/popups/reset/init.lua b/lua/neogit/popups/reset/init.lua index d723c3a3f..82535122f 100644 --- a/lua/neogit/popups/reset/init.lua +++ b/lua/neogit/popups/reset/init.lua @@ -1,6 +1,5 @@ local popup = require("neogit.lib.popup") local actions = require("neogit.popups.reset.actions") -local branch_actions = require("neogit.popups.branch.actions") local M = {} @@ -10,7 +9,7 @@ function M.create(env) :name("NeogitResetPopup") :group_heading("Reset") :action("f", "file", actions.a_file) - :action("b", "branch", branch_actions.reset_branch) + :action("b", "branch", actions.a_branch) :new_action_group("Reset this") :action("m", "mixed (HEAD and index)", actions.mixed) :action("s", "soft (HEAD only)", actions.soft) diff --git a/lua/neogit/popups/revert/actions.lua b/lua/neogit/popups/revert/actions.lua index bf0a2638a..2ecd010c5 100644 --- a/lua/neogit/popups/revert/actions.lua +++ b/lua/neogit/popups/revert/actions.lua @@ -1,53 +1,41 @@ local M = {} +local config = require("neogit.config") local git = require("neogit.lib.git") local client = require("neogit.client") local notification = require("neogit.lib.notification") -local CommitSelectViewBuffer = require("neogit.buffers.commit_select_view") +local input = require("neogit.lib.input") +local util = require("neogit.lib.util") +local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") ---@param popup any ----@return CommitLogEntry[] -local function get_commits(popup) - local commits - if #popup.state.env.commits > 0 then - commits = popup.state.env.commits +---@param thing string +---@return string[] +local function get_commits(popup, thing) + if #popup.state.env.commits > 1 then + return popup.state.env.commits else - commits = CommitSelectViewBuffer.new( - git.log.list { "--max-count=256" }, - "Select one or more commits to revert with , or to abort" - ):open_async() - end - - return commits or {} -end + local refs = + util.merge(popup.state.env.commits, git.refs.list_branches(), git.refs.list_tags(), git.refs.heads()) -local function build_commit_message(commits) - local message = {} - table.insert(message, string.format("Revert %d commits\n", #commits)) - - for _, commit in ipairs(commits) do - table.insert(message, string.format("%s '%s'", commit:sub(1, 7), git.log.message(commit))) + return { FuzzyFinderBuffer.new(refs):open_async { prompt_prefix = "Revert " .. thing } } end - - return table.concat(message, "\n") end function M.commits(popup) - local commits = get_commits(popup) + local commits = get_commits(popup, "commits") if #commits == 0 then return end local args = popup:get_arguments() - - local success = git.revert.commits(commits, args) - + local success, msg = git.revert.commits(commits, args) if not success then - notification.error("Revert failed. Resolve conflicts before continuing") + notification.error("Revert failed with " .. msg) return end - local commit_cmd = git.cli.commit.no_verify.with_message(build_commit_message(commits)) + local commit_cmd = git.cli.commit.no_verify if vim.tbl_contains(args, "--edit") then commit_cmd = commit_cmd.edit else @@ -56,19 +44,26 @@ function M.commits(popup) client.wrap(commit_cmd, { autocmd = "NeogitRevertComplete", + interactive = true, msg = { success = "Reverted", }, + show_diff = config.values.commit_editor.show_staged_diff, }) end function M.changes(popup) - local commits = get_commits(popup) - if not commits[1] then - return + local commits = get_commits(popup, "changes") + if #commits > 0 then + local success, msg = git.revert.commits(commits, popup:get_arguments()) + if not success then + notification.error("Revert failed with " .. msg) + end end +end - git.revert.commits(commits, popup:get_arguments()) +function M.hunk(popup) + git.revert.hunk(popup:get_env("hunk"), popup:get_arguments()) end function M.continue() @@ -80,7 +75,9 @@ function M.skip() end function M.abort() - git.revert.abort() + if input.get_permission("Abort revert?") then + git.revert.abort() + end end return M diff --git a/lua/neogit/popups/revert/init.lua b/lua/neogit/popups/revert/init.lua index 092f16596..c926e1e50 100644 --- a/lua/neogit/popups/revert/init.lua +++ b/lua/neogit/popups/revert/init.lua @@ -8,21 +8,31 @@ function M.create(env) local in_progress = git.sequencer.pick_or_revert_in_progress() -- TODO: enabled = true needs to check if incompatible switch is toggled in internal state, and not apply. -- if you enable 'no edit', and revert, next time you load the popup both will be enabled - -- - -- :option("s", "strategy", "", "Strategy") - -- :switch("s", "signoff", "Add Signed-off-by lines") - -- :option("S", "gpg-sign", "", "Sign using gpg") - -- stylua: ignore local p = popup .builder() :name("NeogitRevertPopup") :option_if(not in_progress, "m", "mainline", "", "Replay merge relative to parent") - :switch_if(not in_progress, "e", "edit", "Edit commit messages", { enabled = true, incompatible = { "no-edit" } }) + :switch_if( + not in_progress, + "e", + "edit", + "Edit commit messages", + { enabled = true, incompatible = { "no-edit" } } + ) :switch_if(not in_progress, "E", "no-edit", "Don't edit commit messages", { incompatible = { "edit" } }) + :switch_if(not in_progress, "s", "signoff", "Add Signed-off-by lines") + :option_if(not in_progress, "s", "strategy", "", "Strategy", { + key_prefix = "=", + choices = { "octopus", "ours", "resolve", "subtree", "recursive" }, + }) + :option_if(not in_progress, "S", "gpg-sign", "", "Sign using gpg", { + key_prefix = "-", + }) :group_heading("Revert") :action_if(not in_progress, "v", "Commit(s)", actions.commits) :action_if(not in_progress, "V", "Changes", actions.changes) + :action_if(((not in_progress) and env.hunk ~= nil), "h", "Hunk", actions.hunk) :action_if(in_progress, "v", "continue", actions.continue) :action_if(in_progress, "s", "skip", actions.skip) :action_if(in_progress, "a", "abort", actions.abort) diff --git a/lua/neogit/popups/stash/actions.lua b/lua/neogit/popups/stash/actions.lua index 7db0d3c21..288d82e6e 100644 --- a/lua/neogit/popups/stash/actions.lua +++ b/lua/neogit/popups/stash/actions.lua @@ -10,12 +10,12 @@ function M.both(popup) git.stash.stash_all(popup:get_arguments()) end -function M.index(popup) - git.stash.stash_index(popup:get_arguments()) +function M.index() + git.stash.stash_index() end -function M.keep_index(popup) - git.stash.stash_keep_index(popup:get_arguments()) +function M.keep_index() + git.stash.stash_keep_index() end function M.push(popup) @@ -27,6 +27,9 @@ function M.push(popup) git.stash.push(popup:get_arguments(), files) end +---@param action string +---@param stash { name: string } +---@param opts { confirm: boolean }|nil local function use(action, stash, opts) opts = opts or {} local name, get_permission @@ -72,7 +75,6 @@ function M.rename(popup) use("rename", popup.state.env.stash) end ---- git stash list function M.list() StashListBuffer.new(git.repo.state.stashes.items):open() end diff --git a/lua/neogit/popups/stash/init.lua b/lua/neogit/popups/stash/init.lua index 1dea0d466..9b461c9a0 100644 --- a/lua/neogit/popups/stash/init.lua +++ b/lua/neogit/popups/stash/init.lua @@ -4,13 +4,15 @@ local popup = require("neogit.lib.popup") local M = {} function M.create(stash) - -- TODO: - -- :switch("u", "include-untracked", "Also save untracked files") - -- :switch("a", "all", "Also save untracked and ignored files") - local p = popup .builder() :name("NeogitStashPopup") + :switch("u", "include-untracked", "Also save untracked files", { + incompatible = { "all" }, + }) + :switch("a", "all", "Also save untracked and ignored files", { + incompatible = { "include-untracked" }, + }) :group_heading("Stash") :action("z", "both", actions.both) :action("i", "index", actions.index) diff --git a/lua/neogit/popups/tag/actions.lua b/lua/neogit/popups/tag/actions.lua index 363a8c374..c93b35307 100644 --- a/lua/neogit/popups/tag/actions.lua +++ b/lua/neogit/popups/tag/actions.lua @@ -6,13 +6,14 @@ local utils = require("neogit.lib.util") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") local input = require("neogit.lib.input") local notification = require("neogit.lib.notification") +local event = require("neogit.lib.event") -local function fire_tag_event(pattern, data) - vim.api.nvim_exec_autocmds("User", { pattern = pattern, modeline = false, data = data }) -end - +---@param popup PopupData function M.create_tag(popup) - local tag_input = input.get_user_input("Create tag", { strip_spaces = true }) + local tag_input = input.get_user_input("Create tag", { + strip_spaces = true, + completion = "customlist,v:lua.require'neogit.lib.git'.refs.list_tags", + }) if not tag_input then return end @@ -36,10 +37,11 @@ function M.create_tag(popup) }, }) if code == 0 then - fire_tag_event("NeogitTagCreate", { name = tag_input, ref = selected }) + event.send("TagCreate", { name = tag_input, ref = selected }) end end +--TODO: --- Create a release tag for `HEAD'. ---@param _ table function M.create_release(_) end @@ -48,7 +50,7 @@ function M.create_release(_) end --- If there are multiple tags then offer to delete those. --- Otherwise prompt for a single tag to be deleted. --- git tag -d TAGS ----@param _ table +---@param _ PopupData function M.delete(_) local tags = FuzzyFinderBuffer.new(git.tag.list()):open_async { allow_multi = true } if #(tags or {}) == 0 then @@ -58,28 +60,27 @@ function M.delete(_) if git.tag.delete(tags) then notification.info("Deleted tags: " .. table.concat(tags, ",")) for _, tag in pairs(tags) do - fire_tag_event("NeogitTagDelete", { name = tag }) + event.send("TagDelete", { name = tag }) end end end --- Prunes differing tags from local and remote ----@param _ table +---@param _ PopupData function M.prune(_) + local tags = git.tag.list() + if #tags == 0 then + notification.info("No tags found") + return + end + local selected_remote = FuzzyFinderBuffer.new(git.remote.list()):open_async { prompt_prefix = "Prune tags using remote", } - if (selected_remote or "") == "" then return end - local tags = git.tag.list() - if #tags == 0 then - notification.info("No tags found") - return - end - notification.info("Fetching remote tags...") local r_out = git.tag.list_remote(selected_remote) local remote_tags = {} @@ -96,7 +97,7 @@ function M.prune(_) notification.delete_all() if #l_tags == 0 and #r_tags == 0 then - notification.info("Same tags exist locally and remotely") + notification.info("Tags are in sync - nothing to do.") return end diff --git a/lua/neogit/popups/tag/init.lua b/lua/neogit/popups/tag/init.lua index 994a32a4b..c7bbef5e7 100644 --- a/lua/neogit/popups/tag/init.lua +++ b/lua/neogit/popups/tag/init.lua @@ -8,7 +8,7 @@ function M.create(env) .builder() :name("NeogitTagPopup") :arg_heading("Arguments") - :switch("f", "force", "Force") + :switch("f", "force", "Force", { persisted = false }) :switch("a", "annotate", "Annotate") :switch("s", "sign", "Sign") :option("u", "local-user", "", "Sign as", { key_prefix = "-" }) diff --git a/lua/neogit/popups/worktree/actions.lua b/lua/neogit/popups/worktree/actions.lua index 60e58fac8..d0ff7b6fb 100644 --- a/lua/neogit/popups/worktree/actions.lua +++ b/lua/neogit/popups/worktree/actions.lua @@ -5,53 +5,85 @@ local input = require("neogit.lib.input") local util = require("neogit.lib.util") local status = require("neogit.buffers.status") local notification = require("neogit.lib.notification") +local event = require("neogit.lib.event") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") -local Path = require("plenary.path") -local scan_dir = require("plenary.scandir").scan_dir ----Poor man's dired +---@param prompt string +---@param branch string? ---@return string|nil -local function get_path(prompt) - local dir = Path.new(".") - repeat - local dirs = scan_dir(dir:absolute(), { depth = 1, only_dirs = true }) - local selected = FuzzyFinderBuffer.new(util.merge({ ".." }, dirs)):open_async { - prompt_prefix = prompt, - } - - if not selected then - return - end - - if vim.startswith(selected, "/") then - dir = Path.new(selected) +local function get_path(prompt, branch) + local path = input.get_user_input(prompt, { + completion = "dir", + prepend = vim.fs.normalize(vim.uv.cwd() .. "/..") .. "/", + }) + + if path then + if branch and vim.uv.fs_stat(path) then + return vim.fs.joinpath(path, branch) else - dir = dir:joinpath(selected) + return path end - until not dir:exists() + else + return nil + end +end - local path, _ = dir:absolute():gsub("%s", "_") - return path +---@param old_cwd string? +---@param new_cwd string +---@return table +local function autocmd_helpers(old_cwd, new_cwd) + return { + old_cwd = old_cwd, + new_cwd = new_cwd, + ---@param filename string the file you want to copy + ---@param callback function? callback to run if copy was successful + copy_if_present = function(filename, callback) + assert(old_cwd, "couldn't resolve old cwd") + + local source = vim.fs.joinpath(old_cwd, filename) + local destination = vim.fs.joinpath(new_cwd, filename) + + if vim.uv.fs_stat(source) and not vim.uv.fs_stat(destination) then + local ok = vim.uv.fs_copyfile(source, destination) + if ok and type(callback) == "function" then + callback() + end + end + end, + } end -function M.checkout_worktree() +---@param prompt string +---@return string|nil +local function get_ref(prompt) local options = util.merge(git.refs.list_branches(), git.refs.list_tags(), git.refs.heads()) - local selected = FuzzyFinderBuffer.new(options):open_async { prompt_prefix = "checkout" } + return FuzzyFinderBuffer.new(options):open_async { prompt_prefix = prompt } +end + +function M.checkout_worktree() + local selected = get_ref("checkout") if not selected then return end - local path = get_path(("Checkout %s in new worktree"):format(selected)) + local path = get_path(("Checkout '%s' in new worktree"):format(selected), selected) if not path then return end - if git.worktree.add(selected, path) then + local success, err = git.worktree.add(selected, path) + if success then + local cwd = vim.uv.cwd() notification.info("Added worktree") + if status.is_open() then status.instance():chdir(path) end + + event.send("WorktreeCreate", autocmd_helpers(cwd, path)) + else + notification.error(err) end end @@ -61,9 +93,7 @@ function M.create_worktree() return end - local options = util.merge(git.refs.list_branches(), git.refs.list_tags(), git.refs.heads()) - local selected = FuzzyFinderBuffer.new(options) - :open_async { prompt_prefix = "Create and checkout branch starting at" } + local selected = get_ref("Create and checkout branch starting at") if not selected then return end @@ -73,10 +103,19 @@ function M.create_worktree() return end - if git.worktree.add(selected, path, { "-b", name }) then - notification.info("Added worktree") - if status.is_open() then - status.instance():chdir(path) + if git.branch.create(name, selected) then + local success, err = git.worktree.add(name, path) + if success then + local cwd = vim.uv.cwd() + notification.info("Added worktree") + + if status.is_open() then + status.instance():chdir(path) + end + + event.send("WorktreeCreate", autocmd_helpers(cwd, path)) + else + notification.error(err) end end end @@ -135,8 +174,9 @@ function M.delete() local success = false if input.get_permission(("Remove worktree at %q?"):format(selected)) then - if change_dir and status.is_open() then - status.instance():chdir(git.worktree.main().path) + local main = git.worktree.main() -- A bare repo has no main, so check + if change_dir and status.is_open() and main then + status.instance():chdir(main.path) end -- This might produce some error messages that need to get suppressed @@ -157,9 +197,15 @@ function M.delete() end function M.visit() - local options = vim.tbl_map(function(w) - return w.path - end, git.worktree.list()) + local options = vim + .iter(git.worktree.list()) + :map(function(w) + return w.path + end) + :filter(function(path) + return path ~= vim.uv.cwd() + end) + :totable() if #options == 0 then notification.info("No worktrees present") diff --git a/lua/neogit/process.lua b/lua/neogit/process.lua index d950808b1..050b98b13 100644 --- a/lua/neogit/process.lua +++ b/lua/neogit/process.lua @@ -5,11 +5,15 @@ local config = require("neogit.config") local logger = require("neogit.logger") local util = require("neogit.lib.util") +local ProcessBuffer = require("neogit.buffers.process") +local Spinner = require("neogit.spinner") + local api = vim.api local fn = vim.fn -local command_mask = - vim.pesc(" --no-pager --literal-pathspecs --no-optional-locks -c core.preloadindex=true -c color.ui=always") +local command_mask = vim.pesc( + " --no-pager --literal-pathspecs --no-optional-locks -c core.preloadindex=true -c color.ui=always -c diff.noprefix=false" +) local function mask_command(cmd) local command, _ = cmd:gsub(command_mask, "") @@ -17,7 +21,6 @@ local function mask_command(cmd) end ---@class ProcessOpts ----@field buffer ProcessBuffer|nil ---@field cmd string[] ---@field cwd string|nil ---@field env table|nil @@ -44,6 +47,7 @@ end ---@field on_partial_line fun(process: Process, data: string)|nil callback on complete lines ---@field on_error (fun(res: ProcessResult): boolean) Intercept the error externally, returning false prevents the error from being logged ---@field defer_show_preview_buffers fun(): nil +---@field spinner Spinner|nil local Process = {} Process.__index = Process @@ -75,6 +79,7 @@ function ProcessResult:trim() return self end +---@return ProcessResult function ProcessResult:remove_ansi() self.stdout = vim.tbl_map(remove_ansi_escape_codes, self.stdout) self.stderr = vim.tbl_map(remove_ansi_escape_codes, self.stderr) @@ -82,13 +87,21 @@ function ProcessResult:remove_ansi() return self end +---@return boolean +function ProcessResult:success() + return self.code == 0 +end + +---@return boolean +function ProcessResult:failure() + return self.code ~= 0 +end + ProcessResult.__index = ProcessResult ---@param process ProcessOpts ---@return Process function Process.new(process) - process.buffer = require("neogit.buffers.process"):new(process) - return setmetatable(process, Process) ---@class Process end @@ -103,7 +116,26 @@ function Process.hide_preview_buffers() end function Process:show_console() - self.buffer:show() + if self.buffer then + self.buffer:show() + end +end + +function Process:show_spinner() + if not config.values.process_spinner or self.suppress_console or self.spinner then + return + end + + self.spinner = Spinner.new(mask_command(table.concat(self.cmd, " "))) + self.spinner:start() +end + +function Process:hide_spinner() + if not self.spinner then + return + end + + self.spinner:stop() end function Process:start_timer() @@ -112,10 +144,11 @@ function Process:start_timer() end if self.timer == nil then - local timer = vim.loop.new_timer() + local timer = vim.uv.new_timer() self.timer = timer - local timeout = assert(self.git_hook and 100 or config.values.console_timeout, "no timeout") + local timeout = assert(self.git_hook and 800 or config.values.console_timeout, "no timeout") + timer:start( timeout, 0, @@ -130,16 +163,15 @@ function Process:start_timer() return end - if config.values.auto_show_console then - self:show_console() - else + if not config.values.auto_show_console then local message = string.format( "Command %q running for more than: %.1f seconds", mask_command(table.concat(self.cmd, " ")), - math.ceil((vim.loop.now() - self.start) / 100) / 10 + math.ceil((vim.uv.now() - self.start) / 100) / 10 ) - notification.warn(message .. "\n\nOpen the command history for details") + elseif config.values.auto_show_console_on == "output" then + self:show_console() end end) ) @@ -185,7 +217,7 @@ end function Process:stop() if self.job then - fn.jobstop(self.job) + assert(fn.jobstop(self.job) == 1, "invalid job id") end end @@ -266,11 +298,15 @@ function Process:spawn(cb) if self.on_partial_line then self:on_partial_line(line) end + + if self.buffer then + self.buffer:append_partial(line) + end end local stdout_on_line = function(line) insert(res.stdout, line) - if not self.suppress_console then + if self.buffer and not self.suppress_console then self.buffer:append(line) end end @@ -279,7 +315,7 @@ function Process:spawn(cb) local stderr_on_line = function(line) insert(res.stderr, line) - if not self.suppress_console then + if self.buffer and not self.suppress_console then self.buffer:append(line) end end @@ -289,17 +325,18 @@ function Process:spawn(cb) local function on_exit(_, code) res.code = code - res.time = (vim.loop.now() - start) + res.time = (vim.uv.now() - start) -- Remove self processes[self.job] = nil self.result = res self:stop_timer() + self:hide_spinner() stdout_cleanup() stderr_cleanup() - if not self.suppress_console then + if self.buffer and not self.suppress_console then self.buffer:append(string.format("Process exited with code: %d", code)) if not self.buffer:is_visible() and code > 0 and self.on_error(res) then @@ -309,10 +346,13 @@ function Process:spawn(cb) insert(output, "> " .. util.remove_ansi_escape_codes(res.stderr[i])) end - local message = - string.format("%s:\n\n%s", mask_command(table.concat(self.cmd, " ")), table.concat(output, "\n")) - - notification.warn(message) + if not config.values.auto_close_console then + local message = + string.format("%s:\n\n%s", mask_command(table.concat(self.cmd, " ")), table.concat(output, "\n")) + notification.warn(message) + elseif config.values.auto_show_console_on == "error" then + self.buffer:show() + end end if @@ -358,6 +398,8 @@ function Process:spawn(cb) self.stdin = job if not hide_console then + self.buffer = ProcessBuffer:new(self, mask_command) + self:show_spinner() self:start_timer() end @@ -371,6 +413,7 @@ function Process:spawn(cb) if not self.cmd[#self.cmd] == "-" then self:send("\04") end + self:close_stdin() end diff --git a/lua/neogit/runner.lua b/lua/neogit/runner.lua index 76d5b3f88..99dbe649d 100644 --- a/lua/neogit/runner.lua +++ b/lua/neogit/runner.lua @@ -62,6 +62,16 @@ local function handle_interactive_password(line) return input.get_secret_user_input(prompt, { cancel = "__CANCEL__" }) or "__CANCEL__" end +---@param line string +---@return string +local function handle_fatal_error(line) + logger.debug("[RUNNER]: Fatal error encountered") + local notification = require("neogit.lib.notification") + + notification.error(line) + return "__CANCEL__" +end + ---@param process Process ---@param line string ---@return boolean @@ -76,6 +86,8 @@ local function handle_line_interactive(process, line) handler = handle_interactive_username elseif line:match("^Enter passphrase") or line:match("^Password for") or line:match("^Enter PIN for") then handler = handle_interactive_password + elseif line:match("^fatal") then + handler = handle_fatal_error end if handler then @@ -100,6 +112,7 @@ end ---@param process Process ---@param opts table +---@return ProcessResult function M.call(process, opts) logger.trace(string.format("[RUNNER]: Executing %q", table.concat(process.cmd, " "))) diff --git a/lua/neogit/spinner.lua b/lua/neogit/spinner.lua new file mode 100644 index 000000000..9b6203fef --- /dev/null +++ b/lua/neogit/spinner.lua @@ -0,0 +1,66 @@ +local util = require("neogit.lib.util") +---@class Spinner +---@field text string +---@field count number +---@field interval number +---@field pattern string[] +---@field timer uv_timer_t +local Spinner = {} +Spinner.__index = Spinner + +---@return Spinner +function Spinner.new(text) + local instance = { + text = util.str_truncate(text, vim.v.echospace - 2, "..."), + interval = 100, + count = 0, + timer = nil, + pattern = { + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏", + }, + } + + return setmetatable(instance, Spinner) +end + +function Spinner:start() + if not self.timer then + self.timer = vim.uv.new_timer() + self.timer:start( + 250, + self.interval, + vim.schedule_wrap(function() + self.count = self.count + 1 + local step = self.pattern[(self.count % #self.pattern) + 1] + vim.cmd(string.format("echo '%s %s' | redraw", step, self.text)) + end) + ) + end +end + +function Spinner:stop() + if self.timer then + local timer = self.timer + self.timer = nil + timer:stop() + + if not timer:is_closing() then + timer:close() + end + end + + vim.schedule(function() + vim.cmd("redraw | echomsg ''") + end) +end + +return Spinner diff --git a/lua/neogit/vendor/types.lua b/lua/neogit/vendor/types.lua new file mode 100644 index 000000000..68b8fb3fa --- /dev/null +++ b/lua/neogit/vendor/types.lua @@ -0,0 +1,19 @@ +--This file exists to facilitate llscheck CI types + +---@class Path +---@field absolute fun(self): boolean +---@field exists fun(self): boolean +---@field touch fun(self, opts:table) +---@field write fun(self, txt:string, flag:string) +---@field read fun(self): string|nil +---@field iter fun(self): self + +---@class uv_timer_t +---@field start fun(self, time:number, repeat: number, fn: function) +---@field stop fun(self) +---@field is_closing fun(self): boolean +---@field close fun(self) +--- +---@class uv_fs_event_t +---@field start fun(self, path: string, opts: table, callback: function) +---@field stop fun(self) diff --git a/lua/neogit/watcher.lua b/lua/neogit/watcher.lua index 773ca6ba1..ec233a56b 100644 --- a/lua/neogit/watcher.lua +++ b/lua/neogit/watcher.lua @@ -1,14 +1,13 @@ -- Adapted from https://github.com/lewis6991/gitsigns.nvim/blob/main/lua/gitsigns/watcher.lua#L103 local logger = require("neogit.logger") -local Path = require("plenary.path") local util = require("neogit.lib.util") local git = require("neogit.lib.git") local config = require("neogit.config") local a = require("plenary.async") ---@class Watcher ----@field git_root string +---@field git_dir string ---@field buffers table ---@field running boolean ---@field fs_event_handler uv_fs_event_t @@ -20,9 +19,9 @@ Watcher.__index = Watcher function Watcher.new(root) local instance = { buffers = {}, - git_root = Path:new(root):joinpath(".git"):absolute(), + git_dir = git.cli.worktree_git_dir(root), running = false, - fs_event_handler = assert(vim.loop.new_fs_event()), + fs_event_handler = assert(vim.uv.new_fs_event()), } setmetatable(instance, Watcher) @@ -83,9 +82,9 @@ function Watcher:start() return self end - logger.debug("[WATCHER] Watching git dir: " .. self.git_root) + logger.debug("[WATCHER] Watching git dir: " .. self.git_dir) self.running = true - self.fs_event_handler:start(self.git_root, {}, self:fs_event_callback()) + self.fs_event_handler:start(self.git_dir, {}, self:fs_event_callback()) return self end @@ -99,7 +98,7 @@ function Watcher:stop() return self end - logger.debug("[WATCHER] Stopped watching git dir: " .. self.git_root) + logger.debug("[WATCHER] Stopped watching git dir: " .. self.git_dir) self.running = false self.fs_event_handler:stop() return self diff --git a/plugin/neogit.lua b/plugin/neogit.lua index 4f0e4b4f7..465eb55b8 100644 --- a/plugin/neogit.lua +++ b/plugin/neogit.lua @@ -16,35 +16,27 @@ api.nvim_create_user_command("NeogitResetState", function() require("neogit.lib.state")._reset() end, { nargs = "*", desc = "Reset any saved flags" }) -api.nvim_create_user_command( - "NeogitLogCurrent", - function(args) - local action = require("neogit").action - local path = vim.fn.expand(args.fargs[1] or "%") +api.nvim_create_user_command("NeogitLogCurrent", function(args) + local action = require("neogit").action + local path = vim.fn.expand(args.fargs[1] or "%") - if args.range > 0 then - action("log", "log_current", { "-L" .. args.line1 .. "," .. args.line2 .. ":" .. path })() - else - action("log", "log_current", { "--", path })() - end - end, - { - nargs = "?", - desc = "Open git log (current) for specified file, or current file if unspecified. Optionally accepts a range.", - range = "%", - complete = "file" - } -) + if args.range > 0 then + action("log", "log_current", { "-L" .. args.line1 .. "," .. args.line2 .. ":" .. path })() + else + action("log", "log_current", { "--", path })() + end +end, { + nargs = "?", + desc = "Open git log (current) for specified file, or current file if unspecified. Optionally accepts a range.", + range = "%", + complete = "file", +}) -api.nvim_create_user_command( - "NeogitCommit", - function(args) - local commit = args.fargs[1] or "HEAD" - local CommitViewBuffer = require("neogit.buffers.commit_view") - CommitViewBuffer.new(commit):open() - end, - { - nargs = "?", - desc = "Open git commit view for specified commit, or HEAD", - } -) +api.nvim_create_user_command("NeogitCommit", function(args) + local commit = args.fargs[1] or "HEAD" + local CommitViewBuffer = require("neogit.buffers.commit_view") + CommitViewBuffer.new(commit):open() +end, { + nargs = "?", + desc = "Open git commit view for specified commit, or HEAD", +}) diff --git a/spec/buffers/commit_buffer_spec.rb b/spec/buffers/commit_buffer_spec.rb new file mode 100644 index 000000000..0ada40be2 --- /dev/null +++ b/spec/buffers/commit_buffer_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Commit Buffer", :git, :nvim do + before do + nvim.keys("ll") + end + + it "can close the view with " do + nvim.keys("") + expect(nvim.filetype).to eq("NeogitLogView") + end + + it "can close the view with q" do + nvim.keys("q") + expect(nvim.filetype).to eq("NeogitLogView") + end + + it "can yank OID" do + nvim.keys("Y") + expect(nvim.screen.last.strip).to match(/\A[a-f0-9]{40}\z/) + end + + it "can open the bisect popup" do + nvim.keys("B") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the branch popup" do + nvim.keys("b") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the cherry pick popup" do + nvim.keys("A") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the commit popup" do + nvim.keys("c") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the diff popup" do + nvim.keys("d") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the pull popup" do + nvim.keys("p") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the fetch popup" do + nvim.keys("f") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the ignore popup" do + nvim.keys("i") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the log popup" do + nvim.keys("l") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the remote popup" do + nvim.keys("M") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the merge popup" do + nvim.keys("m") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the push popup" do + nvim.keys("P") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the rebase popup" do + nvim.keys("r") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the tag popup" do + nvim.keys("t") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the revert popup" do + nvim.keys("v") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the worktree popup" do + nvim.keys("w") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the reset popup" do + nvim.keys("X") + expect(nvim.filetype).to eq("NeogitPopup") + end + + it "can open the stash popup" do + nvim.keys("Z") + expect(nvim.filetype).to eq("NeogitPopup") + end +end diff --git a/spec/buffers/commit_select_buffer_spec.rb b/spec/buffers/commit_select_buffer_spec.rb new file mode 100644 index 000000000..e613ca868 --- /dev/null +++ b/spec/buffers/commit_select_buffer_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Commit Select Buffer", :git, :nvim do + it "renders, raising no errors" do + nvim.keys("AA") + expect(nvim.errors).to be_empty + expect(nvim.filetype).to eq("NeogitCommitSelectView") + end +end diff --git a/spec/buffers/refs_buffer_spec.rb b/spec/buffers/refs_buffer_spec.rb index d0802440d..50216688a 100644 --- a/spec/buffers/refs_buffer_spec.rb +++ b/spec/buffers/refs_buffer_spec.rb @@ -4,14 +4,8 @@ RSpec.describe "Refs Buffer", :git, :nvim do it "renders, raising no errors" do - nvim.keys("lr") + nvim.keys("y") expect(nvim.errors).to be_empty - expect(nvim.filetype).to eq("NeogitReflogView") - end - - it "can open CommitView" do - nvim.keys("lr") - expect(nvim.errors).to be_empty - expect(nvim.filetype).to eq("NeogitCommitView") + expect(nvim.filetype).to eq("NeogitRefsView") end end diff --git a/spec/buffers/stash_list_buffer_spec.rb b/spec/buffers/stash_list_buffer_spec.rb new file mode 100644 index 000000000..32c119b6e --- /dev/null +++ b/spec/buffers/stash_list_buffer_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Stash list Buffer", :git, :nvim do + before do + create_file("1") + git.add("1") + git.commit("test") + create_file("1", content: "hello world") + git.lib.stash_save("test") + nvim.refresh + end + + it "renders, raising no errors" do + nvim.keys("Zl") + expect(nvim.screen[1..2]).to eq( + [ + " Stashes (1) ", + "stash@{0} On master: test 0 seconds ago" + ] + ) + + expect(nvim.errors).to be_empty + expect(nvim.filetype).to eq("NeogitStashView") + end + + it "can open CommitView" do + nvim.keys("Zl") + expect(nvim.errors).to be_empty + expect(nvim.filetype).to eq("NeogitCommitView") + end +end diff --git a/spec/general_spec.rb b/spec/general_spec.rb new file mode 100644 index 000000000..30fb8f4d2 --- /dev/null +++ b/spec/general_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +RSpec.describe "general things", :git, :nvim do + popups = %w[ + bisect branch branch_config cherry_pick commit + diff fetch help ignore log merge pull push rebase + remote remote_config reset revert stash tag worktree + ] + + popups.each do |popup| + it "can invoke #{popup} popup without status buffer", :with_remote_origin do + nvim.keys("q") + nvim.lua("require('neogit').open({ '#{popup}' })") + sleep(0.1) # Allow popup to open + + expect(nvim.filetype).to eq("NeogitPopup") + expect(nvim.errors).to be_empty + end + end +end diff --git a/spec/popups/bisect_popup_spec.rb b/spec/popups/bisect_popup_spec.rb index 87714c9ab..3654228ef 100644 --- a/spec/popups/bisect_popup_spec.rb +++ b/spec/popups/bisect_popup_spec.rb @@ -3,8 +3,7 @@ require "spec_helper" RSpec.describe "Bisect Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("B") } - + let(:keymap) { "B" } let(:view) do [ " Arguments ", diff --git a/spec/popups/branch_config_popup_spec.rb b/spec/popups/branch_config_popup_spec.rb index c6e28b812..bc0875581 100644 --- a/spec/popups/branch_config_popup_spec.rb +++ b/spec/popups/branch_config_popup_spec.rb @@ -3,8 +3,7 @@ require "spec_helper" RSpec.describe "Branch Config Popup", :git, :nvim, :popup do - before { nvim.keys("bC") } - + let(:keymap) { "bC" } let(:view) do [ " Configure branch ", @@ -46,11 +45,11 @@ end end - describe "rebase" do - end + # describe "rebase" do + # end - describe "pullRemote" do - end + # describe "pullRemote" do + # end end describe "Actions" do @@ -67,21 +66,21 @@ end end - describe "remote.pushDefault" do - end + # describe "remote.pushDefault" do + # end - describe "neogit.baseBranch" do - end + # describe "neogit.baseBranch" do + # end - describe "neogit.askSetPushDefault" do - end + # describe "neogit.askSetPushDefault" do + # end end - describe "Branch creation" do - describe "autoSetupMerge" do - end - - describe "autoSetupRebase" do - end - end + # describe "Branch creation" do + # describe "autoSetupMerge" do + # end + # + # describe "autoSetupRebase" do + # end + # end end diff --git a/spec/popups/branch_popup_spec.rb b/spec/popups/branch_popup_spec.rb index 8089c1d29..fac371074 100644 --- a/spec/popups/branch_popup_spec.rb +++ b/spec/popups/branch_popup_spec.rb @@ -3,11 +3,10 @@ require "spec_helper" RSpec.describe "Branch Popup", :git, :nvim, :popup do - before { nvim.keys("b") } - + let(:keymap) { "b" } let(:view) do [ - " Variables ", + " Configure branch ", " d branch.master.description unset ", " u branch.master.merge unset ", " branch.master.remote unset ", @@ -48,6 +47,25 @@ expect(git.config("branch.#{git.branch.name}.remote")).to eq(".") expect(git.config("branch.#{git.branch.name}.merge")).to eq("refs/heads/master") end + + it "unsets both values if already set" do + nvim.keys("umaster") + + expect(nvim.screen[8..9]).to eq( + [" u branch.master.merge refs/heads/master ", + " branch.master.remote . "] + ) + + nvim.keys("u") + + expect_git_failure { git.config("branch.#{git.branch.name}.remote") } + expect_git_failure { git.config("branch.#{git.branch.name}.merge") } + + expect(nvim.screen[8..9]).to eq( + [" u branch.master.merge unset ", + " branch.master.remote unset "] + ) + end end describe "branch..rebase" do diff --git a/spec/popups/cherry_pick_popup_spec.rb b/spec/popups/cherry_pick_popup_spec.rb index 5aed593e7..2b7cb78eb 100644 --- a/spec/popups/cherry_pick_popup_spec.rb +++ b/spec/popups/cherry_pick_popup_spec.rb @@ -3,8 +3,7 @@ require "spec_helper" RSpec.describe "Cherry Pick Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("A") } - + let(:keymap) { "A" } let(:view) do [ " Arguments ", @@ -25,5 +24,5 @@ end %w[-m =s -F -x -e -s -S].each { include_examples "argument", _1 } - %w[A a].each { include_examples "interaction", _1 } + %w[A a m d h].each { include_examples "interaction", _1 } end diff --git a/spec/popups/commit_popup_spec.rb b/spec/popups/commit_popup_spec.rb index 313a2fbf9..2fa2294ce 100644 --- a/spec/popups/commit_popup_spec.rb +++ b/spec/popups/commit_popup_spec.rb @@ -3,8 +3,7 @@ require "spec_helper" RSpec.describe "Commit Popup", :git, :nvim, :popup do - before { nvim.keys("c") } - + let(:keymap) { "c" } let(:view) do [ " Arguments ", @@ -18,15 +17,17 @@ " -S Sign using gpg (--gpg-sign=) ", " -C Reuse commit message (--reuse-message=) ", " ", - " Create Edit HEAD Edit ", - " c Commit e Extend f Fixup F Instant Fixup ", - " x Absorb w Reword s Squash S Instant Squash ", - " a Amend A Augment " + " Create Edit HEAD Edit Edit and rebase Spread across commits ", + " c Commit e Extend f Fixup F Instant Fixup x Absorb ", + " s Squash S Instant Squash ", + " a Amend A Alter ", + " n Augment ", + " w Reword W Revise " ] end %w[-a -e -v -h -R -A -s -S -C].each { include_examples "argument", _1 } - %w[c x e w a f s A F S].each { include_examples "interaction", _1 } + %w[c x e w a f s A F S n W].each { include_examples "interaction", _1 } describe "Actions" do describe "Create Commit" do @@ -149,19 +150,19 @@ end end - describe "Fixup" do - end + # describe "Fixup" do + # end - describe "Squash" do - end + # describe "Squash" do + # end - describe "Augment" do - end + # describe "Augment" do + # end - describe "Instant Fixup" do - end + # describe "Instant Fixup" do + # end - describe "Instant Squash" do - end + # describe "Instant Squash" do + # end end end diff --git a/spec/popups/diff_popup_spec.rb b/spec/popups/diff_popup_spec.rb index cff609ffa..b78b695ce 100644 --- a/spec/popups/diff_popup_spec.rb +++ b/spec/popups/diff_popup_spec.rb @@ -3,8 +3,7 @@ require "spec_helper" RSpec.describe "Diff Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("d") } - + let(:keymap) { "d" } let(:view) do [ " Diff Show ", diff --git a/spec/popups/fetch_popup_spec.rb b/spec/popups/fetch_popup_spec.rb index 390abf356..755d83f3d 100644 --- a/spec/popups/fetch_popup_spec.rb +++ b/spec/popups/fetch_popup_spec.rb @@ -3,8 +3,7 @@ require "spec_helper" RSpec.describe "Fetch Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("f") } - + let(:keymap) { "f" } let(:view) do [ " Arguments ", diff --git a/spec/popups/help_popup_spec.rb b/spec/popups/help_popup_spec.rb index 0e673f10a..1208765d6 100644 --- a/spec/popups/help_popup_spec.rb +++ b/spec/popups/help_popup_spec.rb @@ -3,24 +3,23 @@ require "spec_helper" RSpec.describe "Help Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("?") } - + let(:keymap) { "?" } let(:view) do [ " Commands Applying changes Essential commands ", " $ History M Remote Stage all Refresh ", " A Cherry Pick m Merge K Untrack Go to file ", - " b Branch P Push s Stage Toggle ", - " B Bisect p Pull S Stage-Unstaged ", + " b Branch p Pull s Stage Toggle ", + " B Bisect P Push S Stage unstaged ", " c Commit Q Command u Unstage ", - " d Diff r Rebase U Unstage-Staged ", + " d Diff r Rebase U Unstage all ", " f Fetch t Tag x Discard ", - " I Init v Revert ", - " i Ignore w Worktree ", - " l Log X Reset ", - " Z Stash " + " i Ignore v Revert ", + " I Init w Worktree ", + " L Margin X Reset ", + " l Log Z Stash " ] end - %w[$ A b B c d f i I l M m P p r t v w X Z].each { include_examples "interaction", _1 } + %w[$ A b B c d f i I l L M m P p r t v w X Z].each { include_examples "interaction", _1 } end diff --git a/spec/popups/ignore_popup_spec.rb b/spec/popups/ignore_popup_spec.rb index ce833f852..3e8deebc2 100644 --- a/spec/popups/ignore_popup_spec.rb +++ b/spec/popups/ignore_popup_spec.rb @@ -3,8 +3,7 @@ require "spec_helper" RSpec.describe "Ignore Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("i") } - + let(:keymap) { "i" } let(:view) do [ " Gitignore ", diff --git a/spec/popups/log_popup_spec.rb b/spec/popups/log_popup_spec.rb index 88bfebfb1..06e434b89 100644 --- a/spec/popups/log_popup_spec.rb +++ b/spec/popups/log_popup_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" RSpec.describe "Log Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("l") } + let(:keymap) { "l" } # TODO: PTY needs to be bigger to show the entire popup let(:view) do diff --git a/spec/popups/margin_popup_spec.rb b/spec/popups/margin_popup_spec.rb new file mode 100644 index 000000000..f834c940f --- /dev/null +++ b/spec/popups/margin_popup_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Margin Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup + let(:keymap) { "L" } + let(:view) do + [ + " Arguments ", + # " -n Limit number of commits (--max-count=256) ", + " -o Order commits by (--[topo|author-date|date]-order) ", + # " -g Show graph (--graph) ", + # " -c Show graph in color (--color) ", + " -d Show refnames (--decorate) ", + " ", + " Refresh Margin ", + " g buffer L toggle visibility ", + " l cycle style ", + " d toggle details ", + " x toggle shortstat " + ] + end + + %w[L l d].each { include_examples "interaction", _1 } +end diff --git a/spec/popups/merge_popup_spec.rb b/spec/popups/merge_popup_spec.rb index 7a4f207a3..e5c9130f5 100644 --- a/spec/popups/merge_popup_spec.rb +++ b/spec/popups/merge_popup_spec.rb @@ -3,8 +3,7 @@ require "spec_helper" RSpec.describe "Merge Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("m") } - + let(:keymap) { "m" } let(:view) do [ " Arguments ", diff --git a/spec/popups/pull_popup_spec.rb b/spec/popups/pull_popup_spec.rb index 6e7f489bd..b535a1573 100644 --- a/spec/popups/pull_popup_spec.rb +++ b/spec/popups/pull_popup_spec.rb @@ -3,8 +3,7 @@ require "spec_helper" RSpec.describe "Pull Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("p") } - + let(:keymap) { "p" } let(:view) do [ " Variables ", @@ -15,6 +14,7 @@ " -r Rebase local commits (--rebase) ", " -a Autostash (--autostash) ", " -t Fetch tags (--tags) ", + " -F Force (--force) ", " ", " Pull into master from Configure ", " p pushRemote, setting that C Set variables... ", @@ -23,5 +23,5 @@ ] end - %w[r -f -r -a -t p u e C].each { include_examples "interaction", _1 } + %w[r -f -r -a -t -F p u e C].each { include_examples "interaction", _1 } end diff --git a/spec/popups/push_popup_spec.rb b/spec/popups/push_popup_spec.rb index 817ec4946..fc9af1360 100644 --- a/spec/popups/push_popup_spec.rb +++ b/spec/popups/push_popup_spec.rb @@ -3,20 +3,22 @@ require "spec_helper" RSpec.describe "Push Popup", :git, :nvim, :popup, :with_remote_origin do - before { nvim.keys("P") } + let(:keymap) { "P" } let(:view) do [ " Arguments ", " -f Force with lease (--force-with-lease) ", " -F Force (--force) ", - " -u Set the upstream before pushing (--set-upstream) ", " -h Disable hooks (--no-verify) ", " -d Dry run (--dry-run) ", + " -u Set the upstream before pushing (--set-upstream) ", + " -T Include all tags (--tags) ", + " -t Include related annotated tags (--follow-tags) ", " ", " Push master to Push Configure ", " p pushRemote, setting that o another branch C Set variables... ", - " u @{upstream}, creating it r explicit refspecs ", + " u @{upstream}, creating it r explicit refspec ", " e elsewhere m matching branches ", " T a tag ", " t all tags " diff --git a/spec/popups/rebase_popup_spec.rb b/spec/popups/rebase_popup_spec.rb index 1faad3661..ad43d5101 100644 --- a/spec/popups/rebase_popup_spec.rb +++ b/spec/popups/rebase_popup_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" RSpec.describe "Rebase Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("r") } + let(:keymap) { "r" } let(:view) do [ diff --git a/spec/popups/remote_popup_spec.rb b/spec/popups/remote_popup_spec.rb index 17f61e8b3..cff948627 100644 --- a/spec/popups/remote_popup_spec.rb +++ b/spec/popups/remote_popup_spec.rb @@ -2,9 +2,8 @@ require "spec_helper" -RSpec.describe "Remote Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("M") } - +RSpec.describe "Remote Popup", :git, :nvim, :popup do + let(:keymap) { "M" } let(:view) do [ " Variables ", @@ -28,4 +27,124 @@ %w[u U s S O a d x C p P b z].each { include_examples "interaction", _1 } %w[-f].each { include_examples "argument", _1 } + + describe "add" do + context "with 'origin 'unset" do + it "allow user to add remote" do + nvim.keys("a") + nvim.keys("origin") + nvim.keys("git@github.com:NeogitOrg/neogit.git") + expect(git.remote.name).to eq("origin") + expect(git.remote.url).to eq("git@github.com:NeogitOrg/neogit.git") + end + end + + context "with 'origin' set" do + before do + git.config("remote.origin.url", "git@github.com:NeogitOrg/neogit.git") + end + + it "auto-populates host/remote" do + nvim.keys("a") + nvim.keys("fork") + expect(nvim.screen.last).to start_with("URL for fork: git@github.com:fork/neogit.git") + end + end + end + + describe "remove" do + context "with no remotes configured" do + it "notifies user" do + nvim.keys("x") + expect(nvim.screen.last).to start_with("No remotes found") + end + end + + context "with a remote configured" do + before do + git.config("remote.origin.url", "git@github.com:NeogitOrg/neogit.git") + end + + it "can remove a remote" do + nvim.keys("x") + nvim.keys("origin") + expect(nvim.screen.last).to start_with("Removed remote 'origin'") + expect(git.remotes).to be_empty + end + end + end + + describe "rename" do + context "with no remotes configured" do + it "notifies user" do + nvim.keys("r") + expect(nvim.screen.last).to start_with("No remotes found") + end + end + + context "with a remote configured" do + before do + git.config("remote.origin.url", "git@github.com:NeogitOrg/neogit.git") + end + + it "can rename a remote" do + nvim.keys("r") + nvim.keys("origin") + nvim.keys("fork") + expect(nvim.screen.last).to start_with("Renamed 'origin' -> 'fork'") + expect(git.remotes.first.name).to eq("fork") + end + end + end + + describe "configure" do + context "with no remotes configured" do + it "notifies user" do + nvim.keys("C") + expect(nvim.screen.last).to start_with("No remotes found") + end + end + + context "with a remote configured" do + before do + git.config("remote.origin.url", "git@github.com:NeogitOrg/neogit.git") + end + + it "can launch remote config popup" do + nvim.keys("C") + nvim.keys("origin") + expect(nvim.screen[14..19]).to eq( + [" Configure remote ", + " u remote.origin.url git@github.com:NeogitOrg/neogit.git ", + " U remote.origin.fetch unset ", + " s remote.origin.pushurl unset ", + " S remote.origin.push unset ", + " O remote.origin.tagOpt [--no-tags|--tags] "] + ) + end + end + end + + describe "prune_branches" do + context "with no remotes configured" do + it "notifies user" do + nvim.keys("p") + expect(nvim.screen.last).to start_with("No remotes found") + end + end + + context "with a remote configured" do + before do + git.config("remote.origin.url", "git@github.com:NeogitOrg/neogit.git") + end + + it "can launch remote config popup" do + nvim.keys("p") + nvim.keys("origin") + await do + expect(nvim.screen.last).to start_with("Pruned remote origin") + end + end + end + end end diff --git a/spec/popups/reset_popup_spec.rb b/spec/popups/reset_popup_spec.rb index d7543366c..81b170145 100644 --- a/spec/popups/reset_popup_spec.rb +++ b/spec/popups/reset_popup_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" RSpec.describe "Reset Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("X") } + let(:keymap) { "X" } let(:view) do [ diff --git a/spec/popups/revert_popup_spec.rb b/spec/popups/revert_popup_spec.rb index eb1736a03..07d97b487 100644 --- a/spec/popups/revert_popup_spec.rb +++ b/spec/popups/revert_popup_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" RSpec.describe "Revert Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("v") } + let(:keymap) { "v" } let(:view) do [ @@ -11,6 +11,9 @@ " =m Replay merge relative to parent (--mainline=) ", " -e Edit commit messages (--edit) ", " -E Don't edit commit messages (--no-edit) ", + " -s Add Signed-off-by lines (--signoff) ", + " =s Strategy (--strategy=) ", + " -S Sign using gpg (--gpg-sign=) ", " ", " Revert ", " v Commit(s) ", @@ -19,5 +22,5 @@ end %w[v V].each { include_examples "interaction", _1 } - %w[=m -e -E].each { include_examples "argument", _1 } + %w[=m -e -E -s =s -S].each { include_examples "argument", _1 } end diff --git a/spec/popups/stash_popup_spec.rb b/spec/popups/stash_popup_spec.rb index fdaf15024..c4c339397 100644 --- a/spec/popups/stash_popup_spec.rb +++ b/spec/popups/stash_popup_spec.rb @@ -2,11 +2,15 @@ require "spec_helper" -RSpec.describe "Stash Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("Z") } +RSpec.describe "Stash Popup", :git, :nvim, :popup do + let(:keymap) { "Z" } let(:view) do [ + " Arguments ", + " -u Also save untracked files (--include-untracked) ", + " -a Also save untracked and ignored files (--all) ", + " ", " Stash Snapshot Use Inspect Transform ", " z both Z both p pop l List b Branch ", " i index I index a apply v Show B Branch here ", @@ -17,4 +21,116 @@ end %w[z i w x P Z I W r p a d l b B m f].each { include_examples "interaction", _1 } + %w[-u -a].each { include_examples "argument", _1 } + + describe "Stash both" do + before do + File.write("foo", "hello foo") + File.write("bar", "hello bar") + File.write("baz", "hello baz") + git.add("foo") + git.add("bar") + git.commit("initial commit") + File.write("foo", "hello world") + File.write("bar", "hello world") + git.add("foo") + end + + context "with --include-untracked" do + it "stashes staged, unstaged, and untracked changed" do + nvim.keys("-u") + nvim.keys("z") + expect(git.status.changed).to be_empty + expect(git.status.untracked).to be_empty + end + end + + context "with --all" do + it "stashes staged, unstaged, untracked, and ignored changes" do + nvim.keys("-a") + nvim.keys("z") + expect(git.status.changed).to be_empty + expect(git.status.untracked).to be_empty + end + end + + it "stashes both staged and unstaged changes" do + nvim.keys("z") + expect(git.status.changed).to be_empty + expect(git.status.untracked).not_to be_empty + end + end + + describe "Stash index" do + before do + File.write("foo", "hello foo") # Staged + File.write("bar", "hello bar") # Unstaged + File.write("baz", "hello baz") # Untracked + + git.add("foo") + git.add("bar") + git.commit("initial commit") + + File.write("foo", "hello world") + File.write("bar", "hello world") + + git.add("foo") + end + + it "stashes only staged changes" do + nvim.keys("i") + expect(git.status.changed.keys).to contain_exactly("bar") + expect(git.status.untracked).not_to be_empty + end + end + + describe "Stash Keeping index" do + before do + File.write("foo", "hello foo") # Staged + File.write("bar", "hello bar") # Unstaged + File.write("baz", "hello baz") # Untracked + + git.add("foo") + git.add("bar") + git.commit("initial commit") + + File.write("foo", "hello world") + File.write("bar", "hello world") + + git.add("foo") + end + + it "stashes only unstaged changes" do + nvim.keys("x") + expect(git.status.changed.keys).to contain_exactly("foo") + expect(git.status.untracked).not_to be_empty + end + end + + describe "Stash push" do + before do + File.write("foo", "hello foo") # Staged + File.write("bar", "hello bar") # Unstaged + File.write("baz", "hello baz") # Untracked + + git.add("foo") + git.add("bar") + git.commit("initial commit") + + File.write("foo", "hello world") + File.write("bar", "hello world") + + git.add("foo") + end + + it "stashes only specified file" do + expect(git.status.changed.keys).to contain_exactly("foo", "bar") + + nvim.keys("Pfoo") + expect(git.status.changed.keys).to contain_exactly("bar") + + nvim.keys("ZPbar") + expect(git.status.changed.keys).to be_empty + end + end end diff --git a/spec/popups/tag_popup_spec.rb b/spec/popups/tag_popup_spec.rb index 31fdf6227..142aaee96 100644 --- a/spec/popups/tag_popup_spec.rb +++ b/spec/popups/tag_popup_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" RSpec.describe "Tag Popup", :git, :nvim, :popup do # rubocop:disable RSpec/EmptyExampleGroup - before { nvim.keys("t") } + let(:keymap) { "t" } let(:view) do [ diff --git a/spec/popups/worktree_popup_spec.rb b/spec/popups/worktree_popup_spec.rb index b8c60fd58..e4a495336 100644 --- a/spec/popups/worktree_popup_spec.rb +++ b/spec/popups/worktree_popup_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" RSpec.describe "Worktree Popup", :git, :nvim, :popup do - before { nvim.keys("w") } + let(:keymap) { "w" } let(:view) do [ @@ -32,9 +32,9 @@ end it "creates a worktree for an existing branch and checks it out", :aggregate_failures do - nvim.keys("w") # Action - nvim.keys("wor") # Select "worktree-test" branch - nvim.keys("#{dir}") # go up level, new folder name + nvim.keys("w") # Action + nvim.keys("wor") # Select "worktree-test" branch + nvim.keys("#{dir}/") # go up level, new folder name expect(git.worktrees.map(&:dir).last).to match(%r{/#{dir}$}) expect(nvim.cmd("pwd").first).to match(%r{/#{dir}$}) @@ -48,11 +48,10 @@ end it "creates a worktree for a new branch and checks it out", :aggregate_failures do - nvim.input("create-worktree-test") # Branch name - - nvim.keys("W") # Action - nvim.keys("#{dir}") # go up level, new folder name - nvim.keys("mas") # Set base branch to 'master' + nvim.keys("W") # Action + nvim.keys("#{dir}/") # new folder name + nvim.keys("mas") # Set base branch to 'master' + nvim.keys("create-worktree-test") # branch name expect(git.worktrees.map(&:dir).last).to match(%r{/#{dir}$}) expect(nvim.cmd("pwd").first).to match(%r{/#{dir}$}) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 16d021f8a..81928d5d7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,10 +5,13 @@ require "neovim" require "debug" require "active_support/all" +require "timeout" +require "super_diff/rspec" +require "super_diff/active_support" ENV["GIT_CONFIG_GLOBAL"] = "" -PROJECT_DIR = File.expand_path(File.join(__dir__, "..")) +PROJECT_DIR = File.expand_path(File.join(__dir__, "..")) unless defined?(PROJECT_DIR) Dir[File.join(File.expand_path("."), "spec", "support", "**", "*.rb")].each { |f| require f } @@ -49,4 +52,12 @@ end end end + + if ENV["CI"].present? + config.around do |example| + Timeout.timeout(10) do + example.run + end + end + end end diff --git a/spec/support/dependencies.rb b/spec/support/dependencies.rb index eb6a53db0..de5b3cc46 100644 --- a/spec/support/dependencies.rb +++ b/spec/support/dependencies.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -return if ENV["CI"] - def dir_name(name) name.match(%r{[^/]+/(?[^\.]+)})[:dir_name] end @@ -16,8 +14,11 @@ def ensure_installed(name, build: nil) puts "Downloading dependency #{name} to #{dir}" Dir.mkdir(dir) - Git.clone("git@github.com:#{name}.git", dir) - Dir.chdir(dir) { system(build) } if build.present? + Git.clone("git@github.com:#{name}.git", dir, filter: "tree:0") + return unless build.present? + + puts "Building #{name} via #{build}" + Dir.chdir(dir) { system(build) } end ensure_installed "nvim-lua/plenary.nvim" diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index 06dea7fc5..9d5ad584c 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -9,21 +9,19 @@ def expect_git_failure(&) expect(&).to raise_error(Git::FailedError) end - # def wait_for_expect - # last_error = nil - # success = false - # - # 5.times do - # begin - # yield - # success = true - # break - # rescue RSpec::Expectations::ExpectationNotMetError => e - # last_error = e - # sleep 0.5 - # end - # end - # - # raise last_error if !success && last_error - # end + def await # rubocop:disable Metrics/MethodLength + last_error = nil + success = false + + 10.times do + yield + success = true + break + rescue RSpec::Expectations::ExpectationNotMetError => e + last_error = e + sleep 0.1 + end + + raise last_error if !success && last_error + end end diff --git a/spec/support/neovim_client.rb b/spec/support/neovim_client.rb index a114cce83..d573e8dd7 100644 --- a/spec/support/neovim_client.rb +++ b/spec/support/neovim_client.rb @@ -25,6 +25,7 @@ def setup(neogit_config) # rubocop:disable Metrics/MethodLength lua <<~LUA require("plenary") + require("diffview").setup() require('neogit').setup(#{neogit_config}) require('neogit').open() LUA diff --git a/spec/support/shared.rb b/spec/support/shared.rb index 187775c0d..c68584ac5 100644 --- a/spec/support/shared.rb +++ b/spec/support/shared.rb @@ -15,10 +15,26 @@ end RSpec.shared_examples "popup", :popup do + before do + nvim.keys(keymap) + end + it "raises no errors" do expect(nvim.errors).to be_empty end + it "raises no errors with detached HEAD" do + nvim.keys("") # close popup + + # Detach HEAD + git.commit("dummy commit", allow_empty: true) + git.checkout("HEAD^") + + sleep(1) # Allow state to propagate + nvim.keys(keymap) # open popup + expect(nvim.errors).to be_empty + end + it "has correct filetype" do expect(nvim.filetype).to eq("NeogitPopup") end diff --git a/tests/init.lua b/tests/init.lua index e8dd5130a..700ad858a 100644 --- a/tests/init.lua +++ b/tests/init.lua @@ -11,10 +11,9 @@ else util.ensure_installed("nvim-lua/plenary.nvim", util.neogit_test_base_dir) end -require("plenary.test_harness").test_directory( - os.getenv("TEST_FILES") == "" and "tests/specs" or os.getenv("TEST_FILES"), - { - minimal_init = "tests/minimal_init.lua", - sequential = true, - } -) +local directory = os.getenv("TEST_FILES") == "" and "tests/specs" or os.getenv("TEST_FILES") or "tests/specs" + +require("plenary.test_harness").test_directory(directory, { + minimal_init = "tests/minimal_init.lua", + sequential = true, +}) diff --git a/tests/specs/neogit/config_spec.lua b/tests/specs/neogit/config_spec.lua index 3a51eb951..0d169c33c 100644 --- a/tests/specs/neogit/config_spec.lua +++ b/tests/specs/neogit/config_spec.lua @@ -56,6 +56,11 @@ describe("Neogit config", function() assert.True(vim.tbl_count(require("neogit.config").validate_config()) ~= 0) end) + it("should return invalid when initial_branch_name isn't a string", function() + config.values.initial_branch_name = false + assert.True(vim.tbl_count(require("neogit.config").validate_config()) ~= 0) + end) + it("should return invalid when kind isn't a string", function() config.values.kind = true assert.True(vim.tbl_count(require("neogit.config").validate_config()) ~= 0) @@ -77,7 +82,12 @@ describe("Neogit config", function() end) it("should return invalid when auto_show_console isn't a boolean", function() - config.values.console_timeout = "not a boolean" + config.values.auto_show_console = "not a boolean" + assert.True(vim.tbl_count(require("neogit.config").validate_config()) ~= 0) + end) + + it("should return invalid when auto_show_console_on isn't a string", function() + config.values.auto_show_console_on = true assert.True(vim.tbl_count(require("neogit.config").validate_config()) ~= 0) end) diff --git a/tests/specs/neogit/docs_spec.lua b/tests/specs/neogit/docs_spec.lua index 644ff1492..8e8e72098 100644 --- a/tests/specs/neogit/docs_spec.lua +++ b/tests/specs/neogit/docs_spec.lua @@ -2,7 +2,7 @@ local Path = require("plenary.path") describe("docs", function() it("doesn't repeat any tags", function() - local docs = Path.new(vim.loop.cwd(), "doc", "neogit.txt") + local docs = Path.new(vim.uv.cwd(), "doc", "neogit.txt") local tags = {} for line in docs:iter() do @@ -14,7 +14,7 @@ describe("docs", function() end) it("doesn't reference any undefined tags", function() - local docs = Path.new(vim.loop.cwd(), "doc", "neogit.txt") + local docs = Path.new(vim.uv.cwd(), "doc", "neogit.txt") local tags = {} local refs = {} diff --git a/tests/specs/neogit/lib/git/cli_spec.lua b/tests/specs/neogit/lib/git/cli_spec.lua index d80c89e8b..a913e9e7b 100644 --- a/tests/specs/neogit/lib/git/cli_spec.lua +++ b/tests/specs/neogit/lib/git/cli_spec.lua @@ -8,7 +8,7 @@ describe("git cli", function() it( "finds the correct git root for a non symlinked directory", in_prepared_repo(function(root_dir) - local detected_root_dir = git_cli.git_root(".") + local detected_root_dir = git_cli.worktree_root(".") eq(detected_root_dir, root_dir) end) ) @@ -35,7 +35,7 @@ describe("git cli", function() vim.fn.system(cmd) vim.api.nvim_set_current_dir(symlink_dir) - local detected_root_dir = git_cli.git_root(".") + local detected_root_dir = git_cli.worktree_root(".") eq(detected_root_dir, git_dir) end) ) diff --git a/tests/specs/neogit/lib/git/index_spec.lua b/tests/specs/neogit/lib/git/index_spec.lua index 3d1be1cc6..cc0358087 100644 --- a/tests/specs/neogit/lib/git/index_spec.lua +++ b/tests/specs/neogit/lib/git/index_spec.lua @@ -10,17 +10,15 @@ local function run_with_hunk(hunk, from, to, reverse) local header_matches = vim.fn.matchlist(lines[1], "@@ -\\(\\d\\+\\),\\(\\d\\+\\) +\\(\\d\\+\\),\\(\\d\\+\\) @@") return generate_patch_from_selection({ - name = "test.txt", - absolute_path = "test.txt", - diff = { lines = lines }, - }, { first = 1, last = #lines, index_from = header_matches[2], index_len = header_matches[3], diff_from = diff_from, diff_to = #lines, - }, diff_from + from, diff_from + to, reverse) + lines = vim.list_slice(lines, 2), + file = "test.txt", + }, { from = from, to = to, reverse = reverse }) end describe("patch creation", function() diff --git a/tests/specs/neogit/lib/git/log_spec.lua b/tests/specs/neogit/lib/git/log_spec.lua index 446eaa07e..f3618c353 100644 --- a/tests/specs/neogit/lib/git/log_spec.lua +++ b/tests/specs/neogit/lib/git/log_spec.lua @@ -96,6 +96,7 @@ describe("lib.git.log.parse", function() index_from = 692, index_len = 33, length = 40, + file = "lua/neogit/status.lua", line = "@@ -692,33 +692,28 @@ end", lines = { " ---@param first_line number", @@ -149,6 +150,7 @@ describe("lib.git.log.parse", function() index_from = 734, index_len = 14, length = 15, + file = "lua/neogit/status.lua", line = "@@ -734,14 +729,10 @@ function M.get_item_hunks(item, first_line, last_line, partial)", lines = { " setmetatable(o, o)", @@ -290,6 +292,7 @@ describe("lib.git.log.parse", function() index_len = 7, length = 9, line = "@@ -1,7 +1,9 @@", + file = "LICENSE", lines = { " MIT License", " ", diff --git a/tests/specs/neogit/lib/git/repository_spec.lua b/tests/specs/neogit/lib/git/repository_spec.lua index c5757e820..5b8e9b8c9 100644 --- a/tests/specs/neogit/lib/git/repository_spec.lua +++ b/tests/specs/neogit/lib/git/repository_spec.lua @@ -8,8 +8,8 @@ describe("lib.git.instance", function() it( "creates cached git instance and returns it", in_prepared_repo(function(root_dir) - local dir1 = git_repo.instance(root_dir).git_root - local dir2 = git_repo.instance().git_root + local dir1 = git_repo.instance(root_dir).worktree_root + local dir2 = git_repo.instance().worktree_root eq(dir1, dir2) end) ) diff --git a/tests/specs/neogit/lib/git/status_spec.lua b/tests/specs/neogit/lib/git/status_spec.lua deleted file mode 100644 index d5894a62c..000000000 --- a/tests/specs/neogit/lib/git/status_spec.lua +++ /dev/null @@ -1,46 +0,0 @@ -local neogit = require("neogit") -local git_harness = require("tests.util.git_harness") -local util = require("tests.util.util") - -local subject = require("neogit.lib.git.status") - -neogit.setup {} - -describe("lib.git.status", function() - before_each(function() - git_harness.prepare_repository() - -- plenary_async.util.block_on(neogit.reset) - end) - - describe("#anything_staged", function() - -- it("returns true when there are staged items", function() - -- util.system("git add --all") - -- plenary_async.util.block_on(neogit.refresh) - -- - -- assert.True(subject.anything_staged()) - -- end) - - it("returns false when there are no staged items", function() - util.system { "git", "reset" } - neogit.refresh() - - assert.False(subject.anything_staged()) - end) - end) - - describe("#anything_unstaged", function() - -- it("returns true when there are unstaged items", function() - -- util.system("git reset") - -- plenary_async.util.block_on(neogit.refresh) - -- - -- assert.True(subject.anything_unstaged()) - -- end) - - it("returns false when there are no unstaged items", function() - util.system { "git", "add", "--all" } - neogit.refresh() - - assert.False(subject.anything_unstaged()) - end) - end) -end) diff --git a/tests/specs/neogit/process_spec.lua b/tests/specs/neogit/process_spec.lua deleted file mode 100644 index 6af9ae870..000000000 --- a/tests/specs/neogit/process_spec.lua +++ /dev/null @@ -1,65 +0,0 @@ -require("plenary.async").tests.add_to_env() -local util = require("tests.util.util") - -local process = require("neogit.process") - -describe("process execution", function() - it("basic command", function() - local result = - process.new({ cmd = { "cat", "process_test" }, cwd = util.get_fixtures_dir() }):spawn_blocking(1) - assert(result) - assert.are.same({ - "This is a test file", - "", - "", - "It is intended to be read by cat and returned to neovim using the process api", - "", - "", - }, result.stdout) - end) - - it("can cat a file", function() - local result = process.new({ cmd = { "cat", "a.txt" }, cwd = util.get_fixtures_dir() }):spawn_blocking(1) - - assert(result) - assert.are.same({ - "Lorem ipsum dolor sit amet, officia excepteur ex fugiat reprehenderit enim labore culpa sint ad nisi Lorem pariatur mollit ex esse exercitation amet.", - "Nisi anim cupidatat excepteur officia.", - "Reprehenderit nostrud nostrud ipsum Lorem est aliquip amet voluptate voluptate dolor minim nulla est proident.", - "Nostrud officia pariatur ut officia.", - "Sit irure elit esse ea nulla sunt ex occaecat reprehenderit commodo officia dolor Lorem duis laboris cupidatat officia voluptate.", - "", - "Culpa proident adipisicing id nulla nisi laboris ex in Lorem sunt duis officia eiusmod.", - "Aliqua reprehenderit commodo ex non excepteur duis sunt velit enim.", - "Voluptate laboris sint cupidatat ullamco ut ea consectetur et est culpa et culpa duis.", - "", - }, result.stdout) - end) - - it("process input", function() - local tmp_dir = util.create_temp_dir() - local input = { "This is a line", "This is another line", "", "" } - local p = process.new { cmd = { "tee", tmp_dir .. "/output" } } - - p:spawn() - p:send(table.concat(input, "\n")) - p:send("\04") - p:close_stdin() - p:wait() - - local result = process.new({ cmd = { "cat", tmp_dir .. "/output" } }):spawn_blocking(1) - assert(result) - assert.are.same({ "This is a line", "This is another line", "", "\04" }, result.stdout) - end) - - it("basic command trim", function() - local result = - process.new({ cmd = { "cat", "process_test" }, cwd = util.get_fixtures_dir() }):spawn_blocking(1) - - assert(result) - assert.are.same({ - "This is a test file", - "It is intended to be read by cat and returned to neovim using the process api", - }, result:trim().stdout) - end) -end) diff --git a/tests/util/util.lua b/tests/util/util.lua index a898df127..f9dabdcc5 100644 --- a/tests/util/util.lua +++ b/tests/util/util.lua @@ -39,7 +39,7 @@ end M.neogit_test_base_dir = "/tmp/neogit-testing/" local function is_macos() - return vim.loop.os_uname().sysname == "Darwin" + return vim.uv.os_uname().sysname == "Darwin" end local function is_gnu_mktemp() @@ -72,7 +72,7 @@ function M.ensure_installed(repo, path) vim.opt.runtimepath:prepend(install_path) - if not vim.loop.fs_stat(install_path) then + if not vim.uv.fs_stat(install_path) then print("* Downloading " .. name .. " to '" .. install_path .. "/'") vim.fn.system { "git", "clone", "--depth=1", "git@github.com:" .. repo .. ".git", install_path }