system-manager-wakasu
  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
 38REMOTE=""
 39BRANCH=""
 40SLEEP_TIME=2
 41DATE_FMT="+%Y-%m-%d %H:%M:%S"
 42COMMITMSG="Scripted auto-commit on change (%d) by gitwatch.sh"
 43LISTCHANGES=-1
 44LISTCHANGES_COLOR="--color=always"
 45GIT_DIR=""
 46SKIP_IF_MERGING=0
 47
 48# Print a message about how to use this script
 49shelp() {
 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
111stderr() {
112  echo "$@" >&2
113}
114
115# clean up at end of program, killing the remaining sleep process if it still exists
116cleanup() {
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
124is_command() {
125  hash "$1" 2> /dev/null
126}
127
128# Test whether or not current git directory has ongoign merge
129is_merging () {
130  [ -f "$(git rev-parse --git-dir)"/MERGE_HEAD ]
131}
132
133###############################################################################
134
135while 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
160done
161
162shift $((OPTIND - 1)) # Shift the input arguments, so that the input file (last arg) is $1 in the code below
163
164if [ $# -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
167fi
168
169# if custom bin names are given for git, inotifywait, or readlink, use those; otherwise fall back to "git", "inotifywait", and "readlink"
170if [ -z "$GW_GIT_BIN" ]; then GIT="git"; else GIT="$GW_GIT_BIN"; fi
171
172if [ -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
185else
186  INW="$GW_INW_BIN"
187fi
188
189if [ -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
192for cmd in "$GIT" "$INW"; do
193  is_command "$cmd" || {
194    stderr "Error: Required command '$cmd' not found."
195    exit 2
196  }
197done
198unset cmd
199
200###############################################################################
201
202SLEEP_PID="" # pid of timeout subprocess
203
204trap "cleanup" EXIT # make sure the timeout is killed when exiting script
205
206# Expand the path to the target to absolute path
207if [ "$(uname)" != "Darwin" ]; then
208  IN=$($RL -f "$1")
209else
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
219fi
220
221if [ -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
234elif [ -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
246else
247  stderr "Error: The target is neither a regular file nor a directory."
248  exit 3
249fi
250
251# If $GIT_DIR is set, verify that it is a directory, and then add parameters to
252# git command as need be
253if [ -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"
261fi
262
263# Check if commit message needs any formatting (date splicing)
264if ! 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
267fi
268
269# CD into right dir
270cd "$TARGETDIR" || {
271  stderr "Error: Can't change directory to '${TARGETDIR}'."
272  exit 5
273}
274
275if [ -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
287else
288  PUSH_CMD="" # if not remote is selected, make sure push command is empty
289fi
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
293diff-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
328eval "$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
391done