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