├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── dependabot.yml ├── FUNDING.yml ├── workflows │ ├── nvim-type-check.yml │ ├── stylua.yml │ ├── markdownlint.yml │ ├── stale-bot.yml │ ├── pr-title.yml │ └── panvimdoc.yml └── pull_request_template.md ├── .gitignore ├── .typos.toml ├── .stylua.toml ├── .emmyrc.json ├── .editorconfig ├── plugin └── ex-commands.lua ├── .luarc.jsonc ├── .markdownlint.yaml ├── lua └── tinygit │ ├── commands │ ├── stash.lua │ ├── undo.lua │ ├── push-pull.lua │ ├── commit │ │ ├── preview.lua │ │ └── msg-input.lua │ ├── github.lua │ ├── stage │ │ └── telescope.lua │ ├── stage.lua │ ├── commit.lua │ └── history.lua │ ├── shared │ ├── picker.lua │ ├── highlights.lua │ ├── backdrop.lua │ ├── utils.lua │ └── diff.lua │ ├── statusline.lua │ ├── init.lua │ ├── statusline │ ├── branch-state.lua │ ├── file-state.lua │ └── blame.lua │ └── config.lua ├── LICENSE ├── README.md └── doc └── nvim-tinygit.txt /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # help-tags auto-generated by lazy.nvim 2 | doc/tags 3 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-words] 2 | noice = "noice" # for noice.nvim 3 | equest = "equest" # used in a pattern 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "chore(dependabot): " 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/github/administering-a-repository/managing-repository-settings/displaying-a-sponsor-button-in-your-repository 2 | 3 | custom: https://www.paypal.me/ChrisGrieser 4 | ko_fi: pseudometa 5 | -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/JohnnyMorganz/StyLua#options 2 | column_width = 100 3 | indent_type = "Tabs" 4 | indent_width = 3 5 | quote_style = "AutoPreferDouble" 6 | call_parentheses = "NoSingleTable" 7 | collapse_simple_statement = "Always" 8 | 9 | [sort_requires] 10 | enabled = true 11 | -------------------------------------------------------------------------------- /.github/workflows/nvim-type-check.yml: -------------------------------------------------------------------------------- 1 | name: nvim type check 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: ["**.lua"] 7 | pull_request: 8 | paths: ["**.lua"] 9 | 10 | jobs: 11 | build: 12 | name: nvim type check 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | - uses: stevearc/nvim-typecheck-action@v2 17 | -------------------------------------------------------------------------------- /.emmyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "runtime": { 3 | "version": "LuaJIT", 4 | "requirePattern": ["lua/?.lua", "lua/?/init.lua"] 5 | }, 6 | "workspace": { 7 | "library": [ 8 | "$VIMRUNTIME", 9 | "$HOME/.local/share/nvim/lazy/luvit-meta/library/uv.lua" 10 | ] 11 | }, 12 | "$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json" 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/stylua.yml: -------------------------------------------------------------------------------- 1 | name: Stylua check 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: ["**.lua"] 7 | pull_request: 8 | paths: ["**.lua"] 9 | 10 | jobs: 11 | stylua: 12 | name: Stylua 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | - uses: JohnnyMorganz/stylua-action@v4 17 | with: 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | version: latest 20 | args: --check . 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | max_line_length = 100 5 | end_of_line = lf 6 | charset = utf-8 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 3 10 | tab_width = 3 11 | trim_trailing_whitespace = true 12 | 13 | [*.{yml,yaml,scm}] 14 | indent_style = space 15 | indent_size = 2 16 | tab_width = 2 17 | 18 | [*.py] 19 | indent_style = space 20 | indent_size = 4 21 | tab_width = 4 22 | 23 | [*.md] 24 | indent_size = 4 25 | tab_width = 4 26 | trim_trailing_whitespace = false 27 | -------------------------------------------------------------------------------- /plugin/ex-commands.lua: -------------------------------------------------------------------------------- 1 | -- CAVEAT this is a simple version of an ex-command which does not accept 2 | -- command-specific arguments and does not detect visual mode yet 3 | 4 | vim.api.nvim_create_user_command("Tinygit", function(ctx) require("tinygit")[ctx.args]() end, { 5 | nargs = 1, 6 | complete = function(query) 7 | local subcommands = vim.tbl_keys(require("tinygit").cmdToModuleMap) 8 | return vim.tbl_filter(function(op) return op:lower():find(query, nil, true) end, subcommands) 9 | end, 10 | }) 11 | -------------------------------------------------------------------------------- /.luarc.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "runtime.version": "LuaJIT", 3 | 4 | "workspace.library": [ 5 | "$VIMRUNTIME/lua", // nvim-lua runtime 6 | "${3rd}/luv/library" // vim.uv 7 | ], 8 | 9 | "diagnostics": { 10 | "unusedLocalExclude": ["_*"], // allow `_varname` for unused variables 11 | "groupFileStatus": { 12 | "luadoc": "Any", // require stricter annotations 13 | "conventions": "Any" // disallow global variables 14 | } 15 | }, 16 | 17 | "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json" 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/markdownlint.yml: -------------------------------------------------------------------------------- 1 | name: Markdownlint check 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "**.md" 8 | - ".github/workflows/markdownlint.yml" 9 | - ".markdownlint.*" # markdownlint config files 10 | pull_request: 11 | paths: 12 | - "**.md" 13 | 14 | jobs: 15 | markdownlint: 16 | name: Markdownlint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v6 20 | - uses: DavidAnson/markdownlint-cli2-action@v21 21 | with: 22 | globs: "**/*.md" 23 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Problem statement 2 | 3 | 4 | ## Proposed solution 5 | 6 | 7 | ## AI usage disclosure 8 | 11 | 12 | ## Checklist 13 | - [ ] Variable names follow `camelCase` convention. 14 | - [ ] All AI-generated code has been reviewed by a human. 15 | - [ ] The `README.md` has been updated for any new or modified functionality 16 | (the `.txt` file is auto-generated and does not need to be modified). 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea 3 | title: "Feature request: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: checkboxes 7 | id: checklist 8 | attributes: 9 | label: Checklist 10 | options: 11 | - label: The feature would be useful to more users than just me. 12 | required: true 13 | - type: textarea 14 | id: feature-requested 15 | attributes: 16 | label: Feature requested 17 | description: A clear and concise description of the feature. 18 | validations: { required: true } 19 | - type: textarea 20 | id: screenshot 21 | attributes: 22 | label: Relevant screenshot 23 | description: If applicable, add screenshots or a screen recording to help explain the request. 24 | -------------------------------------------------------------------------------- /.github/workflows/stale-bot.yml: -------------------------------------------------------------------------------- 1 | name: Stale bot 2 | on: 3 | schedule: 4 | - cron: "18 04 * * 3" 5 | 6 | permissions: 7 | issues: write 8 | pull-requests: write 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Close stale issues 15 | uses: actions/stale@v10 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | # DOCS https://github.com/actions/stale#all-options 20 | days-before-stale: 180 21 | days-before-close: 7 22 | stale-issue-label: "Stale" 23 | stale-issue-message: | 24 | This issue has been automatically marked as stale. 25 | **If this issue is still affecting you, please leave any comment**, for example "bump", and it will be kept open. 26 | close-issue-message: | 27 | This issue has been closed due to inactivity, and will not be monitored. 28 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # DOCS https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md 2 | #------------------------------------------------------------------------------- 3 | 4 | default: warning 5 | 6 | #-MODIFIED SETTINGS------------------------------------------------------------- 7 | blanks-around-headings: { lines_below: 0 } # rule of proximity 8 | ul-style: { style: sublist } 9 | ol-prefix: { style: ordered } 10 | line-length: { tables: false, code_block_line_length: 90 } 11 | no-inline-html: { allowed_elements: [img, details, summary, kbd, a, table, tr, th, td] } 12 | 13 | #-DISABLED---------------------------------------------------------------------- 14 | ul-indent: false # not compatible with using tabs 15 | no-hard-tabs: false # taken care of by .editorconfig 16 | blanks-around-lists: false # space waster 17 | first-line-heading: false # ignore-comments for linters can be in the first line 18 | no-emphasis-as-heading: false # for small sections that shouldn't be linked in the ToC 19 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | name: PR title 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | - reopened 10 | - ready_for_review 11 | 12 | permissions: 13 | pull-requests: read 14 | 15 | jobs: 16 | semantic-pull-request: 17 | name: Check PR title 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: amannn/action-semantic-pull-request@v6 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | with: 24 | requireScope: false 25 | subjectPattern: ^(?![A-Z]).+$ # disallow title starting with capital 26 | types: | # add `improv` to the list of allowed types 27 | improv 28 | fix 29 | feat 30 | refactor 31 | build 32 | ci 33 | style 34 | test 35 | chore 36 | perf 37 | docs 38 | break 39 | revert 40 | -------------------------------------------------------------------------------- /.github/workflows/panvimdoc.yml: -------------------------------------------------------------------------------- 1 | name: panvimdoc 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - README.md 8 | - .github/workflows/panvimdoc.yml 9 | workflow_dispatch: {} # allows manual execution 10 | 11 | permissions: 12 | contents: write 13 | 14 | #─────────────────────────────────────────────────────────────────────────────── 15 | 16 | jobs: 17 | docs: 18 | runs-on: ubuntu-latest 19 | name: README.md to vimdoc 20 | steps: 21 | - uses: actions/checkout@v6 22 | - run: git pull # fix failure when multiple commits are pushed in succession 23 | - run: mkdir -p doc 24 | 25 | - name: panvimdoc 26 | uses: kdheepak/panvimdoc@main 27 | with: 28 | vimdoc: ${{ github.event.repository.name }} 29 | version: "Neovim" 30 | demojify: true 31 | treesitter: true 32 | 33 | - run: git pull 34 | - name: push changes 35 | uses: stefanzweifel/git-auto-commit-action@v7 36 | with: 37 | commit_message: "chore: auto-generate vimdocs" 38 | branch: ${{ github.head_ref }} 39 | -------------------------------------------------------------------------------- /lua/tinygit/commands/stash.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local u = require("tinygit.shared.utils") 3 | -------------------------------------------------------------------------------- 4 | 5 | function M.stashPush() 6 | if u.notInGitRepo() then return end 7 | 8 | local result = vim.system({ "git", "stash", "push" }):wait() 9 | if u.nonZeroExit(result) then return end 10 | local infoText = vim.trim(result.stdout):gsub("^Saved working directory and index state ", "") 11 | local stashStat = u.syncShellCmd { "git", "stash", "show", "0" } 12 | 13 | u.notify(infoText .. "\n" .. stashStat, "info", { title = "Stash push" }) 14 | vim.cmd.checktime() -- reload this file from disk 15 | end 16 | 17 | function M.stashPop() 18 | if u.notInGitRepo() then return end 19 | 20 | local result = vim.system({ "git", "stash", "pop" }):wait() 21 | if u.nonZeroExit(result) then return end 22 | local infoText = vim.trim(result.stdout) 23 | 24 | u.notify(infoText, "info", { title = "Stash pop" }) 25 | vim.cmd.checktime() -- reload this file from disk 26 | end 27 | 28 | -------------------------------------------------------------------------------- 29 | return M 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Christopher Grieser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lua/tinygit/shared/picker.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | ---@param prompt string 5 | ---@param items any[] 6 | ---@param itemFormatter fun(item: any): string 7 | ---@param stylingFunc fun() 8 | ---@param onChoice fun(item: any, index?: number) 9 | function M.pick(prompt, items, itemFormatter, stylingFunc, onChoice) 10 | -- Add some basic styling & backdrop, if using `telescope` or `snacks.picker` 11 | local autocmd = vim.api.nvim_create_autocmd("FileType", { 12 | desc = "Tinygit: Styling for TelescopeResults", 13 | once = true, 14 | pattern = { "TelescopeResults", "snacks_picker_list" }, 15 | callback = function(ctx) 16 | vim.schedule(function() vim.api.nvim_buf_call(ctx.buf, stylingFunc) end) 17 | require("tinygit.shared.backdrop").new(ctx.buf) 18 | end, 19 | }) 20 | 21 | vim.ui.select(items, { 22 | prompt = prompt, 23 | format_item = itemFormatter, 24 | }, function(selection, index) 25 | if selection then onChoice(selection, index) end 26 | vim.api.nvim_del_autocmd(autocmd) 27 | end) 28 | end 29 | 30 | -------------------------------------------------------------------------------- 31 | return M 32 | -------------------------------------------------------------------------------- /lua/tinygit/commands/undo.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local u = require("tinygit.shared.utils") 3 | local updateStatusline = require("tinygit.statusline").updateAllComponents 4 | -------------------------------------------------------------------------------- 5 | 6 | function M.undoLastCommitOrAmend() 7 | if u.notInGitRepo() then return end 8 | 9 | -- GUARD last operation was not a commit or amend 10 | local lastReflogLine = u.syncShellCmd { "git", "reflog", "show", "-1", "HEAD@{0}" } 11 | local lastChangeType = vim.trim(vim.split(lastReflogLine, ":")[2]) 12 | if not lastChangeType:find("commit") then 13 | local msg = ("Aborting: Last operation was %q, not a commit or amend."):format(lastChangeType) 14 | u.notify(msg, "warn", { title = "Undo last commit/amend" }) 15 | return 16 | end 17 | 18 | local result = vim.system({ "git", "reset", "--mixed", "HEAD@{1}" }):wait() 19 | if u.nonZeroExit(result) then return end 20 | local infoText = vim.trim(result.stdout) 21 | 22 | u.notify(infoText, "info", { title = "Undo last commit/amend" }) 23 | vim.cmd.checktime() -- updates the current buffer 24 | updateStatusline() 25 | end 26 | 27 | -------------------------------------------------------------------------------- 28 | return M 29 | -------------------------------------------------------------------------------- /lua/tinygit/statusline.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | function M.blame() return require("tinygit.statusline.blame").getBlame() end 5 | function M.branchState() return require("tinygit.statusline.branch-state").getBranchState() end 6 | function M.fileState() return require("tinygit.statusline.file-state").getFileState() end 7 | 8 | function M.updateAllComponents() 9 | -- conditions to avoid unnecessarily loading the modules 10 | if package.loaded["tinygit.statusline.blame"] then 11 | require("tinygit.statusline.blame").refreshBlame() 12 | end 13 | if package.loaded["tinygit.statusline.branch-state"] then 14 | require("tinygit.statusline.branch-state").refreshBranchState() 15 | end 16 | if package.loaded["tinygit.statusline.file-state"] then 17 | require("tinygit.statusline.file-state").refreshFileState() 18 | end 19 | 20 | -- Needs to be triggered manually, since lualine updates the git diff 21 | -- component only on BufEnter. 22 | if package.loaded["lualine"] then 23 | require("lualine.components.diff.git_diff").update_diff_args() 24 | require("lualine").refresh() 25 | end 26 | end 27 | 28 | -------------------------------------------------------------------------------- 29 | return M 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: checkboxes 7 | id: checklist 8 | attributes: 9 | label: Make sure you have done the following 10 | options: 11 | - label: I have updated to the latest version of the plugin. 12 | required: true 13 | - label: I have read the README. 14 | required: true 15 | - type: textarea 16 | id: bug-description 17 | attributes: 18 | label: Bug description 19 | description: A clear and concise description of the bug. 20 | validations: { required: true } 21 | - type: textarea 22 | id: screenshot 23 | attributes: 24 | label: Relevant screenshot 25 | description: 26 | If applicable, add screenshots or a screen recording to help explain your problem. 27 | - type: textarea 28 | id: reproduction-steps 29 | attributes: 30 | label: To reproduce 31 | description: Steps to reproduce the problem 32 | placeholder: | 33 | For example: 34 | 1. Go to '...' 35 | 2. Click on '...' 36 | 3. Scroll down to '...' 37 | - type: textarea 38 | id: version-info 39 | attributes: 40 | label: neovim version 41 | render: Text 42 | validations: { required: true } 43 | -------------------------------------------------------------------------------- /lua/tinygit/init.lua: -------------------------------------------------------------------------------- 1 | local version = vim.version() 2 | if version.major == 0 and version.minor < 10 then 3 | vim.notify("tinygit requires at least nvim 0.10.", vim.log.levels.WARN) 4 | return 5 | end 6 | -------------------------------------------------------------------------------- 7 | local M = {} 8 | 9 | ---@param userConfig? Tinygit.Config 10 | function M.setup(userConfig) require("tinygit.config").setup(userConfig) end 11 | 12 | M.cmdToModuleMap = { 13 | interactiveStaging = "stage", 14 | smartCommit = "commit", 15 | fixupCommit = "commit", 16 | amendOnlyMsg = "commit", 17 | amendNoEdit = "commit", 18 | undoLastCommitOrAmend = "undo", 19 | stashPop = "stash", 20 | stashPush = "stash", 21 | push = "push-pull", 22 | githubUrl = "github", 23 | issuesAndPrs = "github", 24 | openIssueUnderCursor = "github", 25 | createGitHubPr = "github", 26 | fileHistory = "history", 27 | } 28 | 29 | setmetatable(M, { 30 | __index = function(_, key) 31 | return function(...) 32 | local u = require("tinygit.shared.utils") 33 | 34 | local module = M.cmdToModuleMap[key] 35 | if not module then 36 | u.notify(("Unknown command `%s`."):format(key), "warn") 37 | return function() end -- prevent function call throwing error 38 | end 39 | require("tinygit.commands." .. module)[key](...) 40 | end 41 | end, 42 | }) 43 | 44 | -------------------------------------------------------------------------------- 45 | return M 46 | -------------------------------------------------------------------------------- /lua/tinygit/shared/highlights.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | function M.inlineCodeAndIssueNumbers() 5 | vim.fn.matchadd("Number", [[#\d\+]]) 6 | vim.fn.matchadd("@markup.raw.markdown_inline", [[`.\{-}`]]) -- .\{-} = non-greedy quantifier 7 | end 8 | 9 | ---@param startingFromLine? number 10 | function M.commitType(startingFromLine) 11 | local prefix = startingFromLine and "\\%>" .. startingFromLine .. "l" or "" 12 | 13 | -- TYPE 14 | -- not restricted to start of string, so prefixes like the numbering from the 15 | -- `snacks-ui-select` do not prevent the highlight 16 | local commitTypePattern = [[\v\w+(\(.{-}\))?!?]] 17 | local commitType = prefix .. commitTypePattern .. [[\ze: ]] -- `\ze`: end of match 18 | vim.fn.matchadd("@keyword.gitcommit", commitType) 19 | local colonAfterType = prefix .. commitTypePattern .. [[\zs: ]] -- `\zs`: start of match 20 | vim.fn.matchadd("@punctuation.special.gitcommit", colonAfterType) 21 | 22 | -- SCOPE 23 | local commitScopeBrackets = prefix .. [[\v\w+\zs(\(.{-}\)\ze): ]] -- matches scope with brackets 24 | vim.fn.matchadd("@punctuation.special.gitcommit", commitScopeBrackets, 10) -- via lower prio for simpler pattern 25 | local commitScope = prefix .. [[\v\w+(\(\zs.{-}\ze\)): ]] -- matches scope without brackets 26 | vim.fn.matchadd("@variable.parameter.gitcommit", commitScope, 11) 27 | 28 | -- BANG 29 | vim.fn.matchadd("@punctuation.special.gitcommit", [[\v\w+(\(.{-}\))?\zs!\ze: ]]) 30 | end 31 | 32 | -------------------------------------------------------------------------------- 33 | return M 34 | -------------------------------------------------------------------------------- /lua/tinygit/shared/backdrop.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | local backdropName = "TinygitBackdrop" 5 | 6 | ---@param referenceBuf number Reference buffer, when that buffer is closed, the backdrop will be closed too 7 | ---@param referenceZindex? number zindex of the reference window, where the backdrop should be placed below 8 | function M.new(referenceBuf, referenceZindex) 9 | local backdrop = require("tinygit.config").config.appearance.backdrop 10 | if not backdrop.enabled then return end 11 | 12 | -- `nvim_open_win` default is 50: https://neovim.io/doc/user/api.html#nvim_open_win() 13 | if not referenceZindex then referenceZindex = 50 end 14 | 15 | local bufnr = vim.api.nvim_create_buf(false, true) 16 | local winnr = vim.api.nvim_open_win(bufnr, false, { 17 | relative = "editor", 18 | row = 0, 19 | col = 0, 20 | width = vim.o.columns, 21 | height = vim.o.lines, 22 | focusable = false, 23 | style = "minimal", 24 | border = "none", -- needs to be explicitly set due to `vim.o.winborder` 25 | zindex = referenceZindex - 1, -- ensure it's below the reference window 26 | }) 27 | vim.api.nvim_set_hl(0, backdropName, { bg = "#000000", default = true }) 28 | vim.wo[winnr].winhighlight = "Normal:" .. backdropName 29 | vim.wo[winnr].winblend = backdrop.blend 30 | vim.bo[bufnr].buftype = "nofile" 31 | vim.bo[bufnr].filetype = backdropName 32 | 33 | -- close backdrop when the reference buffer is closed 34 | vim.api.nvim_create_autocmd({ "WinClosed", "BufLeave" }, { 35 | group = vim.api.nvim_create_augroup(backdropName, { clear = true }), 36 | once = true, 37 | buffer = referenceBuf, 38 | callback = function() 39 | if vim.api.nvim_win_is_valid(winnr) then vim.api.nvim_win_close(winnr, true) end 40 | if vim.api.nvim_buf_is_valid(bufnr) then 41 | vim.api.nvim_buf_delete(bufnr, { force = true }) 42 | end 43 | end, 44 | }) 45 | end 46 | 47 | -------------------------------------------------------------------------------- 48 | return M 49 | -------------------------------------------------------------------------------- /lua/tinygit/statusline/branch-state.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | ---@return string? state lualine stringifys result, so need to return empty string instead of nil 5 | ---@nodiscard 6 | local function getBranchState() 7 | local cwd = vim.uv.cwd() 8 | if not cwd then return end -- file without cwd 9 | 10 | local allBranchInfo = vim.system({ "git", "-C", cwd, "branch", "--verbose" }):wait() 11 | if allBranchInfo.code ~= 0 then return "" end -- not in git repo 12 | 13 | -- get only line on current branch (which starts with `*`) 14 | local branches = vim.split(allBranchInfo.stdout, "\n") 15 | local currentBranchInfo 16 | for _, line in pairs(branches) do 17 | currentBranchInfo = line:match("^%* .*") 18 | if currentBranchInfo then break end 19 | end 20 | if not currentBranchInfo then return "" end -- not on a branch, e.g., detached HEAD 21 | local ahead = currentBranchInfo:match("ahead (%d+)") 22 | local behind = currentBranchInfo:match("behind (%d+)") 23 | 24 | local icons = require("tinygit.config").config.statusline.branchState.icons 25 | if ahead and behind then 26 | return (icons.diverge .. " %s/%s"):format(ahead, behind) 27 | elseif ahead then 28 | return icons.ahead .. ahead 29 | elseif behind then 30 | return icons.behind .. behind 31 | end 32 | return "" 33 | end 34 | 35 | -------------------------------------------------------------------------------- 36 | 37 | function M.refreshBranchState() 38 | local state = getBranchState() 39 | if state then vim.b.tinygit_branchState = state end 40 | end 41 | 42 | function M.getBranchState() return vim.b.tinygit_branchState or "" end 43 | 44 | vim.api.nvim_create_autocmd({ "BufEnter", "DirChanged", "FocusGained" }, { 45 | group = vim.api.nvim_create_augroup("tinygit_branchState", { clear = true }), 46 | callback = function() 47 | -- defer so cwd changes take place before checking 48 | vim.defer_fn(M.refreshBranchState, 1) 49 | end, 50 | }) 51 | vim.defer_fn(M.refreshBranchState, 1) -- initialize in case of lazy-loading 52 | 53 | -------------------------------------------------------------------------------- 54 | return M 55 | -------------------------------------------------------------------------------- /lua/tinygit/statusline/file-state.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | ---@return string? state lualine stringifys result, so need to return empty string instead of nil 5 | ---@nodiscard 6 | local function getFileState() 7 | if not vim.uv.cwd() then return end -- file without cwd 8 | 9 | local u = require("tinygit.shared.utils") 10 | local gitroot = u.syncShellCmd { "git", "rev-parse", "--show-toplevel" } 11 | if not gitroot then return "" end 12 | local gitStatus = vim.system({ "git", "-C", gitroot, "status", "--porcelain" }):wait() 13 | if gitStatus.code ~= 0 then return "" end 14 | 15 | local icons = { 16 | added = "+", 17 | modified = "~", 18 | deleted = "-", 19 | untracked = "?", 20 | renamed = "R", 21 | } 22 | 23 | local changes = vim.iter(vim.split(gitStatus.stdout, "\n")):fold({}, function(acc, line) 24 | local label = vim.trim(line:sub(1, 2)) 25 | if #label > 1 then label = label:sub(1, 1) end -- prefer staged over unstaged 26 | local map = { 27 | ["?"] = icons.untracked, 28 | A = icons.added, 29 | M = icons.modified, 30 | R = icons.renamed, 31 | D = icons.deleted, 32 | } 33 | local key = map[label] 34 | if key then acc[key] = (acc[key] or 0) + 1 end 35 | return acc 36 | end) 37 | 38 | local stateStr = "" 39 | for icon, count in pairs(changes) do 40 | stateStr = stateStr .. icon .. count .. " " 41 | end 42 | 43 | local icon = require("tinygit.config").config.statusline.fileState.icon 44 | return vim.trim(icon .. " " .. stateStr) 45 | end 46 | 47 | -------------------------------------------------------------------------------- 48 | 49 | function M.refreshFileState() 50 | local state = getFileState() 51 | if state then vim.b.tinygit_fileState = state end 52 | end 53 | 54 | function M.getFileState() return vim.b.tinygit_fileState or "" end 55 | 56 | vim.api.nvim_create_autocmd({ "BufEnter", "DirChanged", "FocusGained" }, { 57 | group = vim.api.nvim_create_augroup("tinygit_fileState", { clear = true }), 58 | callback = function() 59 | -- defer so cwd changes take place before checking 60 | vim.defer_fn(M.refreshFileState, 1) 61 | end, 62 | }) 63 | vim.defer_fn(M.refreshFileState, 1) -- initialize in case of lazy-loading 64 | 65 | -------------------------------------------------------------------------------- 66 | return M 67 | -------------------------------------------------------------------------------- /lua/tinygit/shared/utils.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | ---@alias Tinygit.NotifyLevel "info"|"trace"|"debug"|"warn"|"error" 5 | 6 | ---@param msg string 7 | ---@param level? Tinygit.NotifyLevel 8 | ---@param opts? table 9 | ---@return unknown -- depends on the notification plugin of the user (if any) 10 | function M.notify(msg, level, opts) 11 | if not level then level = "info" end 12 | if not opts then opts = {} end 13 | 14 | opts.title = opts.title and "tinygit: " .. opts.title or "tinygit" 15 | if not opts.icon then opts.icon = require("tinygit.config").config.appearance.mainIcon end 16 | 17 | return vim.notify(vim.trim(msg), vim.log.levels[level:upper()], opts) 18 | end 19 | 20 | ---checks if command was successful, if not, notifies 21 | ---@nodiscard 22 | ---@return boolean 23 | ---@param result vim.SystemCompleted 24 | function M.nonZeroExit(result) 25 | local msg = (result.stdout or "") .. (result.stderr or "") 26 | if result.code ~= 0 then M.notify(msg, "error") end 27 | return result.code ~= 0 28 | end 29 | 30 | ---also notifies if not in git repo 31 | ---@nodiscard 32 | ---@return boolean 33 | function M.notInGitRepo() 34 | local notInRepo = vim.system({ "git", "rev-parse", "--is-inside-work-tree" }):wait().code ~= 0 35 | if notInRepo then M.notify("Not in a git repo", "error") end 36 | return notInRepo 37 | end 38 | 39 | ---@nodiscard 40 | ---@return boolean 41 | function M.inShallowRepo() 42 | return M.syncShellCmd { "git", "rev-parse", "--is-shallow-repository" } == "true" 43 | end 44 | 45 | ---@nodiscard 46 | ---@param cmd string[] 47 | ---@param notrim? any 48 | ---@return string stdout 49 | function M.syncShellCmd(cmd, notrim) 50 | local stdout = vim.system(cmd):wait().stdout or "" 51 | if notrim then return stdout end 52 | return vim.trim(stdout) 53 | end 54 | 55 | function M.intentToAddUntrackedFiles() 56 | local gitLsResponse = M.syncShellCmd { "git", "ls-files", "--others", "--exclude-standard" } 57 | local newFiles = gitLsResponse ~= "" and vim.split(gitLsResponse, "\n") or {} 58 | for _, file in ipairs(newFiles) do 59 | vim.system({ "git", "add", "--intent-to-add", "--", file }):wait() 60 | end 61 | end 62 | 63 | ---@param longStr string 64 | ---@return string shortened 65 | function M.shortenRelativeDate(longStr) 66 | local shortStr = (longStr:match("%d+ %ai?n?") or "") -- 1 unit char (expect min) 67 | :gsub("m$", "mo") -- "month" -> "mo" to keep it distinguishable from "min" 68 | :gsub(" ", "") 69 | :gsub("%d+s$", "just now") -- secs -> just now 70 | if shortStr ~= "just now" then shortStr = shortStr .. " ago" end 71 | return shortStr 72 | end 73 | 74 | -------------------------------------------------------------------------------- 75 | return M 76 | -------------------------------------------------------------------------------- /lua/tinygit/statusline/blame.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local u = require("tinygit.shared.utils") 4 | -------------------------------------------------------------------------------- 5 | 6 | ---@param bufnr? number 7 | ---@return string? blame lualine stringifys result, so need to return empty string instead of nil 8 | ---@nodiscard 9 | local function getBlame(bufnr) 10 | bufnr = bufnr or 0 11 | local bufPath = vim.api.nvim_buf_get_name(bufnr) 12 | 13 | -- GUARD valid buffer 14 | if not vim.api.nvim_buf_is_valid(bufnr) then return end 15 | if vim.bo[bufnr].buftype ~= "" then return end 16 | if vim.uv.fs_stat(bufPath) == nil then return end -- non-existing file 17 | 18 | local config = require("tinygit.config").config.statusline.blame 19 | local gitLogCmd = { "git", "log", "--max-count=1", "--format=%H\t%an\t%cr\t%s", "--", bufPath } 20 | local gitLogResult = vim.system(gitLogCmd):wait() 21 | 22 | -- GUARD git log output 23 | local stdout = vim.trim(gitLogResult.stdout) 24 | if stdout == "" or gitLogResult.code ~= 0 then return "" end 25 | 26 | local hash, author, relDate, msg = unpack(vim.split(stdout, "\t")) 27 | if vim.tbl_contains(config.ignoreAuthors, author) then return "" end 28 | local shortRelDate = u.shortenRelativeDate(relDate) 29 | 30 | -- GUARD shallow and on first commit 31 | -- get first commit: https://stackoverflow.com/a/5189296/22114136 32 | local isOnFirstCommit = hash == u.syncShellCmd { "git", "rev-list", "--max-parents=0", "HEAD" } 33 | local shallowRepo = require("tinygit.shared.utils").inShallowRepo() 34 | if shallowRepo and isOnFirstCommit then return "" end 35 | 36 | if vim.list_contains(config.showOnlyTimeIfAuthor, author) then 37 | return vim.trim(("%s %s"):format(config.icon, shortRelDate)) 38 | end 39 | 40 | local trimmedMsg = #msg <= config.maxMsgLen and msg 41 | or vim.trim(msg:sub(1, config.maxMsgLen)) .. "…" 42 | local authorInitials = not (author:find("%s")) and author:sub(1, 2) -- "janedoe" -> "ja" 43 | or author:sub(1, 1) .. author:match("%s(%S)") -- "Jane Doe" -> "JD" 44 | local authorStr = vim.list_contains(config.hideAuthorNames, author) and "" 45 | or " by " .. authorInitials 46 | return vim.trim(("%s %s [%s%s]"):format(config.icon, trimmedMsg, shortRelDate, authorStr)) 47 | end 48 | 49 | -------------------------------------------------------------------------------- 50 | 51 | ---@param bufnr? number 52 | function M.refreshBlame(bufnr) 53 | bufnr = bufnr or 0 54 | if not vim.api.nvim_buf_is_valid(bufnr) then return end 55 | vim.b[bufnr].tinygit_blame = getBlame(bufnr) 56 | end 57 | 58 | vim.api.nvim_create_autocmd({ "BufEnter", "DirChanged", "FocusGained" }, { 59 | group = vim.api.nvim_create_augroup("tinygit_blame", { clear = true }), 60 | callback = function(ctx) 61 | -- defer so buftype is set before checking the buffer 62 | vim.defer_fn(function() M.refreshBlame(ctx.buf) end, 1) 63 | end, 64 | }) 65 | 66 | vim.defer_fn(M.refreshBlame, 1) -- initialize in case of lazy-loading 67 | 68 | function M.getBlame() return vim.b.tinygit_blame or "" end 69 | 70 | -------------------------------------------------------------------------------- 71 | return M 72 | -------------------------------------------------------------------------------- /lua/tinygit/shared/diff.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | ---@enum (key) Tinygit.FileMode 5 | local FILEMODES = { 6 | new = 0, 7 | deleted = 1, 8 | modified = 2, 9 | renamed = 3, 10 | binary = 4, 11 | line_or_function_history = 5, 12 | } 13 | 14 | -------------------------------------------------------------------------------- 15 | 16 | -- remove diff header, if the input has it. checking for `@@`, as number of 17 | -- header lines can vary (e.g., diff to new file are 5 lines, not 4) 18 | ---@param diffLines string[] 19 | ---@return string[] headerLines 20 | ---@return string[] outputWithoutHeader 21 | ---@return Tinygit.FileMode fileMode 22 | ---@return { from?: string, to?: string } rename 23 | ---@nodiscard 24 | function M.splitOffDiffHeader(diffLines) 25 | local headerLines = {} 26 | while not vim.startswith(diffLines[1], "@@") do 27 | local headerLine = table.remove(diffLines, 1) 28 | table.insert(headerLines, headerLine) 29 | if #diffLines == 0 then break end -- renamed file without changes have no `@@` 30 | end 31 | 32 | local fileMode = headerLines[2]:match("^(%w+) file") or headerLines[3]:match("^(%w+) file") 33 | local rename = {} 34 | if not fileMode and headerLines[4] then 35 | rename.from = headerLines[3]:match("^rename from (.+)$") 36 | rename.to = headerLines[4]:match("^rename to (.+)$") 37 | fileMode = rename.from and "renamed" or "modified" 38 | elseif not fileMode and not headerLines[4] then 39 | fileMode = "line_or_function_history" 40 | end 41 | if fileMode then fileMode = fileMode:lower() end 42 | 43 | assert( 44 | vim.tbl_contains(vim.tbl_keys(FILEMODES), fileMode), 45 | "Unknown file mode, please create an issue: " .. fileMode 46 | ) 47 | 48 | return diffLines, headerLines, fileMode, rename 49 | end 50 | 51 | -------------------------------------------------------------------------------- 52 | 53 | ---@param bufnr number 54 | ---@param diffLinesWithHeader string[] 55 | ---@param filetype string|nil 56 | ---@param sepLength number|false -- false to not draw separators 57 | function M.setDiffBuffer(bufnr, diffLinesWithHeader, filetype, sepLength) 58 | local ns = vim.api.nvim_create_namespace("tinygit.diffBuffer") 59 | local sepChar = "┄" 60 | local sepHlGroup = "Comment" 61 | local diffLines, _, fileMode, rename = M.splitOffDiffHeader(diffLinesWithHeader) 62 | 63 | -- context line is useless in this case 64 | if fileMode == "deleted" or fileMode == "new" then 65 | table.remove(diffLines, 1) 66 | elseif fileMode == "renamed" then 67 | -- dummy blanks for virtual text, as nvim does not support placing a 68 | -- virtual line above the first line 69 | table.insert(diffLines, 1, "") 70 | table.insert(diffLines, 1, "") 71 | end 72 | 73 | -- remove diff signs and remember line numbers 74 | local diffAddLines, diffDelLines, diffHunkHeaderLines = {}, {}, {} 75 | for i = 1, #diffLines do 76 | local line = diffLines[i] 77 | local lnum = i - 1 78 | if line:find("^%+") then 79 | table.insert(diffAddLines, lnum) 80 | elseif line:find("^%-") then 81 | table.insert(diffDelLines, lnum) 82 | elseif line:find("^@@") then 83 | -- remove preproc info and inject the lnum later as inline text 84 | -- as keeping in the text breaks filetype-highlighting 85 | local originalLnum, cleanLine = line:match("^@@ %-.- %+(%d+).* @@ ?(.*)") 86 | diffLines[i] = cleanLine or "" -- nil on new file 87 | diffHunkHeaderLines[lnum] = originalLnum 88 | end 89 | if not line:find("^@@") then diffLines[i] = line:sub(2) end 90 | end 91 | 92 | -- set lines & buffer properties 93 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, diffLines) 94 | vim.bo[bufnr].modifiable = false 95 | if filetype then 96 | local hasTsParser = pcall(vim.treesitter.start, bufnr, filetype) 97 | if not hasTsParser then vim.bo[bufnr].filetype = filetype end 98 | end 99 | 100 | -- add highlights 101 | for _, ln in pairs(diffAddLines) do 102 | vim.api.nvim_buf_set_extmark(bufnr, ns, ln, 0, { line_hl_group = "DiffAdd" }) 103 | end 104 | for _, ln in pairs(diffDelLines) do 105 | vim.api.nvim_buf_set_extmark(bufnr, ns, ln, 0, { line_hl_group = "DiffDelete" }) 106 | end 107 | if fileMode == "renamed" then 108 | vim.api.nvim_buf_set_extmark(bufnr, ns, 0, 0, { 109 | virt_text = { { ("renamed from %q"):format(rename.from), "Comment" } }, 110 | virt_text_pos = "inline", 111 | }) 112 | vim.api.nvim_buf_set_extmark(bufnr, ns, 1, 0, { 113 | virt_text = { { ("to %q"):format(rename.to), "Comment" } }, 114 | virt_text_pos = "inline", 115 | }) 116 | end 117 | for ln, originalLnum in pairs(diffHunkHeaderLines) do 118 | vim.api.nvim_buf_set_extmark(bufnr, ns, ln, 0, { 119 | virt_text = { 120 | { originalLnum .. ":", "diffLine" }, 121 | { " " }, 122 | }, 123 | virt_text_pos = "inline", 124 | line_hl_group = "DiffText", 125 | }) 126 | 127 | -- separator between hunks 128 | if ln > 1 and sepLength then 129 | vim.api.nvim_buf_set_extmark(bufnr, ns, ln, 0, { 130 | virt_lines = { 131 | { { sepChar:rep(sepLength), sepHlGroup } }, 132 | }, 133 | virt_lines_above = true, 134 | }) 135 | end 136 | end 137 | 138 | -- separator below last hunk for clarity 139 | if sepLength then 140 | vim.api.nvim_buf_set_extmark(bufnr, ns, #diffLines, 0, { 141 | virt_lines = { 142 | { { sepChar:rep(sepLength), sepHlGroup } }, 143 | }, 144 | virt_lines_above = true, 145 | }) 146 | end 147 | end 148 | 149 | -------------------------------------------------------------------------------- 150 | return M 151 | -------------------------------------------------------------------------------- /lua/tinygit/commands/push-pull.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local u = require("tinygit.shared.utils") 4 | local createGitHubPr = require("tinygit.commands.github").createGitHubPr 5 | local updateStatusline = require("tinygit.statusline").updateAllComponents 6 | -------------------------------------------------------------------------------- 7 | 8 | ---@param commitRange string|nil 9 | local function openReferencedIssues(commitRange) 10 | if not commitRange then return end -- e.g. for "Everything is up-to-date" 11 | local repo = require("tinygit.commands.github").getGithubRemote("silent") 12 | if not repo then return end 13 | 14 | local pushedCommits = u.syncShellCmd { "git", "log", commitRange, "--format=%s" } 15 | for issue in pushedCommits:gmatch("#(%d+)") do 16 | local url = ("https://github.com/%s/issues/%s"):format(repo, issue) 17 | 18 | -- deferred, so github registers the change before tab is opened, and so 19 | -- the user can register notifications before switching to browser 20 | vim.defer_fn(function() vim.ui.open(url) end, 400) 21 | end 22 | end 23 | 24 | ---@param opts { pullBefore?: boolean|nil, forceWithLease?: boolean, createGitHubPr?: boolean } 25 | local function pushCmd(opts) 26 | local config = require("tinygit.config").config.push 27 | local gitCommand = { "git", "push" } 28 | local title = opts.forceWithLease and "Force push" or "Push" 29 | if opts.forceWithLease then table.insert(gitCommand, "--force-with-lease") end 30 | 31 | vim.system( 32 | gitCommand, 33 | { detach = true }, 34 | vim.schedule_wrap(function(result) 35 | local out = vim.trim((result.stdout or "") .. (result.stderr or "")) 36 | out = out:gsub("\n%s+", "\n") -- remove padding 37 | local commitRange = out:match("%x+%.%.%x+") ---@type string|nil 38 | local ft = opts.forceWithLease and "text" or "markdown" -- force-push has `+` which gets md-highlight 39 | 40 | -- notify 41 | if result.code == 0 then 42 | local numOfPushedCommits = u.syncShellCmd { "git", "rev-list", "--count", commitRange } 43 | if numOfPushedCommits ~= "" then 44 | local plural = numOfPushedCommits ~= "1" and "s" or "" 45 | -- `[]` -> simple highlighting for `snacks.nvim` 46 | out = out .. ("\n[%d commit%s]"):format(numOfPushedCommits, plural) 47 | end 48 | end 49 | u.notify(out, result.code == 0 and "info" or "error", { title = title, ft = ft }) 50 | 51 | -- sound 52 | if config.confirmationSound and jit.os == "OSX" then 53 | local sound = result.code == 0 54 | and "/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/siri/jbl_confirm.caf" -- codespell-ignore 55 | or "/System/Library/Sounds/Basso.aiff" 56 | vim.system { "afplay", sound } -- run async 57 | end 58 | 59 | -- post-push actions 60 | if config.openReferencedIssues and not opts.forceWithLease then 61 | openReferencedIssues(commitRange) 62 | end 63 | updateStatusline() 64 | if opts.createGitHubPr then 65 | vim.defer_fn(createGitHubPr, 1000) -- deferred so GitHub has registered the PR 66 | end 67 | end) 68 | ) 69 | end 70 | -------------------------------------------------------------------------------- 71 | 72 | ---@param opts? { pullBefore?: boolean, forceWithLease?: boolean, createGitHubPr?: boolean } 73 | ---@param calledByCommitFunc? boolean 74 | function M.push(opts, calledByCommitFunc) 75 | local config = require("tinygit.config").config.push 76 | if not opts then opts = {} end 77 | local title = opts.forceWithLease and "Force push" or "Push" 78 | 79 | -- GUARD 80 | if u.notInGitRepo() then return end 81 | if config.preventPushingFixupOrSquashCommits then 82 | local fixupOrSquashCommits = 83 | u.syncShellCmd { "git", "log", "--oneline", "--grep=^fixup!", "--grep=^squash!" } 84 | if fixupOrSquashCommits ~= "" then 85 | local msg = "Aborting: There are fixup or squash commits.\n\n" .. fixupOrSquashCommits 86 | u.notify(msg, "warn", { title = title }) 87 | return 88 | end 89 | end 90 | 91 | -- extra notification when called by user 92 | if not calledByCommitFunc then 93 | if opts.pullBefore then title = "Pull & " .. title:lower() end 94 | u.notify(title .. "…", "info") 95 | end 96 | 97 | -- Only Push 98 | if not opts.pullBefore then 99 | pushCmd(opts) 100 | return 101 | end 102 | 103 | -- Handle missing tracking branch, see #21 104 | local hasNoTrackingBranch = u.syncShellCmd({ "git", "status", "--short", "--branch" }) 105 | :find("## (.-)%.%.%.") == nil 106 | if hasNoTrackingBranch then 107 | local noAutoSetupRemote = u.syncShellCmd { "git", "config", "--get", "push.autoSetupRemote" } 108 | == "false" 109 | if noAutoSetupRemote then 110 | u.notify("There is no tracking branch. Aborting push.", "warn", { title = title }) 111 | return 112 | end 113 | if opts.pullBefore then 114 | local msg = "Not pulling since not tracking any branch. Skipping to push." 115 | u.notify(msg, "info", { title = title }) 116 | pushCmd(opts) 117 | return 118 | end 119 | end 120 | 121 | -- Pull & Push 122 | vim.system( 123 | { "git", "pull" }, 124 | { detach = true }, 125 | vim.schedule_wrap(function(result) 126 | -- Git messaging is weird and sometimes puts normal messages into 127 | -- stderr, thus we need to merge stdout and stderr. 128 | local out = (result.stdout or "") .. (result.stderr or "") 129 | 130 | local silenceMsg = out:find("Current branch .* is up to date") 131 | or out:find("Already up to date") 132 | or out:find("Successfully rebased and updated") 133 | if not silenceMsg then 134 | local severity = result.code == 0 and "info" or "error" 135 | u.notify(out, severity, { title = "Pull" }) 136 | end 137 | 138 | -- update buffer in case the pull changed it 139 | vim.cmd.checktime() 140 | 141 | -- only push if pull was successful 142 | if result.code == 0 then pushCmd(opts) end 143 | end) 144 | ) 145 | end 146 | 147 | -------------------------------------------------------------------------------- 148 | return M 149 | -------------------------------------------------------------------------------- /lua/tinygit/config.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | -------------------------------------------------------------------------------- 3 | 4 | local fallbackBorder = "rounded" 5 | 6 | ---@return string 7 | local function getBorder() 8 | local hasWinborder, winborder = pcall(function() return vim.o.winborder end) 9 | if not hasWinborder or winborder == "" or winborder == "none" then return fallbackBorder end 10 | return winborder 11 | end 12 | 13 | -------------------------------------------------------------------------------- 14 | 15 | ---@class Tinygit.Config 16 | local defaultConfig = { 17 | stage = { -- requires `telescope.nvim` 18 | contextSize = 1, -- larger values "merge" hunks. 0 is not supported. 19 | stagedIndicator = "󰐖", 20 | keymaps = { -- insert & normal mode 21 | stagingToggle = "", -- stage/unstage hunk 22 | gotoHunk = "", 23 | resetHunk = "", 24 | }, 25 | moveToNextHunkOnStagingToggle = false, 26 | 27 | -- accepts the common telescope picker config 28 | telescopeOpts = { 29 | layout_strategy = "horizontal", 30 | layout_config = { 31 | horizontal = { 32 | preview_width = 0.65, 33 | height = { 0.7, min = 20 }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | commit = { 39 | keepAbortedMsgSecs = 300, 40 | border = getBorder(), -- `vim.o.winborder` on nvim 0.11, otherwise "rounded" 41 | spellcheck = false, -- vim's builtin spellcheck 42 | wrap = "hard", ---@type "hard"|"soft"|"none" 43 | keymaps = { 44 | normal = { abort = "q", confirm = "" }, 45 | insert = { confirm = "" }, 46 | }, 47 | keymapHints = true, 48 | preview = { 49 | loglines = 3, 50 | }, 51 | subject = { 52 | -- automatically apply formatting to the subject line 53 | autoFormat = function(subject) ---@type nil|fun(subject: string): string 54 | subject = subject:gsub("%.$", "") -- remove trailing dot 55 | return subject 56 | end, 57 | 58 | -- disallow commits that do not use an allowed type 59 | enforceType = false, 60 | -- stylua: ignore 61 | types = { 62 | "fix", "feat", "chore", "docs", "refactor", "build", "test", 63 | "perf", "style", "revert", "ci", "break", 64 | }, 65 | }, 66 | body = { 67 | enforce = false, 68 | }, 69 | }, 70 | push = { 71 | preventPushingFixupCommits = true, 72 | confirmationSound = true, -- currently macOS only, PRs welcome 73 | 74 | -- If pushed commits contain references to issues, open them in the browser 75 | -- (not used when force-pushing). 76 | openReferencedIssues = false, 77 | }, 78 | github = { 79 | icons = { 80 | openIssue = "🟢", 81 | closedIssue = "🟣", 82 | notPlannedIssue = "⚪", 83 | openPR = "🟩", 84 | mergedPR = "🟪", 85 | draftPR = "⬜", 86 | closedPR = "🟥", 87 | }, 88 | }, 89 | history = { 90 | diffPopup = { 91 | width = 0.8, -- between 0-1 92 | height = 0.8, 93 | border = getBorder(), -- `vim.o.winborder` on nvim 0.11, otherwise "rounded" 94 | }, 95 | autoUnshallowIfNeeded = false, 96 | }, 97 | appearance = { 98 | mainIcon = "󰊢", 99 | backdrop = { 100 | enabled = true, 101 | blend = 40, -- 0-100 102 | }, 103 | hlGroups = { 104 | addedText = "Added", -- i.e. use hlgroup `Added` 105 | removedText = "Removed", 106 | }, 107 | }, 108 | statusline = { 109 | blame = { 110 | ignoreAuthors = {}, -- hide component if from these authors (useful for bots) 111 | hideAuthorNames = {}, -- show component, but hide names (useful for your own name) 112 | showOnlyTimeIfAuthor = {}, -- show only time if these authors (useful for automated commits) 113 | maxMsgLen = 40, 114 | icon = "ﰖ", 115 | }, 116 | branchState = { 117 | icons = { 118 | ahead = "󰶣", 119 | behind = "󰶡", 120 | diverge = "󰃻", 121 | }, 122 | }, 123 | fileState = { 124 | icon = "", 125 | }, 126 | }, 127 | } 128 | 129 | -------------------------------------------------------------------------------- 130 | 131 | M.config = defaultConfig -- in case user does not call `setup` 132 | 133 | ---@param userConfig? Tinygit.Config 134 | function M.setup(userConfig) 135 | M.config = vim.tbl_deep_extend("force", defaultConfig, userConfig or {}) 136 | local function warn(msg) require("tinygit.shared.utils").notify(msg, "warn", { ft = "markdown" }) end 137 | 138 | -- VALIDATE border `none` does not work with and title/footer used by this plugin 139 | if M.config.history.diffPopup.border == "none" or M.config.history.diffPopup.border == "" then 140 | M.config.history.diffPopup.border = fallbackBorder 141 | warn(('Border type "none" is not supported, falling back to %q.'):format(fallbackBorder)) 142 | end 143 | if M.config.commit.border == "none" or M.config.commit.border == "" then 144 | M.config.commit.border = fallbackBorder 145 | warn(('Border type "none" is not supported, falling back to %q.'):format(fallbackBorder)) 146 | end 147 | 148 | -- VALIDATE `context` > 0 (0 is not supported without `--unidiff-zero`) 149 | -- DOCS https://git-scm.com/docs/git-apply#Documentation/git-apply.txt---unidiff-zero 150 | -- However, it is discouraged in the git manual, and `git apply` tends to 151 | -- fail quite often, probably as line count changes are not accounted for 152 | -- when splitting up changes into hunks in `getHunksFromDiffOutput`. 153 | -- Using context=1 works, but has the downside of not being 1:1 the same 154 | -- hunks as with `gitsigns.nvim`. Since many small hunks are actually abit 155 | -- cumbersome, and since it's discouraged by git anyway, we simply disallow 156 | -- context=0 for now. 157 | if M.config.stage.contextSize < 1 then M.config.stage.contextSize = 1 end 158 | 159 | -- `preview_width` is only supported by `horizontal` & `cursor` strategies, 160 | -- see https://github.com/chrisgrieser/nvim-scissors/issues/28 161 | local strategy = M.config.stage.telescopeOpts.layout_strategy 162 | if strategy ~= "horizontal" and strategy ~= "cursor" then 163 | M.config.stage.telescopeOpts.layout_config.preview_width = nil 164 | end 165 | end 166 | 167 | -------------------------------------------------------------------------------- 168 | return M 169 | -------------------------------------------------------------------------------- /lua/tinygit/commands/commit/preview.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local u = require("tinygit.shared.utils") 4 | 5 | local state = { 6 | bufnr = -1, 7 | winid = -1, 8 | diffHeight = -1, 9 | } 10 | -------------------------------------------------------------------------------- 11 | 12 | ---@param mode "stage-all-and-commit"|"commit" 13 | ---@param width number 14 | ---@return string[] cleanedOutput 15 | ---@return string summary 16 | ---@return number stagedLinesCount 17 | ---@nodiscard 18 | function M.getDiffStats(mode, width) 19 | ---@type fun(args: string[]): string[], string 20 | local function runGitStatsAndCleanUp(args) 21 | local output = vim.split(u.syncShellCmd(args), "\n") 22 | 23 | local summary = table 24 | .remove(output) 25 | :gsub("^%s*", "") -- remove indentation 26 | :gsub(" changed", "") 27 | :gsub(" insertions?", "") 28 | :gsub(" deletions?", "") 29 | :gsub("[()]", "") 30 | :gsub(",", " ") 31 | :gsub("files?", "%0,") 32 | :gsub(" ", " ") 33 | 34 | local cleanedOutput = vim.tbl_map(function(line) 35 | local cleanLine = line 36 | :gsub(" | ", " │ ") -- full vertical bars instead of pipes 37 | :gsub(" Bin ", "  ") -- icon for binaries 38 | :gsub("^%s*", "") -- remove indentation 39 | return cleanLine 40 | end, output) 41 | 42 | return cleanedOutput, summary 43 | end 44 | 45 | local gitStatsArgs = { "git", "diff", "--compact-summary", "--stat=" .. width } 46 | 47 | if mode == "stage-all-and-commit" then 48 | u.intentToAddUntrackedFiles() -- include new files in diff stats 49 | local staged, summary = runGitStatsAndCleanUp(gitStatsArgs) 50 | return staged, summary, #staged 51 | end 52 | 53 | local notStaged, _ = runGitStatsAndCleanUp(gitStatsArgs) 54 | local staged, summary = runGitStatsAndCleanUp(vim.list_extend(gitStatsArgs, { "--staged" })) 55 | local stagedLinesCount = #staged -- save, since `list_extend` mutates 56 | return vim.list_extend(staged, notStaged), summary, stagedLinesCount 57 | end 58 | 59 | ---@return string[] logLines 60 | function M.getGitLog() 61 | local loglines = require("tinygit.config").config.commit.preview.loglines 62 | local args = { "git", "log", "--max-count=" .. loglines, "--format=%s %cr" } -- subject, date 63 | local lines = vim.split(u.syncShellCmd(args), "\n") 64 | 65 | return vim.tbl_map( 66 | function(line) return line:gsub("%d+ %a+ ago$", u.shortenRelativeDate) end, 67 | lines 68 | ) 69 | end 70 | 71 | ---@param bufnr number 72 | ---@param stagedLines number 73 | ---@param diffstatLines number 74 | local function highlightPreviewWin(bufnr, stagedLines, diffstatLines) 75 | -- highlight diffstat for STAGED lines 76 | local hlGroups = require("tinygit.config").config.appearance.hlGroups 77 | local highlightPatterns = { 78 | { hlGroups.addedText, [[ \zs+\+]] }, -- added lines 79 | { hlGroups.removedText, "[ +]\\zs-\\+" }, -- removed lines 80 | { "Keyword", [[(new.*)]] }, 81 | { "Keyword", [[(gone.*)]] }, 82 | { "Function", [[.*\ze/]] }, -- directory of a file 83 | { "WarningMsg", "/" }, -- path separator 84 | { "Comment", "│" }, -- vertical separator 85 | } 86 | local endToken = "\\%<" .. stagedLines + 1 .. "l" -- limit pattern to range, see :help \% 0 then 119 | table.insert(previewLines, ("─"):rep(textWidth)) -- separator 120 | local logLines = M.getGitLog() 121 | previewLines = vim.list_extend(previewLines, logLines) 122 | end 123 | 124 | -- CREATE WINDOW 125 | local bufnr = vim.api.nvim_create_buf(false, true) 126 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, previewLines) 127 | local winid = vim.api.nvim_open_win(bufnr, false, { 128 | relative = "win", 129 | win = inputWinid, 130 | row = inputWin.height + 1, 131 | col = -1, 132 | width = inputWin.width, 133 | height = 1, -- just to initialize, will be updated later 134 | border = inputWin.border, 135 | style = "minimal", 136 | focusable = false, 137 | footer = #diffStatLines > 1 and " " .. summary .. " " or "", 138 | footer_pos = "right", 139 | }) 140 | state.bufnr = bufnr 141 | state.winid = winid 142 | state.diffHeight = #previewLines 143 | vim.bo[bufnr].filetype = "tinygit.diffstats" 144 | vim.wo[winid].statuscolumn = " " -- = left-padding 145 | vim.wo[winid].winhighlight = "FloatFooter:Comment,FloatBorder:Comment,Normal:Normal" 146 | 147 | M.adaptWinPosition(inputWin) 148 | vim.api.nvim_win_call( 149 | winid, 150 | function() highlightPreviewWin(bufnr, stagedLinesCount, diffstatLineCount) end 151 | ) 152 | end 153 | 154 | ---@param inputWin vim.api.keyset.win_config 155 | function M.adaptWinPosition(inputWin) 156 | if not vim.api.nvim_win_is_valid(state.winid) then return end 157 | 158 | local winConf = vim.api.nvim_win_get_config(state.winid) 159 | 160 | local borders = 4 -- 2x this win & 2x input win 161 | local linesToBottomOfEditor = vim.o.lines - (inputWin.row + inputWin.height + borders) 162 | winConf.height = math.min(state.diffHeight, linesToBottomOfEditor) 163 | winConf.row = inputWin.height + 1 164 | 165 | vim.api.nvim_win_set_config(state.winid, winConf) 166 | end 167 | 168 | function M.unmount() 169 | if vim.api.nvim_buf_is_valid(state.bufnr) then vim.cmd.bwipeout(state.bufnr) end 170 | end 171 | 172 | -------------------------------------------------------------------------------- 173 | return M 174 | -------------------------------------------------------------------------------- /lua/tinygit/commands/github.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local u = require("tinygit.shared.utils") 3 | -------------------------------------------------------------------------------- 4 | 5 | ---@param silent? "silent" 6 | ---@return string? "user/name" of repo, without the trailing ".git" 7 | ---@nodiscard 8 | function M.getGithubRemote(silent) 9 | local remotes = vim.system({ "git", "remote", "--verbose" }):wait().stdout or "" 10 | local githubRemote = remotes:match("github%.com[/:](%S+)") 11 | if not githubRemote then 12 | if not silent then 13 | u.notify("Remote does not appear to be at GitHub: " .. githubRemote, "warn") 14 | end 15 | return 16 | end 17 | return githubRemote:gsub("%.git$", "") 18 | end 19 | 20 | -------------------------------------------------------------------------------- 21 | 22 | ---opens current buffer in the browser & copies the link to the clipboard 23 | ---normal mode: link to file 24 | ---visual mode: link to selected lines 25 | ---@param what? "file"|"repo"|"blame" 26 | function M.githubUrl(what) 27 | -- GUARD 28 | if u.notInGitRepo() then return end 29 | local repo = M.getGithubRemote() 30 | if not repo then return end -- not on github 31 | 32 | -- just open repo url 33 | if what == "repo" then 34 | local url = "https://github.com/" .. repo 35 | vim.ui.open(url) 36 | vim.fn.setreg("+", url) 37 | return 38 | end 39 | 40 | -- GUARD unpushed commits 41 | local alreadyPushed = u.syncShellCmd { "git", "branch", "--remote", "--contains", "HEAD" } ~= "" 42 | if not alreadyPushed then 43 | u.notify("Cannot open at GitHub, current commit has not been pushed yet.", "warn") 44 | return 45 | end 46 | 47 | -- PARAMETERS 48 | local filepath = vim.api.nvim_buf_get_name(0) 49 | local gitroot = u.syncShellCmd { "git", "rev-parse", "--show-toplevel" } 50 | local pathInRepo = filepath:sub(#gitroot + 2) 51 | local pathInRepoEncoded = pathInRepo:gsub("%s+", "%%20") 52 | local hash = u.syncShellCmd { "git", "rev-parse", "HEAD" } 53 | local url = "https://github.com/" .. repo 54 | local location = "" 55 | local mode = vim.fn.mode() 56 | 57 | if mode:find("[Vv]") then 58 | vim.cmd.normal { mode, bang = true } -- leave visual mode, so marks are set 59 | local startLn = vim.api.nvim_buf_get_mark(0, "<")[1] 60 | local endLn = vim.api.nvim_buf_get_mark(0, ">")[1] 61 | if startLn == endLn then -- one-line-selection 62 | location = "#L" .. startLn 63 | elseif startLn < endLn then 64 | location = "#L" .. startLn .. "-L" .. endLn 65 | else 66 | location = "#L" .. endLn .. "-L" .. startLn 67 | end 68 | end 69 | local type = what == "blame" and "blame" or "blob" 70 | url = url .. ("/%s/%s/%s%s"):format(type, hash, pathInRepoEncoded, location) 71 | 72 | vim.ui.open(url) 73 | vim.fn.setreg("+", url) -- copy to clipboard 74 | end 75 | 76 | -------------------------------------------------------------------------------- 77 | 78 | ---CAVEAT Due to GitHub API limitations, only the last 100 issues are shown. 79 | ---@param opts? { state?: string, type?: string } 80 | function M.issuesAndPrs(opts) 81 | -- GUARD 82 | if u.notInGitRepo() then return end 83 | local repo = M.getGithubRemote() 84 | if not repo then return end 85 | if vim.fn.executable("curl") == 0 then 86 | u.notify("`curl` cannot be found.", "warn") 87 | return 88 | end 89 | 90 | local defaultOpts = { state = "all", type = "all" } 91 | opts = vim.tbl_deep_extend("force", defaultOpts, opts or {}) 92 | 93 | -- DOCS https://docs.github.com/en/free-pro-team@latest/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues 94 | local baseUrl = ("https://api.github.com/repos/%s/issues"):format(repo) 95 | local rawJsonUrl = baseUrl .. ("?per_page=100&state=%s&sort=updated"):format(opts.state) 96 | local rawJSON = vim.system({ "curl", "-sL", rawJsonUrl }):wait().stdout or "" 97 | local issues = vim.json.decode(rawJSON) 98 | if not issues then 99 | u.notify("Failed to fetch issues.", "warn") 100 | return 101 | end 102 | if issues and opts.type ~= "all" then 103 | issues = vim.tbl_filter(function(issue) 104 | local isPR = issue.pull_request ~= nil 105 | local isRightKind = (isPR and opts.type == "pr") or (not isPR and opts.type == "issue") 106 | return isRightKind 107 | end, issues) 108 | end 109 | 110 | if #issues == 0 then 111 | local state = opts.state == "all" and "" or opts.state .. " " 112 | local type = opts.type == "all" and "issues or PRs " or opts.type .. "s " 113 | local msg = ("There are no %s%sfor this repo."):format(state, type) 114 | u.notify(msg, "warn") 115 | return 116 | end 117 | 118 | local type = opts.type == "all" and "Issue/PR" or opts.type 119 | local mainIcon = require("tinygit.config").config.appearance.mainIcon 120 | local prompt = vim.trim(("%s Select %s (%s)"):format(mainIcon, type, opts.state)) 121 | local onChoice = function(choice) vim.ui.open(choice.html_url) end 122 | local stylingFunc = function() 123 | local highlights = require("tinygit.shared.highlights") 124 | highlights.commitType() 125 | highlights.inlineCodeAndIssueNumbers() 126 | vim.fn.matchadd("DiagnosticError", [[\v[Bb]ug]]) 127 | vim.fn.matchadd("DiagnosticInfo", [[\v[Ff]eature [Rr]equest|FR]]) 128 | vim.fn.matchadd("Comment", [[\vby [A-Za-z0-9-]+\s*$]]) 129 | end 130 | local function issueListFormatter(issue) 131 | local icons = require("tinygit.config").config.github.icons 132 | local icon 133 | if issue.pull_request then 134 | if issue.draft then 135 | icon = icons.draftPR 136 | elseif issue.state == "open" then 137 | icon = icons.openPR 138 | elseif issue.pull_request.merged_at then 139 | icon = icons.mergedPR 140 | else 141 | icon = icons.closedPR 142 | end 143 | else 144 | if issue.state == "open" then 145 | icon = icons.openIssue 146 | elseif issue.state_reason == "completed" then 147 | icon = icons.closedIssue 148 | elseif issue.state_reason == "not_planned" then 149 | icon = icons.notPlannedIssue 150 | end 151 | end 152 | return ("%s #%s %s by %s"):format(icon, issue.number, issue.title, issue.user.login) 153 | end 154 | 155 | require("tinygit.shared.picker").pick(prompt, issues, issueListFormatter, stylingFunc, onChoice) 156 | end 157 | 158 | function M.openIssueUnderCursor() 159 | -- ensure `#` is part of cword 160 | local prevKeywordSetting = vim.opt_local.iskeyword:get() 161 | vim.opt_local.iskeyword:append("#") 162 | 163 | local cword = vim.fn.expand("") 164 | if not cword:match("^#%d+$") then 165 | local msg = "Word under cursor is not an issue id of the form `#123`" 166 | u.notify(msg, "warn", { ft = "markdown" }) 167 | return 168 | end 169 | 170 | local issue = cword:sub(2) -- remove the `#` 171 | local repo = M.getGithubRemote() 172 | if not repo then return end 173 | local url = ("https://github.com/%s/issues/%s"):format(repo, issue) 174 | vim.ui.open(url) 175 | 176 | vim.opt_local.iskeyword = prevKeywordSetting 177 | end 178 | 179 | function M.createGitHubPr() 180 | local branchName = u.syncShellCmd { "git", "branch", "--show-current" } 181 | local repo = M.getGithubRemote() 182 | if not repo then return end 183 | local prUrl = ("https://github.com/%s/pull/new/%s"):format(repo, branchName) 184 | vim.ui.open(prUrl) 185 | end 186 | 187 | -------------------------------------------------------------------------------- 188 | return M 189 | -------------------------------------------------------------------------------- /lua/tinygit/commands/stage/telescope.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local pickers = require("telescope.pickers") 4 | local telescopeConf = require("telescope.config").values 5 | local actionState = require("telescope.actions.state") 6 | local actions = require("telescope.actions") 7 | local finders = require("telescope.finders") 8 | local previewers = require("telescope.previewers") 9 | 10 | local stage = require("tinygit.commands.stage") 11 | local setDiffBuffer = require("tinygit.shared.diff").setDiffBuffer 12 | -------------------------------------------------------------------------------- 13 | 14 | ---@param hunks Tinygit.Hunk[] 15 | ---@return unknown -- `TelescopeFinder` typing not available 16 | local function newFinder(hunks) 17 | local conf = require("tinygit.config").config.stage 18 | 19 | return finders.new_table { 20 | results = hunks, 21 | entry_maker = function(hunk) 22 | local entry = { value = hunk } 23 | 24 | -- search for filenames, but also changed line contents 25 | local changeLines = vim.iter(vim.split(hunk.patch, "\n")) 26 | :filter(function(line) return line:match("^[+-]") end) 27 | :join("\n") 28 | entry.ordinal = hunk.relPath .. "\n" .. changeLines 29 | 30 | -- format: icon (for stage status), filename, lnum, added, removed 31 | entry.display = function(_entry) 32 | ---@type Tinygit.Hunk 33 | local h = _entry.value 34 | local changeWithoutHunk = h.lnum == -1 35 | 36 | local name = vim.fs.basename(h.relPath) 37 | local added = h.added > 0 and (" +" .. h.added) or "" 38 | local del = h.removed > 0 and (" -" .. h.removed) or "" 39 | local location = "" 40 | if h.fileMode == "new" then 41 | added = added .. " (new file)" 42 | elseif h.fileMode == "deleted" then 43 | del = del .. " (deleted file)" 44 | elseif changeWithoutHunk then 45 | location = h.fileMode == "binary" and " (binary)" or " (renamed)" 46 | else 47 | location = ":" .. h.lnum 48 | if h.fileMode == "renamed" then location = location .. " (renamed)" end 49 | end 50 | 51 | local iconWidth = vim.api.nvim_strwidth(conf.stagedIndicator) 52 | local status = h.alreadyStaged and conf.stagedIndicator or (" "):rep(iconWidth) 53 | status = status .. " " -- padding 54 | local iconHlWidth = #status -- needed for double-width chars, see #27 55 | 56 | local out = status .. name .. location .. added .. del 57 | local statPos = #status + #name + #location 58 | local hlGroups = require("tinygit.config").config.appearance.hlGroups 59 | local highlights = { 60 | { { 0, iconHlWidth }, "Keyword" }, -- icon for stage status 61 | { { #status + #name, statPos }, "Comment" }, -- lnum 62 | { { statPos, statPos + #added }, hlGroups.addedText }, -- added 63 | { { statPos + #added + 1, statPos + #added + #del }, hlGroups.removedText }, -- removed 64 | } 65 | 66 | return out, highlights 67 | end 68 | 69 | return entry 70 | end, 71 | } 72 | end 73 | 74 | ---@param hunks Tinygit.Hunk[] 75 | ---@param prompt_bufnr number 76 | local function refreshPicker(hunks, prompt_bufnr) 77 | -- temporarily register a callback which keeps selection on refresh 78 | -- SOURCE https://github.com/nvim-telescope/telescope.nvim/blob/bfcc7d5c6f12209139f175e6123a7b7de6d9c18a/lua/telescope/builtin/__git.lua#L412-L421 79 | local picker = actionState.get_current_picker(prompt_bufnr) 80 | local selection = picker:get_selection_row() 81 | local callbacks = { unpack(picker._completion_callbacks) } -- shallow copy 82 | picker:register_completion_callback(function(self) 83 | self:set_selection(selection) 84 | self._completion_callbacks = callbacks 85 | end) 86 | 87 | picker:refresh(newFinder(hunks), { reset_prompt = false }) 88 | end 89 | 90 | -------------------------------------------------------------------- 91 | 92 | -- DOCS https://github.com/nvim-telescope/telescope.nvim/blob/master/developers.md 93 | ---@param hunks Tinygit.Hunk[] 94 | function M.pickHunk(hunks) 95 | local icon = require("tinygit.config").config.appearance.mainIcon 96 | local conf = require("tinygit.config").config.stage 97 | 98 | pickers 99 | .new(conf.telescopeOpts, { 100 | prompt_title = vim.trim(icon .. " Git hunks"), 101 | sorter = telescopeConf.generic_sorter(conf.telescopeOpts), 102 | 103 | finder = newFinder(hunks), 104 | 105 | -- DOCS `:help telescope.previewers` 106 | previewer = previewers.new_buffer_previewer { 107 | ---@type fun(self: table, entry: { value: Tinygit.Hunk }) 108 | define_preview = function(self, entry) 109 | local bufnr = self.state.bufnr 110 | local hunk = entry.value 111 | local diffLines = vim.split(hunk.patch, "\n") 112 | local ft = stage.getFiletype(hunk.absPath) 113 | setDiffBuffer(bufnr, diffLines, ft, false) 114 | vim.wo[self.state.winid].conceallevel = 0 -- do not hide chars in markdown/json 115 | end, 116 | ---@param entry { value: Tinygit.Hunk } 117 | ---@return string relPath 118 | dyn_title = function(_, entry) 119 | local hunk = entry.value 120 | if hunk.added + hunk.removed == 0 then return hunk.relPath end -- renamed w/o changes 121 | local stats = ("(+%d -%d)"):format(hunk.added, hunk.removed) 122 | if hunk.added == 0 then stats = ("(-%d)"):format(hunk.removed) end 123 | if hunk.removed == 0 then stats = ("(+%d)"):format(hunk.added) end 124 | return hunk.relPath .. " " .. stats 125 | end, 126 | }, 127 | 128 | attach_mappings = function(prompt_bufnr, map) 129 | map({ "n", "i" }, conf.keymaps.gotoHunk, function() 130 | local hunk = actionState.get_selected_entry().value 131 | actions.close(prompt_bufnr) 132 | -- hunk lnum starts at beginning of context, not change 133 | local hunkStart = hunk.lnum + conf.contextSize 134 | vim.cmd(("edit +%d %s"):format(hunkStart, hunk.absPath)) 135 | end, { desc = "Goto hunk" }) 136 | 137 | map({ "n", "i" }, conf.keymaps.stagingToggle, function() 138 | local entry = actionState.get_selected_entry() 139 | local hunk = entry.value 140 | local success = stage.applyPatch(hunk, "toggle") 141 | if success then 142 | -- Change value for selected hunk in cached hunk-list 143 | hunks[entry.index].alreadyStaged = not hunks[entry.index].alreadyStaged 144 | if conf.moveToNextHunkOnStagingToggle then 145 | actions.move_selection_next(prompt_bufnr) 146 | end 147 | refreshPicker(hunks, prompt_bufnr) 148 | end 149 | end, { desc = "Toggle staged" }) 150 | 151 | map({ "n", "i" }, conf.keymaps.resetHunk, function() 152 | local entry = actionState.get_selected_entry() 153 | local hunk = entry.value 154 | 155 | -- a staged hunk cannot be reset, so we unstage it first 156 | if hunk.alreadyStaged then 157 | local success1 = stage.applyPatch(hunk, "toggle") 158 | if not success1 then return end 159 | hunk.alreadyStaged = false 160 | end 161 | 162 | local success2 = stage.applyPatch(hunk, "reset") 163 | if not success2 then return end 164 | table.remove(hunks, entry.index) -- remove from list as not a hunk anymore 165 | refreshPicker(hunks, prompt_bufnr) 166 | end, { desc = "Reset hunk" }) 167 | 168 | return true -- keep default mappings 169 | end, 170 | }) 171 | :find() 172 | end 173 | 174 | -------------------------------------------------------------------------------- 175 | return M 176 | -------------------------------------------------------------------------------- /lua/tinygit/commands/stage.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local u = require("tinygit.shared.utils") 3 | -------------------------------------------------------------------------------- 4 | 5 | ---@class (exact) Tinygit.Hunk 6 | ---@field absPath string 7 | ---@field relPath string 8 | ---@field lnum number 9 | ---@field added number 10 | ---@field removed number 11 | ---@field patch string 12 | ---@field alreadyStaged boolean 13 | ---@field fileMode Tinygit.FileMode 14 | 15 | ---@param msg string 16 | ---@param level? Tinygit.NotifyLevel 17 | ---@param opts? table 18 | local function notify(msg, level, opts) 19 | if not opts then opts = {} end 20 | opts.title = "Staging" 21 | u.notify(msg, level, opts) 22 | end 23 | 24 | -------------------------------------------------------------------------------- 25 | 26 | ---@param absPath string 27 | ---@return string|nil ft 28 | function M.getFiletype(absPath) 29 | local ft = vim.filetype.match { filename = vim.fs.basename(absPath) } 30 | if ft then return ft end 31 | 32 | -- In some cases, the filename alone is not unambiguous (like `.ts` files for 33 | -- typescript), so we need the actual buffer to determine the filetype. 34 | local bufnr = vim.iter(vim.api.nvim_list_bufs()) 35 | :find(function(buf) return vim.api.nvim_buf_get_name(buf) == absPath end) 36 | 37 | -- If there are changes in files that are not loaded as buffer, we have to 38 | -- add file to a buffer to be able to determine the filetype from it. 39 | if not bufnr then bufnr = vim.fn.bufadd(absPath) end 40 | 41 | return vim.filetype.match { buf = bufnr } 42 | end 43 | 44 | ---@param diffCmdStdout string 45 | ---@param diffIsOfStaged boolean 46 | ---@return Tinygit.Hunk[] hunks 47 | local function getHunksFromDiffOutput(diffCmdStdout, diffIsOfStaged) 48 | local splitOffDiffHeader = require("tinygit.shared.diff").splitOffDiffHeader 49 | 50 | if diffCmdStdout == "" then return {} end -- no hunks 51 | local gitroot = u.syncShellCmd { "git", "rev-parse", "--show-toplevel" } 52 | local changesPerFile = vim.split(diffCmdStdout, "\ndiff --git a/", { plain = true }) 53 | 54 | -- Loop through each file, and then through each hunk of that file. Construct 55 | -- flattened list of hunks, each with their own diff header, so they work as 56 | -- independent patches. Those patches in turn are needed for `git apply` 57 | -- stage only part of a file. 58 | ---@type Tinygit.Hunk[] 59 | local hunks = {} 60 | for _, file in ipairs(changesPerFile) do 61 | if not vim.startswith(file, "diff --git a/") then -- first file still has this 62 | file = "diff --git a/" .. file -- needed to make patches valid 63 | end 64 | -- split off diff header 65 | local diffLines = vim.split(file, "\n") 66 | local changesInFile, diffHeaderLines, fileMode, _ = splitOffDiffHeader(diffLines) 67 | local diffHeader = table.concat(diffHeaderLines, "\n") 68 | local relPath = diffHeaderLines[1]:match("b/(.+)") 69 | assert(relPath, "Failed to parse diff header:\n" .. table.concat(diffHeaderLines, "\n")) 70 | local absPath = gitroot .. "/" .. relPath 71 | 72 | -- split remaining output into hunks 73 | local hunksInFile = {} 74 | for _, line in ipairs(changesInFile) do 75 | if vim.startswith(line, "@@") then 76 | table.insert(hunksInFile, line) 77 | else 78 | hunksInFile[#hunksInFile] = hunksInFile[#hunksInFile] .. "\n" .. line 79 | end 80 | end 81 | 82 | -- special case: file renamed without any other changes 83 | -- (needs to be handled separately because it has no hunks, that is no `@@` lines) 84 | if #changesInFile == 0 and (fileMode == "renamed" or fileMode == "binary") then 85 | ---@type Tinygit.Hunk 86 | local hunkObj = { 87 | absPath = absPath, 88 | relPath = relPath, 89 | lnum = -1, 90 | added = 0, 91 | removed = 0, 92 | patch = diffHeader .. "\n", 93 | alreadyStaged = diffIsOfStaged, 94 | fileMode = fileMode, 95 | } 96 | table.insert(hunks, hunkObj) 97 | end 98 | 99 | -- loop hunks 100 | for _, hunk in ipairs(hunksInFile) do 101 | -- meaning of @@-line: https://www.gnu.org/software/diffutils/manual/html_node/Detailed-Unified.html 102 | local lnum = tonumber(hunk:match("^@@ .- %+(%d+)")) 103 | assert(lnum, "lnum not found.") 104 | 105 | -- not from `@@` line, since number includes lines between two changes and context lines 106 | local _, added = hunk:gsub("\n%+", "") 107 | local _, removed = hunk:gsub("\n%-", "") 108 | 109 | -- needs trailing newline for valid patch 110 | local patch = diffHeader .. "\n" .. hunk .. "\n" 111 | 112 | ---@type Tinygit.Hunk 113 | local hunkObj = { 114 | absPath = absPath, 115 | relPath = relPath, 116 | lnum = lnum, 117 | added = added, 118 | removed = removed, 119 | patch = patch, 120 | alreadyStaged = diffIsOfStaged, 121 | fileMode = fileMode, 122 | } 123 | table.insert(hunks, hunkObj) 124 | end 125 | end 126 | return hunks 127 | end 128 | 129 | -- `git apply` to stage only part of a file https://stackoverflow.com/a/66618356/22114136 130 | ---@param hunk Tinygit.Hunk 131 | ---@param mode "toggle" | "reset" 132 | ---@return boolean success 133 | function M.applyPatch(hunk, mode) 134 | local args = { 135 | "git", 136 | "apply", 137 | "--verbose", -- so the error messages are more informative 138 | "-", -- read patch from stdin 139 | } 140 | if mode == "toggle" then 141 | table.insert(args, "--cached") -- = only affect staging area, not working tree 142 | if hunk.alreadyStaged then table.insert(args, "--reverse") end 143 | elseif mode == "reset" then 144 | assert(hunk.alreadyStaged == false, "A staged hunk cannot be reset, unstage it first.") 145 | table.insert(args, "--reverse") -- undoing patch 146 | end 147 | local applyResult = vim.system(args, { stdin = hunk.patch }):wait() 148 | 149 | local success = applyResult.code == 0 150 | if success and mode == "reset" then 151 | vim.cmd.checktime() -- refresh buffer 152 | local filename = vim.fs.basename(hunk.absPath) 153 | notify(('Hunk "%s:%s" reset.'):format(filename, hunk.lnum)) 154 | end 155 | if not success then notify(applyResult.stderr, "error") end 156 | return success 157 | end 158 | 159 | -------------------------------------------------------------------------------- 160 | 161 | function M.interactiveStaging() 162 | -- GUARD 163 | if u.notInGitRepo() then return end 164 | vim.cmd("silent! update") 165 | local noChanges = u.syncShellCmd { "git", "status", "--porcelain" } == "" 166 | if noChanges then 167 | notify("There are no staged or unstaged changes.", "warn") 168 | return 169 | end 170 | local installed, _ = pcall(require, "telescope") 171 | if not installed then 172 | u.notify("telescope.nvim is not installed.", "warn") 173 | return 174 | end 175 | 176 | -- GET ALL HUNKS 177 | u.intentToAddUntrackedFiles() -- include untracked files, enables using `--diff-filter=A` 178 | local contextSize = require("tinygit.config").config.stage.contextSize 179 | -- stylua: ignore start 180 | local diffArgs = { 181 | "git", 182 | "-c", "diff.mnemonicPrefix=false", -- `mnemonicPrefix` creates irregular diff header (#34) 183 | "-c", "diff.srcPrefix=a/", -- in case use overwrites any of these 184 | "-c", "diff.dstPrefix=b/", 185 | "-c", "diff.noPrefix=false", 186 | "--no-pager", 187 | "diff", 188 | "--no-ext-diff", "--unified=" .. contextSize, "--diff-filter=ADMR", 189 | } 190 | -- stylua: ignore end 191 | -- no trimming, since trailing empty lines can be blank context lines in diff output 192 | local changesDiff = u.syncShellCmd(diffArgs, "notrim") 193 | local changedHunks = getHunksFromDiffOutput(changesDiff, false) 194 | 195 | table.insert(diffArgs, "--staged") 196 | local stagedDiff = u.syncShellCmd(diffArgs, "notrim") 197 | local stagedHunks = getHunksFromDiffOutput(stagedDiff, true) 198 | 199 | local allHunks = vim.list_extend(changedHunks, stagedHunks) 200 | 201 | -- START TELESCOPE PICKER 202 | require("tinygit.commands.stage.telescope").pickHunk(allHunks) 203 | end 204 | -------------------------------------------------------------------------------- 205 | return M 206 | -------------------------------------------------------------------------------- /lua/tinygit/commands/commit.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local u = require("tinygit.shared.utils") 4 | -------------------------------------------------------------------------------- 5 | 6 | ---@nodiscard 7 | ---@return boolean 8 | local function hasNoStagedChanges() 9 | return vim.system({ "git", "diff", "--staged", "--quiet" }):wait().code == 0 10 | end 11 | 12 | ---@nodiscard 13 | ---@return boolean 14 | local function hasNoUnstagedChanges() 15 | return vim.system({ "git", "diff", "--quiet" }):wait().code == 0 16 | end 17 | 18 | ---@nodiscard 19 | ---@return boolean 20 | local function hasNoChanges() 21 | vim.cmd("silent update") 22 | local noChanges = u.syncShellCmd { "git", "status", "--porcelain" } == "" 23 | if noChanges then u.notify("There are no staged or unstaged changes.", "warn") end 24 | return noChanges 25 | end 26 | 27 | ---@param notifTitle string 28 | ---@param doStageAllChanges boolean 29 | ---@param commitTitle string 30 | ---@param extraLines string 31 | local function postCommitNotif(notifTitle, doStageAllChanges, commitTitle, extraLines) 32 | local stageAllText = "Staged all changes." 33 | 34 | -- if using `snacks.nvim` or `nvim-notify`, add extra highlighting to the notification 35 | if package.loaded["snacks"] or package.loaded["notify"] then 36 | vim.api.nvim_create_autocmd("FileType", { 37 | pattern = { "noice", "notify", "snacks_notif" }, 38 | once = true, 39 | callback = function(ctx) 40 | vim.defer_fn(function() 41 | vim.api.nvim_buf_call(ctx.buf, function() 42 | local highlights = require("tinygit.shared.highlights") 43 | highlights.commitType() 44 | highlights.inlineCodeAndIssueNumbers() 45 | vim.fn.matchadd("Comment", stageAllText) 46 | vim.fn.matchadd("Comment", extraLines) 47 | end) 48 | end, 1) 49 | end, 50 | }) 51 | end 52 | 53 | local lines = { commitTitle, extraLines } 54 | if doStageAllChanges then table.insert(lines, 1, stageAllText) end 55 | u.notify(table.concat(lines, "\n"), "info", { title = notifTitle }) 56 | end 57 | 58 | -------------------------------------------------------------------------------- 59 | 60 | ---@param opts? { pushIfClean?: boolean, pullBeforePush?: boolean } 61 | function M.smartCommit(opts) 62 | if u.notInGitRepo() or hasNoChanges() then return end 63 | 64 | local defaultOpts = { pushIfClean = false, pullBeforePush = true } 65 | opts = vim.tbl_deep_extend("force", defaultOpts, opts or {}) 66 | 67 | local doStageAllChanges = hasNoStagedChanges() 68 | local cleanAfterCommit = hasNoUnstagedChanges() or doStageAllChanges 69 | 70 | local prompt = "Commit" 71 | if doStageAllChanges then prompt = "Stage all · " .. prompt:lower() end 72 | if cleanAfterCommit and opts.pushIfClean then prompt = prompt .. " · push" end 73 | 74 | local inputMode = doStageAllChanges and "stage-all-and-commit" or "commit" 75 | 76 | -- check if pre-commit would pass before opening message input 77 | local preCommitResult = vim.system({ "git", "hook", "run", "--ignore-missing", "pre-commit" }) 78 | :wait() 79 | if u.nonZeroExit(preCommitResult) then return end 80 | 81 | require("tinygit.commands.commit.msg-input").new(inputMode, prompt, function(title, body) 82 | -- stage 83 | if doStageAllChanges then 84 | local result = vim.system({ "git", "add", "--all" }):wait() 85 | if u.nonZeroExit(result) then return end 86 | end 87 | 88 | -- commit 89 | -- (using `--no-verify`, since we checked the pre-commit earlier already) 90 | local commitArgs = { "git", "commit", "--no-verify", "--message=" .. title } 91 | if body then table.insert(commitArgs, "--message=" .. body) end 92 | local result = vim.system(commitArgs):wait() 93 | if u.nonZeroExit(result) then return end 94 | 95 | -- notification 96 | local extra = "" 97 | if opts.pushIfClean then 98 | extra = cleanAfterCommit and "Pushing…" or "Not pushing since repo still dirty." 99 | end 100 | postCommitNotif("Smart commit", doStageAllChanges, title, extra) 101 | 102 | -- push 103 | if opts.pushIfClean and cleanAfterCommit then 104 | require("tinygit.commands.push-pull").push({ pullBefore = opts.pullBeforePush }, true) 105 | end 106 | 107 | require("tinygit.statusline").updateAllComponents() 108 | end) 109 | end 110 | 111 | ---@param opts? { forcePushIfDiverged?: boolean, stageAllIfNothingStaged?: boolean } 112 | function M.amendNoEdit(opts) 113 | if u.notInGitRepo() or hasNoChanges() then return end 114 | 115 | local defaultOpts = { forcePushIfDiverged = false, stageAllIfNothingStaged = true } 116 | opts = vim.tbl_deep_extend("force", defaultOpts, opts or {}) 117 | 118 | -- stage 119 | local doStageAllChanges = false 120 | if hasNoStagedChanges() then 121 | if opts.stageAllIfNothingStaged then 122 | doStageAllChanges = true 123 | local result = vim.system({ "git", "add", "--all" }):wait() 124 | if u.nonZeroExit(result) then return end 125 | else 126 | u.notify("Nothing staged. Aborting.", "warn") 127 | return 128 | end 129 | end 130 | 131 | -- commit 132 | local result = vim.system({ "git", "commit", "--amend", "--no-edit" }):wait() 133 | if u.nonZeroExit(result) then return end 134 | 135 | -- push & notification 136 | local lastCommitMsg = u.syncShellCmd { "git", "log", "-1", "--format=%s" } 137 | local branchInfo = vim.system({ "git", "branch", "--verbose" }):wait().stdout or "" 138 | local prevCommitWasPushed = branchInfo:find("%[ahead 1, behind 1%]") ~= nil 139 | local extraInfo 140 | if opts.forcePushIfDiverged and prevCommitWasPushed then 141 | extraInfo = "Force pushing…" 142 | require("tinygit.commands.push-pull").push({ forceWithLease = true }, true) 143 | end 144 | postCommitNotif("Amend-no-edit", doStageAllChanges, lastCommitMsg, extraInfo) 145 | require("tinygit.statusline").updateAllComponents() 146 | end 147 | 148 | ---@param opts? { forcePushIfDiverged?: boolean } 149 | function M.amendOnlyMsg(opts) 150 | if u.notInGitRepo() then return end 151 | if not opts then opts = {} end 152 | 153 | local prompt = "Amend message" 154 | 155 | require("tinygit.commands.commit.msg-input").new("amend-msg", prompt, function(title, body) 156 | -- commit 157 | -- (skip precommit via `--no-verify`, since only editing message) 158 | local commitArgs = { "git", "commit", "--no-verify", "--amend", "--message=" .. title } 159 | if body then table.insert(commitArgs, "--message=" .. body) end 160 | local result = vim.system(commitArgs):wait() 161 | if u.nonZeroExit(result) then return end 162 | 163 | -- push & notification 164 | local prevCommitWasPushed = u.syncShellCmd({ "git", "branch", "--verbose" }) 165 | :find("%[ahead 1, behind 1%]") 166 | local extra = "" 167 | if opts.forcePushIfDiverged and prevCommitWasPushed then 168 | require("tinygit.commands.push-pull").push({ forceWithLease = true }, true) 169 | extra = "Force pushing…" 170 | end 171 | postCommitNotif(prompt, false, title, extra) 172 | 173 | require("tinygit.statusline").updateAllComponents() 174 | end) 175 | end 176 | 177 | ---@param opts? { selectFromLastXCommits?: number, squashInstead: boolean, autoRebase?: boolean } 178 | function M.fixupCommit(opts) 179 | if u.notInGitRepo() or hasNoChanges() then return end 180 | 181 | local defaultOpts = { selectFromLastXCommits = 15, autoRebase = false } 182 | opts = vim.tbl_deep_extend("force", defaultOpts, opts or {}) 183 | 184 | -- get commits 185 | local gitlogFormat = "%h\t%s\t%cr" -- hash, subject, date, `\t` as delimiter required 186 | local result = vim.system({ 187 | "git", 188 | "log", 189 | "--max-count=" .. opts.selectFromLastXCommits, 190 | "--format=" .. gitlogFormat, 191 | }):wait() 192 | if u.nonZeroExit(result) then return end 193 | local commits = vim.split(vim.trim(result.stdout), "\n") 194 | 195 | -- user selection of commit 196 | local icon = require("tinygit.config").config.appearance.mainIcon 197 | local prompt = vim.trim(icon .. " Select commit to fixup") 198 | local commitFormatter = function(commitLine) 199 | local _, subject, relDate, nameAtCommit = unpack(vim.split(commitLine, "\t")) 200 | local shortRelDate = u.shortenRelativeDate(relDate) 201 | local displayLine = ("%s\t%s"):format(subject, shortRelDate) 202 | -- append name at commit, if it exists 203 | if nameAtCommit then displayLine = displayLine .. ("\t(%s)"):format(nameAtCommit) end 204 | return displayLine 205 | end 206 | local stylingFunc = function() 207 | local highlights = require("tinygit.shared.highlights") 208 | highlights.commitType() 209 | highlights.inlineCodeAndIssueNumbers() 210 | vim.fn.matchadd("Comment", [[\t.*$]]) 211 | end 212 | local onChoice = function(commit) 213 | -- stage 214 | local doStageAllChanges = hasNoStagedChanges() 215 | if doStageAllChanges then 216 | local _result = vim.system({ "git", "add", "--all" }):wait() 217 | if u.nonZeroExit(_result) then return end 218 | end 219 | 220 | -- commit 221 | local hash = commit:match("^%w+") 222 | local commitResult = vim.system({ "git", "commit", "--fixup", hash }):wait() 223 | if u.nonZeroExit(commitResult) then return end 224 | u.notify(commitResult.stdout, "info", { title = "Fixup commit" }) 225 | 226 | -- rebase 227 | if opts.autoRebase then 228 | local _result = vim.system({ 229 | "git", 230 | "-c", 231 | "sequence.editor=:", -- HACK ":" is a "no-op-"editor https://www.reddit.com/r/git/comments/uzh2no/what_is_the_utility_of_noninteractive_rebase/ 232 | "rebase", 233 | "--interactive", 234 | "--committer-date-is-author-date", -- preserves dates 235 | "--autostash", 236 | "--autosquash", 237 | hash .. "^", -- rebase up until the selected commit 238 | }):wait() 239 | if u.nonZeroExit(_result) then return end 240 | 241 | vim.cmd.checktime() -- reload in case of conflicts 242 | u.notify("Auto-rebase applied.", "info", { title = "Fixup commit" }) 243 | end 244 | 245 | require("tinygit.statusline").updateAllComponents() 246 | end 247 | 248 | require("tinygit.shared.picker").pick(prompt, commits, commitFormatter, stylingFunc, onChoice) 249 | end 250 | 251 | -------------------------------------------------------------------------------- 252 | return M 253 | -------------------------------------------------------------------------------- /lua/tinygit/commands/commit/msg-input.lua: -------------------------------------------------------------------------------- 1 | local u = require("tinygit.shared.utils") 2 | 3 | local M = {} 4 | 5 | local state = { 6 | abortedCommitMsg = {}, ---@type table -- saves message per cwd 7 | winid = -1, 8 | bufnr = -1, 9 | } 10 | 11 | local MAX_TITLE_LEN = 72 12 | local INPUT_WIN_HEIGHT = { small = 3, big = 6 } 13 | 14 | ---@alias Tinygit.Input.ConfirmationCallback fun(commitTitle: string, commitBody?: string) 15 | 16 | -------------------------------------------------------------------------------- 17 | 18 | ---@param msg string 19 | local function warn(msg) u.notify(msg, "warn", { title = "Commit message" }) end 20 | 21 | ---@param confirmationCallback Tinygit.Input.ConfirmationCallback 22 | local function setupKeymaps(confirmationCallback) 23 | local bufnr = state.bufnr 24 | local conf = require("tinygit.config").config.commit 25 | local function map(mode, lhs, rhs) 26 | vim.keymap.set(mode, lhs, rhs, { buffer = bufnr, nowait = true }) 27 | end 28 | 29 | ----------------------------------------------------------------------------- 30 | 31 | map("n", conf.keymaps.normal.abort, function() 32 | -- save msg 33 | if state.mode ~= "amend" then 34 | local cwd = vim.uv.cwd() or "" 35 | state.abortedCommitMsg[cwd] = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) 36 | vim.defer_fn( 37 | function() state.abortedCommitMsg[cwd] = nil end, 38 | 1000 * conf.keepAbortedMsgSecs 39 | ) 40 | end 41 | 42 | vim.cmd.bwipeout(bufnr) 43 | end) 44 | 45 | ----------------------------------------------------------------------------- 46 | 47 | local function confirm() 48 | -- TITLE 49 | local commitSubject = vim.trim(vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1]) 50 | if conf.subject.autoFormat then commitSubject = conf.subject.autoFormat(commitSubject) end 51 | if #commitSubject > MAX_TITLE_LEN then 52 | warn("Subject is too long.") 53 | return 54 | end 55 | if #commitSubject == 0 then 56 | warn("Subject is empty.") 57 | return 58 | end 59 | if conf.subject.enforceType then 60 | local firstWord = commitSubject:match("^%w+") 61 | if not vim.tbl_contains(conf.subject.types, firstWord) then 62 | local msg = "Not using a type allowed by the config `commit.subject.types`. " 63 | .. "(Alternatively, you can also disable `commit.subject.enforceType`.)" 64 | warn(msg) 65 | return 66 | end 67 | end 68 | 69 | -- BODY 70 | local bodytext = vim 71 | .iter(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) 72 | :skip(1) -- skip title 73 | :join("\n") -- join for shell command 74 | ---@type string|nil 75 | local commitBody = vim.trim(bodytext) 76 | if commitBody == "" then 77 | if conf.body.enforce then 78 | warn("Body is empty.") 79 | return 80 | end 81 | commitBody = nil 82 | end 83 | 84 | -- reset remembered message 85 | local cwd = vim.uv.cwd() or "" 86 | state.abortedCommitMsg[cwd] = nil 87 | 88 | -- confirm and close 89 | confirmationCallback(commitSubject, commitBody) 90 | vim.cmd.bwipeout(bufnr) 91 | vim.cmd.stopinsert() -- when confirming in insert mode don't stay in insert mode 92 | end 93 | 94 | map("n", conf.keymaps.normal.confirm, confirm) 95 | map("i", conf.keymaps.insert.confirm, confirm) 96 | end 97 | 98 | ---@param borderChar string 99 | local function setupTitleCharCount(borderChar) 100 | local bufnr, winid = state.bufnr, state.winid 101 | 102 | local function updateTitleCharCount() 103 | local titleChars = vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1]:len() 104 | local countHighlight = titleChars <= MAX_TITLE_LEN and "FloatBorder" or "ErrorMsg" 105 | 106 | local winConf = vim.api.nvim_win_get_config(winid) 107 | winConf.footer[#winConf.footer - 2] = { titleChars < 10 and borderChar or "", "FloatBorder" } 108 | winConf.footer[#winConf.footer - 1] = { " " .. tostring(titleChars), countHighlight } 109 | 110 | vim.api.nvim_win_set_config(winid, winConf) 111 | end 112 | 113 | updateTitleCharCount() -- initialize 114 | vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { 115 | desc = "Tinygit: update char count in input window", 116 | buffer = bufnr, 117 | callback = updateTitleCharCount, 118 | }) 119 | end 120 | 121 | local function setupUnmount() 122 | vim.api.nvim_create_autocmd("WinLeave", { 123 | desc = "Tinygit: unmount of commit message input window", 124 | callback = function() 125 | local curWin = vim.api.nvim_get_current_win() 126 | if curWin == state.winid then 127 | vim.cmd.bwipeout(state.bufnr) 128 | require("tinygit.commands.commit.preview").unmount() 129 | return true -- deletes this autocmd 130 | end 131 | end, 132 | }) 133 | end 134 | 135 | local function setupSeparator(width) 136 | local function updateSeparator() 137 | local ns = vim.api.nvim_create_namespace("tinygit.commitMsgInput") 138 | vim.api.nvim_buf_clear_namespace(state.bufnr, ns, 0, -1) 139 | local separator = { char = "┄", hlgroup = "NonText" } 140 | 141 | vim.api.nvim_buf_set_extmark(state.bufnr, ns, 0, 0, { 142 | virt_lines = { 143 | { { separator.char:rep(width - 2), separator.hlgroup } }, 144 | }, 145 | }) 146 | end 147 | updateSeparator() -- initialize 148 | 149 | -- ensure the separator is always there, even if user has deleted first line 150 | vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { 151 | desc = "Tinygit: update separator in input window", 152 | buffer = state.bufnr, 153 | callback = updateSeparator, 154 | }) 155 | end 156 | 157 | local function setupWinHeightUpdate() 158 | local function updateWinHeight() 159 | local winConf = vim.api.nvim_win_get_config(state.winid) 160 | local bodyLines = vim.api.nvim_buf_line_count(state.bufnr) - 1 161 | winConf.height = bodyLines > 1 and INPUT_WIN_HEIGHT.big or INPUT_WIN_HEIGHT.small 162 | 163 | vim.api.nvim_win_set_config(state.winid, winConf) 164 | require("tinygit.commands.commit.preview").adaptWinPosition(winConf) 165 | end 166 | 167 | updateWinHeight() -- initialize 168 | vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { 169 | desc = "Tinygit: update input window height", 170 | buffer = state.bufnr, 171 | callback = updateWinHeight, 172 | }) 173 | end 174 | 175 | -------------------------------------------------------------------------------- 176 | 177 | ---@param mode "stage-all-and-commit"|"commit"|"amend-msg" 178 | ---@param prompt string 179 | ---@param confirmationCallback Tinygit.Input.ConfirmationCallback 180 | function M.new(mode, prompt, confirmationCallback) 181 | -- PARAMS 182 | local conf = require("tinygit.config").config.commit 183 | local icon = require("tinygit.config").config.appearance.mainIcon 184 | prompt = vim.trim(icon .. " " .. prompt) 185 | local borderChar = conf.border == "double" and "═" or "─" 186 | 187 | local height = INPUT_WIN_HEIGHT.small 188 | local width = MAX_TITLE_LEN + 2 189 | 190 | -- PREFILL 191 | local msgLines = {} 192 | if mode == "amend-msg" then 193 | local lastCommitTitle = u.syncShellCmd { "git", "log", "--max-count=1", "--pretty=%s" } 194 | local lastCommitBody = u.syncShellCmd { "git", "log", "--max-count=1", "--pretty=%b" } 195 | msgLines = { lastCommitTitle, lastCommitBody } 196 | else 197 | local cwd = vim.uv.cwd() or "" 198 | msgLines = state.abortedCommitMsg[cwd] or {} 199 | while #msgLines < 2 do -- so there is always a body 200 | table.insert(msgLines, "") 201 | end 202 | end 203 | 204 | -- FOOTER 205 | local keymapHints = {} 206 | if conf.keymapHints then 207 | local nmaps = conf.keymaps.normal 208 | local hlgroupKey, hlgroupDesc = "Comment", "NonText" 209 | keymapHints = { 210 | { " normal: ", "FloatBorder" }, 211 | { " " .. nmaps.confirm, hlgroupKey }, 212 | { " confirm ", hlgroupDesc }, 213 | { " " }, 214 | { " " .. nmaps.abort, hlgroupKey }, 215 | { " abort ", hlgroupDesc }, 216 | { " " }, 217 | } 218 | end 219 | 220 | local titleCharCount = { 221 | { borderChar:rep(3), "FloatBorder" }, 222 | { borderChar, "FloatBorder" }, -- extend border if title count < 10 223 | { " 0", "FloatBorder" }, -- initial count 224 | { "/" .. MAX_TITLE_LEN .. " ", "FloatBorder" }, 225 | } 226 | local footer = vim.list_extend(keymapHints, titleCharCount) 227 | 228 | -- CREATE WINDOW & BUFFER 229 | local bufnr = vim.api.nvim_create_buf(false, true) 230 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, msgLines) 231 | local winid = vim.api.nvim_open_win(bufnr, true, { 232 | relative = "editor", 233 | height = height, 234 | width = width, 235 | row = math.ceil((vim.o.lines - height) / 2) - 2 - conf.preview.loglines, 236 | col = math.ceil((vim.o.columns - width) / 2), 237 | border = conf.border, 238 | title = " " .. prompt .. " ", 239 | footer = footer, 240 | footer_pos = "right", 241 | style = "minimal", 242 | }) 243 | state.winid, state.bufnr = winid, bufnr 244 | 245 | vim.cmd.startinsert { bang = true } 246 | 247 | -- needs to be set after window creation to trigger local opts from ftplugin, 248 | -- but before the plugin sets its options, so they aren't overridden by the 249 | -- user's config 250 | vim.bo[bufnr].filetype = "gitcommit" 251 | 252 | vim.wo[winid].winfixbuf = true 253 | vim.wo[winid].statuscolumn = " " -- just for left-padding (also makes line numbers not show up) 254 | 255 | vim.wo[winid].scrolloff = 0 256 | vim.wo[winid].sidescrolloff = 0 257 | vim.wo[winid].list = true 258 | vim.wo[winid].listchars = "precedes:…,extends:…" 259 | vim.wo[winid].spell = conf.spellcheck 260 | 261 | -- wrapping 262 | vim.bo[bufnr].textwidth = MAX_TITLE_LEN 263 | vim.wo[winid].wrap = conf.wrap == "soft" 264 | if conf.wrap == "hard" then 265 | vim.bo[bufnr].formatoptions = vim.bo[bufnr].formatoptions .. "t" -- auto-wrap at textwidth 266 | end 267 | 268 | -- COMMIT PREVIEW 269 | if mode ~= "amend-msg" then 270 | ---@cast mode "stage-all-and-commit"|"commit" -- ensured above 271 | require("tinygit.commands.commit.preview").createWin(mode, winid) 272 | end 273 | 274 | -- STYLING 275 | -- 1. disable highlights, since we do that more intuitively with our separator 276 | -- 2. Linking to regular `Normal` looks better in nvim 0.10, but worse in 0.11 277 | local winHls = "@markup.heading.gitcommit:,@markup.link.gitcommit:" 278 | if vim.fn.has("nvim-0.11") == 0 then winHls = winHls .. ",Normal:Normal" end 279 | vim.wo[winid].winhighlight = winHls 280 | 281 | vim.api.nvim_win_call(winid, function() 282 | require("tinygit.shared.highlights").inlineCodeAndIssueNumbers() 283 | -- overlength 284 | -- * `\%<2l` to only highlight 1st line https://neovim.io/doc/user/pattern.html#search-range 285 | -- * match only starts after `\zs` https://neovim.io/doc/user/pattern.html#%2Fordinary-atom 286 | vim.fn.matchadd("ErrorMsg", [[\%<2l.\{]] .. MAX_TITLE_LEN .. [[}\zs.*]]) 287 | end) 288 | 289 | -- AUTOCMDS 290 | require("tinygit.shared.backdrop").new(bufnr) 291 | setupKeymaps(confirmationCallback) 292 | setupTitleCharCount(borderChar) 293 | setupUnmount() 294 | setupSeparator(width) 295 | setupWinHeightUpdate() 296 | end 297 | 298 | -------------------------------------------------------------------------------- 299 | return M 300 | -------------------------------------------------------------------------------- /lua/tinygit/commands/history.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local u = require("tinygit.shared.utils") 3 | -------------------------------------------------------------------------------- 4 | 5 | ---@class (exact) Tinygit.HistoryState 6 | ---@field absPath string 7 | ---@field ft string 8 | ---@field type? "stringSearch"|"function"|"line" 9 | ---@field unshallowingRunning boolean 10 | ---@field query string search query or function name 11 | ---@field hashList string[] ordered list of all hashes where the string/function was found 12 | ---@field lnum? number only for line history 13 | ---@field offset? number only for line history 14 | 15 | ---@type Tinygit.HistoryState 16 | local state = { 17 | hashList = {}, 18 | absPath = "", 19 | query = "", 20 | ft = "", 21 | unshallowingRunning = false, 22 | } 23 | 24 | -- What is passed to `git log --format`. hash/`%h` follows by a tab is required 25 | -- at the beginning, the rest is decorative, though \t as delimiter as 26 | -- assumed by the others parts here. 27 | local gitlogFormat = "%h\t%s\t%cr" -- hash, subject, date 28 | 29 | -------------------------------------------------------------------------------- 30 | 31 | ---@param msg string 32 | ---@param level? Tinygit.NotifyLevel 33 | ---@param opts? table 34 | local function notify(msg, level, opts) 35 | if not opts then opts = {} end 36 | opts.title = "History" 37 | u.notify(msg, level, opts) 38 | end 39 | 40 | ---If `autoUnshallowIfNeeded = true`, will also run `git fetch --unshallow` and 41 | ---and also returns `false` then. This is so the caller can check whether the 42 | ---function should be aborted. However, if called with callback, will return 43 | ---`true`, since the original call can be aborted, as the callback will be 44 | ---called once the auto-unshallowing is done. 45 | ---@param callback? function called when auto-unshallowing is done 46 | ---@return boolean whether the repo is shallow 47 | ---@async 48 | local function repoIsShallow(callback) 49 | if state.unshallowingRunning or not u.inShallowRepo() then return false end 50 | 51 | local autoUnshallow = require("tinygit.config").config.history.autoUnshallowIfNeeded 52 | if not autoUnshallow then 53 | local msg = "Aborting: Repository is shallow.\nRun `git fetch --unshallow`." 54 | notify(msg, "warn") 55 | return true 56 | end 57 | 58 | notify("Auto-unshallowing: fetching repo history…") 59 | state.unshallowingRunning = true 60 | 61 | -- run async, to allow user input while waiting for the command 62 | vim.system( 63 | { "git", "fetch", "--unshallow" }, 64 | {}, 65 | vim.schedule_wrap(function(out) 66 | if u.nonZeroExit(out) then return end 67 | state.unshallowingRunning = false 68 | notify("Auto-unshallowing done.") 69 | if callback then callback() end 70 | end) 71 | ) 72 | if callback then return true end -- original call aborted, callback will be called 73 | return false 74 | end 75 | 76 | ---@param hash string 77 | local function restoreFileToCommit(hash) 78 | local out = vim.system({ "git", "restore", "--source=" .. hash, "--", state.absPath }):wait() 79 | if u.nonZeroExit(out) then return end 80 | notify(("Restored file to [%s]"):format(hash)) 81 | vim.cmd.checktime() 82 | end 83 | 84 | -------------------------------------------------------------------------------- 85 | 86 | ---@param commitIdx number index of the selected commit in the list of commits 87 | local function showDiff(commitIdx) 88 | local setDiffBuffer = require("tinygit.shared.diff").setDiffBuffer 89 | 90 | local hashList = state.hashList 91 | local hash = hashList[commitIdx] 92 | local query = state.query 93 | local type = state.type 94 | local date = u.syncShellCmd { "git", "log", "--max-count=1", "--format=(%cr)", hash } 95 | local commitMsg = u.syncShellCmd { "git", "log", "--max-count=1", "--format=%s", hash } 96 | local config = require("tinygit.config").config.history 97 | 98 | -- DETERMINE FILENAME (in case of renaming) 99 | local filenameInPresent = state.absPath 100 | local gitroot = u.syncShellCmd { "git", "rev-parse", "--show-toplevel" } 101 | local nameHistory = u.syncShellCmd { 102 | "git", 103 | "-C", 104 | gitroot, -- in case cwd is not the git root 105 | "log", 106 | hash .. "^..", 107 | "--follow", 108 | "--name-only", 109 | "--format=", -- suppress commit info 110 | "--", 111 | filenameInPresent, 112 | } 113 | local nameAtCommit = table.remove(vim.split(nameHistory, "\n")) 114 | 115 | -- DIFF COMMAND 116 | local diffCmd = { "git", "-C", gitroot } 117 | local args = {} 118 | if type == "stringSearch" then 119 | args = { "show", "--format=", hash, "--", nameAtCommit } 120 | elseif type == "function" or type == "line" then 121 | args = { "log", "--format=", "--max-count=1", hash } 122 | local extra = type == "function" and ("-L:%s:%s"):format(query, nameAtCommit) 123 | or ("-L%d,+%d:%s"):format(state.lnum, state.offset, nameAtCommit) 124 | table.insert(args, extra) 125 | end 126 | local diffResult = vim.system(vim.list_extend(diffCmd, args)):wait() 127 | if u.nonZeroExit(diffResult) then return end 128 | 129 | local diff = assert(diffResult.stdout, "No diff output.") 130 | local diffLines = vim.split(diff, "\n", { trimempty = true }) 131 | 132 | -- WINDOW PARAMS 133 | local relWidth = math.min(config.diffPopup.width, 1) 134 | local relHeight = math.min(config.diffPopup.height, 1) 135 | local absWidth = math.floor(relWidth * vim.o.columns) - 2 136 | local absHeight = math.floor(relHeight * vim.o.lines) - 2 137 | 138 | -- BUFFER 139 | local bufnr = vim.api.nvim_create_buf(false, true) 140 | vim.api.nvim_buf_set_name(bufnr, hash .. " " .. nameAtCommit) 141 | setDiffBuffer(bufnr, diffLines, state.ft, absWidth) 142 | 143 | -- TITLE 144 | local maxMsgLen = absWidth - #date - 3 145 | commitMsg = commitMsg:sub(1, maxMsgLen) 146 | local title = (" %s %s "):format(commitMsg, date) 147 | 148 | -- FOOTER 149 | local hlgroup = { key = "Comment", desc = "NonText" } 150 | local footer = { 151 | { " ", "FloatBorder" }, 152 | { "q", hlgroup.key }, 153 | { " close", hlgroup.desc }, 154 | { " ", "FloatBorder" }, 155 | { "/", hlgroup.key }, 156 | { " next/prev commit", hlgroup.desc }, 157 | { " ", "FloatBorder" }, 158 | { "yh", hlgroup.key }, 159 | { " yank hash", hlgroup.desc }, 160 | { " ", "FloatBorder" }, 161 | { "R", hlgroup.key }, 162 | { " restore to commit", hlgroup.desc }, 163 | { " ", "FloatBorder" }, 164 | } 165 | if type == "stringSearch" and query ~= "" then 166 | vim.list_extend(footer, { 167 | { " ", "FloatBorder" }, 168 | { "n/N", hlgroup.key }, 169 | { " next/prev occ.", hlgroup.desc }, 170 | { " ", "FloatBorder" }, 171 | }) 172 | end 173 | 174 | -- CREATE WINDOW 175 | local historyZindex = 40 -- below nvim-notify, which has 50 176 | local winnr = vim.api.nvim_open_win(bufnr, true, { 177 | relative = "editor", 178 | width = absWidth, 179 | height = absHeight, 180 | row = math.ceil((1 - relHeight) * vim.o.lines / 2), 181 | col = math.ceil((1 - relWidth) * vim.o.columns / 2), 182 | title = title, 183 | title_pos = "center", 184 | border = config.diffPopup.border, 185 | style = "minimal", 186 | footer = footer, 187 | zindex = historyZindex, 188 | }) 189 | vim.wo[winnr].winfixheight = true 190 | vim.wo[winnr].conceallevel = 0 -- do not hide chars in markdown/json 191 | require("tinygit.shared.backdrop").new(bufnr, historyZindex) 192 | 193 | -- search for the query 194 | local ignoreCaseBefore = vim.o.ignorecase 195 | local smartCaseBefore = vim.o.smartcase 196 | if query ~= "" and type == "stringSearch" then 197 | -- consistent with git's `--regexp-ignore-case` 198 | vim.o.ignorecase = true 199 | vim.o.smartcase = false 200 | 201 | vim.fn.matchadd("Search", query) -- highlight, CAVEAT: is case-sensitive 202 | vim.fn.setreg("/", query) -- so `n` searches directly 203 | 204 | -- (pcall to prevent error when query cannot found, due to non-equivalent 205 | -- case-sensitivity with git, because of git-regex or due to file renamings) 206 | pcall(vim.cmd.normal, { "n", bang = true }) -- move to first match 207 | end 208 | 209 | -- KEYMAPS 210 | local function keymap(lhs, rhs) vim.keymap.set("n", lhs, rhs, { buffer = bufnr, nowait = true }) end 211 | 212 | -- keymaps: closing 213 | local function closePopup() 214 | if vim.api.nvim_win_is_valid(winnr) then vim.api.nvim_win_close(winnr, true) end 215 | if vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_delete(bufnr, { force = true }) end 216 | vim.o.ignorecase = ignoreCaseBefore 217 | vim.o.smartcase = smartCaseBefore 218 | end 219 | keymap("q", closePopup) 220 | 221 | -- also close the popup on leaving buffer, ensures there is not leftover 222 | -- buffer when user closes popup in a different way, such as `:close`. 223 | vim.api.nvim_create_autocmd("BufLeave", { 224 | buffer = bufnr, 225 | callback = closePopup, 226 | }) 227 | 228 | -- keymaps: next/prev commit 229 | keymap("", function() 230 | if commitIdx == #hashList then 231 | notify("Already on last commit.", "warn") 232 | return 233 | end 234 | closePopup() 235 | showDiff(commitIdx + 1) 236 | end) 237 | keymap("", function() 238 | if commitIdx == 1 then 239 | notify("Already on first commit.", "warn") 240 | return 241 | end 242 | closePopup() 243 | showDiff(commitIdx - 1) 244 | end) 245 | 246 | keymap("R", function() 247 | closePopup() 248 | restoreFileToCommit(hash) 249 | end) 250 | 251 | -- keymaps: yank hash 252 | keymap("yh", function() 253 | vim.fn.setreg("+", hash) 254 | notify("Copied hash: " .. hash) 255 | end) 256 | end 257 | 258 | ---Given a list of commits, prompt user to select one 259 | ---@param commitList string raw response from `git log` 260 | local function selectFromCommits(commitList) 261 | -- GUARD 262 | commitList = vim.trim(commitList or "") 263 | if commitList == "" then 264 | notify(("No commits found where %q was changed."):format(state.query), "warn") 265 | return 266 | end 267 | 268 | -- INFO due to `git log --name-only`, information on one commit is split across 269 | -- three lines (1: info, 2: blank, 3: filename). This loop merges them into one. 270 | -- This only compares basenames, file movements are not accounted 271 | -- for, however this is for display purposes only, so this is not a problem. 272 | local commits = {} 273 | if state.type == "stringSearch" then 274 | local oneCommitPer3Lines = vim.split(commitList, "\n") 275 | for i = 1, #oneCommitPer3Lines, 3 do 276 | local commitLine = oneCommitPer3Lines[i] 277 | local nameAtCommit = vim.fs.basename(oneCommitPer3Lines[i + 2]) 278 | -- append name at commit only when it is not the same name as in the present 279 | if vim.fs.basename(state.absPath) ~= nameAtCommit then 280 | -- tab-separated for consistently with `--format` output 281 | commitLine = commitLine .. "\t" .. nameAtCommit 282 | end 283 | table.insert(commits, commitLine) 284 | end 285 | 286 | -- CAVEAT `git log -L` does not support `--follow` and `--name-only`, so we 287 | -- cannot add the name here 288 | elseif state.type == "function" or state.type == "line" then 289 | commits = vim.split(commitList, "\n") 290 | end 291 | 292 | -- save state 293 | state.hashList = vim.tbl_map(function(commitLine) 294 | local hash = vim.split(commitLine, "\t")[1] 295 | return hash 296 | end, commits) 297 | 298 | -- select commit 299 | local searchMode = state.query == "" and vim.fs.basename(state.absPath) or state.query 300 | local icon = require("tinygit.config").config.appearance.mainIcon 301 | local prompt = vim.trim(("%s Commits that changed %q"):format(icon, searchMode)) 302 | local commitFormatter = function(commitLine) 303 | local _, subject, date, nameAtCommit = unpack(vim.split(commitLine, "\t")) 304 | local displayLine = ("%s\t%s"):format(subject, date) 305 | -- append name at commit, if it exists 306 | if nameAtCommit then displayLine = displayLine .. ("\t(%s)"):format(nameAtCommit) end 307 | return displayLine 308 | end 309 | local stylingFunc = function() 310 | local hl = require("tinygit.shared.highlights") 311 | hl.commitType() 312 | hl.inlineCodeAndIssueNumbers() 313 | vim.fn.matchadd("Comment", [[\t.*$]]) 314 | end 315 | local onChoice = function(_, commitIdx) showDiff(commitIdx) end 316 | 317 | require("tinygit.shared.picker").pick(prompt, commits, commitFormatter, stylingFunc, onChoice) 318 | end 319 | 320 | -------------------------------------------------------------------------------- 321 | 322 | ---@param prefill? string only needed when recursively calling this function 323 | local function searchHistoryForString(prefill) 324 | if repoIsShallow() then return end 325 | 326 | -- prompt for a search query 327 | local icon = require("tinygit.config").config.appearance.mainIcon 328 | local prompt = vim.trim(icon .. " Search file history") 329 | vim.ui.input({ prompt = prompt, default = prefill }, function(query) 330 | if not query then return end -- aborted 331 | 332 | -- GUARD loop back when unshallowing is still running 333 | if state.unshallowingRunning then 334 | notify("Unshallowing still running. Please wait a moment.", "warn") 335 | searchHistoryForString(query) -- call this function again, preserving current query 336 | return 337 | end 338 | 339 | state.query = query 340 | -- without argument, search all commits that touched the current file 341 | local args = { 342 | "git", 343 | "log", 344 | "--format=" .. gitlogFormat, 345 | "--follow", -- follow file renamings 346 | "--name-only", -- add filenames to display renamed files 347 | "--", 348 | state.absPath, 349 | } 350 | if query ~= "" then 351 | local posBeforeDashDash = #args - 2 352 | table.insert(args, posBeforeDashDash, "--regexp-ignore-case") 353 | table.insert(args, posBeforeDashDash, "-G" .. query) 354 | end 355 | local result = vim.system(args):wait() 356 | if u.nonZeroExit(result) then return end 357 | 358 | selectFromCommits(result.stdout) 359 | end) 360 | end 361 | 362 | local function functionHistory() 363 | -- INFO in case of auto-unshallowing, will abort this call and be called 364 | -- again once auto-unshallowing is done. 365 | if repoIsShallow(functionHistory) then return end 366 | 367 | -- get selection 368 | local startLn, startCol = unpack(vim.api.nvim_buf_get_mark(0, "<")) 369 | local endLn, endCol = unpack(vim.api.nvim_buf_get_mark(0, ">")) 370 | local selection = vim.api.nvim_buf_get_text(0, startLn - 1, startCol, endLn - 1, endCol + 1, {}) 371 | local funcname = table.concat(selection, "\n") 372 | state.query = funcname 373 | 374 | -- select from commits 375 | -- CAVEAT `git log -L` does not support `--follow` and `--name-only` 376 | local result = vim.system({ 377 | "git", 378 | "log", 379 | "--format=" .. gitlogFormat, 380 | ("-L:%s:%s"):format(funcname, state.absPath), 381 | "--no-patch", 382 | }):wait() 383 | if u.nonZeroExit(result) then return end 384 | 385 | selectFromCommits(result.stdout) 386 | end 387 | 388 | local function lineHistory() 389 | -- INFO in case of auto-unshallowing, will abort this call and be called 390 | -- again once auto-unshallowing is done. 391 | if repoIsShallow(lineHistory) then return end 392 | 393 | local lnum, offset 394 | local startOfVisual = vim.api.nvim_buf_get_mark(0, "<")[1] 395 | local endOfVisual = vim.api.nvim_buf_get_mark(0, ">")[1] 396 | lnum = startOfVisual 397 | offset = endOfVisual - startOfVisual + 1 398 | local onlyOneLine = endOfVisual == startOfVisual 399 | state.query = "L" .. startOfVisual .. (onlyOneLine and "" or "-L" .. endOfVisual) 400 | 401 | state.lnum = lnum 402 | state.offset = offset 403 | 404 | -- CAVEAT `git log -L` does not support `--follow` and `--name-only` 405 | local result = vim.system({ 406 | "git", 407 | "log", 408 | "--format=" .. gitlogFormat, 409 | ("-L%d,+%d:%s"):format(lnum, offset, state.absPath), 410 | "--no-patch", 411 | }):wait() 412 | if u.nonZeroExit(result) then return end 413 | 414 | selectFromCommits(result.stdout) 415 | end 416 | 417 | -------------------------------------------------------------------------------- 418 | 419 | function M.fileHistory() 420 | if u.notInGitRepo() then return end 421 | 422 | state.absPath = vim.api.nvim_buf_get_name(0) 423 | state.ft = vim.bo.filetype 424 | local mode = vim.fn.mode() 425 | 426 | if mode == "n" then 427 | state.type = "stringSearch" 428 | searchHistoryForString() 429 | elseif mode == "v" then 430 | vim.cmd.normal { "v", bang = true } -- leave visual mode 431 | state.type = "function" 432 | functionHistory() 433 | elseif mode == "V" then 434 | vim.cmd.normal { "V", bang = true } 435 | state.type = "line" 436 | lineHistory() 437 | else 438 | notify("Unsupported mode: " .. mode, "warn") 439 | end 440 | end 441 | 442 | -------------------------------------------------------------------------------- 443 | return M 444 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nvim-tinygit 2 | 3 | badge 4 | 5 | Bundle of commands focused on swift and streamlined git operations. 6 | 7 | ## Feature overview 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
Interactive stagingSmart commit
interactive stagingsmart commit
File history
file history
27 | 28 | - **Interactive staging** of hunks (parts of a file). Displays hunk diffs with 29 | syntax highlighting, and allows resetting or navigating to the hunk. 30 | - **Smart-commit**: Open a popup to enter a commit message with syntax 31 | highlighting, commit preview, and commit title length indicators. If there are 32 | no staged changes, stages all changes before doing so (`git add -A`). 33 | Optionally trigger a `git push` afterward. 34 | - Convenient commands for **amending, stashing, fixup, or undoing commits**. 35 | - Search **issues & PRs**. Open the selected issue or PR in the browser. 36 | - Open the **GitHub URL** of the current file, repo, or selected lines. Also 37 | supports opening GitHub's blame view. 38 | - **Explore file history**: Search the git history of a file for a string ("git 39 | pickaxe"), or examine the history of a function or line range. Displays the 40 | results in a diff view with syntax highlighting, correctly following file 41 | renamings. 42 | - **Status line components:** `git blame` of a file, branch state, and file 43 | state. 44 | - **Streamlined workflow:** operations are smartly combined to minimize 45 | friction. For instance, the smart-commit command combines staging, committing, 46 | and pushing, and searching the file history combines unshallowing, searching, 47 | and diff navigation. 48 | 49 | ## Table of contents 50 | 51 | 52 | 53 | - [Installation](#installation) 54 | - [Configuration](#configuration) 55 | - [Commands](#commands) 56 | * [Interactive staging](#interactive-staging) 57 | * [Smart commit](#smart-commit) 58 | * [Amend and fixup commits](#amend-and-fixup-commits) 59 | * [Undo last commit/amend](#undo-last-commitamend) 60 | * [GitHub interaction](#github-interaction) 61 | * [Push & PRs](#push--prs) 62 | * [File history](#file-history) 63 | * [Stash](#stash) 64 | - [Status line components](#status-line-components) 65 | * [git blame](#git-blame) 66 | * [Branch state](#branch-state) 67 | * [File state](#file-state) 68 | - [Credits](#credits) 69 | 70 | 71 | 72 | ## Installation 73 | **Requirements** 74 | - nvim 0.10+ 75 | - A plugin implementing `vim.ui.select`, such as: 76 | * [snacks.picker](http://github.com/folke/snacks.nvim) 77 | * [mini.pick](http://github.com/echasnovski/mini.pick) 78 | * [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) with 79 | [telescope-ui-select](https://github.com/nvim-telescope/telescope-ui-select.nvim) 80 | * [fzf-lua](https://github.com/ibhagwan/fzf-lua) 81 | - For interactive staging: 82 | [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim). (For 83 | `snacks.nvim`, the `git_diff` picker allows interactive staging.) 84 | - For GitHub-related commands: `curl` 85 | - *Recommended*: Treesitter parser for syntax highlighting `TSInstall 86 | gitcommit`. 87 | 88 | ```lua 89 | -- lazy.nvim 90 | { 91 | "chrisgrieser/nvim-tinygit", 92 | -- dependencies = "nvim-telescope/telescope.nvim", -- only for interactive staging 93 | }, 94 | 95 | -- packer 96 | use { 97 | "chrisgrieser/nvim-tinygit", 98 | -- requires = "nvim-telescope/telescope.nvim", -- only for interactive staging 99 | } 100 | ``` 101 | 102 | ## Configuration 103 | The `setup` call is optional. 104 | 105 | ```lua 106 | -- default config 107 | require("tinygit").setup { 108 | stage = { -- requires `telescope.nvim` 109 | contextSize = 1, -- larger values "merge" hunks. 0 is not supported. 110 | stagedIndicator = "󰐖", 111 | keymaps = { -- insert & normal mode 112 | stagingToggle = "", -- stage/unstage hunk 113 | gotoHunk = "", 114 | resetHunk = "", 115 | }, 116 | moveToNextHunkOnStagingToggle = false, 117 | 118 | -- accepts the common telescope picker config 119 | telescopeOpts = { 120 | layout_strategy = "horizontal", 121 | layout_config = { 122 | horizontal = { 123 | preview_width = 0.65, 124 | height = { 0.7, min = 20 }, 125 | }, 126 | }, 127 | }, 128 | }, 129 | commit = { 130 | keepAbortedMsgSecs = 300, 131 | border = getBorder(), -- `vim.o.winborder` on nvim 0.11, otherwise "rounded" 132 | spellcheck = false, -- vim's builtin spellcheck 133 | wrap = "hard", ---@type "hard"|"soft"|"none" 134 | keymaps = { 135 | normal = { abort = "q", confirm = "" }, 136 | insert = { confirm = "" }, 137 | }, 138 | keymapHints = true, 139 | preview = { 140 | loglines = 3, 141 | }, 142 | subject = { 143 | -- automatically apply formatting to the subject line 144 | autoFormat = function(subject) ---@type nil|fun(subject: string): string 145 | subject = subject:gsub("%.$", "") -- remove trailing dot 146 | return subject 147 | end, 148 | 149 | -- disallow commits that do not use an allowed type 150 | enforceType = false, 151 | -- stylua: ignore 152 | types = { 153 | "fix", "feat", "chore", "docs", "refactor", "build", "test", 154 | "perf", "style", "revert", "ci", "break", 155 | }, 156 | }, 157 | body = { 158 | enforce = false, 159 | }, 160 | }, 161 | push = { 162 | preventPushingFixupCommits = true, 163 | confirmationSound = true, -- currently macOS only, PRs welcome 164 | 165 | -- If pushed commits contain references to issues, open them in the browser 166 | -- (not used when force-pushing). 167 | openReferencedIssues = false, 168 | }, 169 | github = { 170 | icons = { 171 | openIssue = "🟢", 172 | closedIssue = "🟣", 173 | notPlannedIssue = "⚪", 174 | openPR = "🟩", 175 | mergedPR = "🟪", 176 | draftPR = "⬜", 177 | closedPR = "🟥", 178 | }, 179 | }, 180 | history = { 181 | diffPopup = { 182 | width = 0.8, -- between 0-1 183 | height = 0.8, 184 | border = getBorder(), -- `vim.o.winborder` on nvim 0.11, otherwise "rounded" 185 | }, 186 | autoUnshallowIfNeeded = false, 187 | }, 188 | appearance = { 189 | mainIcon = "󰊢", 190 | backdrop = { 191 | enabled = true, 192 | blend = 40, -- 0-100 193 | }, 194 | hlGroups = { 195 | addedText = "Added", -- i.e. use hlgroup `Added` 196 | removedText = "Removed", 197 | }, 198 | }, 199 | statusline = { 200 | blame = { 201 | ignoreAuthors = {}, -- hide component if from these authors (useful for bots) 202 | hideAuthorNames = {}, -- show component, but hide names (useful for your own name) 203 | showOnlyTimeIfAuthor = {}, -- show only time if these authors (useful for automated commits) 204 | maxMsgLen = 40, 205 | icon = "ﰖ", 206 | }, 207 | branchState = { 208 | icons = { 209 | ahead = "󰶣", 210 | behind = "󰶡", 211 | diverge = "󰃻", 212 | }, 213 | }, 214 | fileState = { 215 | icon = "", 216 | }, 217 | }, 218 | } 219 | ``` 220 | 221 | ## Commands 222 | All commands are available as Lua function or as subcommand of `:Tinygit`, 223 | for example `require("tinygit").interactiveStaging()` and `:Tinygit 224 | interactiveStaging`. Note that the Lua function is preferable, 225 | since `:Tinygit` does not accept command-specific options and does not 226 | trigger visual-mode specific changes. 227 | 228 | ### Interactive staging 229 | - Interactive straging requires `telescope`. 230 | - This command stages hunks, that is, *parts* of a file instead of the full 231 | file. It is roughly comparable to `git add -p`. 232 | - Use `` to stage/unstage the hunk, `` to go to the hunk, or `` 233 | to reset the hunk (mappings customizable). Your regular `telescope` mappings 234 | also apply. 235 | - The size of the hunks is determined by the setting `staging.contextSize`. 236 | Larger context size is going to "merge" changes that are close to one another 237 | into one hunk. (As such, the hunks displayed are not 1:1 the same as the hunks 238 | from `gitsigns.nvim`.) A context size between 1 and 4 is recommended. 239 | - Limitation: `contextSize=0` (= no merging at all) is not supported. 240 | 241 | ```lua 242 | require("tinygit").interactiveStaging() 243 | ``` 244 | 245 | > [!NOTE] 246 | > For `snacks.nvim`, you can just use the `git_diff` picker, which pretty much 247 | > does the same thing. 248 | 249 | ### Smart commit 250 | - Open a commit popup, alongside a preview of what is going to be committed. If 251 | there are no staged changes, stage all changes (`git add --all`) before the 252 | commit. Optionally run `git push` if the repo is clean after committing. 253 | - The window title of the input field displays what actions are going to be 254 | performed. You can see at glance whether all changes are going to be 255 | committed, or whether there a `git push` is triggered afterward, so there are 256 | no surprises. 257 | - Input field contents of aborted commits are briefly kept, if you just want to 258 | fix a detail. 259 | - The first line is used as commit subject, the rest as commit body. 260 | 261 | ```lua 262 | -- values shown are the defaults 263 | require("tinygit").smartCommit { pushIfClean = false, pullBeforePush = true } 264 | ``` 265 | 266 | **Example workflow** 267 | Assuming these keybindings: 268 | 269 | ```lua 270 | vim.keymap.set( 271 | "n", 272 | "ga", 273 | function() require("tinygit").interactiveStaging() end, 274 | { desc = "git add" } 275 | ) 276 | vim.keymap.set( 277 | "n", 278 | "gc", 279 | function() require("tinygit").smartCommit() end, 280 | { desc = "git commit" } 281 | ) 282 | vim.keymap.set( 283 | "n", 284 | "gp", 285 | function() require("tinygit").push() end, 286 | { desc = "git push" } 287 | ) 288 | ``` 289 | 290 | 1. Stage some changes via `ga`. 291 | 2. Use `gc` to enter a commit message. 292 | 3. Repeat 1 and 2. 293 | 4. When done, use `gp` to push the commits. 294 | 295 | Using `require("tinygit").smartCommit({pushIfClean = true})` allows you to 296 | combine staging, committing, and pushing into a single step, when it is the last 297 | commit you intend to make. 298 | 299 | ### Amend and fixup commits 300 | **Amending** 301 | - `amendOnlyMsg` just opens the commit popup to change the last commit message, 302 | and does not stage any changes. 303 | - `amendNoEdit` keeps the last commit message; if there are no staged changes, 304 | stages all changes (`git add --all`), like `smartCommit`. 305 | - Optionally runs `git push --force-with-lease` afterward, if the branch has 306 | diverged (that is, the amended commit was already pushed). 307 | 308 | ```lua 309 | -- values shown are the defaults 310 | require("tinygit").amendOnlyMsg { forcePushIfDiverged = false } 311 | require("tinygit").amendNoEdit { 312 | forcePushIfDiverged = false, 313 | stageAllIfNothingStaged = true, 314 | } 315 | ``` 316 | 317 | **Fixup commits** 318 | - `fixupCommit` lets you select a commit from the last X commits and runs `git 319 | commit --fixup` on the selected commit. 320 | - If there are no staged changes, stages all changes (`git add --all`), like 321 | `smartCommit`. 322 | - `autoRebase = true` automatically runs rebase with `--autosquash` and 323 | `--autostash` afterward, confirming all fixups and squashes **without opening a 324 | rebase to do editor**. Note that this can potentially result in conflicts. 325 | 326 | ```lua 327 | -- values shown are the defaults 328 | require("tinygit").fixupCommit { 329 | selectFromLastXCommits = 15, 330 | autoRebase = false, 331 | } 332 | ``` 333 | 334 | ### Undo last commit/amend 335 | 336 | ```lua 337 | require("tinygit").undoLastCommitOrAmend() 338 | ``` 339 | 340 | - Changes in the working directory are kept but unstaged. (In the background, 341 | this uses `git reset --mixed`.) 342 | - If there was a `push` operation done as a followup, the last commit is not 343 | undone. 344 | 345 | ### GitHub interaction 346 | **Search issues & PRs** 347 | - All GitHub interaction commands require `curl`. 348 | 349 | ```lua 350 | -- state: all|closed|open (default: all) 351 | -- type: all|issue|pr (default: all) 352 | require("tinygit").issuesAndPrs { type = "all", state = "all" } 353 | 354 | -- alternative: if the word under the cursor is of the form `#123`, 355 | -- open that issue/PR 356 | require("tinygit").openIssueUnderCursor() 357 | ``` 358 | 359 | **GitHub URL** 360 | Creates a permalink to the current file/lines at GitHub. The link is opened in 361 | the browser and copied to the system clipboard. In normal mode, uses the current 362 | file, in visual mode, uses the selected lines. (Note that visual mode detection 363 | requires you to use the Lua function below instead of the `:Tinygit` 364 | ex-command.) 365 | - `"file"`: link to the file (normal mode) or the selected lines (visual mode) 366 | - `"blame"`: link to the blame view of the file 367 | - `"repo"`: link to the repo root 368 | 369 | ```lua 370 | -- "file"|"repo"|"blame" (default: "file") 371 | require("tinygit").githubUrl("file") 372 | ``` 373 | 374 | ### Push & PRs 375 | - `push` can be combined with other actions, depending on the options. 376 | - `createGitHubPr` opens a PR from the current branch browser. (This requires 377 | the 378 | repo to be a fork with sufficient information on the remote.) 379 | 380 | ```lua 381 | -- values shown are the defaults 382 | require("tinygit").push { 383 | pullBefore = false, 384 | forceWithLease = false, 385 | createGitHubPr = false, 386 | } 387 | 388 | require("tinygit").createGitHubPr() 389 | -- to push before the PR, use `require("tinygit").push { createGitHubPr = true }` 390 | ``` 391 | 392 | ### File history 393 | Search the git history of the current file. Select from the matching commits to 394 | open a popup with a diff view of the changes. 395 | 396 | If the config `history.autoUnshallowIfNeeded` is set to `true`, will also 397 | automatically unshallow the repo if needed. 398 | 399 | ```lua 400 | require("tinygit").fileHistory() 401 | ``` 402 | 403 | The type of history search depends on the mode `.fileHistory` is called from: 404 | - **Normal mode**: search file history for a string (`git log -G`) 405 | * Correctly follows file renamings, and displays past filenames in the 406 | commit selection. 407 | * The search input is case-insensitive and supports regex. 408 | * Leave the input field empty to display *all* commits that changed the 409 | current file. 410 | - **Visual mode**: function history (`git log -L`). 411 | * The selected text is assumed to be the name of the function whose history 412 | you want to explore. 413 | * Note that [`git` uses heuristics to determine the enclosing function of 414 | a change](https://news.ycombinator.com/item?id=38153309), so this is not 415 | 100% perfect and has varying reliability across languages. 416 | * Caveat: for function history, git does not support to follow renamings of 417 | the file or function name. 418 | - **Visual line mode**: line range history (`git log -L`). 419 | * Uses the selected lines as the line range. 420 | * Caveat: for line history, git does not support to follow file renamings. 421 | 422 | Note that visual mode detection requires you to use the Lua function above 423 | instead of the `:Tinygit` ex-command. 424 | 425 | ### Stash 426 | Simple wrappers around `git stash push` and `git stash pop`. 427 | 428 | ```lua 429 | require("tinygit").stashPush() 430 | require("tinygit").stashPop() 431 | ``` 432 | 433 | ## Status line components 434 | 435 | 436 | ### git blame 437 | 438 | Shows the message and date (`git blame`) of the last commit that changed the 439 | current *file* (not line). 440 | 441 | ```lua 442 | require("tinygit.statusline").blame() 443 | ``` 444 | 445 | > [!TIP] 446 | > Some status line plugins also allow you to put components into the tab line or 447 | > win bar. If your status line is too crowded, you can add the blame-component 448 | > to one of those bars instead. 449 | 450 | The component can be configured with the `statusline.blame` options in the 451 | [plugin 452 | configuration](#configuration). 453 | 454 | ### Branch state 455 | Shows whether the local branch is ahead or behind of its remote counterpart. 456 | (Note that this component does not run `git fetch` for performance reasons, so 457 | the component may not be up-to-date with remote changes.) 458 | 459 | ```lua 460 | require("tinygit.statusline").branchState() 461 | ``` 462 | 463 | ### File state 464 | Shows the number of changed files, similar to terminal prompts. 465 | 466 | ```lua 467 | require("tinygit.statusline").fileState() 468 | ``` 469 | 470 | ## Credits 471 | In my day job, I am a sociologist studying the social mechanisms underlying the 472 | digital economy. For my PhD project, I investigate the governance of the app 473 | economy and how software ecosystems manage the tension between innovation and 474 | compatibility. If you are interested in this subject, feel free to get in touch. 475 | 476 | - [Website](https://chris-grieser.de/) 477 | - [Mastodon](https://pkm.social/@pseudometa) 478 | - [ResearchGate](https://www.researchgate.net/profile/Christopher-Grieser) 479 | - [LinkedIn](https://www.linkedin.com/in/christopher-grieser-ba693b17a/) 480 | 481 | Buy Me a Coffee at ko-fi.com 484 | -------------------------------------------------------------------------------- /doc/nvim-tinygit.txt: -------------------------------------------------------------------------------- 1 | *nvim-tinygit.txt* For Neovim Last change: 2025 December 13 2 | 3 | ============================================================================== 4 | Table of Contents *nvim-tinygit-table-of-contents* 5 | 6 | 1. nvim-tinygit |nvim-tinygit-nvim-tinygit| 7 | - Feature overview |nvim-tinygit-nvim-tinygit-feature-overview| 8 | - Table of contents |nvim-tinygit-nvim-tinygit-table-of-contents| 9 | - Installation |nvim-tinygit-nvim-tinygit-installation| 10 | - Configuration |nvim-tinygit-nvim-tinygit-configuration| 11 | - Commands |nvim-tinygit-nvim-tinygit-commands| 12 | - Status line components |nvim-tinygit-nvim-tinygit-status-line-components| 13 | - Credits |nvim-tinygit-nvim-tinygit-credits| 14 | 15 | ============================================================================== 16 | 1. nvim-tinygit *nvim-tinygit-nvim-tinygit* 17 | 18 | 19 | 20 | Bundle of commands focused on swift and streamlined git operations. 21 | 22 | 23 | FEATURE OVERVIEW *nvim-tinygit-nvim-tinygit-feature-overview* 24 | 25 | Interactive stagingSmart commitFile history- **Interactive staging** of hunks (parts of a file). Displays hunk diffs with 26 | syntax highlighting, and allows resetting or navigating to the hunk. 27 | - **Smart-commit**: Open a popup to enter a commit message with syntax 28 | highlighting, commit preview, and commit title length indicators. If there are 29 | no staged changes, stages all changes before doing so (`git add -A`). 30 | Optionally trigger a `git push` afterward. 31 | - Convenient commands for **amending, stashing, fixup, or undoing commits**. 32 | - Search **issues & PRs**. Open the selected issue or PR in the browser. 33 | - Open the **GitHub URL** of the current file, repo, or selected lines. Also 34 | supports opening GitHub’s blame view. 35 | - **Explore file history**: Search the git history of a file for a string ("git 36 | pickaxe"), or examine the history of a function or line range. Displays the 37 | results in a diff view with syntax highlighting, correctly following file 38 | renamings. 39 | - **Status line components:** `git blame` of a file, branch state, and file 40 | state. 41 | - **Streamlined workflow:** operations are smartly combined to minimize 42 | friction. For instance, the smart-commit command combines staging, committing, 43 | and pushing, and searching the file history combines unshallowing, searching, 44 | and diff navigation. 45 | 46 | 47 | TABLE OF CONTENTS *nvim-tinygit-nvim-tinygit-table-of-contents* 48 | 49 | - |nvim-tinygit-installation| 50 | - |nvim-tinygit-configuration| 51 | - |nvim-tinygit-commands| 52 | - |nvim-tinygit-interactive-staging| 53 | - |nvim-tinygit-smart-commit| 54 | - |nvim-tinygit-amend-and-fixup-commits| 55 | - |nvim-tinygit-undo-last-commit/amend| 56 | - |nvim-tinygit-github-interaction| 57 | - |nvim-tinygit-push-&-prs| 58 | - |nvim-tinygit-file-history| 59 | - |nvim-tinygit-stash| 60 | - |nvim-tinygit-status-line-components| 61 | - |nvim-tinygit-git-blame| 62 | - |nvim-tinygit-branch-state| 63 | - |nvim-tinygit-file-state| 64 | - |nvim-tinygit-credits| 65 | 66 | 67 | INSTALLATION *nvim-tinygit-nvim-tinygit-installation* 68 | 69 | **Requirements** - nvim 0.10+ - A plugin implementing `vim.ui.select`, such as: 70 | snacks.picker mini.pick 71 | telescope.nvim 72 | with telescope-ui-select 73 | fzf-lua 74 | - For interactive staging: telescope.nvim 75 | . (For `snacks.nvim`, the 76 | `git_diff` picker allows interactive staging.) - For GitHub-related commands: 77 | `curl` - _Recommended_: Treesitter parser for syntax highlighting `TSInstall 78 | gitcommit`. 79 | 80 | >lua 81 | -- lazy.nvim 82 | { 83 | "chrisgrieser/nvim-tinygit", 84 | -- dependencies = "nvim-telescope/telescope.nvim", -- only for interactive staging 85 | }, 86 | 87 | -- packer 88 | use { 89 | "chrisgrieser/nvim-tinygit", 90 | -- requires = "nvim-telescope/telescope.nvim", -- only for interactive staging 91 | } 92 | < 93 | 94 | 95 | CONFIGURATION *nvim-tinygit-nvim-tinygit-configuration* 96 | 97 | The `setup` call is optional. 98 | 99 | >lua 100 | -- default config 101 | require("tinygit").setup { 102 | stage = { -- requires `telescope.nvim` 103 | contextSize = 1, -- larger values "merge" hunks. 0 is not supported. 104 | stagedIndicator = "󰐖", 105 | keymaps = { -- insert & normal mode 106 | stagingToggle = "", -- stage/unstage hunk 107 | gotoHunk = "", 108 | resetHunk = "", 109 | }, 110 | moveToNextHunkOnStagingToggle = false, 111 | 112 | -- accepts the common telescope picker config 113 | telescopeOpts = { 114 | layout_strategy = "horizontal", 115 | layout_config = { 116 | horizontal = { 117 | preview_width = 0.65, 118 | height = { 0.7, min = 20 }, 119 | }, 120 | }, 121 | }, 122 | }, 123 | commit = { 124 | keepAbortedMsgSecs = 300, 125 | border = getBorder(), -- `vim.o.winborder` on nvim 0.11, otherwise "rounded" 126 | spellcheck = false, -- vim's builtin spellcheck 127 | wrap = "hard", ---@type "hard"|"soft"|"none" 128 | keymaps = { 129 | normal = { abort = "q", confirm = "" }, 130 | insert = { confirm = "" }, 131 | }, 132 | keymapHints = true, 133 | preview = { 134 | loglines = 3, 135 | }, 136 | subject = { 137 | -- automatically apply formatting to the subject line 138 | autoFormat = function(subject) ---@type nil|fun(subject: string): string 139 | subject = subject:gsub("%.$", "") -- remove trailing dot 140 | return subject 141 | end, 142 | 143 | -- disallow commits that do not use an allowed type 144 | enforceType = false, 145 | -- stylua: ignore 146 | types = { 147 | "fix", "feat", "chore", "docs", "refactor", "build", "test", 148 | "perf", "style", "revert", "ci", "break", 149 | }, 150 | }, 151 | body = { 152 | enforce = false, 153 | }, 154 | }, 155 | push = { 156 | preventPushingFixupCommits = true, 157 | confirmationSound = true, -- currently macOS only, PRs welcome 158 | 159 | -- If pushed commits contain references to issues, open them in the browser 160 | -- (not used when force-pushing). 161 | openReferencedIssues = false, 162 | }, 163 | github = { 164 | icons = { 165 | openIssue = "🟢", 166 | closedIssue = "🟣", 167 | notPlannedIssue = "⚪", 168 | openPR = "🟩", 169 | mergedPR = "🟪", 170 | draftPR = "⬜", 171 | closedPR = "🟥", 172 | }, 173 | }, 174 | history = { 175 | diffPopup = { 176 | width = 0.8, -- between 0-1 177 | height = 0.8, 178 | border = getBorder(), -- `vim.o.winborder` on nvim 0.11, otherwise "rounded" 179 | }, 180 | autoUnshallowIfNeeded = false, 181 | }, 182 | appearance = { 183 | mainIcon = "󰊢", 184 | backdrop = { 185 | enabled = true, 186 | blend = 40, -- 0-100 187 | }, 188 | hlGroups = { 189 | addedText = "Added", -- i.e. use hlgroup `Added` 190 | removedText = "Removed", 191 | }, 192 | }, 193 | statusline = { 194 | blame = { 195 | ignoreAuthors = {}, -- hide component if from these authors (useful for bots) 196 | hideAuthorNames = {}, -- show component, but hide names (useful for your own name) 197 | showOnlyTimeIfAuthor = {}, -- show only time if these authors (useful for automated commits) 198 | maxMsgLen = 40, 199 | icon = "ﰖ", 200 | }, 201 | branchState = { 202 | icons = { 203 | ahead = "󰶣", 204 | behind = "󰶡", 205 | diverge = "󰃻", 206 | }, 207 | }, 208 | fileState = { 209 | icon = "", 210 | }, 211 | }, 212 | } 213 | < 214 | 215 | 216 | COMMANDS *nvim-tinygit-nvim-tinygit-commands* 217 | 218 | All commands are available as Lua function or as subcommand of `:Tinygit`, for 219 | example `require("tinygit").interactiveStaging()` and `:Tinygit 220 | interactiveStaging`. Note that the Lua function is preferable, since `:Tinygit` 221 | does not accept command-specific options and does not trigger visual-mode 222 | specific changes. 223 | 224 | 225 | INTERACTIVE STAGING ~ 226 | 227 | - Interactive straging requires `telescope`. 228 | - This command stages hunks, that is, _parts_ of a file instead of the full 229 | file. It is roughly comparable to `git add -p`. 230 | - Use `` to stage/unstage the hunk, `` to go to the hunk, or `` 231 | to reset the hunk (mappings customizable). Your regular `telescope` mappings 232 | also apply. 233 | - The size of the hunks is determined by the setting `staging.contextSize`. 234 | Larger context size is going to "merge" changes that are close to one another 235 | into one hunk. (As such, the hunks displayed are not 1:1 the same as the hunks 236 | from `gitsigns.nvim`.) A context size between 1 and 4 is recommended. 237 | - Limitation: `contextSize=0` (= no merging at all) is not supported. 238 | 239 | >lua 240 | require("tinygit").interactiveStaging() 241 | < 242 | 243 | 244 | [!NOTE] For `snacks.nvim`, you can just use the `git_diff` picker, which pretty 245 | much does the same thing. 246 | 247 | SMART COMMIT ~ 248 | 249 | - Open a commit popup, alongside a preview of what is going to be committed. If 250 | there are no staged changes, stage all changes (`git add --all`) before the 251 | commit. Optionally run `git push` if the repo is clean after committing. 252 | - The window title of the input field displays what actions are going to be 253 | performed. You can see at glance whether all changes are going to be 254 | committed, or whether there a `git push` is triggered afterward, so there are 255 | no surprises. 256 | - Input field contents of aborted commits are briefly kept, if you just want to 257 | fix a detail. 258 | - The first line is used as commit subject, the rest as commit body. 259 | 260 | >lua 261 | -- values shown are the defaults 262 | require("tinygit").smartCommit { pushIfClean = false, pullBeforePush = true } 263 | < 264 | 265 | **Example workflow** Assuming these keybindings: 266 | 267 | >lua 268 | vim.keymap.set( 269 | "n", 270 | "ga", 271 | function() require("tinygit").interactiveStaging() end, 272 | { desc = "git add" } 273 | ) 274 | vim.keymap.set( 275 | "n", 276 | "gc", 277 | function() require("tinygit").smartCommit() end, 278 | { desc = "git commit" } 279 | ) 280 | vim.keymap.set( 281 | "n", 282 | "gp", 283 | function() require("tinygit").push() end, 284 | { desc = "git push" } 285 | ) 286 | < 287 | 288 | 1. Stage some changes via `ga`. 289 | 2. Use `gc` to enter a commit message. 290 | 3. Repeat 1 and 2. 291 | 4. When done, use `gp` to push the commits. 292 | 293 | Using `require("tinygit").smartCommit({pushIfClean = true})` allows you to 294 | combine staging, committing, and pushing into a single step, when it is the 295 | last commit you intend to make. 296 | 297 | 298 | AMEND AND FIXUP COMMITS ~ 299 | 300 | **Amending** - `amendOnlyMsg` just opens the commit popup to change the last 301 | commit message, and does not stage any changes. - `amendNoEdit` keeps the last 302 | commit message; if there are no staged changes, stages all changes (`git add 303 | --all`), like `smartCommit`. - Optionally runs `git push --force-with-lease` 304 | afterward, if the branch has diverged (that is, the amended commit was already 305 | pushed). 306 | 307 | >lua 308 | -- values shown are the defaults 309 | require("tinygit").amendOnlyMsg { forcePushIfDiverged = false } 310 | require("tinygit").amendNoEdit { 311 | forcePushIfDiverged = false, 312 | stageAllIfNothingStaged = true, 313 | } 314 | < 315 | 316 | **Fixup commits** - `fixupCommit` lets you select a commit from the last X 317 | commits and runs `git commit --fixup` on the selected commit. - If there are no 318 | staged changes, stages all changes (`git add --all`), like `smartCommit`. - 319 | `autoRebase = true` automatically runs rebase with `--autosquash` and 320 | `--autostash` afterward, confirming all fixups and squashes **without opening a 321 | rebase to do editor**. Note that this can potentially result in conflicts. 322 | 323 | >lua 324 | -- values shown are the defaults 325 | require("tinygit").fixupCommit { 326 | selectFromLastXCommits = 15, 327 | autoRebase = false, 328 | } 329 | < 330 | 331 | 332 | UNDO LAST COMMIT/AMEND ~ 333 | 334 | >lua 335 | require("tinygit").undoLastCommitOrAmend() 336 | < 337 | 338 | - Changes in the working directory are kept but unstaged. (In the background, 339 | this uses `git reset --mixed`.) 340 | - If there was a `push` operation done as a followup, the last commit is not 341 | undone. 342 | 343 | 344 | GITHUB INTERACTION ~ 345 | 346 | **Search issues & PRs** - All GitHub interaction commands require `curl`. 347 | 348 | >lua 349 | -- state: all|closed|open (default: all) 350 | -- type: all|issue|pr (default: all) 351 | require("tinygit").issuesAndPrs { type = "all", state = "all" } 352 | 353 | -- alternative: if the word under the cursor is of the form `#123`, 354 | -- open that issue/PR 355 | require("tinygit").openIssueUnderCursor() 356 | < 357 | 358 | **GitHub URL** Creates a permalink to the current file/lines at GitHub. The 359 | link is opened in the browser and copied to the system clipboard. In normal 360 | mode, uses the current file, in visual mode, uses the selected lines. (Note 361 | that visual mode detection requires you to use the Lua function below instead 362 | of the `:Tinygit` ex-command.) - `"file"`: link to the file (normal mode) or 363 | the selected lines (visual mode) - `"blame"`: link to the blame view of the 364 | file - `"repo"`: link to the repo root 365 | 366 | >lua 367 | -- "file"|"repo"|"blame" (default: "file") 368 | require("tinygit").githubUrl("file") 369 | < 370 | 371 | 372 | PUSH & PRS ~ 373 | 374 | - `push` can be combined with other actions, depending on the options. 375 | - `createGitHubPr` opens a PR from the current branch browser. (This requires 376 | the 377 | repo to be a fork with sufficient information on the remote.) 378 | 379 | >lua 380 | -- values shown are the defaults 381 | require("tinygit").push { 382 | pullBefore = false, 383 | forceWithLease = false, 384 | createGitHubPr = false, 385 | } 386 | 387 | require("tinygit").createGitHubPr() 388 | -- to push before the PR, use `require("tinygit").push { createGitHubPr = true }` 389 | < 390 | 391 | 392 | FILE HISTORY ~ 393 | 394 | Search the git history of the current file. Select from the matching commits to 395 | open a popup with a diff view of the changes. 396 | 397 | If the config `history.autoUnshallowIfNeeded` is set to `true`, will also 398 | automatically unshallow the repo if needed. 399 | 400 | >lua 401 | require("tinygit").fileHistory() 402 | < 403 | 404 | The type of history search depends on the mode `.fileHistory` is called from: - 405 | **Normal mode**: search file history for a string (`git log -G`) Correctly 406 | follows file renamings, and displays past filenames in the commit selection. 407 | The search input is case-insensitive and supports regex. Leave the input field 408 | empty to display _all_ commits that changed the current file. - **Visual 409 | mode**: function history (`git log -L`). The selected text is assumed to be the 410 | name of the function whose history you want to explore. Note that `git` uses 411 | heuristics to determine the enclosing function of a change 412 | , so this is not 100% perfect 413 | and has varying reliability across languages. Caveat: for function history, git 414 | does not support to follow renamings of the file or function name. - **Visual 415 | line mode**: line range history (`git log -L`). Uses the selected lines as the 416 | line range. Caveat: for line history, git does not support to follow file 417 | renamings. 418 | 419 | Note that visual mode detection requires you to use the Lua function above 420 | instead of the `:Tinygit` ex-command. 421 | 422 | 423 | STASH ~ 424 | 425 | Simple wrappers around `git stash push` and `git stash pop`. 426 | 427 | >lua 428 | require("tinygit").stashPush() 429 | require("tinygit").stashPop() 430 | < 431 | 432 | 433 | STATUS LINE COMPONENTS *nvim-tinygit-nvim-tinygit-status-line-components* 434 | 435 | 436 | GIT BLAME ~ 437 | 438 | Shows the message and date (`git blame`) of the last commit that changed the 439 | current _file_ (not line). 440 | 441 | >lua 442 | require("tinygit.statusline").blame() 443 | < 444 | 445 | 446 | [!TIP] Some status line plugins also allow you to put components into the tab 447 | line or win bar. If your status line is too crowded, you can add the 448 | blame-component to one of those bars instead. 449 | The component can be configured with the `statusline.blame` options in the 450 | |nvim-tinygit-plugin-configuration|. 451 | 452 | 453 | BRANCH STATE ~ 454 | 455 | Shows whether the local branch is ahead or behind of its remote counterpart. 456 | (Note that this component does not run `git fetch` for performance reasons, so 457 | the component may not be up-to-date with remote changes.) 458 | 459 | >lua 460 | require("tinygit.statusline").branchState() 461 | < 462 | 463 | 464 | FILE STATE ~ 465 | 466 | Shows the number of changed files, similar to terminal prompts. 467 | 468 | >lua 469 | require("tinygit.statusline").fileState() 470 | < 471 | 472 | 473 | CREDITS *nvim-tinygit-nvim-tinygit-credits* 474 | 475 | In my day job, I am a sociologist studying the social mechanisms underlying the 476 | digital economy. For my PhD project, I investigate the governance of the app 477 | economy and how software ecosystems manage the tension between innovation and 478 | compatibility. If you are interested in this subject, feel free to get in 479 | touch. 480 | 481 | - Website 482 | - Mastodon 483 | - ResearchGate 484 | - LinkedIn 485 | 486 | 487 | 488 | Generated by panvimdoc 489 | 490 | vim:tw=78:ts=8:noet:ft=help:norl: 491 | --------------------------------------------------------------------------------