irongit

Configure synchronization of files and directories through a plugin.

initial commit

huncholanehuncholaneauthored
commit 7dd7e3a05eae07ab22d8f010bfe217b0d93b3901Browse files

12 files changed, +533 -0

+9-0.gitignore
@@ -0,0 +1,9 @@
1+*.log
2+/.repro
3+/.tests
4+/build
5+/debug
6+/doc/tags
7+foo.*
8+node_modules
9+tt.*
+0-0.lua-format

No content changes (mode or rename only).

+100-0README.md
@@ -0,0 +1,100 @@
1+# syncmap.nvim
2+
3+A lightweight Neovim plugin that keeps directories in sync using `inotifywait`, `rsync`, and some clever Vimscript + Lua integration.
4+
5+## Motivation
6+
7+- Dotfiles
8+
9+## Features
10+
11+- Automatically sync source → destination directories on file changes
12+- Uses `inotifywait`, `pgrep`, `pkill`, and `rsync`
13+- Supports `.gitignore`-like exclusions via `--exclude-from`
14+- Reverse sync on startup (optional)
15+- Logs and restarts lost watchers automatically
16+- Fully typed config for autocompletion and inline docs
17+
18+## Requirements
19+
20+- Linux (uses `inotifywait`)
21+- Neovim 0.8+
22+- Tools:
23+ - `inotifywait` (from `inotify-tools`)
24+ - `rsync`
25+ - `pgrep` and `pkill` (from `procps-ng`)
26+
27+## Installation
28+
29+Using [lazy.nvim](https://github.com/folke/lazy.nvim):
30+
31+```lua
32+{
33+ dir = vim.fn.expand("~/code/nvim-plugins/syncmap/"),
34+ name = "syncmap.nvim",
35+ opts = {
36+ map = {
37+ { vim.fn.expand("~/.dotfiles/test/"), vim.fn.expand("~/.config/test/") },
38+ },
39+ log_level = "info",
40+ },
41+}
42+````
43+
44+## Default Options
45+
46+```lua
47+{
48+ map = {
49+ { vim.fn.expand("~/.dotfiles/nvim/"), vim.fn.expand("~/.config/nvim/") },
50+ },
51+ reverse_sync_on_startup = true,
52+ rsync = { "-a", "--delete" },
53+ log_level = "error",
54+}
55+```
56+
57+## Type Hints
58+
59+For inline type hints and autocomplete, annotate your `opts` using:
60+
61+```lua
62+if false then
63+ require("syncmap.lazy")
64+end
65+---@type SyncmapLazySpec
66+```
67+
68+![type-sample](./doc/type.png)
69+
70+This gives you full visibility into available options and correct types.
71+
72+---
73+
74+### LICENSE
75+
76+```txt
77+MIT License
78+
79+Copyright (c) 2025 Huncho
80+
81+Permission is hereby granted, free of charge, to any person obtaining a copy
82+of this software and associated documentation files (the "Software"), to deal
83+in the Software without restriction, including without limitation the rights
84+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
85+copies of the Software, and to permit persons to whom the Software is
86+furnished to do so, subject to the following conditions:
87+
88+The above copyright notice and this permission notice shall be included in all
89+copies or substantial portions of the Software.
90+
91+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
92+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
93+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
94+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
95+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
96+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
97+SOFTWARE.
98+```
99+
100+---
+0-0doc/type.png

Binary file changed.

+11-0lua/syncmap/default.lua
@@ -0,0 +1,11 @@
1+local expand = vim.fn.expand
2+
3+---@type FinalSyncmapOpts
4+return {
5+ map = {
6+ { expand("~/.dotfiles/nvim/"), expand("~/.config/nvim/") },
7+ },
8+ reverse_sync_on_startup = true,
9+ rsync = { "-a", "--delete" },
10+ log_level = "error",
11+}
+31-0lua/syncmap/init.lua
@@ -0,0 +1,31 @@
1+local M = {}
2+M.state = require("syncmap.state")
3+M.rsync = require("syncmap.rsync")
4+M.utils = require("syncmap.utils")
5+M.default = require("syncmap.default")
6+M.log = require("syncmap.log")
7+
8+M.opts = M.default
9+
10+---Setup the sync map plugin
11+---@param opts SyncmapOpts
12+function M.setup(opts)
13+ M.opts = vim.tbl_deep_extend("force", {}, M.default, opts or {})
14+ M.utils.opts = M.opts
15+ M.state.opts = M.opts
16+ M.rsync.opts = M.opts
17+ M.log.opts = M.opts
18+ M.state.sync(M.opts)
19+end
20+
21+---Shows the current state of Syncmap
22+function M.show_state()
23+ vim.print(M.state.active)
24+end
25+
26+---Shows the current options of Syncmap
27+function M.show_opts()
28+ vim.print(M.opts)
29+end
30+
31+return M
+7-0lua/syncmap/lazy.lua
@@ -0,0 +1,7 @@
1+require("lazy.types")
2+
3+---@class SyncMapOptsWrapper
4+---@field opts? SyncmapOpts
5+
6+---LazyVim Specs for syncmap
7+---@alias SyncmapLazySpec LazySpec|SyncMapOptsWrapper
+38-0lua/syncmap/log.lua
@@ -0,0 +1,38 @@
1+local M = {
2+ opts = require("syncmap.default"),
3+}
4+
5+---@param opts FinalSyncmapOpts
6+function M.setup(opts)
7+ M.opts = opts
8+end
9+
10+---@type table<LogLevel, vim.log.levels>
11+M.log_table = {
12+ ["info"] = vim.log.levels.INFO,
13+ ["error"] = vim.log.levels.ERROR,
14+ ["none"] = vim.log.levels.OFF,
15+}
16+
17+---Logs a message based on options log level
18+---@param msg any
19+---@param level vim.log.levels
20+function M.log(msg, level)
21+ if level >= M.log_table[M.opts.log_level] then
22+ vim.notify("[syncmap] " .. msg, level)
23+ end
24+end
25+
26+---Logs message as info
27+---@param msg any
28+function M.info(msg)
29+ M.log(msg, vim.log.levels.INFO)
30+end
31+
32+---Logs message as error
33+---@param msg any
34+function M.error(msg)
35+ M.log(msg, vim.log.levels.ERROR)
36+end
37+
38+return M
+61-0lua/syncmap/rsync.lua
@@ -0,0 +1,61 @@
1+local log = require("syncmap.log")
2+local utils = require("syncmap.utils")
3+local M = {
4+ opts = require("syncmap.default"),
5+}
6+
7+---Runs rsync
8+---@param args RsyncParams
9+function M.run(args)
10+ local cmd = { "rsync", unpack(args.flags), args.src, args.dst }
11+ local result = vim.fn.system(cmd)
12+ if vim.v.shell_error ~= 0 then
13+ log.error(table.concat(cmd, " ") .. " failed:\n" .. result)
14+ else
15+ log.info(table.concat(cmd, " ") .. " succed")
16+ end
17+ return result
18+end
19+
20+---@param args RsyncParams
21+function M.spawn_watcher(args)
22+ if M.opts.reverse_sync_on_startup then
23+ M.run(args)
24+ end
25+ local cmd = string.format(
26+ "while inotifywait -r -e modify,create,delete %q; do rsync %s %q %q; done",
27+ args.src,
28+ table.concat(utils.extract_flags(args.flags), " "),
29+ args.src,
30+ args.dst
31+ )
32+
33+ local handle
34+ handle, _ = vim.uv.spawn("sh", {
35+ args = {
36+ "-c",
37+ string.format(
38+ "while inotifywait -r -e modify,create,delete %q; do rsync %s %q %q; done",
39+ args.src,
40+ table.concat(args.flags, " "),
41+ args.src,
42+ args.dst
43+ ),
44+ },
45+ stdio = { nil, nil, nil },
46+ cwd = args.src,
47+ env = vim.fn.environ(),
48+ detached = true,
49+ hide = true,
50+ ---@diagnostic disable-next-line: assign-type-mismatch
51+ uid = vim.uv.getuid(),
52+ verbatim = false,
53+ ---@diagnostic disable-next-line: assign-type-mismatch
54+ gid = vim.uv.getgid(),
55+ }, function(code, signal)
56+ log.info(cmd .. "\nEnded with code " .. code .. " and signal " .. signal)
57+ handle:close()
58+ end)
59+end
60+
61+return M
+111-0lua/syncmap/state.lua
@@ -0,0 +1,111 @@
1+local rsync = require("syncmap.rsync")
2+local utils = require("syncmap.utils")
3+local log = require("syncmap.log")
4+local M = {
5+ opts = require("syncmap.default"),
6+}
7+
8+M.dir = vim.fn.expand("~/.local/share/nvim/syncmap/")
9+
10+---Make sure the directory exists
11+if vim.fn.isdirectory(M.dir) == 0 then
12+ vim.fn.mkdir(M.dir, "p")
13+end
14+
15+M.state_file = vim.fn.expand("~/.local/share/nvim/syncmap/state.json")
16+
17+-- Create file if it doesn't exist
18+local function create_state_file()
19+ if vim.fn.filereadable(M.state_file) == 0 then
20+ local fd = io.open(M.state_file, "w")
21+ if fd then
22+ fd:write("{}") -- empty JSON object
23+ fd:close()
24+ else
25+ utils.log("Failed to create state file", vim.log.levels.ERROR)
26+ end
27+ end
28+end
29+create_state_file()
30+
31+---@alias RunningSrcs table<RsyncPath, boolean>
32+
33+---A dictionairy keyed on the src for a match located in ~/.local/share/nvim/syncmap/state.json
34+---@type RunningSrcs
35+M.active = {}
36+
37+---Load the existing config if possible
38+local function load_state()
39+ local content = vim.fn.readfile(M.state_file)
40+ local joined = table.concat(content, "\n")
41+ local ok, parsed = pcall(vim.json.decode, joined)
42+ if ok and type(parsed) == "table" then
43+ M.active = parsed
44+ else
45+ utils.log("Failed to parse state file", vim.log.levels.ERROR)
46+ end
47+end
48+load_state()
49+
50+---Removes stale states, calls reverse_rsync on new folders, and starts watch on dead pids
51+---@param opts FinalSyncmapOpts
52+function M.sync(opts)
53+ ---@type table<RsyncPath, SyncmapConfigMatch>
54+ local lookup = {}
55+ for _, m in ipairs(opts.map) do
56+ lookup[m[1]] = m
57+ end
58+
59+ for src in pairs(M.active) do
60+ if not lookup[src] then
61+ M.active[src] = nil
62+ utils.kill(src)
63+ elseif utils.search(src) == "" then
64+ local m = lookup[src]
65+ local r = utils.row_to_rsync_params(m)
66+ log.info("Couldn't find process for " .. m[1] .. " " .. m[2])
67+ rsync.spawn_watcher(r)
68+ M.active[r.src] = true
69+ end
70+ end
71+
72+ for _, m in ipairs(opts.map) do
73+ if not M.active[m[1]] then
74+ local r = utils.row_to_rsync_params(m)
75+ rsync.spawn_watcher(r)
76+ M.active[r.src] = true
77+ end
78+ end
79+ M.save()
80+end
81+
82+function M.save()
83+ local ok, encoded = pcall(vim.json.encode, M.active)
84+ if not ok then
85+ utils.log("Failed to encode state", vim.log.levels.ERROR)
86+ return
87+ end
88+
89+ local fd = io.open(M.state_file, "w")
90+ if not fd then
91+ utils.log("Failed to open state file for writing", vim.log.levels.ERROR)
92+ return
93+ end
94+
95+ fd:write(encoded)
96+ fd:close()
97+end
98+
99+---Clears the saved information by deleting the state file
100+function M.clear()
101+ for _, a in ipairs(M.active) do
102+ end
103+ local ok, err = os.remove(M.state_file)
104+ if not ok then
105+ log.error("Failed to delete state file: " .. err)
106+ else
107+ log.info("State file deleted")
108+ end
109+end
110+
111+return M
+58-0lua/syncmap/types.lua
@@ -0,0 +1,58 @@
1+---A file or folder to use with rsync
2+---Keep in mind that slash matters. A final slash means to use the contents so you most likely want to use slashes at the end of folders.
3+---
4+---A file or folder to use with rsync.
5+---⚠️ A trailing slash means \"sync the contents\" — without it, rsync syncs the whole folder.
6+---You usually want a slash at the end (e.g., `"~/.dotfiles/nvim/"`)
7+---@class RsyncPath : string
8+
9+---@alias ReverseSyncOnSpawn boolean @[default=true]
10+
11+---@alias RsyncFlag
12+---| "-a" # archive: preserve everything
13+---| "--delete" # delete files not in source
14+---| "-v" # verbose output
15+---| "-z" # compress during transfer
16+---| "--dry-run" # simulate the sync
17+---| string # allow custom flags too
18+
19+---@alias LogLevel
20+---| "info" show info logs
21+---| "error" show error logs
22+---| "none" show no logs
23+
24+---Ex: `--exclude-from='.gitignore'`
25+---@alias ExcludeFrom string @[default=".gitignore"] Adds --exclude-from in rsync
26+
27+---A match to use for syncing using files or folders.
28+---
29+---Ex: `{vim.fn.expand("~/.dotfiles/nvim/"), vim.fn.expand("~/.config/nvim/")}` sync everything within `~/.dotfiles/nvim` into `~/.config/nvim`
30+---@class SyncmapConfigMatch
31+---@field [1] RsyncPath @[required] Path to sync from
32+---@field [2] RsyncPath @[required] Path to sync to
33+---@field reverse_sync_on_spawn? ReverseSyncOnSpawn @[default=parent.reverse_sync_on_startup]
34+---@field rsync? RsyncFlag[] @[default=parent.rsync] Flags to use with rsync
35+---@field exclude_from? ExcludeFrom @[default=parent.exclude_from]
36+
37+---Configurations for syncmap
38+---@class SyncmapOpts
39+---@field map? SyncmapConfigMatch[] @[default=~/.dotfiles/nvim/ ~/.config/nvim] The folders and files to keep synchronized
40+---@field reverse_sync_on_startup? ReverseSyncOnSpawn
41+---@field rsync? RsyncFlag[] @[default={"-a", "--delete"}] Rsync flags that will be used if a map item doesn't include anything. I.E. Default flags
42+---@field log_level? LogLevel @[default="error"] Sets the log level for syncmap
43+---@field exclude_from? ExcludeFrom
44+
45+---Final config
46+---@class FinalSyncmapOpts
47+---@field map SyncmapConfigMatch[] @[default=~/.dotfiles/nvim/ ~/.config/nvim] The folders and files to keep synchronized
48+---@field reverse_sync_on_startup ReverseSyncOnSpawn
49+---@field rsync RsyncFlag[] @[default={"-a", "--delete"}] Rsync flags that will be used if a map item doesn't include anything. I.E. Default flags
50+---@field log_level LogLevel @[default="error"] Sets the log level for syncmap
51+---@field exclude_from? ExcludeFrom
52+
53+---Parameters pased into rsync methods
54+---@class RsyncParams
55+---@field src RsyncPath Source path to sync from
56+---@field dst RsyncPath Destination path to sync from
57+---@field flags? RsyncFlag[] @[default={}] Flags used while running rsync. Default to {} for safety precautions.
58+---@field reverse_sync_on_spawn ReverseSyncOnSpawn
+107-0lua/syncmap/utils.lua
@@ -0,0 +1,107 @@
1+local M = {
2+ opts = require("syncmap.default"),
3+}
4+
5+---Correctly extracts reverse_sync_on_spawn
6+---@param row SyncmapConfigMatch
7+function M.extract_reverse(row)
8+ if row.reverse_sync_on_spawn == nil then
9+ if M.opts.reverse_sync_on_startup == nil then
10+ return true
11+ else
12+ return M.opts.reverse_sync_on_startup
13+ end
14+ else
15+ return row.reverse_sync_on_spawn
16+ end
17+end
18+
19+---Checks if a file exists
20+---@param path string
21+local function file_exists(path)
22+ local f = io.open(path, "r")
23+ if f then
24+ f:close()
25+ return true
26+ end
27+ return false
28+end
29+
30+---Checks if a path is a directory
31+---@param path string
32+local function is_dir(path)
33+ local stat = vim.uv.fs_stat(path)
34+ return stat and stat.type == "directory"
35+end
36+
37+--- Ensures trailing slash
38+---@param path string
39+local function with_trailing_slash(path)
40+ if path:sub(-1) ~= "/" then
41+ return path .. "/"
42+ end
43+ return path
44+end
45+
46+---@param s string
47+function M.string_to_exclude_from(s)
48+ return "--exclude_from='" .. s .. "'"
49+end
50+
51+---Gets the exclude from using parent pattern
52+---@param m SyncmapConfigMatch
53+function M.extract_exclude_from(m)
54+ if m.exclude_from == nil then
55+ if M.opts.exclude_from ~= nil then
56+ return M.string_to_exclude_from(M.opts.exclude_from)
57+ end
58+ return nil
59+ else
60+ return M.string_to_exclude_from(m.exclude_from)
61+ end
62+end
63+
64+---Makes sure to extract flags correctly
65+---@param m SyncmapConfigMatch
66+---@return RsyncFlag[]
67+function M.extract_flags(m)
68+ local flags = m.rsync
69+ if flags == nil then
70+ flags = M.opts.rsync
71+ end
72+ if m.exclude_from == nil then
73+ return flags
74+ end
75+ if is_dir(m[1]) then
76+ if file_exists(with_trailing_slash(m[1]) .. m.exclude_from) then
77+ table.insert(flags, M.extract_exclude_from(m))
78+ end
79+ end
80+ return flags
81+end
82+
83+---@param m SyncmapConfigMatch
84+function M.row_to_rsync_params(m)
85+ local flags = M.extract_flags(m)
86+ ---@type RsyncParams
87+ return {
88+ src = m[1],
89+ dst = m[2],
90+ flags = flags,
91+ reverse_sync_on_spawn = M.extract_reverse(m),
92+ }
93+end
94+
95+---Kills an inotifywait instance based on the src name
96+---@param src string
97+function M.kill(src)
98+ vim.fn.system({ "pkill", "-f", "^inotifywait.*" .. src })
99+end
100+
101+---Searches for an existing inotifywait instance based on the src name
102+---@param src string
103+function M.search(src)
104+ return vim.fn.system({ "pgrep", "-f", "^inotifywait.*" .. src })
105+end
106+
107+return M