Skip to content

Commit

Permalink
refactor: modularize the plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisgrieser committed Sep 28, 2023
1 parent 08b9422 commit 38584d7
Show file tree
Hide file tree
Showing 7 changed files with 532 additions and 452 deletions.
198 changes: 198 additions & 0 deletions lua/tinygit/commit-and-amend.lua
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions lua/tinygit/config.lua
Original file line number Diff line number Diff line change
@@ -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
119 changes: 119 additions & 0 deletions lua/tinygit/github.lua
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 38584d7

Please sign in to comment.