home

My NixOS systems configurations.
Log | Files | Refs | LICENSE

gitwatch.sh (15527B)


      1 #!/usr/bin/env bash
      2 #
      3 # gitwatch - watch file or directory and git commit all changes as they happen
      4 #
      5 # Copyright (C) 2013-2018  Patrick Lehner
      6 #   with modifications and contributions by:
      7 #   - Matthew McGowan
      8 #   - Dominik D. Geyer
      9 #   - Phil Thompson
     10 #   - Dave Musicant
     11 #
     12 #############################################################################
     13 #    This program is free software: you can redistribute it and/or modify
     14 #    it under the terms of the GNU General Public License as published by
     15 #    the Free Software Foundation, either version 3 of the License, or
     16 #    (at your option) any later version.
     17 #
     18 #    This program is distributed in the hope that it will be useful,
     19 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
     20 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     21 #    GNU General Public License for more details.
     22 #
     23 #    You should have received a copy of the GNU General Public License
     24 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
     25 #############################################################################
     26 #
     27 #   Idea and original code taken from http://stackoverflow.com/a/965274
     28 #       original work by Lester Buck
     29 #       (but heavily modified by now)
     30 #
     31 #   Requires the command 'inotifywait' to be available, which is part of
     32 #   the inotify-tools (See https://github.com/rvoicilas/inotify-tools ),
     33 #   and (obviously) git.
     34 #   Will check the availability of both commands using the `which` command
     35 #   and will abort if either command (or `which`) is not found.
     36 #
     37 
     38 REMOTE=""
     39 BRANCH=""
     40 SLEEP_TIME=2
     41 DATE_FMT="+%Y-%m-%d %H:%M:%S"
     42 COMMITMSG="Scripted auto-commit on change (%d) by gitwatch.sh"
     43 LISTCHANGES=-1
     44 LISTCHANGES_COLOR="--color=always"
     45 GIT_DIR=""
     46 SKIP_IF_MERGING=0
     47 
     48 # Print a message about how to use this script
     49 shelp() {
     50   echo "gitwatch - watch file or directory and git commit all changes as they happen"
     51   echo ""
     52   echo "Usage:"
     53   echo "${0##*/} [-s <secs>] [-d <fmt>] [-r <remote> [-b <branch>]]"
     54   echo "          [-m <msg>] [-l|-L <lines>] [-M] <target>"
     55   echo ""
     56   echo "Where <target> is the file or folder which should be watched. The target needs"
     57   echo "to be in a Git repository, or in the case of a folder, it may also be the top"
     58   echo "folder of the repo."
     59   echo ""
     60   echo " -s <secs>        After detecting a change to the watched file or directory,"
     61   echo "                  wait <secs> seconds until committing, to allow for more"
     62   echo "                  write actions of the same batch to finish; default is 2sec"
     63   echo " -d <fmt>         The format string used for the timestamp in the commit"
     64   echo "                  message; see 'man date' for details; default is "
     65   echo '                  "+%Y-%m-%d %H:%M:%S"'
     66   echo " -r <remote>      If given and non-empty, a 'git push' to the given <remote>"
     67   echo "                  is done after every commit; default is empty, i.e. no push"
     68   echo " -b <branch>      The branch which should be pushed automatically;"
     69   echo "                - if not given, the push command used is  'git push <remote>',"
     70   echo "                    thus doing a default push (see git man pages for details)"
     71   echo "                - if given and"
     72   echo "                  + repo is in a detached HEAD state (at launch)"
     73   echo "                    then the command used is  'git push <remote> <branch>'"
     74   echo "                  + repo is NOT in a detached HEAD state (at launch)"
     75   echo "                    then the command used is"
     76   echo "                    'git push <remote> <current branch>:<branch>'  where"
     77   echo "                    <current branch> is the target of HEAD (at launch)"
     78   echo "                  if no remote was defined with -r, this option has no effect"
     79   echo " -g <path>        Location of the .git directory, if stored elsewhere in"
     80   echo "                  a remote location. This specifies the --git-dir parameter"
     81   echo " -l <lines>       Log the actual changes made in this commit, up to a given"
     82   echo "                  number of lines, or all lines if 0 is given"
     83   echo " -L <lines>       Same as -l but without colored formatting"
     84   echo " -m <msg>         The commit message used for each commit; all occurrences of"
     85   echo "                  %d in the string will be replaced by the formatted date/time"
     86   echo "                  (unless the <fmt> specified by -d is empty, in which case %d"
     87   echo "                  is replaced by an empty string); the default message is:"
     88   echo '                  "Scripted auto-commit on change (%d) by gitwatch.sh"'
     89   echo " -e <events>      Events passed to inotifywait to watch (defaults to "
     90   echo "                  '$EVENTS')"
     91   echo "                  (useful when using inotify-win, e.g. -e modify,delete,move)"
     92   echo "                  (currently ignored on Mac, which only uses default values)"
     93   echo " -M               Prevent commits when there is an ongoing merge in the repo"
     94   echo ""
     95   echo "As indicated, several conditions are only checked once at launch of the"
     96   echo "script. You can make changes to the repo state and configurations even while"
     97   echo "the script is running, but that may lead to undefined and unpredictable (even"
     98   echo "destructive) behavior!"
     99   echo "It is therefore recommended to terminate the script before changing the repo's"
    100   echo "config and restarting it afterwards."
    101   echo ""
    102   echo 'By default, gitwatch tries to use the binaries "git", "inotifywait", and'
    103   echo "\"readline\", expecting to find them in the PATH (it uses 'which' to check this"
    104   echo "and will abort with an error if they cannot be found). If you want to use"
    105   echo "binaries that are named differently and/or located outside of your PATH, you can"
    106   echo "define replacements in the environment variables GW_GIT_BIN, GW_INW_BIN, and"
    107   echo "GW_RL_BIN for git, inotifywait, and readline, respectively."
    108 }
    109 
    110 # print all arguments to stderr
    111 stderr() {
    112   echo "$@" >&2
    113 }
    114 
    115 # clean up at end of program, killing the remaining sleep process if it still exists
    116 cleanup() {
    117   if [[ -n $SLEEP_PID ]] && kill -0 "$SLEEP_PID" &> /dev/null; then
    118     kill "$SLEEP_PID" &> /dev/null
    119   fi
    120   exit 0
    121 }
    122 
    123 # Tests for the availability of a command
    124 is_command() {
    125   hash "$1" 2> /dev/null
    126 }
    127 
    128 # Test whether or not current git directory has ongoign merge
    129 is_merging () {
    130   [ -f "$(git rev-parse --git-dir)"/MERGE_HEAD ]
    131 }
    132 
    133 ###############################################################################
    134 
    135 while getopts b:d:h:g:L:l:m:p:r:s:e:M option; do # Process command line options
    136   case "${option}" in
    137     b) BRANCH=${OPTARG} ;;
    138     d) DATE_FMT=${OPTARG} ;;
    139     h)
    140       shelp
    141       exit
    142       ;;
    143     g) GIT_DIR=${OPTARG} ;;
    144     l) LISTCHANGES=${OPTARG} ;;
    145     L)
    146       LISTCHANGES=${OPTARG}
    147       LISTCHANGES_COLOR=""
    148       ;;
    149     m) COMMITMSG=${OPTARG} ;;
    150     M) SKIP_IF_MERGING=1 ;;
    151     p | r) REMOTE=${OPTARG} ;;
    152     s) SLEEP_TIME=${OPTARG} ;;
    153     e) EVENTS=${OPTARG} ;;
    154     *)
    155       stderr "Error: Option '${option}' does not exist."
    156       shelp
    157       exit 1
    158       ;;
    159   esac
    160 done
    161 
    162 shift $((OPTIND - 1)) # Shift the input arguments, so that the input file (last arg) is $1 in the code below
    163 
    164 if [ $# -ne 1 ]; then # If no command line arguments are left (that's bad: no target was passed)
    165   shelp               # print usage help
    166   exit                # and exit
    167 fi
    168 
    169 # if custom bin names are given for git, inotifywait, or readlink, use those; otherwise fall back to "git", "inotifywait", and "readlink"
    170 if [ -z "$GW_GIT_BIN" ]; then GIT="git"; else GIT="$GW_GIT_BIN"; fi
    171 
    172 if [ -z "$GW_INW_BIN" ]; then
    173   # if Mac, use fswatch
    174   if [ "$(uname)" != "Darwin" ]; then
    175     INW="inotifywait"
    176     EVENTS="${EVENTS:-close_write,move,move_self,delete,create,modify}"
    177   else
    178     INW="fswatch"
    179     # default events specified via a mask, see
    180     # https://emcrisostomo.github.io/fswatch/doc/1.14.0/fswatch.html/Invoking-fswatch.html#Numeric-Event-Flags
    181     # default of 414 = MovedTo + MovedFrom + Renamed + Removed + Updated + Created
    182     #                = 256 + 128+ 16 + 8 + 4 + 2
    183     EVENTS="${EVENTS:---event=414}"
    184   fi
    185 else
    186   INW="$GW_INW_BIN"
    187 fi
    188 
    189 if [ -z "$GW_RL_BIN" ]; then RL="readlink"; else RL="$GW_RL_BIN"; fi
    190 
    191 # Check availability of selected binaries and die if not met
    192 for cmd in "$GIT" "$INW"; do
    193   is_command "$cmd" || {
    194     stderr "Error: Required command '$cmd' not found."
    195     exit 2
    196   }
    197 done
    198 unset cmd
    199 
    200 ###############################################################################
    201 
    202 SLEEP_PID="" # pid of timeout subprocess
    203 
    204 trap "cleanup" EXIT # make sure the timeout is killed when exiting script
    205 
    206 # Expand the path to the target to absolute path
    207 if [ "$(uname)" != "Darwin" ]; then
    208   IN=$($RL -f "$1")
    209 else
    210   if is_command "greadlink"; then
    211     IN=$(greadlink -f "$1")
    212   else
    213     IN=$($RL -f "$1")
    214     if [ $? -eq 1 ]; then
    215       echo "Seems like your readlink doesn't support '-f'. Running without. Please 'brew install coreutils'."
    216       IN=$($RL "$1")
    217     fi
    218   fi
    219 fi
    220 
    221 if [ -d "$1" ]; then # if the target is a directory
    222 
    223   TARGETDIR=$(sed -e "s/\/*$//" <<< "$IN") # dir to CD into before using git commands: trim trailing slash, if any
    224   # construct inotifywait-commandline
    225   if [ "$(uname)" != "Darwin" ]; then
    226     INW_ARGS=("-qmr" "-e" "$EVENTS" "--exclude" "'(\.git/|\.git$)'" "\"$TARGETDIR\"")
    227   else
    228     # still need to fix EVENTS since it wants them listed one-by-one
    229     INW_ARGS=("--recursive" "$EVENTS" "-E" "--exclude" "'(\.git/|\.git$)'" "\"$TARGETDIR\"")
    230   fi
    231   GIT_ADD_ARGS="--all ." # add "." (CWD) recursively to index
    232   GIT_COMMIT_ARGS=""     # add -a switch to "commit" call just to be sure
    233 
    234 elif [ -f "$1" ]; then # if the target is a single file
    235 
    236   TARGETDIR=$(dirname "$IN") # dir to CD into before using git commands: extract from file name
    237   # construct inotifywait-commandline
    238   if [ "$(uname)" != "Darwin" ]; then
    239     INW_ARGS=("-qm" "-e" "$EVENTS" "$IN")
    240   else
    241     INW_ARGS=("$EVENTS" "$IN")
    242   fi
    243 
    244   GIT_ADD_ARGS="$IN" # add only the selected file to index
    245   GIT_COMMIT_ARGS="" # no need to add anything more to "commit" call
    246 else
    247   stderr "Error: The target is neither a regular file nor a directory."
    248   exit 3
    249 fi
    250 
    251 # If $GIT_DIR is set, verify that it is a directory, and then add parameters to
    252 # git command as need be
    253 if [ -n "$GIT_DIR" ]; then
    254 
    255   if [ ! -d "$GIT_DIR" ]; then
    256     stderr ".git location is not a directory: $GIT_DIR"
    257     exit 4
    258   fi
    259 
    260   GIT="$GIT --no-pager --work-tree $TARGETDIR --git-dir $GIT_DIR"
    261 fi
    262 
    263 # Check if commit message needs any formatting (date splicing)
    264 if ! grep "%d" > /dev/null <<< "$COMMITMSG"; then # if commitmsg didn't contain %d, grep returns non-zero
    265   DATE_FMT=""                                     # empty date format (will disable splicing in the main loop)
    266   FORMATTED_COMMITMSG="$COMMITMSG"                # save (unchanging) commit message
    267 fi
    268 
    269 # CD into right dir
    270 cd "$TARGETDIR" || {
    271   stderr "Error: Can't change directory to '${TARGETDIR}'."
    272   exit 5
    273 }
    274 
    275 if [ -n "$REMOTE" ]; then        # are we pushing to a remote?
    276   if [ -z "$BRANCH" ]; then      # Do we have a branch set to push to ?
    277     PUSH_CMD="$GIT push $REMOTE" # Branch not set, push to remote without a branch
    278   else
    279     # check if we are on a detached HEAD
    280     if HEADREF=$($GIT symbolic-ref HEAD 2> /dev/null); then # HEAD is not detached
    281       #PUSH_CMD="$GIT push $REMOTE $(sed "s_^refs/heads/__" <<< "$HEADREF"):$BRANCH"
    282       PUSH_CMD="$GIT push $REMOTE ${HEADREF#refs/heads/}:$BRANCH"
    283     else # HEAD is detached
    284       PUSH_CMD="$GIT push $REMOTE $BRANCH"
    285     fi
    286   fi
    287 else
    288   PUSH_CMD="" # if not remote is selected, make sure push command is empty
    289 fi
    290 
    291 # A function to reduce git diff output to the actual changed content, and insert file line numbers.
    292 # Based on "https://stackoverflow.com/a/12179492/199142" by John Mellor
    293 diff-lines() {
    294   local path=
    295   local line=
    296   local previous_path=
    297   while read -r; do
    298     esc=$'\033'
    299     if [[ $REPLY =~ ---\ (a/)?([^[:blank:]$esc]+).* ]]; then
    300       previous_path=${BASH_REMATCH[2]}
    301       continue
    302     elif [[ $REPLY =~ \+\+\+\ (b/)?([^[:blank:]$esc]+).* ]]; then
    303       path=${BASH_REMATCH[2]}
    304     elif [[ $REPLY =~ @@\ -[0-9]+(,[0-9]+)?\ \+([0-9]+)(,[0-9]+)?\ @@.* ]]; then
    305       line=${BASH_REMATCH[2]}
    306     elif [[ $REPLY =~ ^($esc\[[0-9;]+m)*([\ +-]) ]]; then
    307       REPLY=${REPLY:0:150} # limit the line width, so it fits in a single line in most git log outputs
    308       if [[ $path == "/dev/null" ]]; then
    309         echo "File $previous_path deleted or moved."
    310         continue
    311       else
    312         echo "$path:$line: $REPLY"
    313       fi
    314       if [[ ${BASH_REMATCH[2]} != - ]]; then
    315         ((line++))
    316       fi
    317     fi
    318   done
    319 }
    320 
    321 ###############################################################################
    322 
    323 # main program loop: wait for changes and commit them
    324 #   whenever inotifywait reports a change, we spawn a timer (sleep process) that gives the writing
    325 #   process some time (in case there are a lot of changes or w/e); if there is already a timer
    326 #   running when we receive an event, we kill it and start a new one; thus we only commit if there
    327 #   have been no changes reported during a whole timeout period
    328 eval "$INW" "${INW_ARGS[@]}" | while read -r line; do
    329   # is there already a timeout process running?
    330   if [[ -n $SLEEP_PID ]] && kill -0 "$SLEEP_PID" &> /dev/null; then
    331     # kill it and wait for completion
    332     kill "$SLEEP_PID" &> /dev/null || true
    333     wait "$SLEEP_PID" &> /dev/null || true
    334   fi
    335 
    336   # start timeout process
    337   (
    338     sleep "$SLEEP_TIME" # wait some more seconds to give apps time to write out all changes
    339 
    340     if [ -n "$DATE_FMT" ]; then
    341       #FORMATTED_COMMITMSG="$(sed "s/%d/$(date "$DATE_FMT")/" <<< "$COMMITMSG")" # splice the formatted date-time into the commit message
    342       FORMATTED_COMMITMSG="${COMMITMSG/\%d/$(date "$DATE_FMT")}" # splice the formatted date-time into the commit message
    343     fi
    344 
    345     if [[ $LISTCHANGES -ge 0 ]]; then # allow listing diffs in the commit log message, unless if there are too many lines changed
    346       DIFF_COMMITMSG="$($GIT diff -U0 "$LISTCHANGES_COLOR" | diff-lines)"
    347       LENGTH_DIFF_COMMITMSG=0
    348       if [[ $LISTCHANGES -ge 1 ]]; then
    349         LENGTH_DIFF_COMMITMSG=$(echo -n "$DIFF_COMMITMSG" | grep -c '^')
    350       fi
    351       if [[ $LENGTH_DIFF_COMMITMSG -le $LISTCHANGES ]]; then
    352         # Use git diff as the commit msg, unless if files were added or deleted but not modified
    353         if [ -n "$DIFF_COMMITMSG" ]; then
    354           FORMATTED_COMMITMSG="$DIFF_COMMITMSG"
    355         else
    356           FORMATTED_COMMITMSG="New files added: $($GIT status -s)"
    357         fi
    358       else
    359         #FORMATTED_COMMITMSG="Many lines were modified. $FORMATTED_COMMITMSG"
    360         FORMATTED_COMMITMSG=$($GIT diff --stat | grep '|')
    361       fi
    362     fi
    363 
    364     # CD into right dir
    365     cd "$TARGETDIR" || {
    366       stderr "Error: Can't change directory to '${TARGETDIR}'."
    367       exit 6
    368     }
    369     STATUS=$($GIT status -s)
    370     if [ -n "$STATUS" ]; then # only commit if status shows tracked changes.
    371       # We want GIT_ADD_ARGS and GIT_COMMIT_ARGS to be word splitted
    372       # shellcheck disable=SC2086
    373 
    374       if [ "$SKIP_IF_MERGING" -eq 1 ] && is_merging; then
    375         echo "Skipping commit - repo is merging"
    376         exit 0
    377       fi
    378 
    379       $GIT add $GIT_ADD_ARGS # add file(s) to index
    380       # shellcheck disable=SC2086
    381       $GIT commit $GIT_COMMIT_ARGS -m"$FORMATTED_COMMITMSG" # construct commit message and commit
    382 
    383       if [ -n "$PUSH_CMD" ]; then
    384         echo "Push command is $PUSH_CMD"
    385         eval "$PUSH_CMD"
    386       fi
    387     fi
    388   ) & # and send into background
    389 
    390   SLEEP_PID=$! # and remember its PID
    391 done