Neovim plugin that records editing history as Toolpath Path documents. Thin Lua
wrapper (~500 lines) around path track CLI subcommands. The plugin owns undo
tree mapping and VCS awareness; the CLI owns diffing, session state, and
document building.
lua/toolpath/init.lua ← all plugin logic (setup, tracking, git, async queue)
lua/toolpath/health.lua ← :checkhealth toolpath
plugin/toolpath.lua ← user command definitions
doc/toolpath.txt ← Neovim :help documentation
README.md ← user-facing docs
local/ ← design docs from the toolpath CLI team (not shipped)
docs/ ← design docs we authored (not shipped)
M.version in lua/toolpath/init.lua is the single source of truth. Bump it
on every user-visible change. It's displayed in :ToolpathStatus and
:checkhealth toolpath.
- Update
lua/toolpath/init.lua— the code itself. - Update
doc/toolpath.txt— if you added/changed/removed any command, Lua API function, config option, buffer variable, or behavior. The help file is the authoritative reference. Keep the contents table section numbers in sync. - Update
README.md— if the change is user-facing. The README mirrors the help doc but is less exhaustive. Update the commands table, Lua API listing, config examples, and any relevant prose sections. - Update
plugin/toolpath.lua— if you added/changed/removed a user command. - Update
lua/toolpath/health.lua— if you added new dependencies, config requirements, or internal accessors. - Bump
M.version— inlua/toolpath/init.lua. - Run
./scripts/test.sh— syntax checks all Lua files and runs the test suites. Add tests for any new pure functions or API changes.
The plugin shells out to path track <subcommand>. The current subcommands
and their args are documented in local/track-source-annotate.md and
docs/cli-graph-output.md. Key points:
init— creates session, returns session path on stdout. Accepts--base-uriand--base-reffor VCS anchoring.step— records an edit. Accepts--source(JSON vcsSource). Async in normal flow, sync during gap replay (on scratch buffer).visit— caches content for undo/redo navigation. No--source.note— sets intent on head step. Still works, not deprecated.annotate— generalized note. Can target any step, set intent, source, refs.close— exports Path document on stdout and deletes session file.export— like close but non-destructive.
All CLI communication is via args + stdin (buffer content) + stdout (results). No temp files, no sockets.
Plugin owns VCS awareness. The CLI is VCS-agnostic. The plugin runs
git rev-parse and passes structured data via --base-uri, --base-ref,
and --source JSON. This keeps the CLI editor-agnostic and VCS-agnostic.
Git cache refreshed on events, not keystrokes. FocusGained and
ShellCmdPost trigger cache refresh. Never run git commands in the
on_text_changed hot path.
Never modify buffer contents. The plugin is a passive observer — it reads
buffer text via nvim_buf_get_lines but must never use vim.cmd("undo"),
nvim_buf_set_lines, or any other API that mutates a tracked buffer. Gap
replay uses a hidden scratch buffer with wundo/rundo to clone the undo
tree. This prevents interference with the user's editing, LSP, and other
plugins.
Async for step/visit, sync for everything else. The on_text_changed
handler enqueues step and visit calls on a per-buffer serial queue using
vim.system() callbacks. Gap replay (intermediate undo entries) is sync
because it replays on a scratch buffer. init, close, note,
annotate, and export are sync.
Stop order matters. M.stop() must: (1) delete the augroup first to
prevent new events, (2) drain the async queue synchronously, (3) then call
close. Getting this wrong causes races or lost steps.
Archive only if edits were made. stop() checks step_count > 0 before
writing to archive/. Viewing a file without editing it shouldn't produce
archive files.
record.dir (e.g. ~/.local/share/toolpath) gets this structure:
<dir>/
├── README.md ← auto-generated on first use, explains the directory
├── live/ ← active session state files (temporary, CLI manages lifecycle)
└── archive/ ← exported Path documents (<filename>.<timestamp>.path.json)
unpackvstable.unpack: Neovim's LuaJIT hasunpackas a global. Standard Lua 5.2+ usestable.unpack. We useunpackthroughout.- Buffer-local autocmds + global events:
VimLeavePreis global but our autocmd usesbuffer = bufnr. It fires for the active buffer on quit; other buffers getBufUnload. Combined withonce = true, each buffer's stop handler fires exactly once. vim.bvariable cleanup: Always nil out buffer variables inclear_buf_vars()when stopping. Stalevim.b.toolpath_tracking = trueon an untracked buffer would confuse statusline consumers.vim.validatepositional form: We use the Neovim 0.10+ positional formvim.validate("name", value, "type", optional). The older table form is deprecated.
Run all tests locally:
./scripts/test.sh
This runs luac -p syntax checks on all Lua files, then two test suites via
nvim --headless -l:
test/test_internals.lua— pure function tests forformat_base_uri,build_parent_map, andfind_step_ancestor(exported viaM._internals).test/test_api.lua— public API surface tests (version format,_setup_donestate,statusline(),_get_bin(), config validation).
The test harness is test/harness.lua (~50 lines, no external dependencies).
Adding a new test: open the appropriate test/test_*.lua file and add a
T.test("name", function() ... end) block. Use T.eq(a, b) for deep equality.
CI: GitHub Actions (.github/workflows/test.yml) runs the test script on
every push to main and every PR, against Neovim stable and nightly.
:checkhealth toolpath— all checks pass- Open a file —
vim.b.toolpath_trackingshould betrue(with auto mode) - Make edits —
:ToolpathStatusshows step count incrementing, no UI lag :ToolpathStop— clean shutdown, no errors- Check
archive/— file appears with correct name pattern
Design documents in local/ come from the CLI team. When the CLI adds new
flags or changes behavior, update local/ with their docs and adapt the
plugin accordingly. The docs/ directory contains design docs we authored
for the CLI team (e.g. cli-graph-output.md was our original proposal;
local/track-source-annotate.md is what they actually implemented).