{ 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}" MAX_PULL_RETRIES="''${5:-6}" RETRY_DELAY_SECONDS="''${6:-4}" 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 send_notification() { local urgency="$1" local title="$2" local message="$3" ${pkgs.libnotify}/bin/notify-send -u "$urgency" "$title" "$message" 2>/dev/null || true } 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 returns 0 on success, 1 on failure 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 1; } 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 return 0 fi return 0 # No remote configured, not a failure } # Initial pull with exponential backoff - must succeed before we start watching PULL_ATTEMPTS=0 CURRENT_DELAY="$RETRY_DELAY_SECONDS" while true; do if pull_remote; then echo "Initial pull succeeded" break fi PULL_ATTEMPTS=$((PULL_ATTEMPTS + 1)) echo "Initial pull failed (attempt $PULL_ATTEMPTS/$MAX_PULL_RETRIES)" if [ "$PULL_ATTEMPTS" -ge "$MAX_PULL_RETRIES" ]; then send_notification "critical" "git-autosync failed: $REPO_DIR" "Unable to pull after $MAX_PULL_RETRIES attempts. Check authentication or network. Service stopping." echo "ERROR: Initial pull failed after $MAX_PULL_RETRIES attempts. Exiting." exit 0 # Exit cleanly so systemd doesn't restart fi echo "Retrying in $CURRENT_DELAY seconds..." sleep "$CURRENT_DELAY" CURRENT_DELAY=$((CURRENT_DELAY * 2)) done 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 = 30; 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."; }; maxPullRetries = lib.mkOption { type = lib.types.int; default = 6; description = "Maximum number of pull retries before giving up and stopping the service."; }; retryDelaySeconds = lib.mkOption { type = lib.types.int; default = 4; description = "Seconds to wait between pull retry attempts, increases exponentially."; }; }; }; 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} ${toString repo.maxPullRetries} ${toString repo.retryDelaySeconds}"; 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; }; }