{ config, lib, pkgs, ... }: let cfg = config.services.git-autosync; git-autosync-script = pkgs.writeShellScript "git-autosync" '' set -euo pipefail REPO_DIR="$1" DEBOUNCE_SECONDS="''${2:-5}" PULL_INTERVAL_SECONDS="''${3:-120}" BRANCH="''${4:-main}" cd "$REPO_DIR" # Ensure we're in a git repo if ! ${pkgs.git}/bin/git rev-parse --is-inside-work-tree &>/dev/null; then echo "ERROR: $REPO_DIR is not a git repository" exit 1 fi commit_and_push() { cd "$REPO_DIR" ${pkgs.git}/bin/git add -A if ! ${pkgs.git}/bin/git diff --cached --quiet; then ${pkgs.git}/bin/git commit -m "autosync: $(date -Iseconds) on $(hostname)" echo "Committed changes" fi if ${pkgs.git}/bin/git remote get-url origin &>/dev/null; then ${pkgs.git}/bin/git push origin "$BRANCH" 2>/dev/null && echo "Pushed" || echo "Push failed (offline?)" fi } pull_remote() { cd "$REPO_DIR" if ${pkgs.git}/bin/git remote get-url origin &>/dev/null; then ${pkgs.git}/bin/git fetch origin "$BRANCH" 2>/dev/null || { echo "Fetch failed (offline?)"; return; } LOCAL=$(${pkgs.git}/bin/git rev-parse HEAD) REMOTE=$(${pkgs.git}/bin/git rev-parse "origin/$BRANCH" 2>/dev/null || echo "") if [ -n "$REMOTE" ] && [ "$LOCAL" != "$REMOTE" ]; then # Stash any uncommitted changes, rebase, then pop STASHED=false if ! ${pkgs.git}/bin/git diff --quiet || ! ${pkgs.git}/bin/git diff --cached --quiet; then ${pkgs.git}/bin/git stash push -m "autosync-temp" STASHED=true fi ${pkgs.git}/bin/git rebase "origin/$BRANCH" || { echo "Rebase conflict — aborting rebase, committing local state" ${pkgs.git}/bin/git rebase --abort } if [ "$STASHED" = true ]; then ${pkgs.git}/bin/git stash pop || { echo "Stash pop conflict — keeping stash, check manually" } fi echo "Pulled remote changes" fi fi } # Initial pull on start pull_remote LAST_PULL=$(date +%s) echo "Watching $REPO_DIR for changes (debounce: ''${DEBOUNCE_SECONDS}s, pull interval: ''${PULL_INTERVAL_SECONDS}s)" # Watch for file changes, debounce, then sync ${pkgs.inotify-tools}/bin/inotifywait \ -m -r \ -e modify,create,delete,move \ --exclude '/\.git/' \ --format '%w%f' \ "$REPO_DIR" | while read -t "$PULL_INTERVAL_SECONDS" CHANGED_FILE || true; do NOW=$(date +%s) if [ -n "''${CHANGED_FILE:-}" ]; then # File changed — debounce by consuming events for DEBOUNCE_SECONDS echo "Change detected: $CHANGED_FILE" while read -t "$DEBOUNCE_SECONDS" EXTRA_FILE; do echo " also changed: $EXTRA_FILE" done commit_and_push LAST_PULL=$NOW fi # Periodic pull if enough time has passed ELAPSED=$((NOW - LAST_PULL)) if [ "$ELAPSED" -ge "$PULL_INTERVAL_SECONDS" ]; then pull_remote LAST_PULL=$(date +%s) fi done ''; repoOpts = { name, ... }: { options = { path = lib.mkOption { type = lib.types.str; description = "Absolute path to the git repository."; example = "/home/user/notes"; }; branch = lib.mkOption { type = lib.types.str; default = "main"; description = "Git branch to sync."; }; debounceSeconds = lib.mkOption { type = lib.types.int; default = 5; description = "Seconds to wait after last file change before committing."; }; pullIntervalSeconds = lib.mkOption { type = lib.types.int; default = 120; description = "Seconds between periodic pulls from remote."; }; sshKey = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "Path to SSH private key for git push/pull. If null, uses the user's default SSH config."; example = "/home/alice/.ssh/id_ed25519"; }; gitName = lib.mkOption { type = lib.types.str; default = "autosync"; description = "Git author name for auto-commits."; }; gitEmail = lib.mkOption { type = lib.types.str; default = "autosync@localhost"; description = "Git author email for auto-commits."; }; }; }; in { options.services.git-autosync = { enable = lib.mkEnableOption "git-autosync file watcher and sync service"; repos = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule repoOpts); default = {}; description = "Set of git repositories to auto-sync."; example = { notes = { path = "/home/alice/notes"; branch = "main"; }; }; }; }; config = lib.mkIf cfg.enable { systemd.user.services = lib.mapAttrs' (name: repo: lib.nameValuePair "git-autosync-${name}" { Unit = { Description = "Git auto-sync for ${repo.path}"; After = [ "network-online.target" ]; Wants = [ "network-online.target" ]; }; Service = { Type = "simple"; Restart = "on-failure"; RestartSec = 10; ExecStart = "${git-autosync-script} ${repo.path} ${toString repo.debounceSeconds} ${toString repo.pullIntervalSeconds} ${repo.branch}"; Environment = [ "GIT_AUTHOR_NAME=${repo.gitName}" "GIT_COMMITTER_NAME=${repo.gitName}" "GIT_AUTHOR_EMAIL=${repo.gitEmail}" "GIT_COMMITTER_EMAIL=${repo.gitEmail}" ] ++ lib.optionals (repo.sshKey != null) [ "GIT_SSH_COMMAND=ssh -i ${repo.sshKey} -o StrictHostKeyChecking=accept-new" ]; }; Install = { WantedBy = [ "default.target" ]; }; } ) cfg.repos; }; }