yadm + Neovim Git plugins didn't work, so I built yadm-git.nvim (and fixed the Bazzite symlink three times)
Introduction
Dotfiles managed by yadm live directly under $HOME, and there is no $HOME/.git. yadm's own git directory sits at ~/.local/share/yadm/repo.git. So Git-aware Neovim plugins like gitsigns.nvim / fugitive / lazygit.nvim decide "this isn't under git control" even while you're editing a file under $HOME.
I built a tiny plugin whose only job is to make them "just work": yadm-git.nvim. This post covers three things:
- The minimal design for letting yadm-managed dotfiles and nvim's Git plugins coexist
- Why detecting yadm with a filesystem check beats a shell call (perf + testability)
- How vim.env.HOME / vim.uv.os_homedir() / vim.fn.expand("~") diverge on an immutable distro, and which one I ended up choosing
The last point — the Bazzite symlink — made me fix home-directory resolution three times over.
What was the problem
yadm is "a wrapper for managing dotfiles with git": it uses $HOME as the work tree and ~/.local/share/yadm/repo.git as the git directory. From the CLI, the yadm command calls git while passing GIT_DIR and GIT_WORK_TREE behind the scenes.
But Neovim's Git plugins like gitsigns.nvim don't know about any of that. Open $HOME/.config/nvim/init.lua and, since there's no .git anywhere up that directory tree, it's judged "outside git control" — no diff, no blame.
Typing GIT_DIR=... GIT_WORK_TREE=... nvim in the shell every time is tedious, and I want it to switch as I cd around. I just wanted a plugin to automate that.
How I solved it
The design decisions are simple.
| Decision | Why |
| ------------------------------------------------- | ------------------------------------------------------------------------------ |
| No commands, no user interaction | It should just work with zero config. Whether you're under yadm is detectable. |
| Filesystem check only (no shell call) | Good perf, stays in Lua, tests need a single mock |
| Only set the env vars GIT_DIR / GIT_WORK_TREE | Minimal intervention — touches nothing in the existing Git plugins |
| Driven by autocmds (VimEnter / DirChanged) | Set / clear as you cd in and out of yadm-managed paths |
The detection logic is three steps:
1. If there's a .git (directory or file) anywhere up the current directory tree, treat it as a normal git repo and skip
2. Check whether the yadm repo exists at ~/.local/share/yadm/repo.git (v3+) or ~/.yadm/repo.git (legacy)
3. Check whether the current directory is under $HOME
If all three are true, treat it as yadm-managed and set vim.env.GIT_DIR and vim.env.GIT_WORK_TREE. On DirChanged out, clear them.
How the implementation evolved
The main PRs in chronological order.
| When | PR | What |
| ---------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| 2025-06-02 | initial | First cut. Detected yadm with a shell call |
| 2025-06 | #6 | Dropped the shell call for a pure-Lua filesystem check. Better perf + testability |
| 2025-06 | #7 | Kept plugin state internally and exposed is_active() / get_yadm_repo_path() / get_state() as a public API |
| 2025-06 | #9 | Added a DirChanged autocmd. Clears the env vars when leaving the yadm dir. Bundled a refactor splitting state / event into separate modules |
| 2025-06 | #10 | Added a lualine status component. Shows a house icon and "dotfiles" while yadm is active |
| 2025-06 | #11 | Moved activation logic from init to event handling. Unified on autocmds so teardown is event-driven too |
| 2025-07 | #14 | Fixed a detection miss when .git is a file (a worktree) |
| 2026-04 | #19 / #20 | Discovered that $HOME becomes a symlink on Bazzite-style immutable distros. Switched to going through vim.uv.os_homedir() |
| 2026-05 | #22 | A further fix on top of #20. Unified on vim.fn.expand("~") |
The current file layout.
lua/yadm-git/
├── init.lua -- entry point (exports setup())
├── yadm.lua -- core: detection and env-var set/clear
├── state.lua -- plugin activation state
├── event.lua -- VimEnter / DirChanged autocmds
├── logger.lua -- debug logging
└── options.lua -- config
Where it bit me
1. Env vars left behind after you cd out (#9)
The first implementation only fired on VimEnter. So it worked if you were under yadm when nvim started, but after :cd ~/work/project (an ordinary git repo, not under yadm), GIT_DIR stayed pointed at ~/.local/share/yadm/repo.git and gitsigns kept showing a broken view of yadm.
The fix: re-evaluate is_yadm_managed() in a DirChanged autocmd and, if false, set the env vars back to nil via clear_yadm_env(). At the same time I split state / event into separate modules so init.lua only exports setup().
2. A worktree exists as a .git file (#14)
I was only checking vim.fn.finddir(".git", ...), so a worktree created with git worktree add (where .git is a text file rather than a directory) was judged "not git," and the yadm environment was set by mistake. Fixed by checking both finddir and findfile.
3. $HOME is a symlink on Bazzite (#19 → #20 → #22)
This was the nastiest one. On immutable Fedora derivatives like Bazzite, /home/user is a symlink to /var/home/user.
- $HOME is /var/home/user (physical path)
- /etc/passwd says /home/user (symlink path)
- Neovim's vim.fn.fnamemodify(":p") and vim.fn.getcwd() resolve the symlink → /home/user/...
- vim.uv.os_homedir() reads $HOME via libuv, so it's /var/home/user
So you get "the current directory is under /home/user/..., but $HOME is /var/home/user," which makes vim.startswith(cwd, home .. "/") false and the path isn't recognized as yadm-managed.
The three fixes in sequence.
| Attempt | Approach | Result |
| ------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| #19 | Was using vim.env.HOME → the symlink-resolution problem surfaced | Reported |
| #20 | Switched to vim.uv.os_homedir() | libuv doesn't resolve the symlink either, so same problem |
| #22 | Unified on vim.fn.expand("~") | Goes through Neovim, so it rides the same symlink-resolution path as fnamemodify / getcwd. Fixed |
The lesson: which "get the home directory" function you use changes the behavior. Whether it resolves symlinks is the key, and it has to match the side you compare against (the function that gets the cwd). In a Neovim plugin, vim.fn.expand("~") was the safest.
The relevant source (with the comment).
-- Resolve the user's home directory.
-- Prefer `vim.fn.expand("~")` over `vim.uv.os_homedir()` or $HOME because on immutable distros
-- (Bazzite, etc.) the folder /home/ is a symlink to /var/home/ which is resolved by Neovim
-- in the functions `vim.fn.fnamemodify()` and `vim.fn.getcwd()` but not when using $HOME
-- or libuv's `os_homedir()`.
-- See issue #19 for more information.
local function get_home()
return vim.fn.expand "~"
end
Results
- gitsigns / fugitive / lazygit just work while editing files under yadm
- It switches automatically as you cd in and out
- lualine can now show "yadm mode is on," which cut down on mistakes
- The core is around 110 lines. Even with tests it's 234 lines + tests. It stays small.
Wrap-up
- The yadm × nvim Git-plugin coexistence problem is solved just by "setting and clearing GIT_DIR / GIT_WORK_TREE from autocmds"
- A filesystem check instead of a shell call buys you both perf and testability
- Home-directory resolution on an immutable distro is full of traps; I fixed it three times. In a Neovim plugin, vim.fn.expand("~") is the current right answer
References
Source and PRs.
- Kohei-Wada/yadm-git.nvim (GitHub)
- PR #6 Optimize external commands — shell call → filesystem
- PR #7 plugin state management and tests — public API
- PR #9 env vars not cleared on cd out — DirChanged handling + module split
- PR #10 lualine component
- PR #11 event-driven refactor
- PR #14 worktree detection
- PR #19 / #20 Bazzite symlink (os_homedir)
- PR #22 expand("~") final fix
Related tools.
- yadm
- gitsigns.nvim
- vim-fugitive
- lazygit.nvim
- Bazzite (immutable Fedora)
---
_This post was written with AI assistance and reviewed by the author._
Typescript errors into vim quickfix
You may use LSP and lua vim.diagnostic.setloclist() to show Typescript errors for a specific buffer. But sometimes you could have errors across the whole project.
You can run tsc in the terminal and manually search for files with errors. But we can do better.
Vim has the :make command which executes the program that is set into makeprg. By default, it's make but we can change it to any terminal program/command.
The output of makeprg is then populated into quickfix.
Update 🖊️
After discussion on reddit someone suggested a better approach.
The error is already parced in compiler tsc.
I just change makeprg=npx\ tsc instead of makeprg=tsc.
vim.cmd [[
augroup strdr4605
autocmd FileType typescript,typescriptreact compiler tsc | setlocal makeprg=npx\ tsc
augroup END
]]
Initial post
Let's create an auto command that will set makeprg to tsc if we are in a typescript project:
vim.cmd [[
augroup strdr4605
autocmd FileType typescript,typescriptreact set makeprg=./node_modules/.bin/tsc
augroup END
]]
or with lua API:
local augroup = vim.api.nvim_create_augroup("strdr4605", { clear = true })
vim.api.nvim_create_autocmd("FileType", {
pattern = "typescript,typescriptreact",
group = augroup,
command = "compiler tsc | setlocal makeprg=npx\\ tsc",
})
Run :make and see the output:
:!./node_modules/.bin/tsc 2>&1| tee /var/folders/3w/m_v388s15pq9rpgswbt131w00000gn/T/nvim.strdr4605/708JAY/2
components/Date.tsx(6,28): error TS2339: Property 'toISOString' does not exist on type 'number'.
(1 of 1): components/Date.tsx(6,28): error TS2339: Property 'toISOString' does not exist on type 'number'.
Press ENTER or type command to continue
and open the quickfix, :copen:
components/Date.tsx(6,28): error TS2339: Property 'toISOString' does not exist on type 'number'.
When trying to navigate to any file from quickfix, I see this error: Buffer not found.
Why? Because the tsc compiler outputs in a format that is not clear for vim quickfix.
The problem is in parenthesis around the file line and column: components/Date.tsx(6,28):
But we can change this by piping the tsc output to sed command and replacing (6,28) with :6:28:.
This is the output if we run it in the terminal:
./node_modules/.bin/tsc | sed 's/(\(.*\),\(.*\)):/:\1:\2:/' 130 ↵
components/Date.tsx:6:28: error TS2339: Property 'toISOString' does not exist on type 'number'.
But to make it work in vim, we need to escape some chars.
The final result is:
vim.cmd [[
augroup strdr4605
autocmd FileType typescript,typescriptreact set makeprg=./node_modules/.bin/tsc\ \\\|\ sed\ 's/(\\(.*\\),\\(.*\\)):/:\\1:\\2:/'
augroup END
]]
Now when running :make the quickfix list is populated correctly and vim navigates to the first file in the list.
I can fix Typescript errors one by one, by navigating with :cnext or using vim-unimpaired.
Mentions
yadm + Neovim Git plugins didn't work, so I built yadm-git.nvim (and fixed the Bazzite symlink three times) Introduction Dotfiles managed by yadm live directly under $HOME, and there is no $HOME/.git. yadm's own git directory sits at ~/.local/share/yadm/repo.git. So Git-aware Neovim plugins like gitsigns.nvim / fugitive / lazygit.nvim decide "this isn't under git control" even while you're editing a file under $HOME. I built a tiny plugin whose only job is to make them "just work": yadm-git.nvim. This post covers three things: - The minimal design for letting yadm-managed dotfiles and nvim's Git plugins coexist - Why detecting yadm with a filesystem check beats a shell call (perf + testability) - How vim.env.HOME / vim.uv.os_homedir() / vim.fn.expand("~") diverge on an immutable distro, and which one I ended up choosing The last point — the Bazzite symlink — made me fix home-directory resolution three times over. What was the problem yadm is "a wrapper for managing dotfiles with git": it uses $HOME as the work tree and ~/.local/share/yadm/repo.git as the git directory. From the CLI, the yadm command calls git while passing GIT_DIR and GIT_WORK_TREE behind the scenes. But Neovim's Git plugins like gitsigns.nvim don't know about any of that. Open $HOME/.config/nvim/init.lua and, since there's no .git anywhere up that directory tree, it's judged "outside git control" — no diff, no blame. Typing GIT_DIR=... GIT_WORK_TREE=... nvim in the shell every time is tedious, and I want it to switch as I cd around. I just wanted a plugin to automate that. How I solved it The design decisions are simple. | Decision | Why | | ------------------------------------------------- | ------------------------------------------------------------------------------ | | No commands, no user interaction | It should just work with zero config. Whether you're under yadm is detectable. | | Filesystem check only (no shell call) | Good perf, stays in Lua, tests need a single mock | | Only set the env vars GIT_DIR / GIT_WORK_TREE | Minimal intervention — touches nothing in the existing Git plugins | | Driven by autocmds (VimEnter / DirChanged) | Set / clear as you cd in and out of yadm-managed paths | The detection logic is three steps: 1. If there's a .git (directory or file) anywhere up the current directory tree, treat it as a normal git repo and skip 2. Check whether the yadm repo exists at ~/.local/share/yadm/repo.git (v3+) or ~/.yadm/repo.git (legacy) 3. Check whether the current directory is under $HOME If all three are true, treat it as yadm-managed and set vim.env.GIT_DIR and vim.env.GIT_WORK_TREE. On DirChanged out, clear them. How the implementation evolved The main PRs in chronological order. | When | PR | What | | ---------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | 2025-06-02 | initial | First cut. Detected yadm with a shell call | | 2025-06 | #6 | Dropped the shell call for a pure-Lua filesystem check. Better perf + testability | | 2025-06 | #7 | Kept plugin state internally and exposed is_active() / get_yadm_repo_path() / get_state() as a public API | | 2025-06 | #9 | Added a DirChanged autocmd. Clears the env vars when leaving the yadm dir. Bundled a refactor splitting state / event into separate modules | | 2025-06 | #10 | Added a lualine status component. Shows a house icon and "dotfiles" while yadm is active | | 2025-06 | #11 | Moved activation logic from init to event handling. Unified on autocmds so teardown is event-driven too | | 2025-07 | #14 | Fixed a detection miss when .git is a file (a worktree) | | 2026-04 | #19 / #20 | Discovered that $HOME becomes a symlink on Bazzite-style immutable distros. Switched to going through vim.uv.os_homedir() | | 2026-05 | #22 | A further fix on top of #20. Unified on vim.fn.expand("~") | The current file layout. lua/yadm-git/ ├── init.lua -- entry point (exports setup()) ├── yadm.lua -- core: detection and env-var set/clear ├── state.lua -- plugin activation state ├── event.lua -- VimEnter / DirChanged autocmds ├── logger.lua -- debug logging └── options.lua -- config Where it bit me 1. Env vars left behind after you cd out (#9) The first implementation only fired on VimEnter. So it worked if you were under yadm when nvim started, but after :cd ~/work/project (an ordinary git repo, not under yadm), GIT_DIR stayed pointed at ~/.local/share/yadm/repo.git and gitsigns kept showing a broken view of yadm. The fix: re-evaluate is_yadm_managed() in a DirChanged autocmd and, if false, set the env vars back to nil via clear_yadm_env(). At the same time I split state / event into separate modules so init.lua only exports setup(). 2. A worktree exists as a .git file (#14) I was only checking vim.fn.finddir(".git", ...), so a worktree created with git worktree add (where .git is a text file rather than a directory) was judged "not git," and the yadm environment was set by mistake. Fixed by checking both finddir and findfile. 3. $HOME is a symlink on Bazzite (#19 → #20 → #22) This was the nastiest one. On immutable Fedora derivatives like Bazzite, /home/user is a symlink to /var/home/user. - $HOME is /var/home/user (physical path) - /etc/passwd says /home/user (symlink path) - Neovim's vim.fn.fnamemodify(":p") and vim.fn.getcwd() resolve the symlink → /home/user/... - vim.uv.os_homedir() reads $HOME via libuv, so it's /var/home/user So you get "the current directory is under /home/user/..., but $HOME is /var/home/user," which makes vim.startswith(cwd, home .. "/") false and the path isn't recognized as yadm-managed. The three fixes in sequence. | Attempt | Approach | Result | | ------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | | #19 | Was using vim.env.HOME → the symlink-resolution problem surfaced | Reported | | #20 | Switched to vim.uv.os_homedir() | libuv doesn't resolve the symlink either, so same problem | | #22 | Unified on vim.fn.expand("~") | Goes through Neovim, so it rides the same symlink-resolution path as fnamemodify / getcwd. Fixed | The lesson: which "get the home directory" function you use changes the behavior. Whether it resolves symlinks is the key, and it has to match the side you compare against (the function that gets the cwd). In a Neovim plugin, vim.fn.expand("~") was the safest. The relevant source (with the comment). -- Resolve the user's home directory. -- Prefer `vim.fn.expand("~")` over `vim.uv.os_homedir()` or $HOME because on immutable distros -- (Bazzite, etc.) the folder /home/ is a symlink to /var/home/ which is resolved by Neovim -- in the functions `vim.fn.fnamemodify()` and `vim.fn.getcwd()` but not when using $HOME -- or libuv's `os_homedir()`. -- See issue #19 for more information. local function get_home() return vim.fn.expand "~" end Results - gitsigns / fugitive / lazygit just work while editing files under yadm - It switches automatically as you cd in and out - lualine can now show "yadm mode is on," which cut down on mistakes - The core is around 110 lines. Even with tests it's 234 lines + tests. It stays small. Wrap-up - The yadm × nvim Git-plugin coexistence problem is solved just by "setting and clearing GIT_DIR / GIT_WORK_TREE from autocmds" - A filesystem check instead of a shell call buys you both perf and testability - Home-directory resolution on an immutable distro is full of traps; I fixed it three times. In a Neovim plugin, vim.fn.expand("~") is the current right answer References Source and PRs. - Kohei-Wada/yadm-git.nvim (GitHub) - PR #6 Optimize external commands — shell call → filesystem - PR #7 plugin state management and tests — public API - PR #9 env vars not cleared on cd out — DirChanged handling + module split - PR #10 lualine component - PR #11 event-driven refactor - PR #14 worktree detection - PR #19 / #20 Bazzite symlink (os_homedir) - PR #22 expand("~") final fix Related tools. - yadm - gitsigns.nvim - vim-fugitive - lazygit.nvim - Bazzite (immutable Fedora) --- _This post was written with AI assistance and reviewed by the author._
Typescript errors into vim quickfix You may use LSP and lua vim.diagnostic.setloclist() to show Typescript errors for a specific buffer. But sometimes you could have errors across the whole project. You can run tsc in the terminal and manually search for files with errors. But we can do better. Vim has the :make command which executes the program that is set into makeprg. By default, it's make but we can change it to any terminal program/command. The output of makeprg is then populated into quickfix. Update 🖊️ After discussion on reddit someone suggested a better approach. The error is already parced in compiler tsc. I just change makeprg=npx\ tsc instead of makeprg=tsc. vim.cmd [[ augroup strdr4605 autocmd FileType typescript,typescriptreact compiler tsc | setlocal makeprg=npx\ tsc augroup END ]] Initial post Let's create an auto command that will set makeprg to tsc if we are in a typescript project: vim.cmd [[ augroup strdr4605 autocmd FileType typescript,typescriptreact set makeprg=./node_modules/.bin/tsc augroup END ]] or with lua API: local augroup = vim.api.nvim_create_augroup("strdr4605", { clear = true }) vim.api.nvim_create_autocmd("FileType", { pattern = "typescript,typescriptreact", group = augroup, command = "compiler tsc | setlocal makeprg=npx\\ tsc", }) Run :make and see the output: :!./node_modules/.bin/tsc 2>&1| tee /var/folders/3w/m_v388s15pq9rpgswbt131w00000gn/T/nvim.strdr4605/708JAY/2 components/Date.tsx(6,28): error TS2339: Property 'toISOString' does not exist on type 'number'. (1 of 1): components/Date.tsx(6,28): error TS2339: Property 'toISOString' does not exist on type 'number'. Press ENTER or type command to continue and open the quickfix, :copen: components/Date.tsx(6,28): error TS2339: Property 'toISOString' does not exist on type 'number'. When trying to navigate to any file from quickfix, I see this error: Buffer not found. Why? Because the tsc compiler outputs in a format that is not clear for vim quickfix. The problem is in parenthesis around the file line and column: components/Date.tsx(6,28): But we can change this by piping the tsc output to sed command and replacing (6,28) with :6:28:. This is the output if we run it in the terminal: ./node_modules/.bin/tsc | sed 's/(\(.*\),\(.*\)):/:\1:\2:/' 130 ↵ components/Date.tsx:6:28: error TS2339: Property 'toISOString' does not exist on type 'number'. But to make it work in vim, we need to escape some chars. The final result is: vim.cmd [[ augroup strdr4605 autocmd FileType typescript,typescriptreact set makeprg=./node_modules/.bin/tsc\ \\\|\ sed\ 's/(\\(.*\\),\\(.*\\)):/:\\1:\\2:/' augroup END ]] Now when running :make the quickfix list is populated correctly and vim navigates to the first file in the list. I can fix Typescript errors one by one, by navigating with :cnext or using vim-unimpaired.