Skip to content

AsLogd/worktabs.nvim

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

worktabs.nvim

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.

Features

  • 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

Requirements

  • Neovim >= 0.10
  • git with worktree support

Installation

{
  "aslogd/worktabs.nvim",
  config = function()
    require("worktabs").setup()
  end,
}
MiniDeps.add("aslogd/worktabs.nvim")
require("worktabs").setup()

Nix (nixvim / buildVimPlugin)

extraPlugins = [
  (pkgs.vimUtils.buildVimPlugin {
    name = "worktabs-nvim";
    src = pkgs.fetchFromGitHub {
      owner = "aslogd";
      repo = "worktabs.nvim";
      rev = "...";
      hash = "...";
    };
  })
];

Then in your extraConfigLua:

require("worktabs").setup()

Configuration

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,
})

Worktree directory layout

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.

Commands

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.

Lua API

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 directory

Keymaps

worktabs.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" },
})

Health check

Run :checkhealth worktabs to verify git and worktree support are available.

Recipes

Tabline integration (tabby.nvim)

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,
})

Tabline integration (barbar.nvim, bufferline.nvim)

Since worktabs isolates buffers per tab via buflisted, buffer-based tablines already show the correct buffers on each tab without extra configuration.

Worktree indices

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_remove callbacks (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.

Keybindings by index

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,
})

Port assignment per worktree

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.

Project switching with set_root

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
end

Session persistence (persistence.nvim)

worktabs.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.

Per-tab Claude Code terminal (claudecode.nvim)

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 },
})

Picker UI (fzf-lua, telescope, dressing.nvim)

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)

Automatic gitsigns cleanup

worktabs.nvim automatically detaches gitsigns.nvim from buffers before deleting a worktree directory, preventing file watcher errors. No configuration needed.

How it works

  1. Setup registers the current tab as the "main" tab (your repo root) and creates autocommands for buffer filtering, redirection, and session management.

  2. Adding a worktree runs git worktree add, opens a new Neovim tab, sets tcd (tab-local working directory) to the worktree path, and registers the mapping in internal state.

  3. 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.

  4. Buffer redirection fires on BufAdd: if you open a file that belongs to a different worktree tab, Neovim automatically switches to that tab.

  5. 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.

  6. Worktree indices are persisted in git's local config under worktab.<branch>.index, surviving Neovim restarts. Indices are gap-filled to stay compact.

Development

Requires devenv. Enter the dev shell to get Neovim, git, and Claude Code:

devenv shell

Running tests

devenv test
# or directly:
nvim --headless -u NONE -l test/run.lua

Tests 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.

Loading the local plugin

To use your local checkout instead of the installed version, point your plugin manager at the path on disk.

lazy.nvim

{
  dir = "~/repos/worktabs.nvim",
  config = function()
    require("worktabs").setup()
  end,
}

mini.deps

MiniDeps.add({ name = "worktabs.nvim", path = "~/repos/worktabs.nvim" })
require("worktabs").setup()

Nix (nixvim / buildVimPlugin)

Override the source with a local path:

extraPlugins = [
  (pkgs.vimUtils.buildVimPlugin {
    name = "worktabs-nvim";
    src = /absolute/path/to/worktabs.nvim;
  })
];

Manual (no plugin manager)

Add the repo to your runtime path before calling setup:

vim.opt.rtp:prepend("~/repos/worktabs.nvim")
require("worktabs").setup()

License

MIT

About

Worktree management for neovim.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors