This Git Workflow guide was made with Neovim's CONTRIBUTING.md in mind; therefore, all examples and general workflow will try to mimic their documentation.
gitlazygit: this guide was made with lazygit in mind, but it can be reproduced using normal git commands.GitHub CLI (gh): the official gh-cli is preferred, but working from their web UI will also work.- neovim, and snacks plugin is mentioned but not required, you can still do
everything from any terminal, using
gh-clior their web UI.
Every step has [Policy] (what the rule is) and [Technique] (how to do it in lazygit/gh-cli).
Steps appended with (IMPORTANT) may need more time to understand or come back to, and that's okay.
- Step 1: Create the branch
- Step 2: Commit your work (IMPORTANT)
- Step 3: Open a PR (IMPORTANT)
- Step 4: Review
- Step 5: Address feedback
- Step 6: Decide your final commit shape
- Step 7: Force-push
[Policy]
Use a feature branch, not main; and always create a branch FROM
main.
[Technique]
Branches panel 3 → n → feat/add-horse → enter.
→ Continue to Step 2
[Policy]:
Avoid unrelated cosmetic changes in a commit, and the final commit(s) must eventually satisfy the format below:
type(scope): subject
Problem:
Solution:
except fixup commits (will be explain in later steps) or commits you already know will be squashed away.
See Conventional Commits in References for the general convention this format is based on.
Note
What "unrelated cosmetic changes" means: don't let incidental formatting/style diffs ride along in a functional commit just because your editor or LSP auto-touched something nearby, e.g. adding a feature while your editor also auto-sorts unrelated imports or reformats a block you didn't mean to touch. If a line's diff has nothing to do with what the commit message says it does, it doesn't belong in that commit. Reviewers (and git blame later) shouldn't have to filter formatter noise out of the functional diff.
There's no rule saying you must write bad messages now and clean up later. This step covers only the time before you push or open a PR, nobody sees these commits yet, so how you write them is entirely on you.
Ask yourself do you already know what this commit's final message should say?
- Yes → write
type(scope): subject + Problem/Solutionnow. No reason to write it twice, if this commit later gets squashed away, the message gets replaced anyway, so nothing was wasted; if it survives to the final PR, you're already done. - No, you're not sure this code will even survive (trying an approach that
might get thrown out, figuring out the right structure as you go) → commit
however you want for now,
wip,asdf, whatever. But before Step 3 (pushing / opening the PR), this branch must be cleaned up to meet thetype(scope): subject + Problem/Solutionformat shown above. Nobody outside your machine should ever see a commit that doesn't follow it.
[Technique]
- Files panel
2 spaceto stage.cto commit.
→ Continue to Step 3
[Policy]
Send a reasonably complete PR. Mark it Draft while you are not yet requesting feedback and still working; convert to Ready when it's actually ready for review.
Ask yourself are you confident this is done and correct right now?
- Yes code works, tests pass, you've read your own diff, you're not expecting to change the approach → open it directly as a normal (non-Draft) PR. There's no rule requiring you to sit in Draft first if you're not actually iterating anymore.
- No you want it pushed for CI feedback, or you want to keep working and
don't want to accidentally get reviewed/merged mid-change → open as Draft,
convert to Ready (
gh pr ready) once you hit the "Yes" case above.
Note
CI = Continuous Integration, automated checks that run against your code every time you push (build, tests, lint, analyzers). It runs whether you're Draft or not — pushing to a Draft PR still gets you CI feedback before anyone reviews it.
PR title: should match what the PR description already says. Same
principle as the export example below, since the feat commit already
tells the whole story (Case 1), the PR title is just that commit's
subject: feat(export): add PDF export option.
One commit: nothing to write. gh pr create --fill uses that commit's
subject/body directly as the PR itself. Since they're the same content,
there's nothing separate to produce.
More than one commit: how much you write depends on whether one commit already covers the whole point, or the PR needs its own explanation the commits don't individually give.
-
Case 1 a commit already tells the story. Example, say a PR has these two commits:
refactor(export): extract shared formatting logic for CSV and JSON export Problem: CSV and JSON export duplicate the same row-formatting logic. Solution: extract a shared formatter used by both.feat(export): add PDF export option Problem: users can only export data as CSV or JSON, not PDF. Solution: add an "Export as PDF" button using the shared formatter.The
featcommit already covers why this PR exists; the refactor is just what it needed first. So the PR description is that commit's body.Problem: users can only export data as CSV or JSON, not PDF. Solution: add an "Export as PDF" button using the shared formatter.Real neovim example of this exact pattern: #40619. The maintainer didn't summarize or write anything new, just copied the primary commit's Problem/Solution in as the PR body directly.
-
Case 2 no single commit covers it. Several related commits building toward one larger effort, where the overall point involves things no individual commit captures on its own like design tradeoffs, breaking changes, open questions. Here you write a separate
Problem/Solutionat the whole PR level. See #40621 and #40647. Both write substantially more at the PR level than any single commit says.
[Technique]
P to push, then:
gh pr create(interactive prompt for title/body)gh pr create --draftfor Draft,gh pr readylater to flip itgh pr create --fillonly for single-commit PRs
→ Continue to Step 4
Did the reviewer request changes?
[Policy]
This uses a rebase approach: your branch's history is treated as rewritable, not append-only. Instead of stacking a new commit for every review comment, you rewrite the existing commit(s) and force-push the corrected result.
[Technique]
- Make the fix, stage it.
ctrl-fin the Files panel to jump to the commit it belongs to.shift+Fto create afixup!commit targeting it.- Immediately
shift+Sto autosquash right there, before pushing anything. - Force-push the now-clean branch.
The fixup commit never leaves your machine; nobody sees it. Real example:
#40621, every force-push
in its timeline already shows clean commits (fix(ui2)!:,
feat(statusline):, docs(ui2):), and the author even comments "rebased
onto master and added news" directly. No fixup! commit ever appears in
the pushed history.
→ Return to Step 4
[Policy]
By this point your commits are already pushed (from Step 3, and likely several more times from Step 5's review rounds), this step rewrites them locally, then Step 7 force-pushes the result back.
For most PRs this isn't really a fresh decision, you already knew the shape back in Step 2 while writing the commits. You can treat this as a final checkpoint: confirm what you already knew, or catch cases where the commits had changed, reviews added fixups that blur a boundary, or a commit you thought was independent but turned out not to be.
Is this really one meaningful change, or several independently meaningful ones?
[Technique]
In the Commits panel (4):
ito start interactive rebase from the branch base.- Mark every commit but the first as
s(squash). mto continue.- Write the combined message when prompted; it should match your PR title
Step 3. You will notice all the commits you have squashed will end up
in the base commit's body, so make sure to rewrite the body of the commit to
Problem/Solution.
Real examples that are all single-parent squash commits. #40653, #40601, #40600, #40585, #40607
→ Continue to Step 7
[Technique]
In the Commits panel (4):
- Select a commit.
rto reword it.- Write its own
type(scope): subject + Problem/Solution. - Repeat for each remaining commit.
Real example: #40619, a real 2-parent merge commit preserving both original commits.
→ Continue to Step 7
[Policy]
Force-pushing after addressing review or rebasing is expected.
[Technique]
P, lazygit will prompt since history was rewritten; confirm.
→ Continue to Step 8
[Policy]
- If the branch ended up as a single commit (Step 6a) →
Squash Merge. In the snacks.nvim picker:
gh_squash. - If the branch ended up as multiple commits (Step 6b) →
Merge (real merge commit). In the snacks.nvim picker:
gh_merge.
| Step | Situation |
|---|---|
| 1 | Create feature branch |
| 2 | Commit |
| 3 | Open PR |
| 4 | Review decision point |
| 5 | Changes requested → local fixup + immediate autosquash + force-push |
| 6 | Decide commit count |
| 6a | Squash to one, full format required |
| 6b | Reword each, full format required |
| 7 | Force-push |
| Step | Situation |
|---|---|
| 8 | gh_squash (6a) or gh_merge (6b) |
- Conventional Commits — the general commit message convention the Step 2 format is based on.
Real neovim/neovim PRs used to verify this guide against actual practice.
| PR | Author | Commits |
|---|---|---|
| #40653 | echasnovski | 1 |
| #40619 | justinmk | 2 |
| #40522 | bfredl | 1 |
| #38546 | dchinmay2 | 2 |
| #40525 | zeertzjq | 6 |
| #40601 | barrettruth | 1 |
| #40600 | barrettruth | 1 |
| #40585 | prwang | 1 |
| #40607 | barrettruth | 1 |
| #40647 | barrettruth | 8 |
| #40621 | epithet | 6 |