Git submodules by hand: my Vim 8 plugins without a manager
Debian and Ubuntu had finally shipped Vim 8 and with it the native package system: pack/plugins/start and pack/plugins/opt. Goodbye pathogen, goodbye my apt-flavored hack (vim-apthogen
, episode one of this series). But Vim 8 packages solve loading, not management: nothing tells you which plugins your config depends on, or how to clone them on a fresh machine.
The answer I chose is the same one I still run today: the config repo is the state. The plugins live as git submodules of the config repo, in pack/plugins/start/ for always-on ones and pack/plugins/opt/ for the opt-in ones. Clone with --recursive and the whole editor materializes.
The bash script era
Because typing git submodule add https://github.com/scrooloose/nerdtree pack/plugins/start/nerdtree twelve times a week gets old, I wrote a 100-line bash script named, creatively, plugins:
fUpdate(){
git submodule sync
git submodule update --remote --merge
git commit -am "Update Modules"
}
That is the whole update story and it exposes both the strength and the disease of this design. One command to update everything. And a commit, automatically, every time, whether the update deserves one or not.
The full verb set: add <url> [name] [opt], remove <name> [-f] (with a yes/no confirmation, -f to skip it), list (a grep on .gitmodules with an awk pretty-printer), status, summary, update. The URL is validated against a regex copied from some gist, the module name defaults to the repo basename and add with an opt argument drops the plugin into opt/.
The necessary devil
I even kept a using-git-submodules.md in the repo, adapted from a blog post of the era, explaining the full lifecycle: init, git submodule deinit, git rm -rf, then rm -rf .git/modules/... because deinit does not clean the metadata (it still does not, by the way). It ends on the sentence that sums up the whole approach: submodules are a necessary devil.
Necessary, because submodules give you pinning for free: the parent repo records an exact commit of every plugin, so “my editor works” is a git checkout away on any machine. Devil, because:
- after every plugin update, the parent repo has uncommitted changes you must commit (manually, or thanks to my auto-commit footgun above);
deinit+git rm+ metadata cleanup is a three-command ceremony to uninstall anything;- everything is sequential: 25 plugins is 25 git clones one after another.
The config around it
The vimrc of that era was the classic monolith: syn on, filetype plugin indent on, a colorscheme commented out with a competing one half-commented too, swapdir/ and undodir/ created by hand and the NERDTree autocmds pasted straight from the README (including the “close Vim if NERDTree is the last window” trio). Also two command aliases of which I am proud: command W w !sudo tee > /dev/null % and command Q qa, because typo-proofing is a lifestyle (a pair still alive today, restored in the 2009 .bashrc article
).
What survived, what got fixed
Eight years of hindsight and this design is still the skeleton: submodules + native pack/, the vimrc as source of truth. What the era lacked and later episodes fixed:
- Parallel installs (the 2018 script clones sequentially).
- Pinning re-asserted from the config:
update --remote --mergehappily floated plugins off their recorded commits. - Lazy loading:
opt/gave on-demand loading, but nothing wired a command trigger topackadd. - Not committing after every update (the auto-commit eventually had to die).
Seven years later the script grew up into vim-plugin-manager , written in VimScript, with parallel background installs and all the sins above corrected. But the state model never changed: it was right in 2018, it is right now.
[edit: 2026] The Ansible implementation of all this: the vim role .