From 8899cc3f8cf4d2701e52c3d3ed0e6b92989964a9 Mon Sep 17 00:00:00 2001 From: Micah Halter Date: Thu, 20 Jul 2023 14:27:09 -0400 Subject: [PATCH] feat: move git and updater utilities --- lua/astrocore/autocmds.lua | 295 -------------------------------- lua/astrocore/git.lua | 216 ++++++++++++++++++++++++ lua/astrocore/init.lua | 288 +++++++++++++++++++++++++++++++- lua/astrocore/updater.lua | 334 +++++++++++++++++++++++++++++++++++++ 4 files changed, 836 insertions(+), 297 deletions(-) delete mode 100644 lua/astrocore/autocmds.lua create mode 100644 lua/astrocore/git.lua create mode 100644 lua/astrocore/updater.lua diff --git a/lua/astrocore/autocmds.lua b/lua/astrocore/autocmds.lua deleted file mode 100644 index cb85974..0000000 --- a/lua/astrocore/autocmds.lua +++ /dev/null @@ -1,295 +0,0 @@ -local augroup = vim.api.nvim_create_augroup -local autocmd = vim.api.nvim_create_autocmd -local cmd = vim.api.nvim_create_user_command -local namespace = vim.api.nvim_create_namespace - -local utils = require "astrocore.utils" -local is_available = utils.is_available -local astroevent = utils.event - -vim.on_key(function(char) - if vim.fn.mode() == "n" then - local new_hlsearch = vim.tbl_contains({ "", "n", "N", "*", "#", "?", "/" }, vim.fn.keytrans(char)) - if vim.opt.hlsearch:get() ~= new_hlsearch then vim.opt.hlsearch = new_hlsearch end - end -end, namespace "auto_hlsearch") - -autocmd("BufReadPre", { - desc = "Disable certain functionality on very large files", - group = augroup("large_buf", { clear = true }), - callback = function(args) - local ok, stats = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(args.buf)) - vim.b[args.buf].large_buf = (ok and stats and stats.size > vim.g.max_file.size) - or vim.api.nvim_buf_line_count(args.buf) > vim.g.max_file.lines - end, -}) - -local bufferline_group = augroup("bufferline", { clear = true }) -autocmd({ "BufAdd", "BufEnter", "TabNewEntered" }, { - desc = "Update buffers when adding new buffers", - group = bufferline_group, - callback = function(args) - local buf_utils = require "astronvim.utils.buffer" - if not vim.t.bufs then vim.t.bufs = {} end - if not buf_utils.is_valid(args.buf) then return end - if args.buf ~= buf_utils.current_buf then - buf_utils.last_buf = buf_utils.is_valid(buf_utils.current_buf) and buf_utils.current_buf or nil - buf_utils.current_buf = args.buf - end - local bufs = vim.t.bufs - if not vim.tbl_contains(bufs, args.buf) then - table.insert(bufs, args.buf) - vim.t.bufs = bufs - end - vim.t.bufs = vim.tbl_filter(buf_utils.is_valid, vim.t.bufs) - astroevent "BufsUpdated" - end, -}) -autocmd("BufDelete", { - desc = "Update buffers when deleting buffers", - group = bufferline_group, - callback = function(args) - local removed - for _, tab in ipairs(vim.api.nvim_list_tabpages()) do - local bufs = vim.t[tab].bufs - if bufs then - for i, bufnr in ipairs(bufs) do - if bufnr == args.buf then - removed = true - table.remove(bufs, i) - vim.t[tab].bufs = bufs - break - end - end - end - end - vim.t.bufs = vim.tbl_filter(require("astronvim.utils.buffer").is_valid, vim.t.bufs) - if removed then astroevent "BufsUpdated" end - vim.cmd.redrawtabline() - end, -}) - -autocmd({ "VimEnter", "FileType", "BufEnter", "WinEnter" }, { - desc = "URL Highlighting", - group = augroup("highlighturl", { clear = true }), - callback = function() utils.set_url_match() end, -}) - -local view_group = augroup("auto_view", { clear = true }) -autocmd({ "BufWinLeave", "BufWritePost", "WinLeave" }, { - desc = "Save view with mkview for real files", - group = view_group, - callback = function(event) - if vim.b[event.buf].view_activated then vim.cmd.mkview { mods = { emsg_silent = true } } end - end, -}) -autocmd("BufWinEnter", { - desc = "Try to load file view if available and enable view saving for real files", - group = view_group, - callback = function(event) - if not vim.b[event.buf].view_activated then - local filetype = vim.api.nvim_get_option_value("filetype", { buf = event.buf }) - local buftype = vim.api.nvim_get_option_value("buftype", { buf = event.buf }) - local ignore_filetypes = { "gitcommit", "gitrebase", "svg", "hgcommit" } - if buftype == "" and filetype and filetype ~= "" and not vim.tbl_contains(ignore_filetypes, filetype) then - vim.b[event.buf].view_activated = true - vim.cmd.loadview { mods = { emsg_silent = true } } - end - end - end, -}) - -autocmd("BufWinEnter", { - desc = "Make q close help, man, quickfix, dap floats", - group = augroup("q_close_windows", { clear = true }), - callback = function(event) - local buftype = vim.api.nvim_get_option_value("buftype", { buf = event.buf }) - if vim.tbl_contains({ "help", "nofile", "quickfix" }, buftype) then - vim.keymap.set("n", "q", "close", { - desc = "Close window", - buffer = event.buf, - silent = true, - nowait = true, - }) - end - end, -}) - -autocmd("TextYankPost", { - desc = "Highlight yanked text", - group = augroup("highlightyank", { clear = true }), - pattern = "*", - callback = function() vim.highlight.on_yank() end, -}) - -autocmd("FileType", { - desc = "Unlist quickfist buffers", - group = augroup("unlist_quickfist", { clear = true }), - pattern = "qf", - callback = function() vim.opt_local.buflisted = false end, -}) - -autocmd("BufEnter", { - desc = "Quit AstroNvim if more than one window is open and only sidebar windows are list", - group = augroup("auto_quit", { clear = true }), - callback = function() - local wins = vim.api.nvim_tabpage_list_wins(0) - -- Both neo-tree and aerial will auto-quit if there is only a single window left - if #wins <= 1 then return end - local sidebar_fts = { aerial = true, ["neo-tree"] = true } - for _, winid in ipairs(wins) do - if vim.api.nvim_win_is_valid(winid) then - local bufnr = vim.api.nvim_win_get_buf(winid) - local filetype = vim.api.nvim_get_option_value("filetype", { buf = bufnr }) - -- If any visible windows are not sidebars, early return - if not sidebar_fts[filetype] then - return - -- If the visible window is a sidebar - else - -- only count filetypes once, so remove a found sidebar from the detection - sidebar_fts[filetype] = nil - end - end - end - if #vim.api.nvim_list_tabpages() > 1 then - vim.cmd.tabclose() - else - vim.cmd.qall() - end - end, -}) - -if is_available "alpha-nvim" then - autocmd({ "User", "BufEnter" }, { - desc = "Disable status and tablines for alpha", - group = augroup("alpha_settings", { clear = true }), - callback = function(event) - if - ( - (event.event == "User" and event.file == "AlphaReady") - or (event.event == "BufEnter" and vim.api.nvim_get_option_value("filetype", { buf = event.buf }) == "alpha") - ) and not vim.g.before_alpha - then - vim.g.before_alpha = { showtabline = vim.opt.showtabline:get(), laststatus = vim.opt.laststatus:get() } - vim.opt.showtabline, vim.opt.laststatus = 0, 0 - elseif - vim.g.before_alpha - and event.event == "BufEnter" - and vim.api.nvim_get_option_value("buftype", { buf = event.buf }) ~= "nofile" - then - vim.opt.laststatus, vim.opt.showtabline = vim.g.before_alpha.laststatus, vim.g.before_alpha.showtabline - vim.g.before_alpha = nil - end - end, - }) - autocmd("VimEnter", { - desc = "Start Alpha when vim is opened with no arguments", - group = augroup("alpha_autostart", { clear = true }), - callback = function() - local should_skip = false - if vim.fn.argc() > 0 or vim.fn.line2byte(vim.fn.line "$") ~= -1 or not vim.o.modifiable then - should_skip = true - else - for _, arg in pairs(vim.v.argv) do - if arg == "-b" or arg == "-c" or vim.startswith(arg, "+") or arg == "-S" then - should_skip = true - break - end - end - end - if not should_skip then - require("alpha").start(true, require("alpha").default_config) - vim.schedule(function() vim.cmd.doautocmd "FileType" end) - end - end, - }) -end - -if is_available "resession.nvim" then - autocmd("VimLeavePre", { - desc = "Save session on close", - group = augroup("resession_auto_save", { clear = true }), - callback = function() - local buf_utils = require "astronvim.utils.buffer" - local autosave = buf_utils.sessions.autosave - if autosave and buf_utils.is_valid_session() then - local save = require("resession").save - if autosave.last then save("Last Session", { notify = false }) end - if autosave.cwd then save(vim.fn.getcwd(), { dir = "dirsession", notify = false }) end - end - end, - }) -end - -if is_available "neo-tree.nvim" then - autocmd("BufEnter", { - desc = "Open Neo-Tree on startup with directory", - group = augroup("neotree_start", { clear = true }), - callback = function() - if package.loaded["neo-tree"] then - vim.api.nvim_del_augroup_by_name "neotree_start" - else - local stats = (vim.uv or vim.loop).fs_stat(vim.api.nvim_buf_get_name(0)) -- TODO: REMOVE vim.loop WHEN DROPPING SUPPORT FOR Neovim v0.9 - if stats and stats.type == "directory" then - vim.api.nvim_del_augroup_by_name "neotree_start" - require "neo-tree" - end - end - end, - }) - autocmd("TermClose", { - pattern = "*lazygit", - desc = "Refresh Neo-Tree git when closing lazygit", - group = augroup("neotree_git_refresh", { clear = true }), - callback = function() - if package.loaded["neo-tree.sources.git_status"] then require("neo-tree.sources.git_status").refresh() end - end, - }) -end - -autocmd({ "VimEnter", "ColorScheme" }, { - desc = "Load custom highlights from user configuration", - group = augroup("astronvim_highlights", { clear = true }), - callback = function() - if vim.g.colors_name then - for _, module in ipairs { "init", vim.g.colors_name } do - for group, spec in pairs(astronvim.user_opts("highlights." .. module)) do - vim.api.nvim_set_hl(0, group, spec) - end - end - end - astroevent "ColorScheme" - end, -}) - -autocmd({ "BufReadPost", "BufNewFile", "BufWritePost" }, { - desc = "AstroNvim user events for file detection (AstroFile and AstroGitFile)", - group = augroup("file_user_events", { clear = true }), - callback = function(args) - if not (vim.fn.expand "%" == "" or vim.api.nvim_get_option_value("buftype", { buf = args.buf }) == "nofile") then - astroevent "File" - if - require("astronvim.utils.git").file_worktree() - or utils.cmd({ "git", "-C", vim.fn.expand "%:p:h", "rev-parse" }, false) - then - astroevent "GitFile" - vim.api.nvim_del_augroup_by_name "file_user_events" - end - end - end, -}) - -cmd( - "AstroChangelog", - function() require("astronvim.utils.updater").changelog() end, - { desc = "Check AstroNvim Changelog" } -) -cmd( - "AstroUpdatePackages", - function() require("astronvim.utils.updater").update_packages() end, - { desc = "Update Plugins and Mason" } -) -cmd("AstroRollback", function() require("astronvim.utils.updater").rollback() end, { desc = "Rollback AstroNvim" }) -cmd("AstroUpdate", function() require("astronvim.utils.updater").update() end, { desc = "Update AstroNvim" }) -cmd("AstroVersion", function() require("astronvim.utils.updater").version() end, { desc = "Check AstroNvim Version" }) -cmd("AstroReload", function() utils.reload() end, { desc = "Reload AstroNvim (Experimental)" }) diff --git a/lua/astrocore/git.lua b/lua/astrocore/git.lua new file mode 100644 index 0000000..94057bd --- /dev/null +++ b/lua/astrocore/git.lua @@ -0,0 +1,216 @@ +--- ### Git LUA API +-- +-- This module can be loaded with `local git = require "astrocore.git"` +-- +-- @module astrocore.git +-- @copyright 2022 +-- @license GNU General Public License v3.0 + +local git = { url = "https://github.com/" } +local utils = require "astrocore.utils" + +local function trim_or_nil(str) return type(str) == "string" and vim.trim(str) or nil end + +--- Run a git command from the AstroNvim installation directory +---@param args string|string[] the git arguments +---@return string|nil # The result of the command or nil if unsuccessful +function git.cmd(args, ...) + if type(args) == "string" then args = { args } end + return utils.cmd(vim.list_extend({ "git", "-C", astronvim.install.home }, args), ...) +end + +--- Get the first worktree that a file belongs to +---@param file string? the file to check, defaults to the current file +---@param worktrees table[]? an array like table of worktrees with entries `toplevel` and `gitdir`, default retrieves from `vim.g.git_worktrees` +---@return table|nil # a table specifying the `toplevel` and `gitdir` of a worktree or nil if not found +function git.file_worktree(file, worktrees) + worktrees = worktrees or vim.g.git_worktrees + if not worktrees then return end + file = file or vim.fn.expand "%" + for _, worktree in ipairs(worktrees) do + if + utils.cmd({ + "git", + "--work-tree", + worktree.toplevel, + "--git-dir", + worktree.gitdir, + "ls-files", + "--error-unmatch", + file, + }, false) + then + return worktree + end + end +end + +--- Check if the AstroNvim is able to reach the `git` command +---@return boolean # The result of running `git --help` +function git.available() return vim.fn.executable "git" == 1 end + +--- Check the git client version number +---@return table|nil # A table with version information or nil if there is an error +function git.git_version() + local output = git.cmd({ "--version" }, false) + if output then + local version_str = output:match "%d+%.%d+%.%d" + local major, min, patch = unpack(vim.tbl_map(tonumber, vim.split(version_str, "%."))) + return { major = major, min = min, patch = patch, str = version_str } + end +end + +--- Check if the AstroNvim home is a git repo +---@return string|nil # The result of the command +function git.is_repo() return git.cmd({ "rev-parse", "--is-inside-work-tree" }, false) end + +--- Fetch git remote +---@param remote string the remote to fetch +---@return string|nil # The result of the command +function git.fetch(remote, ...) return git.cmd({ "fetch", remote }, ...) end + +--- Pull the git repo +---@return string|nil # The result of the command +function git.pull(...) return git.cmd({ "pull", "--rebase" }, ...) end + +--- Checkout git target +---@param dest string the target to checkout +---@return string|nil # The result of the command +function git.checkout(dest, ...) return git.cmd({ "checkout", dest }, ...) end + +--- Hard reset to a git target +-- @param dest the target to hard reset to +---@return string|nil # The result of the command +function git.hard_reset(dest, ...) return git.cmd({ "reset", "--hard", dest }, ...) end + +--- Check if a branch contains a commit +---@param remote string the git remote to check +---@param branch string the git branch to check +---@param commit string the git commit to check for +---@return boolean # The result of the command +function git.branch_contains(remote, branch, commit, ...) + return git.cmd({ "merge-base", "--is-ancestor", commit, remote .. "/" .. branch }, ...) ~= nil +end + +--- Get the remote name for a given branch +---@param branch string the git branch to check +---@return string|nil # The name of the remote for the given branch +function git.branch_remote(branch, ...) return trim_or_nil(git.cmd({ "config", "branch." .. branch .. ".remote" }, ...)) end + +--- Add a git remote +---@param remote string the remote to add +---@param url string the url of the remote +---@return string|nil # The result of the command +function git.remote_add(remote, url, ...) return git.cmd({ "remote", "add", remote, url }, ...) end + +--- Update a git remote URL +---@param remote string the remote to update +---@param url string the new URL of the remote +---@return string|nil # The result of the command +function git.remote_update(remote, url, ...) return git.cmd({ "remote", "set-url", remote, url }, ...) end + +--- Get the URL of a given git remote +---@param remote string the remote to get the URL of +---@return string|nil # The url of the remote +function git.remote_url(remote, ...) return trim_or_nil(git.cmd({ "remote", "get-url", remote }, ...)) end + +--- Get branches from a git remote +---@param remote string the remote to setup branches for +---@param branch string the branch to setup +---@return string|nil # The result of the command +function git.remote_set_branches(remote, branch, ...) return git.cmd({ "remote", "set-branches", remote, branch }, ...) end + +--- Get the current version with git describe including tags +---@return string|nil # The current git describe string +function git.current_version(...) return trim_or_nil(git.cmd({ "describe", "--tags" }, ...)) end + +--- Get the current branch +---@return string|nil # The branch of the AstroNvim installation +function git.current_branch(...) return trim_or_nil(git.cmd({ "rev-parse", "--abbrev-ref", "HEAD" }, ...)) end + +--- Verify a reference +---@return string|nil # The referenced commit +function git.ref_verify(ref, ...) return trim_or_nil(git.cmd({ "rev-parse", "--verify", ref }, ...)) end + +--- Get the current head of the git repo +---@return string|nil # the head string +function git.local_head(...) return trim_or_nil(git.cmd({ "rev-parse", "HEAD" }, ...)) end + +--- Get the current head of a git remote +---@param remote string the remote to check +---@param branch string the branch to check +---@return string|nil # The head string of the remote branch +function git.remote_head(remote, branch, ...) + return trim_or_nil(git.cmd({ "rev-list", "-n", "1", remote .. "/" .. branch }, ...)) +end + +--- Get the commit hash of a given tag +---@param tag string the tag to resolve +---@return string|nil # The commit hash of a git tag +function git.tag_commit(tag, ...) return trim_or_nil(git.cmd({ "rev-list", "-n", "1", tag }, ...)) end + +--- Get the commit log between two commit hashes +---@param start_hash? string the start commit hash +---@param end_hash? string the end commit hash +---@return string[] # An array like table of commit messages +function git.get_commit_range(start_hash, end_hash, ...) + local range = start_hash and end_hash and start_hash .. ".." .. end_hash or nil + local log = git.cmd({ "log", "--no-merges", '--pretty="format:[%h] %s"', range }, ...) + return log and vim.fn.split(log, "\n") or {} +end + +--- Get a list of all tags with a regex filter +---@param search? string a regex to search the tags with (defaults to "v*" for version tags) +---@return string[] # An array like table of tags that match the search +function git.get_versions(search, ...) + local tags = git.cmd({ "tag", "-l", "--sort=version:refname", search == "latest" and "v*" or search }, ...) + return tags and vim.fn.split(tags, "\n") or {} +end + +--- Get the latest version of a list of versions +---@param versions? table a list of versions to search (defaults to all versions available) +---@return string|nil # The latest version from the array +function git.latest_version(versions, ...) + if not versions then versions = git.get_versions(...) end + return versions[#versions] +end + +--- Parse a remote url +---@param str string the remote to parse to a full git url +---@return string # The full git url for the given remote string +function git.parse_remote_url(str) + return vim.fn.match(str, utils.url_matcher) == -1 + and git.url .. str .. (vim.fn.match(str, "/") == -1 and "/AstroNvim.git" or ".git") + or str +end + +--- Check if a Conventional Commit commit message is breaking or not +---@param commit string a commit message +---@return boolean true if the message is breaking, false if the commit message is not breaking +function git.is_breaking(commit) return vim.fn.match(commit, "\\[.*\\]\\s\\+\\w\\+\\((\\w\\+)\\)\\?!:") ~= -1 end + +--- Get a list of breaking commits from commit messages using Conventional Commit standard +---@param commits string[] an array like table of commit messages +---@return string[] # An array like table of commits that are breaking +function git.breaking_changes(commits) return vim.tbl_filter(git.is_breaking, commits) end + +--- Generate a table of commit messages for neovim's echo API with highlighting +---@param commits string[] an array like table of commit messages +---@return string[][] # An array like table of echo messages to provide to nvim_echo or astronvim.echo +function git.pretty_changelog(commits) + local changelog = {} + for _, commit in ipairs(commits) do + local hash, type, msg = commit:match "(%[.*%])(.*:)(.*)" + if hash and type and msg then + vim.list_extend(changelog, { + { hash, "DiffText" }, + { type, git.is_breaking(commit) and "DiffDelete" or "DiffChange" }, + { msg }, + { "\n" }, + }) + end + end + return changelog +end + +return git diff --git a/lua/astrocore/init.lua b/lua/astrocore/init.lua index 8662e42..07896d1 100644 --- a/lua/astrocore/init.lua +++ b/lua/astrocore/init.lua @@ -8,8 +8,292 @@ function M.setup(opts) -- mappings utils.set_mappings(M.config.mappings) - -- autocmds - require "astrocore.autocmds" + vim.on_key(function(char) + if vim.fn.mode() == "n" then + local new_hlsearch = vim.tbl_contains({ "", "n", "N", "*", "#", "?", "/" }, vim.fn.keytrans(char)) + if vim.opt.hlsearch:get() ~= new_hlsearch then vim.opt.hlsearch = new_hlsearch end + end + end, vim.api.nvim_create_namespace "auto_hlsearch") + + local augroup = vim.api.nvim_create_augroup + local autocmd = vim.api.nvim_create_autocmd + + autocmd("BufReadPre", { + desc = "Disable certain functionality on very large files", + group = augroup("large_buf", { clear = true }), + callback = function(args) + local ok, stats = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(args.buf)) + vim.b[args.buf].large_buf = (ok and stats and stats.size > vim.g.max_file.size) + or vim.api.nvim_buf_line_count(args.buf) > vim.g.max_file.lines + end, + }) + + local bufferline_group = augroup("bufferline", { clear = true }) + autocmd({ "BufAdd", "BufEnter", "TabNewEntered" }, { + desc = "Update buffers when adding new buffers", + group = bufferline_group, + callback = function(args) + local buf_utils = require "astronvim.utils.buffer" + if not vim.t.bufs then vim.t.bufs = {} end + if not buf_utils.is_valid(args.buf) then return end + if args.buf ~= buf_utils.current_buf then + buf_utils.last_buf = buf_utils.is_valid(buf_utils.current_buf) and buf_utils.current_buf or nil + buf_utils.current_buf = args.buf + end + local bufs = vim.t.bufs + if not vim.tbl_contains(bufs, args.buf) then + table.insert(bufs, args.buf) + vim.t.bufs = bufs + end + vim.t.bufs = vim.tbl_filter(buf_utils.is_valid, vim.t.bufs) + utils.event "BufsUpdated" + end, + }) + autocmd("BufDelete", { + desc = "Update buffers when deleting buffers", + group = bufferline_group, + callback = function(args) + local removed + for _, tab in ipairs(vim.api.nvim_list_tabpages()) do + local bufs = vim.t[tab].bufs + if bufs then + for i, bufnr in ipairs(bufs) do + if bufnr == args.buf then + removed = true + table.remove(bufs, i) + vim.t[tab].bufs = bufs + break + end + end + end + end + vim.t.bufs = vim.tbl_filter(require("astronvim.utils.buffer").is_valid, vim.t.bufs) + if removed then utils.event "BufsUpdated" end + vim.cmd.redrawtabline() + end, + }) + + autocmd({ "VimEnter", "FileType", "BufEnter", "WinEnter" }, { + desc = "URL Highlighting", + group = augroup("highlighturl", { clear = true }), + callback = function() utils.set_url_match() end, + }) + + local view_group = augroup("auto_view", { clear = true }) + autocmd({ "BufWinLeave", "BufWritePost", "WinLeave" }, { + desc = "Save view with mkview for real files", + group = view_group, + callback = function(event) + if vim.b[event.buf].view_activated then vim.cmd.mkview { mods = { emsg_silent = true } } end + end, + }) + autocmd("BufWinEnter", { + desc = "Try to load file view if available and enable view saving for real files", + group = view_group, + callback = function(event) + if not vim.b[event.buf].view_activated then + local filetype = vim.api.nvim_get_option_value("filetype", { buf = event.buf }) + local buftype = vim.api.nvim_get_option_value("buftype", { buf = event.buf }) + local ignore_filetypes = { "gitcommit", "gitrebase", "svg", "hgcommit" } + if buftype == "" and filetype and filetype ~= "" and not vim.tbl_contains(ignore_filetypes, filetype) then + vim.b[event.buf].view_activated = true + vim.cmd.loadview { mods = { emsg_silent = true } } + end + end + end, + }) + + autocmd("BufWinEnter", { + desc = "Make q close help, man, quickfix, dap floats", + group = augroup("q_close_windows", { clear = true }), + callback = function(event) + local buftype = vim.api.nvim_get_option_value("buftype", { buf = event.buf }) + if vim.tbl_contains({ "help", "nofile", "quickfix" }, buftype) then + vim.keymap.set("n", "q", "close", { + desc = "Close window", + buffer = event.buf, + silent = true, + nowait = true, + }) + end + end, + }) + + autocmd("TextYankPost", { + desc = "Highlight yanked text", + group = augroup("highlightyank", { clear = true }), + pattern = "*", + callback = function() vim.highlight.on_yank() end, + }) + + autocmd("FileType", { + desc = "Unlist quickfist buffers", + group = augroup("unlist_quickfist", { clear = true }), + pattern = "qf", + callback = function() vim.opt_local.buflisted = false end, + }) + + autocmd("BufEnter", { + desc = "Quit AstroNvim if more than one window is open and only sidebar windows are list", + group = augroup("auto_quit", { clear = true }), + callback = function() + local wins = vim.api.nvim_tabpage_list_wins(0) + -- Both neo-tree and aerial will auto-quit if there is only a single window left + if #wins <= 1 then return end + local sidebar_fts = { aerial = true, ["neo-tree"] = true } + for _, winid in ipairs(wins) do + if vim.api.nvim_win_is_valid(winid) then + local bufnr = vim.api.nvim_win_get_buf(winid) + local filetype = vim.api.nvim_get_option_value("filetype", { buf = bufnr }) + -- If any visible windows are not sidebars, early return + if not sidebar_fts[filetype] then + return + -- If the visible window is a sidebar + else + -- only count filetypes once, so remove a found sidebar from the detection + sidebar_fts[filetype] = nil + end + end + end + if #vim.api.nvim_list_tabpages() > 1 then + vim.cmd.tabclose() + else + vim.cmd.qall() + end + end, + }) + + if utils.is_available "alpha-nvim" then + autocmd({ "User", "BufEnter" }, { + desc = "Disable status and tablines for alpha", + group = augroup("alpha_settings", { clear = true }), + callback = function(event) + if + ( + (event.event == "User" and event.file == "AlphaReady") + or (event.event == "BufEnter" and vim.api.nvim_get_option_value("filetype", { buf = event.buf }) == "alpha") + ) and not vim.g.before_alpha + then + vim.g.before_alpha = { showtabline = vim.opt.showtabline:get(), laststatus = vim.opt.laststatus:get() } + vim.opt.showtabline, vim.opt.laststatus = 0, 0 + elseif + vim.g.before_alpha + and event.event == "BufEnter" + and vim.api.nvim_get_option_value("buftype", { buf = event.buf }) ~= "nofile" + then + vim.opt.laststatus, vim.opt.showtabline = vim.g.before_alpha.laststatus, vim.g.before_alpha.showtabline + vim.g.before_alpha = nil + end + end, + }) + autocmd("VimEnter", { + desc = "Start Alpha when vim is opened with no arguments", + group = augroup("alpha_autostart", { clear = true }), + callback = function() + local should_skip = false + if vim.fn.argc() > 0 or vim.fn.line2byte(vim.fn.line "$") ~= -1 or not vim.o.modifiable then + should_skip = true + else + for _, arg in pairs(vim.v.argv) do + if arg == "-b" or arg == "-c" or vim.startswith(arg, "+") or arg == "-S" then + should_skip = true + break + end + end + end + if not should_skip then + require("alpha").start(true, require("alpha").default_config) + vim.schedule(function() vim.cmd.doautocmd "FileType" end) + end + end, + }) + end + + if utils.is_available "resession.nvim" then + autocmd("VimLeavePre", { + desc = "Save session on close", + group = augroup("resession_auto_save", { clear = true }), + callback = function() + local buf_utils = require "astronvim.utils.buffer" + local autosave = buf_utils.sessions.autosave + if autosave and buf_utils.is_valid_session() then + local save = require("resession").save + if autosave.last then save("Last Session", { notify = false }) end + if autosave.cwd then save(vim.fn.getcwd(), { dir = "dirsession", notify = false }) end + end + end, + }) + end + + if utils.is_available "neo-tree.nvim" then + autocmd("BufEnter", { + desc = "Open Neo-Tree on startup with directory", + group = augroup("neotree_start", { clear = true }), + callback = function() + if package.loaded["neo-tree"] then + vim.api.nvim_del_augroup_by_name "neotree_start" + else + local stats = (vim.uv or vim.loop).fs_stat(vim.api.nvim_buf_get_name(0)) -- TODO: REMOVE vim.loop WHEN DROPPING SUPPORT FOR Neovim v0.9 + if stats and stats.type == "directory" then + vim.api.nvim_del_augroup_by_name "neotree_start" + require "neo-tree" + end + end + end, + }) + autocmd("TermClose", { + pattern = "*lazygit", + desc = "Refresh Neo-Tree git when closing lazygit", + group = augroup("neotree_git_refresh", { clear = true }), + callback = function() + if package.loaded["neo-tree.sources.git_status"] then require("neo-tree.sources.git_status").refresh() end + end, + }) + end + + autocmd({ "VimEnter", "ColorScheme" }, { + desc = "Load custom highlights from user configuration", + group = augroup("astronvim_highlights", { clear = true }), + callback = function() + if vim.g.colors_name then + for _, module in ipairs { "init", vim.g.colors_name } do + for group, spec in pairs(astronvim.user_opts("highlights." .. module)) do + vim.api.nvim_set_hl(0, group, spec) + end + end + end + utils.event "ColorScheme" + end, + }) + + autocmd({ "BufReadPost", "BufNewFile", "BufWritePost" }, { + desc = "AstroNvim user events for file detection (AstroFile and AstroGitFile)", + group = augroup("file_user_events", { clear = true }), + callback = function(args) + if not (vim.fn.expand "%" == "" or vim.api.nvim_get_option_value("buftype", { buf = args.buf }) == "nofile") then + utils.event "File" + if + require("astrocore.git").file_worktree() + or utils.cmd({ "git", "-C", vim.fn.expand "%:p:h", "rev-parse" }, false) + then + utils.event "GitFile" + vim.api.nvim_del_augroup_by_name "file_user_events" + end + end + end, + }) + + local cmd = vim.api.nvim_create_user_command + cmd("AstroChangelog", function() require("astrocore.updater").changelog() end, { desc = "Check AstroNvim Changelog" }) + cmd( + "AstroUpdatePackages", + function() require("astrocore.updater").update_packages() end, + { desc = "Update Plugins and Mason" } + ) + cmd("AstroRollback", function() require("astrocore.updater").rollback() end, { desc = "Rollback AstroNvim" }) + cmd("AstroUpdate", function() require("astrocore.updater").update() end, { desc = "Update AstroNvim" }) + cmd("AstroVersion", function() require("astrocore.updater").version() end, { desc = "Check AstroNvim Version" }) + cmd("AstroReload", function() utils.reload() end, { desc = "Reload AstroNvim (Experimental)" }) end return M diff --git a/lua/astrocore/updater.lua b/lua/astrocore/updater.lua new file mode 100644 index 0000000..b2e45f2 --- /dev/null +++ b/lua/astrocore/updater.lua @@ -0,0 +1,334 @@ +--- ### AstroNvim Updater +-- +-- AstroNvim Updater utilities to use within AstroNvim and user configurations. +-- +-- This module can also loaded with `local updater = require("astrocore.updater")` +-- +-- @module astrocore.updater +-- @see astrocore.utils +-- @copyright 2022 +-- @license GNU General Public License v3.0 +local M = {} + +local git = require "astrocore.git" +local utils = require "astrocore.utils" +local notify = utils.notify + +local function echo(messages) + -- if no parameter provided, echo a new line + messages = messages or { { "\n" } } + if type(messages) == "table" then vim.api.nvim_echo(messages, false, {}) end +end + +local function confirm_prompt(messages, type) + return vim.fn.confirm(messages, "&Yes\n&No", (type == "Error" or type == "Warning") and 2 or 1, type or "Question") + == 1 +end + +--- Helper function to generate AstroNvim snapshots (For internal use only) +---@param write? boolean Whether or not to write to the snapshot file (default: false) +---@return table # The plugin specification table of the snapshot +function M.generate_snapshot(write) + local file + local prev_snapshot = require(astronvim.updater.snapshot.module) + for _, plugin in ipairs(prev_snapshot) do + prev_snapshot[plugin[1]] = plugin + end + local plugins = assert(require("lazy").plugins()) + table.sort(plugins, function(l, r) return l[1] < r[1] end) + local function git_commit(dir) + local commit = assert(utils.cmd({ "git", "-C", dir, "rev-parse", "HEAD" }, false)) + if commit then return vim.trim(commit) end + end + if write == true then + file = assert(io.open(astronvim.updater.snapshot.path, "w")) + file:write "return {\n" + end + local snapshot = vim.tbl_map(function(plugin) + plugin = { plugin[1], commit = git_commit(plugin.dir), version = plugin.version } + if prev_snapshot[plugin[1]] and prev_snapshot[plugin[1]].version then + plugin.version = prev_snapshot[plugin[1]].version + end + if file then + file:write((" { %q, "):format(plugin[1])) + if plugin.version then + file:write(("version = %q"):format(plugin.version)) + else + file:write(("commit = %q"):format(plugin.commit)) + end + file:write ", optional = true },\n" + end + return plugin + end, plugins) + if file then + file:write "}\n" + file:close() + end + return snapshot +end + +--- Get the current AstroNvim version +---@param quiet? boolean Whether to quietly execute or send a notification +---@return string # The current AstroNvim version string +function M.version(quiet) + local version = astronvim.install.version or git.current_version(false) or "unknown" + if astronvim.updater.options.channel ~= "stable" then version = ("nightly (%s)"):format(version) end + if version and not quiet then notify(("Version: *%s*"):format(version)) end + return version +end + +--- Get the full AstroNvim changelog +---@param quiet? boolean Whether to quietly execute or display the changelog +---@return table # The current AstroNvim changelog table of commit messages +function M.changelog(quiet) + local summary = {} + vim.list_extend(summary, git.pretty_changelog(git.get_commit_range())) + if not quiet then echo(summary) end + return summary +end + +--- Attempt an update of AstroNvim +---@param target string The target if checking out a specific tag or commit or nil if just pulling +local function attempt_update(target, opts) + -- if updating to a new stable version or a specific commit checkout the provided target + if opts.channel == "stable" or opts.commit then + return git.checkout(target, false) + -- if no target, pull the latest + else + return git.pull(false) + end +end + +--- Cancelled update message +local cancelled_message = { { "Update cancelled", "WarningMsg" } } + +--- Sync Packer and then update Mason +function M.update_packages() + require("lazy").sync { wait = true } + require("astronvim.utils.mason").update_all() +end + +--- Create a table of options for the currently installed AstroNvim version +---@param write? boolean Whether or not to write to the rollback file (default: false) +---@return table # The table of updater options +function M.create_rollback(write) + local snapshot = { branch = git.current_branch(), commit = git.local_head() } + if snapshot.branch == "HEAD" then snapshot.branch = "main" end + snapshot.remote = git.branch_remote(snapshot.branch, false) or "origin" + snapshot.remotes = { [snapshot.remote] = git.remote_url(snapshot.remote) } + + if write == true then + local file = assert(io.open(astronvim.updater.rollback_file, "w")) + file:write("return " .. vim.inspect(snapshot, { newline = " ", indent = "" })) + file:close() + end + + return snapshot +end + +--- AstroNvim's rollback to saved previous version function +function M.rollback() + local rollback_avail, rollback_opts = pcall(dofile, astronvim.updater.rollback_file) + if not rollback_avail then + notify("No rollback file available", vim.log.levels.ERROR) + return + end + M.update(rollback_opts) +end + +--- Check if an update is available +---@param opts? table the settings to use for checking for an update +---@return table|boolean? # The information of an available update (`{ source = string, target = string }`), false if no update is available, or nil if there is an error +function M.update_available(opts) + if not opts then opts = astronvim.updater.options end + opts = utils.extend_tbl({ remote = "origin" }, opts) + -- if the git command is not available, then throw an error + if not git.available() then + notify( + "`git` command is not available, please verify it is accessible in a command line. This may be an issue with your `PATH`", + vim.log.levels.ERROR + ) + return + end + + -- if installed with an external package manager, disable the internal updater + if not git.is_repo() then + notify("Updater not available for non-git installations", vim.log.levels.ERROR) + return + end + -- set up any remotes defined by the user if they do not exist + for remote, entry in pairs(opts.remotes and opts.remotes or {}) do + local url = git.parse_remote_url(entry) + local current_url = git.remote_url(remote, false) + local check_needed = false + if not current_url then + git.remote_add(remote, url) + check_needed = true + elseif + current_url ~= url + and confirm_prompt( + ("Remote %s is currently: %s\n" .. "Would you like us to set it to %s ?"):format(remote, current_url, url) + ) + then + git.remote_update(remote, url) + check_needed = true + end + if check_needed and git.remote_url(remote, false) ~= url then + vim.api.nvim_err_writeln("Error setting up remote " .. remote .. " to " .. url) + return + end + end + local is_stable = opts.channel == "stable" + if is_stable then + opts.branch = "main" + elseif not opts.branch then + opts.branch = "nightly" + end + -- setup branch if missing + if not git.ref_verify(opts.remote .. "/" .. opts.branch, false) then + git.remote_set_branches(opts.remote, opts.branch, false) + end + -- fetch the latest remote + if not git.fetch(opts.remote) then + vim.api.nvim_err_writeln("Error fetching remote: " .. opts.remote) + return + end + -- switch to the necessary branch only if not on the stable channel + if not is_stable then + local local_branch = (opts.remote == "origin" and "" or (opts.remote .. "_")) .. opts.branch + if git.current_branch() ~= local_branch then + echo { + { "Switching to branch: " }, + { opts.remote .. "/" .. opts.branch .. "\n\n", "String" }, + } + if not git.checkout(local_branch, false) then + git.checkout("-b " .. local_branch .. " " .. opts.remote .. "/" .. opts.branch, false) + end + end + -- check if the branch was switched to successfully + if git.current_branch() ~= local_branch then + vim.api.nvim_err_writeln("Error checking out branch: " .. opts.remote .. "/" .. opts.branch) + return + end + end + local update = { source = git.local_head() } + if is_stable then -- if stable get tag commit + local version_search = opts.version or "latest" + update.version = git.latest_version(git.get_versions(version_search)) + if not update.version then -- continue only if stable version is found + vim.api.nvim_err_writeln("Error finding version: " .. version_search) + return + end + update.target = git.tag_commit(update.version) + elseif opts.commit then -- if commit specified use it + update.target = git.branch_contains(opts.remote, opts.branch, opts.commit) and opts.commit or nil + else -- get most recent commit + update.target = git.remote_head(opts.remote, opts.branch) + end + + if not update.source or not update.target then -- continue if current and target commits were found + vim.api.nvim_err_writeln "Error checking for updates" + return + elseif update.source ~= update.target then + -- update available + return update + else + return false + end +end + +--- AstroNvim's updater function +---@param opts? table the settings to use for the update +function M.update(opts) + if not opts then opts = astronvim.updater.options end + opts = utils.extend_tbl({ remote = "origin", show_changelog = true, sync_plugins = true, auto_quit = false }, opts) + local available_update = M.update_available(opts) + if available_update == nil then + return + elseif not available_update then -- continue if current and target commits were found + notify "No updates available" + elseif -- prompt user if they want to accept update + not opts.skip_prompts + and not confirm_prompt( + ("Update available to %s\nUpdating requires a restart, continue?"):format( + available_update.version or available_update.target + ) + ) + then + echo(cancelled_message) + return + else -- perform update + local source, target = available_update.source, available_update.target + M.create_rollback(true) -- create rollback file before updating + -- calculate and print the changelog + local changelog = git.get_commit_range(source, target) + local breaking = git.breaking_changes(changelog) + if + #breaking > 0 + and not opts.skip_prompts + and not confirm_prompt( + ("Update contains the following breaking changes:\n%s\nWould you like to continue?"):format( + table.concat(breaking, "\n") + ), + "Warning" + ) + then + echo(cancelled_message) + return + end + -- attempt an update + local updated = attempt_update(target, opts) + -- check for local file conflicts and prompt user to continue or abort + if + not updated + and not opts.skip_prompts + and not confirm_prompt( + "Unable to pull due to local modifications to base files.\nReset local files and continue?", + "Error" + ) + then + echo(cancelled_message) + return + -- if continued and there were errors reset the base config and attempt another update + elseif not updated then + git.hard_reset(source) + updated = attempt_update(target, opts) + end + -- if update was unsuccessful throw an error + if not updated then + vim.api.nvim_err_writeln "Error occurred performing update" + return + end + -- print a summary of the update with the changelog + local summary = { + { "AstroNvim updated successfully to ", "Title" }, + { git.current_version(), "String" }, + { "!\n", "Title" }, + { + opts.auto_quit and "AstroNvim will now update plugins and quit.\n\n" + or "After plugins update, please restart.\n\n", + "WarningMsg", + }, + } + if opts.show_changelog and #changelog > 0 then + vim.list_extend(summary, { { "Changelog:\n", "Title" } }) + vim.list_extend(summary, git.pretty_changelog(changelog)) + end + echo(summary) + + -- if the user wants to auto quit, create an autocommand to quit AstroNvim on the update completing + if opts.auto_quit then + vim.api.nvim_create_autocmd("User", { + desc = "Auto quit AstroNvim after update completes", + pattern = "AstroUpdateComplete", + command = "quitall", + }) + end + + require("lazy.core.plugin").load() -- force immediate reload of lazy + if opts.sync_plugins then require("lazy").sync { wait = true } end + utils.event "UpdateComplete" + end +end + +return M