Grapple.nvim tags a file by name, and the name points to a different file on every branch
The git_branch scope gives each branch its own tag file on disk, so checking out another branch loads a different set of names under keymaps that never change.
A session touches five files, and none of them have a name
A ticket touches a handful of files: the handler, its test, a
migration, a config flag, maybe a doc. By the third hour you're
moving between them by feel, not by name. Neovim gives three
habits for this. Cycle the buffer list and recognize a file by its
tail end. Drop a mark and hope you remember which letter you used.
Type enough of a path into :b and trust fuzzy
matching to guess right. None of the three remembers what you
called a file, because none of them let you call it anything.
Grapple.nvim adds a fourth habit. Tag a file, give the tag a name, and ask for that name back later. A tag is a persistent pointer to a file path, saved with the cursor position, so a later jump lands on the line you left rather than just the file.1 The catch is that "later" is scoped. Grapple decides which project a name belongs to, and, if you tell it to, which git branch. Switch branches, and the same name can resolve to a different file.
The project a tag belongs to is whatever a resolver returns
Underneath the tagging, a scope is nothing more than a function.
Each one defines a resolver that returns an identifier and a path,
or returns nothing at all, in which case Grapple falls through to
a fallback scope instead of erroring.2
Grapple ships six of them out of the box.1
global puts every tag in one namespace and ignores
the project entirely. static and cwd key
off Neovim's working directory, one frozen at startup and one that
re-resolves on every DirChanged. lsp
keys off the attached language server's root directory, falling
back to git if nothing is attached; git
keys off the repository root, falling back to cwd if
there isn't one.
The scope that does the most work is git_branch. Its
resolver walks up from the current buffer for a
.git directory. Then it shells out to read the
current branch and folds the two together into one identifier:
root path and branch name, joined with a colon.2
The same repository root on main and on
feature/rate-limit produces two different
identifiers, and Grapple keeps a separate set of tags for each
one. Neither the git nor the git_branch
resolver runs on every keystroke. Both cache their result and only
re-resolve on buffer entry or focus, debounced by a second.2
Switching windows doesn't mean shelling out to git each time.
Nothing in that design hardcodes a project to mean a git
repository. A resolver is any function with that shape. The
README's own example keys a custom scope to
$HOME in four lines,1
and the same pattern works for a monorepo package root, or a
ticket number pulled out of a branch name.
One config: tag by name, jump by name, and the branch decides which file answers
The scope choice only matters once tagging and jumping go through
a name instead of a position. Here is a complete lazy.nvim spec
that does both, using the git_branch scope so the
proof below actually has something to switch:
return {
"cbochs/grapple.nvim",
event = { "BufReadPost", "BufNewFile" },
cmd = "Grapple",
opts = {
scope = "git_branch",
},
keys = {
{
"<leader>mm",
function()
vim.ui.input({ prompt = "Tag name: " }, function(name)
if name and name ~= "" then
require("grapple").tag({ name = name })
end
end)
end,
desc = "Grapple: tag current file with a name",
},
{
"<leader>mj",
function()
vim.ui.input({ prompt = "Jump to: " }, function(name)
if name and name ~= "" then
require("grapple").select({ name = name })
end
end)
end,
desc = "Grapple: jump to a name",
},
{ "<leader>mt", "<cmd>Grapple toggle_tags<cr>", desc = "Grapple: list tags for this scope" },
},
}
Grapple.tag and
Grapple.select both accept a name
field; the scope that resolves at the moment of the call decides
which tag set the name looks up in.1
Run that on two branches of the same repository and the same name
stops meaning one file. On feature/rate-limit, open
internal/limiter.go and press
<leader>mm, typing hot as the
name. Check out main, where
internal/limiter.go may not even exist yet, open
cmd/server/main.go instead, and tag it
hot too. <leader>mj hot now opens
a different file depending on which branch was checked out when
you pressed it. The keymap never changed. The scope did.
Because every tag mutation writes straight to disk,3
hot survives quitting Neovim entirely. Reopen the
repository on feature/rate-limit next week and
<leader>mj hot still opens
internal/limiter.go, cursor on the line you left.
Deleting a tag never renumbers the names around it
Grapple stores tags in an ordered list, the same list a
harpoon-style keymap would iterate by number. It also keeps a
second lookup table keyed by name.4
Selecting by index trusts the list's current order:
select({ index = 2 }) returns whatever tag happens to
sit second right now. Selecting by name skips the list and goes
straight to the name table, which never changes shape just
because a different tag was removed.4
Delete the tag that used to occupy the first slot and
select({ index = 1 }) now returns what used to be
second. select({ name = "hot" }) still returns
hot.
Harpoon runs on the same list-and-index model, without the second
path. Its documented setup wires keys straight to
list():select(1) through select(4),5
and the list itself can hold any number of entries,5
but there is no field to address one by. A file's position is its
only identity: reorder the list, or drop an entry from the middle
of it, and the number that used to mean the test file now means
whatever moved into that slot.
Native Neovim marks sit further out on that same spectrum. A
lowercase mark is local to one buffer. It disappears once that
buffer leaves the buffer list. An uppercase mark is a genuine
cross-file pointer, written to the 'shada' file the
same way Grapple writes its own JSON.6
Neovim still hands you exactly 26 of them, though, and lets you
name each only with a single letter. :b keeps no
state of its own at all. Every jump re-derives itself from
whatever substring you type against whatever buffers happen to be
open in that session.
Old branches keep their tag files long after the branch is gone
Every scope Grapple resolves gets its own save file: a JSON blob
in Neovim's data directory, named after the scope's identifier and
written to disk the moment a tag changes.3
Switch to the git_branch scope and that identifier is
the repository root plus the branch name, so a repository worked
across a dozen feature branches accumulates a dozen separate tag
files, none of them touching the others.2
Nothing removes those files when a branch merges and its ref
disappears. Grapple ships a prune setting instead,
keyed to how long a save file has sat unmodified, defaulting to
thirty days,2 but
pruning only runs when something calls it. There's no background
timer clearing stale branches on its own; leave the default alone
and short-lived branches accumulate quietly in Neovim's data
directory until a prune runs or someone notices the folder.
A second cost lands sooner: only one tag survives per file, per scope. Tag a file a second time under a different name, and Grapple overwrites the name instead of stacking a second entry for the same path.1 Coming from harpoon's plain list, where re-adding a file just moves it, that overwrite is the one behavior a new user has to specifically unlearn.
The commits stopped in 2024; the bug reports about newer Neovim did not
The last commit to Grapple's main branch landed on September 29, 2024: a documentation regeneration, not a feature.7 The last pull request the maintainer merged, adding highlight groups for the tag window, landed four months earlier than that, on May 18, 2024.8
The reports keep arriving anyway. In April 2026, a user found that
Grapple's deprecated table-form vim.validate call
throws warnings under Neovim 0.12, and named the three files
responsible.9
A pull request fixing it, tested against Neovim 0.10 through 0.12,
has been open since that same day.10
Neither has drawn a reply from the maintainer.
Everything the plugin already does still works, because Neovim's
API hasn't broken it yet. The one place it has broken, the
vim.validate warning, has a reviewed, tested fix that
has simply gone unmerged for months. A team adopting Grapple now
is adopting the repository roughly as it stood in 2024, plus
whatever patches Neovim's own changes eventually force someone
other than the maintainer to carry.
Sources
- cbochs/grapple.nvim · README.md
- cbochs/grapple.nvim · lua/grapple/settings.lua (default scope resolvers)
- cbochs/grapple.nvim · lua/grapple/state.lua (per-scope JSON persistence)
- cbochs/grapple.nvim · lua/grapple/tag_container.lua (name and index lookup)
- ThePrimeagen/harpoon · README.md (harpoon2 branch)
- Neovim documentation · motion.txt, mark-motions
- cbochs/grapple.nvim · commit b41ddfc, September 29, 2024
- cbochs/grapple.nvim · pull request #164, merged May 18, 2024
- cbochs/grapple.nvim · issue #197, opened April 3, 2026
- cbochs/grapple.nvim · pull request #198, opened April 3, 2026