guest@6admin.io:~$

vim-plugin-manager: turning a decade of bash into a real Vim plugin

The bash script from the 2018 episode did its job for seven years: plugins as git submodules, native pack/ loading, a plugins script for the choreography. What it could never do was live inside the editor: no progress while cloning, no lazy loading wired to commands, no help. In March 2025 I finally gave it the ending it deserved: rewritten from scratch in VimScript as vim-plugin-manager , about 5,600 lines of legacy VimScript.

The bet: git is the lockfile

The design bet from day one, kept through every later version: plugins are real git submodules committed in your ~/.vim repo and loading uses Vim 8’s native packages (pack/plugins/start, pack/plugins/opt). No invented lockfile format, no plugin registry of my own to maintain, no diff format to break. Reproducibility comes from git itself: your config repo records an exact commit per plugin and git clone --recursive rebuilds your editor.

The declarative block reads like vim-plug, because conventions matter more than originality:

PluginBegin
  Plugin 'tpope/vim-fugitive'
  Plugin 'preservim/nerdtree', {'on': ['NERDTreeToggle']}
PluginEnd

Two design consequences worth defending:

  • Pinning is re-asserted on every update. Declare 'tag': 'v2.1.0' and the update flow re-checks that pin every time: the vimrc is the source of truth, not a snapshot in a lockfile that drifted from reality.
  • Missing plugins install in the background, in parallel. First launch with a fresh clone: a queue of git clones (default 4 concurrent) runs as async jobs with a spinner sidebar. The editor never freezes and you did not have to run an install command at all.

Lazy loading: the placeholder trick

The 'on' option needs to create :NERDTreeToggle before the plugin exists, then load the plugin on first use. The trick that took some debugging: the placeholder command must be deleted before packadd, not after, because the plugin redefines the command with :command! and a delete-after-load would remove the real one. Then the original invocation is re-dispatched with its mods, range, bang and args intact, so the first call behaves as if the command had always been there. Failed loads restore the placeholder. 'for': 'yaml' follows the same shape with FileType autocmds and already-open buffers trigger the load immediately.

The plumbing that made it real

  • async.vim: a job/channel abstraction with a concurrency queue and timeouts, plus a synchronous fallback that mirrors the async callback dict exactly, so callers work on a Vim without +job with zero code paths changed (there is even a test override to force the fallback).
  • Errors are structured: PM_ERROR:<component>:<CODE>:<message>, parsed rather than grepped.
  • Git is always called as git -C <dir>: no global :cd, which was the old script’s silent footgun.
  • :PluginManager gc: orphan cleanup for submodules no longer declared in the vimrc; the manager never collects itself.

Scope honesty

Linux only. Vim 8.2+ (the floor is RHEL 9 / AlmaLinux 9’s Vim 8.2.2637, supported until 2032, which is a floor you can build a decade on). Neovim is not supported: it already has lazy.nvim and packer and building a third manager for an editor with two good ones is vanity. The plugin warns but still loads if you insist. All of this is tested by a Vader suite (thousands of lines, with a real-async CI job running under a pty because headless vim -es never runs the event loop: a lesson worth an article of its own).

Where it went next

v1.0.0 shipped in April 2025 and kept moving: the hardening story (security validation, subprocess budgets, detached-plugin detection) closes the series and MyVim is the live dogfood: my entire editor config as a plugin managed by it. The bash script is gone, but the 2018 design bet, the repo as state, turned out to be the part worth keeping.

[edit: 2026] The Ansible implementation of all this: the vim role .