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