]> git.madduck.net Git - code/vcsh.git/blob - vcsh

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

All patches and comments are welcome. Please squash your changes to logical commits before using git-format-patch and git-send-email to patches@git.madduck.net. If you'd read over the Git project's submission guidelines and adhered to them, I'd be especially grateful.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

vcsh: Implement pre-/post-init hooks
[code/vcsh.git] / vcsh
1 #!/bin/sh
2
3 # This program is licensed under the GNU GPL version 2 or later.
4 # (c) Richard "RichiH" Hartmann <richih@debian.org>, 2011-2013
5 # For details, see LICENSE. To submit patches, you have to agree to
6 # license your code under the GNU GPL version 2 or later.
7
8 # While the following is not legally binding, the author would like to
9 # explain the choice of GPLv2+ over GPLv3+.
10 # The author prefers GPLv3+ over GPLv2+ but feels it's better to maintain
11 # full compatibility's with Git. In case Git ever changes its licensing terms,
12 # which is admittedly extremely unlikely to the point of being impossible,
13 # this software will most likely follow suit.
14
15 # This should always be the first line of code to facilitate debugging
16 [ -n "$VCSH_DEBUG" ] && set -vx
17
18 basename() {
19         # Implemented in shell to avoid spawning another process
20         local file
21         file="${1##*/}"
22         [ -z "$2" ] || file="${file%$2}"
23         echo "$file"
24 }
25
26 SELF=$(basename $0)
27 VERSION='1.20130724'
28
29 fatal() {
30         echo "$SELF: fatal: $1" >&2
31         exit $2
32 }
33
34 # We need to run getops as soon as possible so we catch -d and other
35 # options that will modify our behaviour.
36 # Commands are handled at the end of this script.
37 while getopts "c:dv" flag; do
38         if [ "$1" = '-d' ] || [ "$1" = '--debug' ]; then
39                 set -vx
40                 VCSH_DEBUG=1
41                 echo "debug mode on"
42                 echo "$SELF $VERSION"
43         elif [ "$1" = '-v' ];then
44                 VCSH_VERBOSE=1
45                 echo "verbose mode on"
46                 echo "$SELF $VERSION"
47         elif [ "$1" = '-c' ];then
48                 VCSH_OPTION_CONFIG=$OPTARG
49         fi
50         shift 1
51 done
52
53 source_all() {
54         # Source file even if it's in $PWD and does not have any slashes in it
55         case "$1" in
56                 */*) . "$1";;
57                 *)   . "$PWD/$1";;
58         esac;
59 }
60
61
62 # Read configuration and set defaults if anything's not set
63 [ -n "$VCSH_DEBUG" ]                  && set -vx
64 [ -z "$XDG_CONFIG_HOME" ]             && XDG_CONFIG_HOME="$HOME/.config"
65
66 # Read configuration files if there are any
67 [ -r "/etc/vcsh/config" ]             && . "/etc/vcsh/config"
68 [ -r "$XDG_CONFIG_HOME/vcsh/config" ] && . "$XDG_CONFIG_HOME/vcsh/config"
69 if [ -n "$VCSH_OPTION_CONFIG" ]; then
70         # Source $VCSH_OPTION_CONFIG if it can be read and is in $PWD of $PATH
71         if [ -r "$VCSH_OPTION_CONFIG" ]; then
72                 source_all "$VCSH_OPTION_CONFIG"
73         else
74                 fatal "Can not read configuration file '$VCSH_OPTION_CONFIG'" 1
75         fi
76 fi
77 [ -n "$VCSH_DEBUG" ]                  && set -vx
78
79 # Read defaults
80 [ -z "$VCSH_REPO_D" ]                 && VCSH_REPO_D="$XDG_CONFIG_HOME/vcsh/repo.d"
81 [ -z "$VCSH_HOOK_D" ]                 && VCSH_HOOK_D="$XDG_CONFIG_HOME/vcsh/hooks-enabled"
82 [ -z "$VCSH_BASE" ]                   && VCSH_BASE="$HOME"
83 [ -z "$VCSH_GITIGNORE" ]              && VCSH_GITIGNORE='exact'
84
85
86 help() {
87         echo "usage: $SELF <options> <command>
88
89    options:
90    -c <file>            Source file
91    -d                   Enable debug mode
92    -v                   Enable verbose mode
93
94    commands:
95    clone <remote> \\
96          [<repo>]       Clone from an existing repository
97    commit               Commit in all repositories
98    delete <repo>        Delete an existing repository
99    enter <repo>         Enter repository; spawn new instance of \$SHELL
100    help                 Display this help text
101    init <repo>          Initialize a new repository
102    list                 List all repositories
103    list-tracked         List all files tracked by vcsh
104    list-tracked-by \\
105         <repo>          List files tracked by a repository
106    pull                 Pull from all vcsh remotes
107    push                 Push to vcsh remotes
108    rename <repo> \\
109           <newname>     Rename repository
110    run <repo> \\
111        <command>        Use this repository
112    status               Show statuses of all vcsh repositories
113    upgrade <repo>       Upgrade repository to currently recommended settings
114    version              Print version information
115    which <substring>    Find substring in name of any tracked file
116    write-gitignore \\
117    <repo>               Write .gitignore.d/<repo> via git ls-files
118
119    <repo> <git command> Shortcut to run git commands directly
120    <repo>               Shortcut to enter repository" >&2
121 }
122
123 debug() {
124         [ -n "$VCSH_DEBUG" ] && echo "$SELF: debug: $@"
125 }
126
127 verbose() {
128         if [ -n "$VCSH_DEBUG" ] || [ -n "$VCSH_VERBOSE" ]; then echo "$SELF: verbose: $@"; fi
129 }
130
131 error() {
132         echo "$SELF: error: $1" >&2
133 }
134
135 info() {
136         echo "$SELF: info: $1"
137 }
138
139 clone() {
140         hook pre-clone
141         init
142         git remote add origin "$GIT_REMOTE"
143         git config branch.master.remote origin
144         git config branch.master.merge  refs/heads/master
145         if [ $(git ls-remote origin master 2> /dev/null | wc -l ) -lt 1 ]; then
146                 info "remote is empty, not merging anything"
147                 exit
148         fi
149         git fetch
150         for object in $(git ls-tree -r origin/master | awk '{print $4}'); do
151                 [ -e "$object" ] &&
152                         error "'$object' exists." &&
153                         VCSH_CONFLICT=1;
154         done
155         [ "$VCSH_CONFLICT" = '1' ] &&
156                 fatal "will stop after fetching and not try to merge!
157   Once this situation has been resolved, run 'vcsh run $VCSH_REPO_NAME git pull' to finish cloning.\n" 17
158         git merge origin/master
159         hook post-clone
160         retire
161         hook post-clone-retired
162 }
163
164 commit() {
165         hook pre-commit
166         for VCSH_REPO_NAME in $(list); do
167                 echo "$VCSH_REPO_NAME: "
168                 export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
169                 use
170                 git commit --untracked-files=no --quiet
171                 echo
172         done
173         hook post-commit
174 }
175
176 delete() {
177         cd "$VCSH_BASE" || fatal "could not enter '$VCSH_BASE'" 11
178         use
179         info "This operation WILL DESTROY DATA!"
180         files=$(git ls-files)
181         echo "These files will be deleted:
182
183 $files
184
185 AGAIN, THIS WILL DELETE YOUR DATA!
186 To continue, type 'Yes, do as I say'"
187         read answer
188         [ "x$answer" = 'xYes, do as I say' ] || exit 16
189         for file in $files; do
190                 rm -f $file || info "could not delete '$file', continuing with deletion"
191         done
192         rm -rf "$GIT_DIR" || error "could not delete '$GIT_DIR'"
193 }
194
195 enter() {
196         hook pre-enter
197         use
198         $SHELL
199         hook post-enter
200 }
201
202 git_dir_exists() {
203         [ -d "$GIT_DIR" ] || fatal "no repository found for '$VCSH_REPO_NAME'" 12
204 }
205
206 hook() {
207         for hook in $VCSH_HOOK_D/$1* $VCSH_HOOK_D/$VCSH_REPO_NAME.$1*; do
208                 [ -x "$hook" ] || continue
209                 verbose "executing '$hook'"
210                 "$hook"
211         done
212 }
213
214 init() {
215         hook pre-init
216         [ ! -e "$GIT_DIR" ] || fatal "'$GIT_DIR' exists" 10
217         export GIT_WORK_TREE="$VCSH_BASE"
218         mkdir -p "$GIT_WORK_TREE" || fatal "could not create '$GIT_WORK_TREE'" 50
219         cd "$GIT_WORK_TREE" || fatal "could not enter '$GIT_WORK_TREE'" 11
220         git init
221         upgrade
222         hook post-init
223 }
224
225 list() {
226         for repo in "$VCSH_REPO_D"/*.git; do
227                 [ -d "$repo" ] && [ -r "$repo" ] && echo $(basename "$repo" .git)
228         done
229 }
230
231 get_files() {
232         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
233         git ls-files
234 }
235
236 list_tracked() {
237         for VCSH_REPO_NAME in $(list); do
238                 get_files
239         done | sort -u
240 }
241
242 list_tracked_by() {
243         use
244         git ls-files | sort -u
245 }
246
247 pull() {
248         hook pre-pull
249         for VCSH_REPO_NAME in $(list); do
250                 echo -n "$VCSH_REPO_NAME: "
251                 export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
252                 use
253                 git pull
254                 echo
255         done
256         hook post-pull
257 }
258
259 push() {
260         hook pre-push
261         for VCSH_REPO_NAME in $(list); do
262                 echo -n "$VCSH_REPO_NAME: "
263                 export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
264                 use
265                 git push
266                 echo
267         done
268         hook post-push
269 }
270
271 retire() {
272         unset GIT_WORK_TREE
273         unset VCSH_DIRECTORY
274 }
275
276 rename() {
277         git_dir_exists
278         [ -d "$GIT_DIR_NEW" ] && fatal "'$GIT_DIR_NEW' exists" 54
279         mv -f "$GIT_DIR" "$GIT_DIR_NEW" || fatal "Could not mv '$GIT_DIR' '$GIT_DIR_NEW'" 52
280
281         # Now that the repository has been renamed, we need to fix up its configuration
282         # Overwrite old name..
283         GIT_DIR="$GIT_DIR_NEW"
284         $VCSH_REPO_NAME="$VCSH_REPO_NAME_NEW"
285         # ..and clobber all old configuration
286         upgrade
287 }
288
289 run() {
290         hook pre-run
291         use
292         "$@"
293         hook post-run
294 }
295
296 status() {
297         for VCSH_REPO_NAME in $(list); do
298                 echo "$VCSH_REPO_NAME:"
299                 export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
300                 use
301                 git status --short --untracked-files='no'
302                 echo
303         done
304 }
305
306 upgrade() {
307         hook pre-upgrade
308         use
309         git config core.worktree     "$GIT_WORK_TREE"
310         [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && git config core.excludesfile ".gitignore.d/$VCSH_REPO_NAME"
311         git config vcsh.vcsh         'true'
312         [ -e "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" ] && git add -f "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME"
313         hook post-upgrade
314 }
315
316 use() {
317         git_dir_exists
318         export GIT_WORK_TREE="$(git config --get core.worktree)"
319         export VCSH_DIRECTORY="$VCSH_REPO_NAME"
320 }
321
322 which() {
323         for VCSH_REPO_NAME in $(list); do
324                 for VCSH_FILE in $(get_files); do
325                         echo $VCSH_FILE | grep -q "$VCSH_COMMAND_PARAMETER" && echo "$VCSH_REPO_NAME: $VCSH_FILE"
326                 done
327         done | sort -u
328 }
329
330 write_gitignore() {
331         # Don't do anything if the user does not want to write gitignore
332         if [ "x$VCSH_GITIGNORE" = 'xnone' ]; then
333                 info "Not writing gitignore as '\$VCSH_GITIGNORE' is set to 'none'"
334                 exit
335         fi
336
337         use
338         cd "$VCSH_BASE" || fatal "could not enter '$VCSH_BASE'" 11
339         gitignores=$(for file in $(git ls-files); do
340                 while true; do
341                         echo $file; new="${file%/*}"
342                         [ "$file" = "$new" ] && break
343                         file="$new"
344                 done;
345         done | sort -u)
346         tempfile=$(mktemp) || fatal "could not create tempfile" 51
347         echo '*' > "$tempfile" || fatal "could not write to '$tempfile'" 57
348         for gitignore in $gitignores; do
349                 echo "$gitignore" | sed 's@^@!/@' >> "$tempfile" || fatal "could not write to '$tempfile'" 57
350                 if [ "x$VCSH_GITIGNORE" = 'xrecursive' ] && [ -d "$gitignore" ]; then
351                         { echo "$gitignore/*" | sed 's@^@!/@' >> "$tempfile" || fatal "could not write to '$tempfile'" 57; }
352                 fi
353         done
354         if diff -N "$tempfile" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" > /dev/null; then
355                 rm -f "$tempfile" || error "could not delete '$tempfile'"
356                 exit
357         fi
358         if [ -e "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" ]; then
359                 info "'$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME' differs from new data, moving it to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak'"
360                 mv -f "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak" ||
361                         fatal "could not move '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME' to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak'" 53
362         fi
363         mv -f "$tempfile" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" ||
364                 fatal "could not move '$tempfile' to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME'" 53
365 }
366
367 if [ ! "x$VCSH_GITIGNORE" = 'xexact' ] && [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && [ ! "x$VCSH_GITIGNORE" = 'xrecursive' ]; then
368         fatal "'\$VCSH_GITIGNORE' must equal 'exact', 'none', or 'recursive'" 1
369 fi
370
371 if [ "$1" = 'clone' ]; then
372         [ -z "$2" ] && fatal "$1: please specify a remote" 1
373         export VCSH_COMMAND="$1"
374         GIT_REMOTE="$2"
375         [ -n "$3" ] && VCSH_REPO_NAME="$3" || VCSH_REPO_NAME=$(basename "$GIT_REMOTE" .git)
376         export VCSH_REPO_NAME
377         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
378 elif [ "$1" = 'version' ]; then
379         echo "$SELF $VERSION"
380         exit
381 elif [ "$1" = 'which' ]; then
382         [ -z "$2" ] && fatal "$1: please specify a filename" 1
383         [ -n "$3" ] && fatal "$1: too many parameters" 1
384         export VCSH_COMMAND="$1"
385         export VCSH_COMMAND_PARAMETER="$2"
386 elif [ "$1" = 'delete' ]           ||
387      [ "$1" = 'enter' ]            ||
388      [ "$1" = 'init' ]             ||
389      [ "$1" = 'list-tracked-by' ]  ||
390      [ "$1" = 'rename' ]           ||
391      [ "$1" = 'run' ]              ||
392      [ "$1" = 'upgrade' ]          ||
393      [ "$1" = 'write-gitignore' ]; then
394         [ -z $2 ]                      && fatal "$1: please specify repository to work on" 1
395         [ "$1" = 'rename' -a -z "$3" ] && fatal "$1: please specify a target name" 1
396         [ "$1" = 'run'    -a -z "$3" ] && fatal "$1: please specify a command" 1
397         export VCSH_COMMAND="$1"
398         export VCSH_REPO_NAME="$2"
399         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
400         [ "$VCSH_COMMAND" = 'rename' ] && { export VCSH_REPO_NAME_NEW="$3";
401                                             export GIT_DIR_NEW="$VCSH_REPO_D/$VCSH_REPO_NAME_NEW.git"; }
402         [ "$VCSH_COMMAND" = 'run' ]    && shift 2
403 elif [ "$1" = 'commit' ] ||
404      [ "$1" = 'list' ] ||
405      [ "$1" = 'list-tracked' ] ||
406      [ "$1" = 'pull' ] ||
407      [ "$1" = 'push' ] ||
408      [ "$1" = 'status' ]; then
409         export VCSH_COMMAND="$1"
410 elif [ -n "$2" ]; then
411         export VCSH_COMMAND='run'
412         export VCSH_REPO_NAME="$1"
413         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
414         [ -d $GIT_DIR ] || { help; exit 1; }
415         shift 1
416         set -- "git" "$@"
417 elif [ -n "$1" ]; then
418         export VCSH_COMMAND='enter'
419         export VCSH_REPO_NAME="$1"
420         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
421         [ -d $GIT_DIR ] || { help; exit 1; }
422 else
423         # $1 is empty, or 'help'
424         help && exit
425 fi
426
427 # Did we receive a directory instead of a name?
428 # Mangle the input to fit normal operation.
429 if echo $VCSH_REPO_NAME | grep -q '/'; then
430         export GIT_DIR=$VCSH_REPO_NAME
431         export VCSH_REPO_NAME=$(basename "$VCSH_REPO_NAME" .git)
432 fi
433
434 check_dir() {
435         check_directory="$1"
436         if [ ! -d "$check_directory" ]; then
437                 if [ -e "$check_directory" ]; then
438                         fatal "'$check_directory' exists but is not a directory" 13
439                 else
440                         info "attempting to create '$check_directory'"
441                         mkdir -p "$check_directory" || fatal "could not create '$check_directory'" 50
442                 fi
443         fi
444 }
445
446 check_dir "$VCSH_REPO_D"
447 [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && check_dir "$VCSH_BASE/.gitignore.d"
448
449 verbose "$VCSH_COMMAND begin"
450 export VCSH_COMMAND=$(echo $VCSH_COMMAND | sed 's/-/_/g')
451 hook pre-command
452 $VCSH_COMMAND "$@"
453 hook post-command
454 verbose "$VCSH_COMMAND end, exiting"