guest@6admin.io:~$

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 --merge happily floated plugins off their recorded commits.
  • Lazy loading: opt/ gave on-demand loading, but nothing wired a command trigger to packadd.
  • 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 .