git-sync

Tool for periodic syncing of git repositories
git clone https://git.sinitax.com/sinitax/git-sync
Log | Files | Refs | sfeed.txt

commit 412657861ed6d6558a4dcbd7373e0426566053cf
Author: Louis Burda <contact@sinitax.com>
Date:   Wed, 16 Apr 2025 21:10:54 +0200

Add basic version based on simonthum/git-sync

Diffstat:
Agit-sync | 384+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agit-syncd | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 475 insertions(+), 0 deletions(-)

diff --git a/git-sync b/git-sync @@ -0,0 +1,384 @@ +#!/usr/bin/env bash +# +# git-sync +# +# synchronize tracking repositories +# +# 2012-20 by Simon Thum and contributors +# Licensed as: CC0 +# +# This script intends to sync via git near-automatically +# in "tracking" repositories where a nice history is not +# crucial, but having one at all is. +# +# Unlike the myriad of scripts to do just that already available, +# it follows the KISS principle: It is small, requires nothing but +# git and bash, but does not even try to shield you from git. +# +# Mode sync (default) +# +# Sync will likely get from you from a dull normal git repo with trivial +# changes to an updated dull normal git repo equal to origin. No more, +# no less. The intent is to do everything that's needed to sync +# automatically, and resort to manual intervention as soon +# as something non-trivial occurs. It is designed to be safe +# in that it will likely refuse to do anything not known to +# be safe. +# +# Mode check +# +# Check only performs the basic checks to make sure the repository +# is in an orderly state to continue syncing, i.e. committing +# changes, pull etc. without losing any data. When check returns +# 0, sync can start immediately. This does not, however, indicate +# that syncing is at all likely to succeed. + +# command used to auto-commit file modifications +DEFAULT_AUTOCOMMIT_CMD="git add -u ; git commit -m \"%message\";" + +# command used to auto-commit all changes +ALL_AUTOCOMMIT_CMD="git add -A ; git commit -m \"%message\";" + +# default commit message substituted into autocommit commands +DEFAULT_AUTOCOMMIT_MSG="changes from $(uname -n) on $(date)" + + +# AUTOCOMMIT_CMD="echo \"Please commit or stash pending changes\"; exit 1;" +# TODO mode for stash push & pop + +print_usage() { + cat << EOF +usage: $0 [-h] [-n] [-s] [MODE] + +Synchronize the current branch to a remote backup +MODE may be either "sync" (the default) or "check", to verify that the branch is ready to sync + +OPTIONS: + -h Show this message + -n Commit new files even if branch.\$branch_name.syncNewFiles isn't set + -s Sync the branch even if branch.\$branch_name.sync isn't set +EOF +} +sync_new_files_anyway="false" +sync_anyway="false" + +while getopts "hns" opt ; do + case $opt in + h ) + print_usage + exit 0 + ;; + n ) + sync_new_files_anyway="true" + ;; + s ) + sync_anyway="true" + ;; + esac +done +shift $((OPTIND-1)) + +# +# utility functions, some adapted from git bash completion +# + +__log_msg() +{ + echo git-sync: $1 +} + +# echo the git dir +__gitdir() +{ + if [ "true" = "$(git rev-parse --is-inside-work-tree "$PWD" | head -1)" ]; then + git rev-parse --git-dir "$PWD" 2>/dev/null + fi +} + +# echos repo state +git_repo_state () +{ + local g="$(__gitdir)" + if [ -n "$g" ]; then + if [ -f "$g/rebase-merge/interactive" ]; then + echo "REBASE-i" + elif [ -d "$g/rebase-merge" ]; then + echo "REBASE-m" + else + if [ -d "$g/rebase-apply" ]; then + echo "AM/REBASE" + elif [ -f "$g/MERGE_HEAD" ]; then + echo "MERGING" + elif [ -f "$g/CHERRY_PICK_HEAD" ]; then + echo "CHERRY-PICKING" + elif [ -f "$g/BISECT_LOG" ]; then + echo "BISECTING" + fi + fi + if [ "true" = "$(git rev-parse --is-inside-git-dir 2>/dev/null)" ]; then + if [ "true" = "$(git rev-parse --is-bare-repository 2>/dev/null)" ]; then + echo "|BARE" + else + echo "|GIT_DIR" + fi + elif [ "true" = "$(git rev-parse --is-inside-work-tree 2>/dev/null)" ]; then + git diff --no-ext-diff --quiet --exit-code || echo "|DIRTY" +# if [ -n "${GIT_PS1_SHOWSTASHSTATE-}" ]; then +# git rev-parse --verify refs/stash >/dev/null 2>&1 && s="$" +# fi +# +# if [ -n "${GIT_PS1_SHOWUNTRACKEDFILES-}" ]; then +# if [ -n "$(git ls-files --others --exclude-standard)" ]; then +# u="%" +# fi +# fi +# +# if [ -n "${GIT_PS1_SHOWUPSTREAM-}" ]; then +# __git_ps1_show_upstream +# fi + fi + else + echo "NOGIT" + fi +} + +# check if we only have untouched, modified or (if configured) new files +check_initial_file_state() +{ + local syncNew="$(git config --get --bool branch.$branch_name.syncNewFiles)" + if [[ "true" == "$syncNew" || "true" == "$sync_new_files_anyway" ]]; then + # allow for new files + if [ ! -z "$(git status --porcelain | grep -E '^[^ \?][^M\?] *')" ]; then + echo "NonNewOrModified" + fi + else + # also bail on new files + if [ ! -z "$(git status --porcelain | grep -E '^[^ ][^M] *')" ]; then + echo "NotOnlyModified" + fi + fi +} + +# look for local changes +# used to decide if autocommit should be invoked +local_changes() +{ + if [ ! -z "$(git status --porcelain | grep -E '^(\?\?|[MARC] |[ MARC][MD])*')" ]; then + echo "LocalChanges" + fi +} + +# determine sync state of repository, i.e. how the remote relates to our HEAD +sync_state() +{ + local count="$(git rev-list --count --left-right $remote_name/$branch_name...HEAD)" + + case "$count" in + "") # no upstream + echo "noUpstream" + false + ;; + "0 0") + echo "equal" + true + ;; + "0 "*) + echo "ahead" + true + ;; + *" 0") + echo "behind" + true + ;; + *) + echo "diverged" + true + ;; + esac +} + +# exit, issue warning if not in sync +exit_assuming_sync() { + if [ "equal" == "$(sync_state)" ] ; then + __log_msg "In sync, all fine." + exit 0; + else + __log_msg "Synchronization FAILED! You should definitely check your repository carefully!" + __log_msg "(Possibly a transient network problem? Please try again in that case.)" + exit 3 + fi +} + +# +# Here git-sync actually starts +# + +# first some sanity checks +rstate="$(git_repo_state)" +if [[ -z "$rstate" || "|DIRTY" = "$rstate" ]]; then + __log_msg "Preparing. Repo in $(__gitdir)" +elif [[ "NOGIT" = "$rstate" ]] ; then + __log_msg "No git repository detected. Exiting." + exit 128 # matches git's error code +else + __log_msg "Git repo state considered unsafe for sync: $(git_repo_state)" + exit 2 +fi + +# determine the current branch (thanks to stackoverflow) +branch_name=$(git symbolic-ref -q HEAD) +branch_name=${branch_name##refs/heads/} + +if [ -z "$branch_name" ] ; then + __log_msg "Syncing is only possible on a branch." + git status + exit 2 +fi + +# while at it, determine the remote to operate on +remote_name=$(git config --get branch.$branch_name.pushRemote) +if [ -z "$remote_name" ] ; then + remote_name=$(git config --get remote.pushDefault) +fi +if [ -z "$remote_name" ] ; then + remote_name=$(git config --get branch.$branch_name.remote) +fi + +if [ -z "$remote_name" ] ; then + __log_msg "the current branch does not have a configured remote." + echo + __log_msg "Please use" + echo + __log_msg " git branch --set-upstream-to=[remote_name]/$branch_name" + echo + __log_msg "replacing [remote_name] with the name of your remote, i.e. - origin" + __log_msg "to set the remote tracking branch for git-sync to work" + exit 2 +fi + +# check if current branch is configured for sync +if [[ "true" != "$(git config --get --bool branch.$branch_name.sync)" && "true" != "$sync_anyway" ]] ; then + echo + __log_msg "Please use" + echo + __log_msg " git config --bool branch.$branch_name.sync true" + echo + __log_msg "to enlist branch $branch_name for synchronization." + __log_msg "Branch $branch_name has to have a same-named remote branch" + __log_msg "for git-sync to work." + echo + __log_msg "(If you don't know what this means, you should change that" + __log_msg "before relying on this script. You have been warned.)" + echo + exit 1 +fi + +# determine mode +if [[ -z "$1" || "$1" == "sync" ]]; then + mode="sync" +elif [[ "check" == "$1" ]]; then + mode="check" +else + __log_msg "Mode $1 not recognized" + exit 100 +fi + +__log_msg "Mode $mode" + +__log_msg "Using $remote_name/$branch_name" + +# check for intentionally unhandled file states +if [ ! -z "$(check_initial_file_state)" ] ; then + __log_msg "There are changed files you should probably handle manually." + git status + exit 1 +fi + +# if in check mode, this is all we need to know +if [ $mode == "check" ] ; then + __log_msg "check OK; sync may start." + exit 0 +fi + +# check if we have to commit local changes, if yes, do so +if [ ! -z "$(local_changes)" ]; then + autocommit_cmd="" + config_autocommit_cmd="$(git config --get branch.$branch_name.autocommitscript)" + + # discern the three ways to auto-commit + if [ ! -z "$config_autocommit_cmd" ]; then + autocommit_cmd="$config_autocommit_cmd" + elif [[ "true" == "$(git config --get --bool branch.$branch_name.syncNewFiles)" || "true" == "$sync_new_files_anyway" ]]; then + autocommit_cmd=${ALL_AUTOCOMMIT_CMD} + else + autocommit_cmd=${DEFAULT_AUTOCOMMIT_CMD} + fi + + commit_msg="$(git config --get branch.$branch_name.syncCommitMsg)" + if [ "" == "$commit_msg" ]; then + commit_msg=${DEFAULT_AUTOCOMMIT_MSG} + fi + autocommit_cmd=$(echo "$autocommit_cmd" | sed "s/%message/$commit_msg/") + + __log_msg "Committing local changes using ${autocommit_cmd}" + eval $autocommit_cmd + + # after autocommit, we should be clean + rstate="$(git_repo_state)" + if [[ ! -z "$rstate" ]]; then + __log_msg "Auto-commit left uncommitted changes. Please add or remove them as desired and retry." + exit 1 + fi +fi + +# fetch remote to get to the current sync state +# TODO make fetching/pushing optional +__log_msg "Fetching from $remote_name/$branch_name" +git fetch $remote_name $branch_name +if [ $? != 0 ] ; then + __log_msg "git fetch $remote_name returned non-zero. Likely a network problem; exiting." + exit 3 +fi + +case "$(sync_state)" in +"noUpstream") + __log_msg "Strange state, you're on your own. Good luck." + exit 2 + ;; +"equal") + exit_assuming_sync + ;; +"ahead") + __log_msg "Pushing changes..." + git push $remote_name $branch_name:$branch_name + if [ $? == 0 ]; then + exit_assuming_sync + else + __log_msg "git push returned non-zero. Likely a connection failure." + exit 3 + fi + ;; +"behind") + __log_msg "We are behind, fast-forwarding..." + git merge --ff --ff-only $remote_name/$branch_name + if [ $? == 0 ]; then + exit_assuming_sync + else + __log_msg "git merge --ff --ff-only returned non-zero ($?). Exiting." + exit 2 + fi + ;; +"diverged") + __log_msg "We have diverged. Trying to rebase..." + git rebase $remote_name/$branch_name + if [[ $? == 0 && -z "$(git_repo_state)" && "ahead" == "$(sync_state)" ]] ; then + __log_msg "Rebasing went fine, pushing..." + git push $remote_name $branch_name:$branch_name + exit_assuming_sync + else + __log_msg "Rebasing failed, likely there are conflicting changes. Resolve them and finish the rebase before repeating git-sync." + exit 1 + fi + # TODO: save master, if rebasing fails, make a branch of old master + ;; +esac diff --git a/git-syncd b/git-syncd @@ -0,0 +1,91 @@ +#!/bin/bash + +GIT_SYNCD_CONFIG_DIR=${GIT_SYNCD_CONFIG_DIR:-"$HOME/.config/git-sync"} +GIT_SYNCD_CONFIG=${GIT_SYNCD_CONFIG:-"$GIT_SYNCD_CONFIG_DIR/syncd.rc"} + +git_syncd_repos=() +git_syncd_ignore=() + +# maximum time seconds between fetching changes and syncing +git_syncd_default_sync_timeout=600 +declare -A git_syncd_sync_timeout + +# number of seconds to wait after deteced change to push +git_syncd_default_sync_delay=300 +declare -A git_syncd_sync_delay + +sync() { + echo "starting sync.." + cd "$repo" && git-sync +} + +watcher() { + repo=$1 + excludes=() + sync_delay=${git_syncd_sync_delay[$repo]:-$git_syncd_default_sync_delay} + sync_timeout=${git_syncd_sync_delay[$repo]:-$git_syncd_default_sync_delay} + sync "$repo" + timeout=$sync_timeout + while true; do + file=$(inotifywait "$repo" -r -e modify,move,create,delete \ + --exclude '\.git' ${excludes[@]} -t "$timeout" 2>/dev/null) + if [ ! -z "$file" ]; then + echo "inotify wake: $file" + timeout=$sync_delay + else + sync "$repo" + timeout=$sync_timeout + fi + sleep 1 + done +} + +log_prefix() { + repo="$1" + repo_name=$(basename "$repo") + repo_dir_name=$(basename $(dirname "$repo")) + while read -r line; do + prefix="$(date "+%Y-%m-%d %T") $repo_dir_name/$repo_name" + prefix="$(echo "$prefix" | tr -d "\"" | tr -d "'")" + echo "[$prefix] $line" + done +} + +git_syncd_watchers=() +kill_watchers() { + for pid in "${git_syncd_watchers}"; do + kill -9 "$pid" + timeout 10 wait "$pid" || notify-send "Failed to kill git-syncd watcher" + done + git_syncd_watchers=() +} +start_watchers() { + for repo in "${git_syncd_repos[@]}"; do + echo "watching '$repo'.." + watcher "$repo" 2>&1 | log_prefix "$repo" & + git_syncd_watchers+=($!) + done +} + +load_config() { + if [ -e "$GIT_SYNCD_CONFIG" ]; then + echo "sourcing '$GIT_SYNCD_CONFIG'.." + source "$GIT_SYNCD_CONFIG" + fi +} + +load_config +start_watchers +while true; do + file=$(inotifywait --format "%w%f" -e modify,delete,create "$GIT_SYNCD_CONFIG_DIR" 2>/dev/null) + if [ ! -z "$file" ]; then + mod_path="$(realpath "$file" 2>/dev/null)" + config_path="$(realpath "$GIT_SYNCD_CONFIG" 2>/dev/null)" + if [ "$mod_path" = "$config_path" ]; then + kill_watchers + load_config + start_watchers + fi + fi + sleep 2 +done