Neojj is a hard fork of Neogit, adapted to work with jj (Jujutsu VCS) instead of git.
Maintainer: Nicholas Zolton
Note: Some features (specifically diffview and codediff integration) currently require a git-colocated repository. Work is underway to remove this dependency.
Requires jj (Jujutsu VCS) to be installed and available on your PATH.
Here's an example spec for Lazy, but you're free to use whichever plugin manager suits you.
{
"NicholasZolton/neojj",
version = "^1.0.0", -- recommended: track 1.x releases (see Versioning below)
lazy = true,
dependencies = {
"nvim-lua/plenary.nvim", -- required
-- Only one of these is needed.
"sindrets/diffview.nvim", -- optional
"esmuellert/codediff.nvim", -- optional
-- Only one of these is needed.
"nvim-telescope/telescope.nvim", -- optional
"ibhagwan/fzf-lua", -- optional
"nvim-mini/mini.pick", -- optional
"folke/snacks.nvim", -- optional
},
cmd = "Neojj",
keys = {
{ "<leader>gg", "<cmd>Neojj<cr>", desc = "Show Neojj UI" }
}
}NeoJJ follows Semantic Versioning: breaking changes only
land in major releases. With version = "^1.0.0", lazy.nvim installs the
latest 1.x tag — you get new features and fixes on :Lazy update, but a
breaking 2.0 requires you to bump the pin yourself. Drop the version line
to track main directly (bleeding edge, may break without warning). Each
release is documented in CHANGELOG.md.
You can either open Neojj by using the Neojj command:
:Neojj " Open the status buffer in a new tab
:Neojj cwd=<cwd> " Use a different repository path
:Neojj cwd=%:p:h " Uses the repository of the current file
:Neojj kind=<kind> " Open specified popup directly
:Neojj commit " Open commit popup
:Neojj bookmark " Open bookmark popup
:Neojj workspace " Open workspace popup
" Map it to a key
nnoremap <leader>gg <cmd>Neojj<cr>-- Or via lua api
vim.keymap.set("n", "<leader>gg", "<cmd>Neojj<cr>", { desc = "Open Neojj UI" })Or using the lua api:
local neojj = require('neojj')
-- open using defaults
neojj.open()
-- open a specific popup
neojj.open({ "commit" })
-- open as a split
neojj.open({ kind = "split" })
-- open with different project
neojj.open({ cwd = "~" })
-- You can map this to a key
vim.keymap.set("n", "<leader>gg", neojj.open, { desc = "Open Neojj UI" })
-- Wrap in a function to pass additional arguments
vim.keymap.set(
"n",
"<leader>gg",
function() neojj.open({ kind = "split" }) end,
{ desc = "Open Neojj UI" }
)The kind option can be one of the following values:
tab(default)replacesplitsplit_abovesplit_above_allsplit_belowsplit_below_allvsplitfloatingauto(vsplitif window would have 80 cols, otherwisesplit)
If you are coming from git, here are some key differences in jj:
- Change IDs vs Commit IDs: Every change has a unique change ID (short, stable identifier) and a commit ID (hash). Change IDs persist across rewrites; commit IDs do not.
- No staging area: There is no index/staging concept. All modifications in your working copy are automatically part of the current change.
- Bookmarks instead of branches: jj uses "bookmarks" where git uses "branches". Bookmarks are pointers to commits, similar to git branches.
- Operations log and undo: Every jj operation is recorded. You can undo any operation with
jj undo. - First-class conflicts: Conflicts are recorded in commits rather than blocking operations. You can continue working and resolve them later.
You can configure Neojj by running the require('neojj').setup {} function, passing a table as the argument.
Default Config
local neojj = require("neojj")
neojj.setup {
-- Path to jj binary. "auto" = auto-detect (resolves shims for performance).
jj_binary = "auto",
-- Hides the hints at the top of the status buffer
disable_hint = false,
-- Disables changing the buffer highlights based on where the cursor is.
disable_context_highlighting = false,
-- Disables signs for sections/items/hunks
disable_signs = false,
-- Changes what mode the Commit Editor starts in. `true` will leave nvim in normal mode, `false` will change nvim to
-- insert mode, and `"auto"` will change nvim to insert mode IF the commit message is empty, otherwise leaving it in
-- normal mode.
disable_insert_on_commit = "auto",
-- When enabled, will watch the `.jj/` directory for changes and refresh the status buffer in response to filesystem
-- events.
filewatcher = {
enabled = true,
},
-- "ascii" is the graph the jj CLI generates
-- "unicode" is a unicode graph style
graph_style = "ascii",
-- Show relative date by default. When set, use `strftime` to display dates
commit_date_format = nil,
log_date_format = nil,
-- Show message with spinning animation when a jj command is running.
process_spinner = false,
-- Allows a different telescope sorter. Defaults to 'fuzzy_with_index_bias'. The example below will use the native fzf
-- sorter instead. By default, this function returns `nil`.
telescope_sorter = function()
return require("telescope").extensions.fzf.native_fzf_sorter()
end,
-- Persist the values of switches/options within and across sessions
remember_settings = true,
-- Scope persisted settings on a per-project basis
use_per_project_settings = true,
-- Table of settings to never persist. Uses format "Filetype--cli-value"
ignored_settings = {},
-- Configure highlight group features
highlight = {
italic = true,
bold = true,
underline = true
},
-- Set to false if you want to be responsible for creating _ALL_ keymappings
use_default_keymaps = true,
-- Change the default way of opening Neojj
kind = "tab",
-- Floating window style
floating = {
relative = "editor",
width = 0.8,
height = 0.7,
style = "minimal",
border = "rounded",
},
-- Disable line numbers
disable_line_numbers = true,
-- Disable relative line numbers
disable_relative_line_numbers = true,
-- The time after which an output console is shown for slow running commands
console_timeout = 2000,
-- Automatically show console if a command takes more than console_timeout milliseconds
auto_show_console = true,
-- If `auto_show_console` is enabled, specify "output" (default) to show
-- the console always, or "error" to auto-show the console only on error
auto_show_console_on = "output",
-- Automatically close the console if the process exits with a 0 (success) status
auto_close_console = true,
notification_icon = "",
-- Shell command to run in new workspace directory after creation. {path} is replaced with the workspace path.
workspace_open_command = nil,
-- Shell command to initialize a new workspace before opening. {path} is replaced with the workspace path.
workspace_initialize_command = nil,
-- Base directory for quick-add worktrees (used by workspace popup's quick add action)
workspace_worktrees_directory = "~/.worktrees",
status = {
show_head_commit_hash = true,
recent_commit_count = 10,
HEAD_padding = 10,
HEAD_folded = false,
mode_padding = 3,
mode_text = {
M = "modified",
N = "new file",
A = "added",
D = "deleted",
C = "copied",
R = "renamed",
["?"] = "",
},
},
commit_editor = {
kind = "tab",
show_diff = true,
diff_split_kind = "split",
spell_check = true,
},
commit_select_view = {
kind = "tab",
},
commit_view = {
kind = "vsplit",
verify_commit = vim.fn.executable("gpg") == 1,
},
log_view = {
kind = "tab",
},
reflog_view = {
kind = "tab",
},
preview_buffer = {
kind = "floating_console",
},
popup = {
kind = "split",
},
signs = {
-- { CLOSED, OPENED }
hunk = { "", "" },
item = { ">", "v" },
section = { ">", "v" },
},
-- Each Integration is auto-detected through plugin presence, however, it can be disabled by setting to `false`
integrations = {
-- If enabled, use telescope for menu selection rather than vim.ui.select.
-- Allows multi-select and some things that vim.ui.select doesn't.
telescope = nil,
-- Neojj only provides inline diffs. If you want a more traditional way to look at diffs, you can use `diffview`.
-- The diffview integration enables the diff popup.
--
-- Requires you to have `sindrets/diffview.nvim` installed.
diffview = nil,
-- Alternative diff viewer integration.
-- Requires you to have `esmuellert/codediff.nvim` installed.
codediff = nil,
-- If enabled, uses fzf-lua for menu selection. If the telescope integration
-- is also selected then telescope is used instead
-- Requires you to have `ibhagwan/fzf-lua` installed.
fzf_lua = nil,
-- If enabled, uses mini.pick for menu selection. If the telescope integration
-- is also selected then telescope is used instead
-- Requires you to have `echasnovski/mini.pick` installed.
mini_pick = nil,
-- If enabled, uses snacks.picker for menu selection. If the telescope integration
-- is also selected then telescope is used instead
-- Requires you to have `folke/snacks.nvim` installed.
snacks = nil,
},
-- Which diff viewer to use. nil = auto-detect (tries diffview first, then codediff).
-- Can be "diffview" or "codediff".
diff_viewer = nil,
-- Forge (GitHub, ...) integration. When enabled and the forge CLI (`gh`) is
-- installed, bookmarks with a matching open PR are annotated with the PR
-- number, and "o" on such a line opens the PR instead of the commit.
forge = {
pr_integration = true,
-- Extra remote hosts per provider, e.g. for GitHub Enterprise:
-- hosts = { github = { "git.yourcompany.com" } },
hosts = {},
},
sections = {
files = {
folded = false,
hidden = false,
},
conflicts = {
folded = false,
hidden = false,
},
untracked = {
folded = false,
hidden = false,
},
bookmarks = {
folded = true,
hidden = false,
show_deleted = true,
show_remote = true,
},
recent = {
folded = true,
hidden = false,
},
},
mappings = {
commit_editor = {
["q"] = "Close",
["<c-c><c-c>"] = "Submit",
["<c-c><c-k>"] = "Abort",
["<m-p>"] = "PrevMessage",
["<m-n>"] = "NextMessage",
["<m-r>"] = "ResetMessage",
},
commit_editor_I = {
["<c-c><c-c>"] = "Submit",
["<c-c><c-k>"] = "Abort",
},
finder = {
["<cr>"] = "Select",
["<c-c>"] = "Close",
["<esc>"] = "Close",
["<c-n>"] = "Next",
["<c-p>"] = "Previous",
["<down>"] = "Next",
["<up>"] = "Previous",
["<tab>"] = "InsertCompletion",
["<c-y>"] = "CopySelection",
["<space>"] = "MultiselectToggleNext",
["<s-space>"] = "MultiselectTogglePrevious",
["<c-j>"] = "NOP",
["<ScrollWheelDown>"] = "ScrollWheelDown",
["<ScrollWheelUp>"] = "ScrollWheelUp",
["<ScrollWheelLeft>"] = "NOP",
["<ScrollWheelRight>"] = "NOP",
["<LeftMouse>"] = "MouseClick",
["<2-LeftMouse>"] = "NOP",
},
-- Setting any of these to `false` will disable the mapping.
popup = {
["?"] = "HelpPopup",
["b"] = "BookmarkPopup",
["c"] = "CommitPopup",
["d"] = "DiffPopup",
["f"] = "FetchPopup",
["l"] = "LogPopup",
["m"] = "RemotePopup",
["p"] = "PushPopup",
["r"] = "RebasePopup",
["s"] = "SquashPopup",
["u"] = "UndoPopup",
["w"] = "WorkspacePopup",
},
status = {
["j"] = "MoveDown",
["k"] = "MoveUp",
["o"] = "OpenTree",
["q"] = "Close",
["I"] = "InitRepo",
["1"] = "Depth1",
["2"] = "Depth2",
["3"] = "Depth3",
["4"] = "Depth4",
["Q"] = "Command",
["<tab>"] = "Toggle",
["za"] = "Toggle",
["zo"] = "OpenFold",
["zc"] = "CloseFold",
["zC"] = "Depth1",
["zO"] = "Depth4",
["x"] = "Discard",
["K"] = "Untrack",
["R"] = "Rename",
["y"] = "ShowRefs",
["$"] = "CommandHistory",
["Y"] = "YankSelected",
["gp"] = "GoToParentRepo",
["<c-r>"] = "RefreshBuffer",
["<cr>"] = "GoToFile",
["<s-cr>"] = "PeekFile",
["<c-v>"] = "VSplitOpen",
["<c-x>"] = "SplitOpen",
["<c-t>"] = "TabOpen",
["{"] = "GoToPreviousHunkHeader",
["}"] = "GoToNextHunkHeader",
["[c"] = "OpenOrScrollUp",
["]c"] = "OpenOrScrollDown",
["<c-k>"] = "PeekUp",
["<c-j>"] = "PeekDown",
["<c-n>"] = "NextSection",
["<c-p>"] = "PreviousSection",
},
},
}The following popup menus are available from the status buffer (press ? for the help popup to see all keybindings):
| Key | Popup | Description |
|---|---|---|
? |
Help | Show available keybindings |
b |
Bookmark | Create, move, delete, forget, rename, track/untrack bookmarks. Move bookmark to another bookmark. |
c |
Change | Commit, new change, new on revision/bookmark, new before, describe, edit, abandon, duplicate, revert. Uppercase variants move bookmarks forward. |
d |
Diff | View diffs (working copy, range, trunk to working copy, specific change, diffedit) |
f |
Fetch | Fetch from remotes |
l |
Log | View log with revset support |
m |
Remote | Add, remove, rename remotes |
p |
Push | Push bookmarks to remotes |
r |
Rebase | Rebase changes (single, range, onto revision) |
s |
Squash | Squash changes into parent |
u |
Undo | Undo/redo jj operations |
w |
Workspace | Add, delete, forget, rename, list workspaces. Quick-add to worktrees directory. |
Many popups will use whatever is currently under the cursor or selected as input for an action. For example, to rebase a range of changes from the log view, a linewise visual selection can be made, and the rebase action will apply to that selection.
The status buffer shows:
- Change / Parent headers with change ID, commit ID, bookmarks (highlighted), and description
- Conflicts section (if any unresolved conflicts exist)
- Modified files with inline diff support (toggle with
<tab>) - Recent Changes showing ancestor commits
- Bookmarks section with local and remote bookmarks (unpushed bookmarks marked with
*)
These keybindings work directly in the status buffer on whatever is under your cursor (a commit in Recent Changes, a bookmark, etc.):
| Key | Action | Description |
|---|---|---|
D |
Describe | Edit the description of the change under cursor |
E |
Edit | Switch your working copy to the change under cursor |
O |
New change on | Create a new change on top of the revision under cursor |
B |
New change before | Insert a new change before the revision under cursor |
F |
Forget bookmark | Forget the bookmark under cursor (or track a remote bookmark) |
o |
Open in browser | Open the line under cursor on GitHub/GitLab: the repo on the project header, the open PR on a line annotated with one, otherwise the commit |
x |
Discard / Abandon | Discard file changes, abandon the change (or divergent variant) under cursor, or delete a bookmark under cursor |
Note
Keybind convention: lowercase keys open popups and pickers; capital letters are contextual actions that act on whatever is under the cursor.
Migrating from older versions: the Squash, Workspace, Push, and Remote popups moved from
S/W/P/M to s/w/p/m. The A (abandon current change) and N (new change)
bindings were removed — use x to abandon the change under the cursor, c a to abandon via
picker, and c n / O / B to create new changes. Custom mappings.popup configs are
unaffected.
Checking out a PR / someone else's branch
In git you'd run git fetch && git checkout feature-branch. In jj, bookmarks are the equivalent of branches:
- Press
fto open the Fetch popup, thenfto fetch all remotes - The remote bookmark will appear in the Bookmarks section (shown as
name@origin) - Move your cursor to the remote bookmark and press
Oto create a new change on top of it — Neojj automatically tracks the remote bookmark for you
Alternatively, from the Change popup (c):
- Press
pto pick a bookmark from a fuzzy finder and create a new change on it - Press
oto pick any revision from a fuzzy finder
CLI equivalent: jj git fetch && jj bookmark track <name>@origin && jj new <bookmark-name>
Creating a PR / pushing a branch
In git you'd create a branch, commit, and push. In jj:
- Make your changes — they're automatically part of the current change (no staging needed)
- Press
cthencto commit (opens an editor for the description), orcthenDto describe inline - Open the Bookmark popup with
b, thencto create a bookmark on your change - Press
pto open the Push popup and push the bookmark
To keep working after committing, press c then n to create a new change on top.
If you want bookmarks to automatically follow your new changes, use the uppercase variants: c then C (commit + move bookmarks) or c then N (new change + move bookmarks).
CLI equivalent: jj commit -m "msg" && jj bookmark create my-branch && jj git push --bookmark my-branch
Stashing changes
You don't need to stash in jj. Every change is automatically its own "stash." To set aside current work:
- Press
cthennto create a new change — your previous work stays in the parent change - Do whatever you need to do
- Press
ctheneto edit back to your original change when you're ready to resume
Since there's no staging area, your working copy changes are always safely captured.
CLI equivalent: jj new (to set aside) then jj edit <change-id> (to resume)
Rebasing onto latest main
In git you'd run git rebase main. In jj:
- Press
ffto fetch the latest changes - Press
rto open the Rebase popup - Press
hto rebase here (@) onto a selected revision, ortto rebase the whole stack onto trunk
If you have a chain of changes, use s in the rebase popup to rebase a source and all its descendants.
CLI equivalent: jj git fetch && jj rebase -d main
Resolving merge conflicts
In jj, conflicts don't block operations — they're recorded in the commit. You can resolve them whenever you want:
- Conflicts appear in the Conflicts section of the status buffer
- Edit the conflicted files directly — jj uses conflict markers similar to git
- Once resolved, the conflict markers disappear from the status buffer on next refresh
CLI equivalent: jj resolve (to use a merge tool) or edit files directly
Cherry-picking a commit
In jj, the equivalent of cherry-pick is "duplicate":
- Press
cto open the Change popup - Press
uto duplicate — pick a change from the fuzzy finder - The duplicated change appears as a new change with the same content
CLI equivalent: jj duplicate <change-id>
Creating a change on top of a bookmark or revision
Quickest way (from status buffer): Move your cursor to a commit in Recent Changes or a bookmark in the Bookmarks section, then press O. This creates a new change directly on top of that revision.
From the Change popup (c):
o— pick any revision from a fuzzy finderO— same, but also moves bookmarks forwardp— pick a bookmark from a fuzzy finderP— same, but also moves bookmarks forward
Inserting a change before an existing one
Sometimes you need to insert a fix before an existing change rather than on top of it.
From status buffer: Move your cursor to the target revision and press B.
From the Change popup (c): Press b to pick a revision from a fuzzy finder.
This runs jj new --insert-before, which creates a new change whose child is the target revision.
Moving bookmarks with your changes
When you create new changes with c then n, bookmarks stay on the old change. If you want bookmarks to follow along:
cthenN— new change + move bookmarks forwardcthenC— commit + move bookmarks forwardcthenO— new change on revision + move bookmarks forwardcthenP— new change on bookmark + move bookmarks forward
The uppercase variants move any bookmarks that were on the old @ to the new @, and bookmarks from the old @- to the new @-.
Splitting and squashing changes
Splitting: Use jj split from the command line to break a change into two. Neojj will support this in a future release.
Squashing (s popup): Merge changes together.
s— squash the current change into its parentS— squash into a specific revision (picked from fuzzy finder)a— absorb changes into prior commits automatically
Navigating and editing historical changes
In jj, you can edit any change in your history, not just the latest one:
- In the Recent Changes section, move your cursor to a commit
- Press
Eto edit that change — your working copy switches to it - Make your modifications (they're automatically captured)
- Press
cthennto create a new change when done, orEon another commit to jump elsewhere
Press D on any commit to edit its description without switching to it.
Use [c and ]c to scroll through a commit's diff in a side panel without leaving the status buffer.
Neojj includes full support for jj workspaces via the w popup:
- Add (
a) / Add at revision (A) — create a new workspace at a chosen path (defaults to parent of repo root) - Quick add (
q) / Quick add at revision (Q) — instantly create a workspace with a random name under a configurable worktrees directory (default~/.worktrees) - Forget (
f) — stop tracking a workspace (files stay on disk) - Delete (
d) — forget and remove the workspace directory - Rename (
r) — rename the current workspace - List (
l) — list all workspaces with paths - Update stale (
u) — recover a stale workspace
Configure hooks to automatically open new workspaces:
neojj.setup {
-- Open a new tmux window in the workspace directory
workspace_open_command = "tmux new-window -c {path}",
-- Run a command in the workspace before opening (e.g., install deps)
workspace_initialize_command = nil,
-- Base directory for quick-add worktrees
workspace_worktrees_directory = "~/.worktrees",
}See the built-in documentation for a comprehensive list of highlight groups. If your theme doesn't style a particular group, we'll try our best to do a nice job.
Neojj emits the following events:
| Event | Description | Event Data |
|---|---|---|
NeojjStatusRefreshed |
Status has been reloaded | {} |
NeojjCommitComplete |
Commit has been created | {} |
NeojjDescribeComplete |
Description has been updated | {} |
NeojjNewChangeComplete |
New change has been created | {} |
NeojjSquashComplete |
Squash has completed | {} |
NeojjPushComplete |
Push has completed | {} |
NeojjFetchComplete |
Fetch has completed | {} |
NeojjBookmarkCreate |
Bookmark was created | { bookmark_name: string } |
NeojjBookmarkDelete |
Bookmark was deleted | { bookmark_name: string } |
NeojjRebaseComplete |
A rebase finished | { commit: string, status: "ok"|"conflict" } |
NeojjAbandonComplete |
A change was abandoned | { change_id: string } |
Neojj is a hard fork of Neogit. Thanks to the Neogit contributors for building the foundation this project is based on. This would not be possible without their work, and I personally use Neogit religiously for all things git.
