From 38584d7e81fa676e69a3d655a301153cba17620b Mon Sep 17 00:00:00 2001 From: pseudometa <73286100+chrisgrieser@users.noreply.github.com> Date: Thu, 28 Sep 2023 13:37:22 +0200 Subject: [PATCH] refactor: modularize the plugin --- lua/tinygit/commit-and-amend.lua | 198 ++++++++++++++ lua/tinygit/config.lua | 71 +++++ lua/tinygit/github.lua | 119 ++++++++ lua/tinygit/init.lua | 448 ++----------------------------- lua/tinygit/push.lua | 69 +++++ lua/tinygit/types.lua | 21 -- lua/tinygit/utils.lua | 58 ++++ 7 files changed, 532 insertions(+), 452 deletions(-) create mode 100644 lua/tinygit/commit-and-amend.lua create mode 100644 lua/tinygit/config.lua create mode 100644 lua/tinygit/github.lua create mode 100644 lua/tinygit/push.lua delete mode 100644 lua/tinygit/types.lua create mode 100644 lua/tinygit/utils.lua diff --git a/lua/tinygit/commit-and-amend.lua b/lua/tinygit/commit-and-amend.lua new file mode 100644 index 0000000..4df9d98 --- /dev/null +++ b/lua/tinygit/commit-and-amend.lua @@ -0,0 +1,198 @@ +local M = {} +local fn = vim.fn +local u = require("tinygit.utils") +local config = require("tinygit.config").config +local push = require("tinygit.push").push +-------------------------------------------------------------------------------- + +-- if there are no staged changes, will add all changes (`git add -A`) +-- if not, indicates the already staged changes +---@return string|nil stageInfo, nil if staging was unsuccessful +local function stageAllIfNoChanges() + fn.system { "git", "diff", "--staged", "--quiet" } + local hasStagedChanges = vim.v.shell_error ~= 0 + + if hasStagedChanges then + local stagedChanges = (fn.system { "git", "diff", "--staged", "--stat" }):gsub("\n.-$", "") + if u.nonZeroExit(stagedChanges) then return end + return stagedChanges + else + local stderr = fn.system { "git", "add", "-A" } + if u.nonZeroExit(stderr) then return end + return "Staged all changes." + end +end + +---process a commit message: length, not empty, adheres to conventional commits +---@param commitMsg string +---@nodiscard +---@return boolean is the commit message valid? +---@return string the (modified) commit message +local function processCommitMsg(commitMsg) + commitMsg = vim.trim(commitMsg) + local conf = config.commitMsg + + if #commitMsg > conf.maxLen then + u.notify("Commit Message too long.", "warn") + local shortenedMsg = commitMsg:sub(1, conf.maxLen) + return false, shortenedMsg + elseif commitMsg == "" then + if not conf.emptyFillIn then + u.notify("Commit Message empty.", "warn") + return false, "" + else + ---@diagnostic disable-next-line: return-type-mismatch -- checked above + return true, conf.emptyFillIn + end + end + + if conf.enforceConvCommits.enabled then + -- stylua: ignore + local firstWord = commitMsg:match("^%w+") + if not vim.tbl_contains(conf.enforceConvCommits.keywords, firstWord) then + u.notify("Not using a Conventional Commits keyword.", "warn") + return false, commitMsg + end + end + + -- message ok + return true, commitMsg +end + +-- Uses ColorColumn to indicate max length of commit messages, and +-- additionally colors commit messages that are too long in red. +local function setGitCommitAppearance() + vim.api.nvim_create_autocmd("FileType", { + pattern = "DressingInput", + once = true, -- do not affect other DressingInputs + callback = function() + local conf = config.commitMsg + local winNs = 1 + + vim.api.nvim_win_set_hl_ns(0, winNs) + + -- custom highlighting + fn.matchadd("overLength", ([[.\{%s}\zs.*\ze]]):format(conf.maxLen - 1)) + fn.matchadd( + "closeToOverlength", + -- \ze = end of match, \zs = start of match + ([[.\{%s}\zs.\{1,%s}\ze]]):format(conf.mediumLen - 1, conf.maxLen - conf.mediumLen) + ) + fn.matchadd("issueNumber", [[#\d\+]]) + vim.api.nvim_set_hl(winNs, "overLength", { link = "ErrorMsg" }) + vim.api.nvim_set_hl(winNs, "closeToOverlength", { link = "WarningMsg" }) + vim.api.nvim_set_hl(winNs, "issueNumber", { link = "Number" }) + + -- colorcolumn as extra indicators of overLength + vim.opt_local.colorcolumn = { conf.mediumLen, conf.maxLen } + + -- treesitter highlighting + vim.bo.filetype = "gitcommit" + vim.api.nvim_set_hl(winNs, "Title", { link = "Normal" }) + + -- activate styling of statusline plugins + vim.api.nvim_buf_set_name(0, "COMMIT_EDITMSG") + end, + }) +end + +-------------------------------------------------------------------------------- + +---@param opts? { forcePush?: boolean } +function M.amendNoEdit(opts) + if not opts then opts = {} end + vim.cmd("silent update") + if u.notInGitRepo() then return end + + local stageInfo = stageAllIfNoChanges() + if not stageInfo then return end + + local stderr = fn.system { "git", "commit", "--amend", "--no-edit" } + if u.nonZeroExit(stderr) then return end + + local lastCommitMsg = vim.trim(fn.system("git log -1 --pretty=%B")) + local body = { stageInfo, ('"%s"'):format(lastCommitMsg) } + if opts.forcePush then table.insert(body, "Force Pushing…") end + local notifyText = table.concat(body, "\n \n") -- need space since empty lines are removed by nvim-notify + u.notify(notifyText, "info", "Amend-No-edit") + + if opts.forcePush then push { force = true } end +end + +---@param opts? { forcePush?: boolean } +---@param prefillMsg? string +function M.amendOnlyMsg(opts, prefillMsg) + if not opts then opts = {} end + vim.cmd("silent update") + if u.notInGitRepo() then return end + + if not prefillMsg then + local lastCommitMsg = vim.trim(fn.system("git log -1 --pretty=%B")) + prefillMsg = lastCommitMsg + end + setGitCommitAppearance() + + vim.ui.input({ prompt = "󰊢 Amend Message", default = prefillMsg }, function(commitMsg) + if not commitMsg then return end -- aborted input modal + local validMsg, cMsg = processCommitMsg(commitMsg) + if not validMsg then -- if msg invalid, run again to fix the msg + M.amendOnlyMsg(opts, cMsg) + return + end + + local stderr = fn.system { "git", "commit", "--amend", "-m", cMsg } + if u.nonZeroExit(stderr) then return end + + local body = ('"%s"'):format(cMsg) + if opts.forcePush then body = body .. "\n\n➤ Force Pushing…" end + u.notify(body, "info", "Amend-Only-Msg") + + if opts.forcePush then push { force = true } end + end) +end + +-------------------------------------------------------------------------------- + +---If there are staged changes, commit them. +---If there aren't, add all changes (`git add -A`) and then commit. +---@param prefillMsg? string +---@param opts? { push?: boolean, openReferencedIssue?: boolean } +function M.smartCommit(opts, prefillMsg) + if u.notInGitRepo() then return end + + vim.cmd("silent update") + if not opts then opts = {} end + if not prefillMsg then prefillMsg = "" end + + setGitCommitAppearance() + vim.ui.input({ prompt = "󰊢 Commit Message", default = prefillMsg }, function(commitMsg) + if not commitMsg then return end -- aborted input modal + local validMsg, processedMsg = processCommitMsg(commitMsg) + if not validMsg then -- if msg invalid, run again to fix the msg + M.smartCommit(opts, processedMsg) + return + end + + local stageInfo = stageAllIfNoChanges() + if not stageInfo then return end + + local stderr = fn.system { "git", "commit", "-m", processedMsg } + if u.nonZeroExit(stderr) then return end + + local body = { stageInfo, ('"%s"'):format(processedMsg) } + if opts.push then table.insert(body, "Pushing…") end + local notifyText = table.concat(body, "\n \n") -- need space since empty lines are removed by nvim-notify + u.notify(notifyText, "info", "Smart-Commit") + + local issueReferenced = processedMsg:match("#(%d+)") + if opts.openReferencedIssue and issueReferenced then + local url = ("https://github.com/%s/issues/%s"):format(u.getRepo(), issueReferenced) + u.openUrl(url) + end + + if opts.push then push { pullBefore = true } end + end) +end + +-------------------------------------------------------------------------------- +return M diff --git a/lua/tinygit/config.lua b/lua/tinygit/config.lua new file mode 100644 index 0000000..f1af51e --- /dev/null +++ b/lua/tinygit/config.lua @@ -0,0 +1,71 @@ +local M = {} +-------------------------------------------------------------------------------- + +---@class pluginConfig +---@field commitMsg commitConfig +---@field asyncOpConfirmationSound boolean +---@field issueIcons issueIconConfig + +---@class issueIconConfig +---@field closedIssue string +---@field openIssue string +---@field openPR string +---@field mergedPR string +---@field closedPR string + +---@class commitConfig +---@field maxLen number +---@field mediumLen number +---@field emptyFillIn string +---@field enforceConvCommits enforceConvCommitsConfig + +---@class enforceConvCommitsConfig +---@field enabled boolean +---@field keywords string[] + +-------------------------------------------------------------------------------- + +---@type pluginConfig +local defaultConfig = { + commitMsg = { + -- Why 50/72 is recommended: https://stackoverflow.com/q/2290016/22114136 + maxLen = 72, + mediumLen = 50, + + -- When conforming the commit message popup with an empty message, fill in + -- this message. Set to `false` to disallow empty commit messages. + emptyFillIn = "chore", ---@type string|false + + -- disallow commit messages without a conventinal commit keyword + enforceConvCommits = { + enabled = true, + -- stylua: ignore + keywords = { + "chore", "build", "test", "fix", "feat", "refactor", "perf", + "style", "revert", "ci", "docs", "break", "improv", + }, + }, + }, + asyncOpConfirmationSound = true, -- currently macOS only + issueIcons = { + closedIssue = "🟣", + openIssue = "🟢", + openPR = "🟦", + mergedPR = "🟨", + closedPR = "🟥", + }, +} + + +-------------------------------------------------------------------------------- + +-- in case user does not call `setup` +M.config = defaultConfig + +---@param userConfig pluginConfig +function M.setupPlugin(userConfig) + M.config = vim.tbl_deep_extend("force", defaultConfig, userConfig) +end + +-------------------------------------------------------------------------------- +return M diff --git a/lua/tinygit/github.lua b/lua/tinygit/github.lua new file mode 100644 index 0000000..9fee950 --- /dev/null +++ b/lua/tinygit/github.lua @@ -0,0 +1,119 @@ +local M = {} +local fn = vim.fn +local u = require("tinygit.utils") +local config = require("tinygit.config").config +-------------------------------------------------------------------------------- + +---opens current buffer in the browser & copies the link to the clipboard +---normal mode: link to file +---visual mode: link to selected lines +---@param justRepo any -- don't link to file with a specific commit, just link to repo +function M.githubUrl(justRepo) + if u.notInGitRepo() then return end + + local filepath = vim.fn.expand("%:p") + local gitroot = vim.fn.system("git --no-optional-locks rev-parse --show-toplevel") + local pathInRepo = filepath:sub(#gitroot + 1) + + local pathInRepoEncoded = pathInRepo:gsub("%s+", "%%20") + local remote = fn.system("git --no-optional-locks remote -v"):gsub(".*:(.-)%.git.*", "%1") + local hash = vim.trim(fn.system("git --no-optional-locks rev-parse HEAD")) + local branch = vim.trim(fn.system("git --no-optional-locks branch --show-current")) + + local selStart = fn.line("v") + local selEnd = fn.line(".") + local isVisualMode = fn.mode():find("[Vv]") + local isNormalMode = fn.mode() == "n" + local url = "https://github.com/" .. remote + + if not justRepo and isNormalMode then + url = url .. ("/blob/%s/%s"):format(branch, pathInRepoEncoded) + elseif not justRepo and isVisualMode then + local location + if selStart == selEnd then -- one-line-selection + location = "#L" .. tostring(selStart) + elseif selStart < selEnd then + location = "#L" .. tostring(selStart) .. "-L" .. tostring(selEnd) + else + location = "#L" .. tostring(selEnd) .. "-L" .. tostring(selStart) + end + url = url .. ("/blob/%s/%s%s"):format(hash, pathInRepoEncoded, location) + end + + u.openUrl(url) + fn.setreg("+", url) -- copy to clipboard +end + +-------------------------------------------------------------------------------- +---formats the list of issues/PRs for vim.ui.select +---@param issue table +---@return table +local function issueListFormatter(issue) + local isPR = issue.pull_request ~= nil + local merged = isPR and issue.pull_request.merged_at ~= nil + + local icon + if issue.state == "open" and isPR then + icon = config.issueIcons.openPR + elseif issue.state == "closed" and isPR and merged then + icon = config.issueIcons.mergedPR + elseif issue.state == "closed" and isPR and not merged then + icon = config.issueIcons.closedPR + elseif issue.state == "closed" and not isPR then + icon = config.issueIcons.closedIssue + elseif issue.state == "open" and not isPR then + icon = config.issueIcons.openIssue + end + + return icon .. " #" .. issue.number .. " " .. issue.title +end + +---Choose a GitHub issue/PR from the current repo to open in the browser. +---CAVEAT Due to GitHub API liminations, only the last 100 issues are shown. +---@param userOpts { state?: string, type?: string } +function M.issuesAndPrs(userOpts) + if u.notInGitRepo() then return end + local defaultOpts = { state = "all", type = "all" } + local opts = vim.tbl_deep_extend("force", defaultOpts, userOpts) + + local repo = u.getRepo() + + -- DOCS https://docs.github.com/en/free-pro-team@latest/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues + local rawJsonUrl = ("https://api.github.com/repos/%s/issues?per_page=100&state=%s&sort=updated"):format( + repo, + opts.state + ) + local rawJSON = fn.system { "curl", "-sL", rawJsonUrl } + local issues = vim.json.decode(rawJSON) + if not issues then + u.notify("Failed to fetch issues.", "warn") + return + end + if issues and opts.type ~= "all" then + issues = vim.tbl_filter(function(issue) + local isPR = issue.pull_request ~= nil + local isRightKind = (isPR and opts.type == "pr") or (not isPR and opts.type == "issue") + return isRightKind + end, issues) + end + + if #issues == 0 then + local state = opts.state == "all" and "" or opts.state .. " " + local type = opts.type == "all" and "issues or PRs " or opts.type .. "s " + u.notify(("There are no %s%sfor this repo."):format(state, type), "warn") + return + end + + local title = opts.type == "all" and "Issue/PR" or opts.type + vim.ui.select(issues, { + prompt = " Select " .. title, + kind = "github_issue", + format_item = function(issue) return issueListFormatter(issue) end, + }, function(choice) + if not choice then return end + u.openUrl(choice.html_url) + end) +end + +-------------------------------------------------------------------------------- +return M diff --git a/lua/tinygit/init.lua b/lua/tinygit/init.lua index cc55b34..8f8cf6c 100644 --- a/lua/tinygit/init.lua +++ b/lua/tinygit/init.lua @@ -1,344 +1,26 @@ local M = {} -local fn = vim.fn - --------------------------------------------------------------------------------- - ----@type pluginConfig -local defaultConfig = { - commitMsg = { - -- Why 50/72 is recommended: https://stackoverflow.com/q/2290016/22114136 - maxLen = 72, - mediumLen = 50, - - -- When conforming the commit message popup with an empty message, fill in - -- this message. Set to `false` to disallow empty commit messages. - emptyFillIn = "chore", ---@type string|false - - -- disallow commit messages without a conventinal commit keyword - enforceConvCommits = { - enabled = true, - -- stylua: ignore - keywords = { - "chore", "build", "test", "fix", "feat", "refactor", "perf", - "style", "revert", "ci", "docs", "break", "improv", - }, - }, - }, - asyncOpConfirmationSound = true, -- currently macOS only - issueIcons = { - closedIssue = "🟣", - openIssue = "🟢", - openPR = "🟦", - mergedPR = "🟨", - closedPR = "🟥", - }, -} - --- set values if setup call is not run -local config = defaultConfig - ----@param userConf? pluginConfig -function M.setup(userConf) config = vim.tbl_extend("force", defaultConfig, userConf or {}) end - -------------------------------------------------------------------------------- --- HELPERS - --- open with the OS-specific shell command ----@param url string -local function openUrl(url) - local opener - if fn.has("macunix") == 1 then - opener = "open" - elseif fn.has("linux") == 1 then - opener = "xdg-open" - elseif fn.has("win64") == 1 or fn.has("win32") == 1 then - opener = "start" - end - local openCommand = ("%s '%s' >/dev/null 2>&1"):format(opener, url) - fn.system(openCommand) -end ----send notification ----@param body string ----@param level? "info"|"trace"|"debug"|"warn"|"error" ----@param title? string -local function notify(body, level, title) - local titlePrefix = "tinygit" - if not level then level = "info" end - local notifyTitle = title and titlePrefix .. ": " .. title or titlePrefix - vim.notify(vim.trim(body), vim.log.levels[level:upper()], { title = notifyTitle }) +---@param userConfig? pluginConfig +function M.setup(userConfig) + require("tinygit.config").setupPlugin(userConfig or {}) end ----checks if last command was successful, if not, notify ----@nodiscard ----@return boolean ----@param errorMsg string -local function nonZeroExit(errorMsg) - local exitCode = vim.v.shell_error - if exitCode ~= 0 then notify(vim.trim(errorMsg), "warn") end - return exitCode ~= 0 +---@param userOpts? { forcePush?: boolean } +function M.amendNoEdit(userOpts) + require("tinygit.commit-and-amend").amendNoEdit(userOpts or {}) end --- if there are no staged changes, will add all changes (`git add -A`) --- if not, indicates the already staged changes ----@return string|nil stageInfo, nil if staging was unsuccessful -local function stageAllIfNoChanges() - fn.system { "git", "diff", "--staged", "--quiet" } - local hasStagedChanges = vim.v.shell_error ~= 0 - - if hasStagedChanges then - local stagedChanges = (fn.system { "git", "diff", "--staged", "--stat" }):gsub("\n.-$", "") - if nonZeroExit(stagedChanges) then return end - return stagedChanges - else - local stderr = fn.system { "git", "add", "-A" } - if nonZeroExit(stderr) then return end - return "Staged all changes." - end -end - ----also notifies if not in git repo ----@nodiscard ----@return boolean -local function notInGitRepo() - fn.system("git rev-parse --is-inside-work-tree") - local notInRepo = nonZeroExit("Not in Git Repo.") - return notInRepo -end - ----CAVEAT currently only on macOS ----@param soundFilepath string -local function confirmationSound(soundFilepath) - local onMacOs = fn.has("macunix") == 1 - if not onMacOs or not config.asyncOpConfirmationSound then return end - fn.system(("afplay %q &"):format(soundFilepath)) -end - ----process a commit message: length, not empty, adheres to conventional commits ----@param commitMsg string ----@nodiscard ----@return boolean is the commit message valid? ----@return string the (modified) commit message -local function processCommitMsg(commitMsg) - commitMsg = vim.trim(commitMsg) - local conf = config.commitMsg - - if #commitMsg > conf.maxLen then - notify("Commit Message too long.", "warn") - local shortenedMsg = commitMsg:sub(1, conf.maxLen) - return false, shortenedMsg - elseif commitMsg == "" then - if not conf.emptyFillIn then - notify("Commit Message empty.", "warn") - return false, "" - else - ---@diagnostic disable-next-line: return-type-mismatch -- checked above - return true, conf.emptyFillIn - end - end - - if conf.enforceConvCommits.enabled then - -- stylua: ignore - local firstWord = commitMsg:match("^%w+") - if not vim.tbl_contains(conf.enforceConvCommits.keywords, firstWord) then - notify("Not using a Conventional Commits keyword.", "warn") - return false, commitMsg - end - end - - -- message ok - return true, commitMsg +---@param userOpts? { forcePush?: boolean } +function M.amendOnlyMsg(userOpts) + require("tinygit.commit-and-amend").amendOnlyMsg(userOpts or {}) end --- Uses ColorColumn to indicate max length of commit messages, and --- additionally colors commit messages that are too long in red. -local function setGitCommitAppearance() - vim.api.nvim_create_autocmd("FileType", { - pattern = "DressingInput", - once = true, -- do not affect other DressingInputs - callback = function() - local conf = config.commitMsg - local winNs = 1 - - vim.api.nvim_win_set_hl_ns(0, winNs) - - -- custom highlighting - fn.matchadd("overLength", ([[.\{%s}\zs.*\ze]]):format(conf.maxLen - 1)) - fn.matchadd( - "closeToOverlength", - -- \ze = end of match, \zs = start of match - ([[.\{%s}\zs.\{1,%s}\ze]]):format(conf.mediumLen - 1, conf.maxLen - conf.mediumLen) - ) - fn.matchadd("issueNumber", [[#\d\+]]) - vim.api.nvim_set_hl(winNs, "overLength", { link = "ErrorMsg" }) - vim.api.nvim_set_hl(winNs, "closeToOverlength", { link = "WarningMsg" }) - vim.api.nvim_set_hl(winNs, "issueNumber", { link = "Number" }) - - -- colorcolumn as extra indicators of overLength - vim.opt_local.colorcolumn = { conf.mediumLen, conf.maxLen } - - -- treesitter highlighting - vim.bo.filetype = "gitcommit" - vim.api.nvim_set_hl(winNs, "Title", { link = "Normal" }) - - -- activate styling of statusline plugins - vim.api.nvim_buf_set_name(0, "COMMIT_EDITMSG") - end, - }) -end - -local function getRepo() - return fn.system("git remote -v | head -n1"):match(":.*%."):sub(2, -2) -end - --------------------------------------------------------------------------------- - ----@param opts? { forcePush?: boolean } -function M.amendNoEdit(opts) - if not opts then opts = {} end - vim.cmd("silent update") - if notInGitRepo() then return end - - local stageInfo = stageAllIfNoChanges() - if not stageInfo then return end - - local stderr = fn.system { "git", "commit", "--amend", "--no-edit" } - if nonZeroExit(stderr) then return end - - local lastCommitMsg = vim.trim(fn.system("git log -1 --pretty=%B")) - local body = { stageInfo, ('"%s"'):format(lastCommitMsg) } - if opts.forcePush then table.insert(body, "Force Pushing…") end - local notifyText = table.concat(body, "\n \n") -- need space since empty lines are removed by nvim-notify - notify(notifyText, "info", "Amend-No-edit") - - if opts.forcePush then M.push { force = true } end -end - ----@param opts? { forcePush?: boolean } ----@param prefillMsg? string -function M.amendOnlyMsg(opts, prefillMsg) - if not opts then opts = {} end - vim.cmd("silent update") - if notInGitRepo() then return end - - if not prefillMsg then - local lastCommitMsg = vim.trim(fn.system("git log -1 --pretty=%B")) - prefillMsg = lastCommitMsg - end - setGitCommitAppearance() - - vim.ui.input({ prompt = "󰊢 Amend Message", default = prefillMsg }, function(commitMsg) - if not commitMsg then return end -- aborted input modal - local validMsg, cMsg = processCommitMsg(commitMsg) - if not validMsg then -- if msg invalid, run again to fix the msg - M.amendOnlyMsg(opts, cMsg) - return - end - - local stderr = fn.system { "git", "commit", "--amend", "-m", cMsg } - if nonZeroExit(stderr) then return end - - local body = ('"%s"'):format(cMsg) - if opts.forcePush then body = body .. "\n\n➤ Force Pushing…" end - notify(body, "info", "Amend-Only-Msg") - - if opts.forcePush then M.push { force = true } end - end) -end - --------------------------------------------------------------------------------- - ---If there are staged changes, commit them. ---If there aren't, add all changes (`git add -A`) and then commit. ----@param prefillMsg? string ----@param opts? { push?: boolean, openReferencedIssue?: boolean } -function M.smartCommit(opts, prefillMsg) - if notInGitRepo() then return end - - vim.cmd("silent update") - if not opts then opts = {} end - if not prefillMsg then prefillMsg = "" end - - setGitCommitAppearance() - vim.ui.input({ prompt = "󰊢 Commit Message", default = prefillMsg }, function(commitMsg) - if not commitMsg then return end -- aborted input modal - local validMsg, processedMsg = processCommitMsg(commitMsg) - if not validMsg then -- if msg invalid, run again to fix the msg - M.smartCommit(opts, processedMsg) - return - end - - local stageInfo = stageAllIfNoChanges() - if not stageInfo then return end - - local stderr = fn.system { "git", "commit", "-m", processedMsg } - if nonZeroExit(stderr) then return end - - local body = { stageInfo, ('"%s"'):format(processedMsg) } - if opts.push then table.insert(body, "Pushing…") end - local notifyText = table.concat(body, "\n \n") -- need space since empty lines are removed by nvim-notify - notify(notifyText, "info", "Smart-Commit") - - local issueReferenced = processedMsg:match("#(%d+)") - if opts.openReferencedIssue and issueReferenced then - local url = ("https://github.com/%s/issues/%s"):format(getRepo(), issueReferenced) - openUrl(url) - end - - if opts.push then M.push { pullBefore = true } end - end) -end - --- pull before to avoid conflicts ----@param opts? { pullBefore?: boolean, force?: boolean } -function M.push(opts) - if not opts then opts = {} end - local title = opts.force and "Force Push" or "Push" - local shellCmd = opts.force and "git push --force" or "git push" - if opts.pullBefore then - shellCmd = "git pull ; " .. shellCmd - title = "Pull & " .. title - end - - fn.jobstart(shellCmd, { - stdout_buffered = true, - stderr_buffered = true, - detach = true, -- finish even when quitting nvim - on_stdout = function(_, data) - if data[1] == "" and #data == 1 then return end - local output = vim.trim(table.concat(data, "\n")) - - -- no need to notify that the pull in `git pull ; git push` yielded no update - if output:find("Current branch .* is up to date") then return end - - notify(output, "info", title) - confirmationSound( - "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/siri/jbl_confirm.caf" -- codespell-ignore - ) - vim.cmd.checktime() -- in case a `git pull` has updated a file - end, - on_stderr = function(_, data) - if data[1] == "" and #data == 1 then return end - local output = vim.trim(table.concat(data, "\n")) - - -- git often puts non-errors into STDERR, therefore checking here again - -- whether it is actually an error or not - local logLevel = "info" - local sound = - "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/siri/jbl_confirm.caf" -- codespell-ignore - if output:lower():find("error") then - logLevel = "error" - sound = "/System/Library/Sounds/Basso.aiff" - elseif output:lower():find("warning") then - logLevel = "warn" - sound = "/System/Library/Sounds/Basso.aiff" - end - - notify(output, logLevel, title) - confirmationSound(sound) - vim.cmd.checktime() -- in case a `git pull` has updated a file - end, - }) +---@param userOpts? { push?: boolean, openReferencedIssue?: boolean } +function M.smartCommit(userOpts) + require("tinygit.commit-and-amend").smartCommit(userOpts or {}) end -------------------------------------------------------------------------------- @@ -347,112 +29,16 @@ end ---normal mode: link to file ---visual mode: link to selected lines ---@param justRepo any -- don't link to file with a specific commit, just link to repo -function M.githubUrl(justRepo) - if notInGitRepo() then return end - - local filepath = vim.fn.expand("%:p") - local gitroot = vim.fn.system("git --no-optional-locks rev-parse --show-toplevel") - local pathInRepo = filepath:sub(#gitroot + 1) - - local pathInRepoEncoded = pathInRepo:gsub("%s+", "%%20") - local remote = fn.system("git --no-optional-locks remote -v"):gsub(".*:(.-)%.git.*", "%1") - local hash = vim.trim(fn.system("git --no-optional-locks rev-parse HEAD")) - local branch = vim.trim(fn.system("git --no-optional-locks branch --show-current")) - - local selStart = fn.line("v") - local selEnd = fn.line(".") - local isVisualMode = fn.mode():find("[Vv]") - local isNormalMode = fn.mode() == "n" - local url = "https://github.com/" .. remote - - if not justRepo and isNormalMode then - url = url .. ("/blob/%s/%s"):format(branch, pathInRepoEncoded) - elseif not justRepo and isVisualMode then - local location - if selStart == selEnd then -- one-line-selection - location = "#L" .. tostring(selStart) - elseif selStart < selEnd then - location = "#L" .. tostring(selStart) .. "-L" .. tostring(selEnd) - else - location = "#L" .. tostring(selEnd) .. "-L" .. tostring(selStart) - end - url = url .. ("/blob/%s/%s%s"):format(hash, pathInRepoEncoded, location) - end - - openUrl(url) - fn.setreg("+", url) -- copy to clipboard -end - --------------------------------------------------------------------------------- ----formats the list of issues/PRs for vim.ui.select ----@param issue table ----@return table -local function issueListFormatter(issue) - local isPR = issue.pull_request ~= nil - local merged = isPR and issue.pull_request.merged_at ~= nil - - local icon - if issue.state == "open" and isPR then - icon = config.issueIcons.openPR - elseif issue.state == "closed" and isPR and merged then - icon = config.issueIcons.mergedPR - elseif issue.state == "closed" and isPR and not merged then - icon = config.issueIcons.closedPR - elseif issue.state == "closed" and not isPR then - icon = config.issueIcons.closedIssue - elseif issue.state == "open" and not isPR then - icon = config.issueIcons.openIssue - end - - return icon .. " #" .. issue.number .. " " .. issue.title -end +function M.githubUrl(justRepo) require("tinygit.github").githubUrl(justRepo) end ---Choose a GitHub issue/PR from the current repo to open in the browser. ---CAVEAT Due to GitHub API liminations, only the last 100 issues are shown. ---@param userOpts? { state?: string, type?: string } -function M.issuesAndPrs(userOpts) - if notInGitRepo() then return end - local defaultOpts = { state = "all", type = "all" } - local opts = vim.tbl_deep_extend("force", defaultOpts, userOpts or {}) - - local repo = getRepo() +function M.issuesAndPrs(userOpts) require("tinygit.github").issuesAndPrs(userOpts or {}) end - -- DOCS https://docs.github.com/en/free-pro-team@latest/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues - local rawJsonUrl = ("https://api.github.com/repos/%s/issues?per_page=100&state=%s&sort=updated"):format( - repo, - opts.state - ) - local rawJSON = fn.system { "curl", "-sL", rawJsonUrl } - local issues = vim.json.decode(rawJSON) - if not issues then - notify("Failed to fetch issues.", "warn") - return - end - if issues and opts.type ~= "all" then - issues = vim.tbl_filter(function(issue) - local isPR = issue.pull_request ~= nil - local isRightKind = (isPR and opts.type == "pr") or (not isPR and opts.type == "issue") - return isRightKind - end, issues) - end - - if #issues == 0 then - local state = opts.state == "all" and "" or opts.state .. " " - local type = opts.type == "all" and "issues or PRs " or opts.type .. "s " - notify(("There are no %s%sfor this repo."):format(state, type), "warn") - return - end - - local title = opts.type == "all" and "Issue/PR" or opts.type - vim.ui.select( - issues, - { prompt = " Select " .. title, kind = "github_issue", format_item = issueListFormatter }, - function(choice) - if not choice then return end - openUrl(choice.html_url) - end - ) -end +-- pull before to avoid conflicts +---@param userOpts? { pullBefore?: boolean, force?: boolean } +function M.push(userOpts) require("tinygit.push").push(userOpts or {}) end -------------------------------------------------------------------------------- return M diff --git a/lua/tinygit/push.lua b/lua/tinygit/push.lua new file mode 100644 index 0000000..d0176b1 --- /dev/null +++ b/lua/tinygit/push.lua @@ -0,0 +1,69 @@ +local M = {} +local fn = vim.fn +local u = require("tinygit.utils") +-------------------------------------------------------------------------------- + +---CAVEAT currently only on macOS +---@param soundFilepath string +local function confirmationSound(soundFilepath) + local onMacOs = fn.has("macunix") == 1 + local useSound = require("tinygit.config").config.asyncOpConfirmationSound + if not (onMacOs and useSound) then return end + fn.system(("afplay %q &"):format(soundFilepath)) +end + +-------------------------------------------------------------------------------- + +-- pull before to avoid conflicts +---@param userOpts { pullBefore?: boolean|nil, force?: boolean|nil } +function M.push(userOpts) + local title = userOpts.force and "Force Push" or "Push" + local shellCmd = userOpts.force and "git push --force" or "git push" + if userOpts.pullBefore then + shellCmd = "git pull ; " .. shellCmd + title = "Pull & " .. title + end + + fn.jobstart(shellCmd, { + stdout_buffered = true, + stderr_buffered = true, + detach = true, -- finish even when quitting nvim + on_stdout = function(_, data) + if data[1] == "" and #data == 1 then return end + local output = vim.trim(table.concat(data, "\n")) + + -- no need to notify that the pull in `git pull ; git push` yielded no update + if output:find("Current branch .* is up to date") then return end + + u.notify(output, "info", title) + confirmationSound( + "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/siri/jbl_confirm.caf" -- codespell-ignore + ) + vim.cmd.checktime() -- in case a `git pull` has updated a file + end, + on_stderr = function(_, data) + if data[1] == "" and #data == 1 then return end + local output = vim.trim(table.concat(data, "\n")) + + -- git often puts non-errors into STDERR, therefore checking here again + -- whether it is actually an error or not + local logLevel = "info" + local sound = + "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/siri/jbl_confirm.caf" -- codespell-ignore + if output:lower():find("error") then + logLevel = "error" + sound = "/System/Library/Sounds/Basso.aiff" + elseif output:lower():find("warning") then + logLevel = "warn" + sound = "/System/Library/Sounds/Basso.aiff" + end + + u.notify(output, logLevel, title) + confirmationSound(sound) + vim.cmd.checktime() -- in case a `git pull` has updated a file + end, + }) +end + +-------------------------------------------------------------------------------- +return M diff --git a/lua/tinygit/types.lua b/lua/tinygit/types.lua deleted file mode 100644 index c918972..0000000 --- a/lua/tinygit/types.lua +++ /dev/null @@ -1,21 +0,0 @@ ----@class pluginConfig ----@field commitMsg commitConfig ----@field asyncOpConfirmationSound boolean ----@field issueIcons issueIconConfig - ----@class issueIconConfig ----@field closedIssue string ----@field openIssue string ----@field openPR string ----@field mergedPR string ----@field closedPR string - ----@class commitConfig ----@field maxLen number ----@field mediumLen number ----@field emptyFillIn string ----@field enforceConvCommits enforceConvCommitsConfig - ----@class enforceConvCommitsConfig ----@field enabled boolean ----@field keywords string[] diff --git a/lua/tinygit/utils.lua b/lua/tinygit/utils.lua new file mode 100644 index 0000000..54c1e1d --- /dev/null +++ b/lua/tinygit/utils.lua @@ -0,0 +1,58 @@ +local M = {} +local fn = vim.fn +-------------------------------------------------------------------------------- + +-- open with the OS-specific shell command +---@param url string +function M.openUrl(url) + local opener + if fn.has("macunix") == 1 then + opener = "open" + elseif fn.has("linux") == 1 then + opener = "xdg-open" + elseif fn.has("win64") == 1 or fn.has("win32") == 1 then + opener = "start" + end + local openCommand = ("%s '%s' >/dev/null 2>&1"):format(opener, url) + fn.system(openCommand) +end + +---send notification +---@param body string +---@param level? "info"|"trace"|"debug"|"warn"|"error" +---@param title? string +function M.notify(body, level, title) + local titlePrefix = "tinygit" + if not level then level = "info" end + local notifyTitle = title and titlePrefix .. ": " .. title or titlePrefix + vim.notify(vim.trim(body), vim.log.levels[level:upper()], { title = notifyTitle }) +end + +---checks if last command was successful, if not, notify +---@nodiscard +---@return boolean +---@param errorMsg string +function M.nonZeroExit(errorMsg) + local exitCode = vim.v.shell_error + if exitCode ~= 0 then M.notify(vim.trim(errorMsg), "warn") end + return exitCode ~= 0 +end + +---also notifies if not in git repo +---@nodiscard +---@return boolean +function M.notInGitRepo() + fn.system("git rev-parse --is-inside-work-tree") + local notInRepo = M.nonZeroExit("Not in Git Repo.") + return notInRepo +end + +---@return string user/name of repo +---@nodiscard +function M.getRepo() + return fn.system("git remote -v | head -n1"):match(":.*%."):sub(2, -2) +end + + +-------------------------------------------------------------------------------- +return M