243 lines
7.5 KiB
Nix
243 lines
7.5 KiB
Nix
{ 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;
|
|
};
|
|
}
|