flake-update-20260201
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