nixos/shared/modules/home-manager/git-autosync.nix

199 lines
5.8 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}"
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;
};
}