Git worktrees as Neovim tabs. Each worktree gets its own tab with isolated buffers and a tab-local working directory.
This plugin was written entirely by Claude, Anthropic's AI assistant.
- One worktree per tab — create, switch, and remove worktrees without leaving Neovim
- Buffer isolation — each tab only shows buffers belonging to its worktree
- Buffer redirection — opening a file auto-switches to the tab that owns it
- Toggle mode — move a branch between worktree isolation and the main directory
- Session support — saves and restores worktree tabs with persistence.nvim
- Stable indices — each worktree gets a persistent numeric index (stored in git config), useful for keybindings and tab-bar labels
- Lifecycle callbacks — hooks for tab rename, worktree create/remove to integrate with tab-bar plugins or other tooling
worktabs-1.mp4
- Neovim >= 0.10
- git with worktree support
{
"aslogd/worktabs.nvim",
config = function()
require("worktabs").setup()
end,
}MiniDeps.add("aslogd/worktabs.nvim")
require("worktabs").setup()extraPlugins = [
(pkgs.vimUtils.buildVimPlugin {
name = "worktabs-nvim";
src = pkgs.fetchFromGitHub {
owner = "aslogd";
repo = "worktabs.nvim";
rev = "...";
hash = "...";
};
})
];Then in your extraConfigLua:
require("worktabs").setup()All options are optional. Defaults:
require("worktabs").setup({
-- Relative path from repo root where worktree directories are created.
-- Default places them in ../<repo-name>-worktrees/
worktree_base_path = "../",
-- Candidate names for the main branch (tried in order)
main_branch_name = { "main", "master" },
-- Enable debug logging via vim.notify
verbose = false,
-- Called when a tab gets a label.
-- Useful for integrating with tabline plugins.
---@param tabid number
---@param name string
on_tab_rename = nil,
-- Called after a worktree is created.
---@param opts { index: number, branch: string, path: string }
on_worktree_create = nil,
-- Called before a worktree is removed.
---@param opts { index: number, branch: string, path: string }
on_worktree_remove = nil,
})By default, worktrees are created as siblings to your repo:
~/projects/
my-project/ # main worktree (repo root)
my-project-worktrees/
feature-a/ # worktree for branch feature-a
fix-bug/ # worktree for branch fix-bug
Change worktree_base_path to customize this. The value is relative to the repo root.
| Command | Description |
|---|---|
:Worktabs add <branch> [--from <base>] |
Create a worktree for <branch> and open it in a new tab. Optionally branch from <base> instead of the main branch. |
:Worktabs remove |
Close the current worktree tab. Prompts whether to also delete the git worktree. |
:Worktabs toggle |
From a worktree tab: collapse it back into the main directory. From the main tab: move the current branch into a worktree. |
:Worktabs list |
Print all active worktab mappings. |
Tab completion is provided for subcommands and branch names.
local worktabs = require("worktabs")
worktabs.add(branch, opts?) -- Create/open a worktree tab
worktabs.remove(opts?) -- Close current worktree tab (opts.delete = true|false to skip prompt)
worktabs.toggle() -- Toggle branch between worktree and main dir
worktabs.list() -- Returns list of { tabid, worktree_path, branch }
worktabs.print_list() -- Display worktab list
worktabs.pick() -- Interactive picker to switch between open tabs
worktabs.pick_worktrees() -- Interactive picker to open an existing git worktree
worktabs.pick_branches() -- Interactive picker to open any branch (fetches remotes)
worktabs.set_root(path) -- Change the main tab's root directoryworktabs.nvim does not set any keymaps. Example configuration using <leader>wt as the prefix (avoids conflict with common <leader>w window mappings):
-- Create a new worktree from the main branch
vim.keymap.set("n", "<leader>wtm", function()
vim.ui.input({ prompt = "Branch name (from main): " }, function(branch)
if branch and branch ~= "" then
require("worktabs").add(branch)
end
end)
end, { desc = "New worktab from main" })
-- Pick any local/remote branch and open as worktree
vim.keymap.set("n", "<leader>wta", function() require("worktabs").pick_branches() end, { desc = "New worktab from branch" })
-- Close tab but keep the worktree on disk
vim.keymap.set("n", "<leader>wtc", function() require("worktabs").remove({ delete = false }) end, { desc = "Close worktab (keep worktree)" })
-- Close tab and delete the worktree
vim.keymap.set("n", "<leader>wtd", function() require("worktabs").remove({ delete = true }) end, { desc = "Close worktab and delete worktree" })
-- Toggle between worktree isolation and main directory
vim.keymap.set("n", "<leader>wtt", "<cmd>Worktabs toggle<cr>", { desc = "Toggle worktree/main" })
-- Pick from open worktab tabs
vim.keymap.set("n", "<leader>wtl", function() require("worktabs").pick() end, { desc = "List worktabs" })
-- Open an existing (on-disk) worktree as a tab
vim.keymap.set("n", "<leader>wto", function() require("worktabs").pick_worktrees() end, { desc = "Open worktree as worktab" })If you use which-key.nvim, register the group:
require("which-key").add({
{ "<leader>wt", group = "Worktab" },
})Run :checkhealth worktabs to verify git and worktree support are available.
Use on_tab_rename to set tab names in tabby.nvim:
require("worktabs").setup({
on_tab_rename = function(tabid, name)
-- Persist in TabbyTabNames so names survive re-renders
local tabnr = vim.api.nvim_tabpage_get_number(tabid)
local ok, existing = pcall(vim.json.decode, vim.g.TabbyTabNames or "{}")
if ok and type(existing) == "table" then
existing[tostring(tabnr)] = name
vim.g.TabbyTabNames = vim.json.encode(existing)
end
-- Update tabby's internal state
vim.schedule(function()
local tab_ok, tab_name = pcall(require, "tabby.feature.tab_name")
if tab_ok then
tab_name.set(tabid, name)
end
end)
end,
})Since worktabs isolates buffers per tab via buflisted, buffer-based tablines already show the correct buffers on each tab without extra configuration.
Each worktree gets a stable numeric index (1, 2, 3, ...) persisted in git's local config under worktab.<branch>.index. Indices are gap-filled — if you remove worktree 2, the next one created takes index 2 again.
The index is available in two ways:
- Inside Neovim — via the
on_worktree_create/on_worktree_removecallbacks (opts.index) - Outside Neovim — via git config, readable from any shell:
git config --local worktab.<branch>.index
This makes indices useful for both Neovim integrations and external tooling.
Jump to worktree tabs by number:
require("worktabs").setup({
on_worktree_create = function(opts)
vim.keymap.set("n", "<leader>" .. opts.index, function()
local tabs = require("worktabs").list()
for _, t in ipairs(tabs) do
if t.worktree_path == opts.path then
vim.api.nvim_set_current_tabpage(t.tabid)
return
end
end
end, { desc = "Worktab " .. opts.index .. ": " .. opts.branch })
end,
on_worktree_remove = function(opts)
pcall(vim.keymap.del, "n", "<leader>" .. opts.index)
end,
})Use the index to derive unique ports, so dev servers across worktrees don't collide.
With direnv (.envrc at your repo root):
branch=$(git branch --show-current)
index=$(git config --local "worktab.${branch}.index" 2>/dev/null || echo 0)
export PORT=$((3000 + index))Or from a shell script / Makefile:
#!/bin/sh
branch=$(git branch --show-current)
index=$(git config --local "worktab.${branch}.index" 2>/dev/null || echo 0)
exec npm start -- --port $((3000 + index))Worktree 1 gets port 3001, worktree 2 gets 3002, and so on. The main directory (index 0) keeps the default port.
Use set_root() to change the main tab's working directory when switching projects. This pairs well with a file explorer:
-- Helper that sets the worktabs root and opens a file tree
local function open_project(path)
require("worktabs").set_root(path)
vim.cmd("Neotree") -- or your preferred file explorer
endworktabs.nvim integrates with persistence.nvim out of the box. It handles:
- Switching to the main tab's root before session save (so the session file records the correct project root)
- Restoring worktree-to-tab mappings after session load
- Clearing stale state when a new session is loaded
No extra configuration is needed — just make sure worktabs.setup() is called before persistence loads a session.
worktabs.nvim ships a terminal provider for claudecode.nvim that gives each worktree tab its own independent Claude Code process:
require("claudecode").setup({
terminal = {
provider = require("worktabs.claude"),
},
})Each tab gets its own terminal split. Toggling Claude Code on one tab doesn't affect another. The terminal's cwd follows the tab's worktree path.
Note: Terminal buffers cannot be persisted by mksession. If you use persistence.nvim, close Claude terminals before session save to avoid stale buffers on restore:
vim.api.nvim_create_autocmd("User", {
pattern = "PersistenceSavePre",
callback = function()
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_valid(bufnr) and vim.bo[bufnr].buftype == "terminal" then
pcall(vim.api.nvim_buf_delete, bufnr, { force = true })
end
end
end,
})To gracefully fall back to the native provider when worktabs isn't installed:
local provider = "native"
local ok, wt_claude = pcall(require, "worktabs.claude")
if ok then provider = wt_claude end
require("claudecode").setup({
terminal = { provider = provider },
})All pickers (pick(), pick_worktrees(), pick_branches()) use vim.ui.select. Override it with any UI plugin:
-- fzf-lua (recommended — register once during setup)
require("fzf-lua").register_ui_select()
-- telescope ui-select
require("telescope").load_extension("ui-select")
-- dressing.nvim (automatic, just install it)worktabs.nvim automatically detaches gitsigns.nvim from buffers before deleting a worktree directory, preventing file watcher errors. No configuration needed.
-
Setup registers the current tab as the "main" tab (your repo root) and creates autocommands for buffer filtering, redirection, and session management.
-
Adding a worktree runs
git worktree add, opens a new Neovim tab, setstcd(tab-local working directory) to the worktree path, and registers the mapping in internal state. -
Buffer isolation is enforced on every
TabEnter: buffers inside the current tab's worktree are listed, buffers belonging to other worktree tabs are hidden (unlisted but kept alive), and orphan buffers are wiped. -
Buffer redirection fires on
BufAdd: if you open a file that belongs to a different worktree tab, Neovim automatically switches to that tab. -
Toggle moves a branch between worktree isolation and the main directory by removing/creating the worktree and checking out the branch in the appropriate location.
-
Worktree indices are persisted in git's local config under
worktab.<branch>.index, surviving Neovim restarts. Indices are gap-filled to stay compact.
Requires devenv. Enter the dev shell to get Neovim, git, and Claude Code:
devenv shelldevenv test
# or directly:
nvim --headless -u NONE -l test/run.luaTests set up a temporary git repo, exercise all core modules headlessly, and clean up after themselves. Exit code is 0 on success, 1 on failure.
To use your local checkout instead of the installed version, point your plugin manager at the path on disk.
{
dir = "~/repos/worktabs.nvim",
config = function()
require("worktabs").setup()
end,
}MiniDeps.add({ name = "worktabs.nvim", path = "~/repos/worktabs.nvim" })
require("worktabs").setup()Override the source with a local path:
extraPlugins = [
(pkgs.vimUtils.buildVimPlugin {
name = "worktabs-nvim";
src = /absolute/path/to/worktabs.nvim;
})
];Add the repo to your runtime path before calling setup:
vim.opt.rtp:prepend("~/repos/worktabs.nvim")
require("worktabs").setup()MIT