]> 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: don't allow `clone` to choke on filenames with spaces
[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
19 # If '.git-HEAD' is appended to the version, you are seeing an unreleased
20 # version of vcsh; the master branch is supposed to be clean at all times
21 # so you can most likely just use it nonetheless
22 VERSION='1.20131229.git-HEAD'
23 SELF=$(basename $0)
24
25 fatal() {
26         echo "$SELF: fatal: $1" >&2
27         exit $2
28 }
29
30 # We need to run getops as soon as possible so we catch -d and other
31 # options that will modify our behaviour.
32 # Commands are handled at the end of this script.
33 while getopts "c:dv" flag; do
34         if [ "$1" = '-d' ] || [ "$1" = '--debug' ]; then
35                 set -vx
36                 VCSH_DEBUG=1
37                 echo "debug mode on"
38                 echo "$SELF $VERSION"
39         elif [ "$1" = '-v' ]; then
40                 VCSH_VERBOSE=1
41                 echo "verbose mode on"
42                 echo "$SELF $VERSION"
43         elif [ "$1" = '-c' ]; then
44                 VCSH_OPTION_CONFIG=$OPTARG
45         fi
46         shift 1
47 done
48
49 source_all() {
50         # Source file even if it's in $PWD and does not have any slashes in it
51         case "$1" in
52                 */*) . "$1";;
53                 *)   . "$PWD/$1";;
54         esac;
55 }
56
57
58 # Read configuration and set defaults if anything's not set
59 [ -n "$VCSH_DEBUG" ]                  && set -vx
60 : ${XDG_CONFIG_HOME:=$HOME/.config}
61
62 # Read configuration files if there are any
63 [ -r "/etc/vcsh/config" ]             && . "/etc/vcsh/config"
64 [ -r "$XDG_CONFIG_HOME/vcsh/config" ] && . "$XDG_CONFIG_HOME/vcsh/config"
65 if [ -n "$VCSH_OPTION_CONFIG" ]; then
66         # Source $VCSH_OPTION_CONFIG if it can be read and is in $PWD of $PATH
67         if [ -r "$VCSH_OPTION_CONFIG" ]; then
68                 source_all "$VCSH_OPTION_CONFIG"
69         else
70                 fatal "Can not read configuration file '$VCSH_OPTION_CONFIG'" 1
71         fi
72 fi
73 [ -n "$VCSH_DEBUG" ]                  && set -vx
74
75 # Read defaults
76 : ${VCSH_REPO_D:=$XDG_CONFIG_HOME/vcsh/repo.d}
77 : ${VCSH_HOOK_D:=$XDH_CONFIG_HOME/vcsh/hooks-enabled}
78 : ${VCSH_BASE:=$HOME}
79 : ${VCSH_GITIGNORE:=exact}
80 : ${VCSH_WORKTREE:=absolute}
81
82 if [ ! "x$VCSH_GITIGNORE" = 'xexact' ] && [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && [ ! "x$VCSH_GITIGNORE" = 'xrecursive' ]; then
83         fatal "'\$VCSH_GITIGNORE' must equal 'exact', 'none', or 'recursive'" 1
84 fi
85
86 if [ ! "x$VCSH_WORKTREE" = 'xabsolute' ] && [ ! "x$VCSH_WORKTREE" = 'xrelative' ]; then
87         fatal "'\$VCSH_WORKTREE' must equal 'absolute', or 'relative'" 1
88 fi
89
90
91 help() {
92         echo "usage: $SELF <options> <command>
93
94    options:
95    -c <file>            Source file
96    -d                   Enable debug mode
97    -v                   Enable verbose mode
98
99    commands:
100    clone <remote> \\
101          [<repo>]       Clone from an existing repository
102    commit               Commit in all repositories
103    delete <repo>        Delete an existing repository
104    enter <repo>         Enter repository; spawn new instance of \$SHELL
105    help                 Display this help text
106    init <repo>          Initialize a new repository
107    list                 List all repositories
108    list-tracked         List all files tracked by vcsh
109    list-tracked-by \\
110         <repo>          List files tracked by a repository
111    pull                 Pull from all vcsh remotes
112    push                 Push to vcsh remotes
113    rename <repo> \\
114           <newname>     Rename repository
115    run <repo> \\
116        <command>        Use this repository
117    status [<repo>]      Show statuses of all/one vcsh repositories
118    upgrade <repo>       Upgrade repository to currently recommended settings
119    version              Print version information
120    which <substring>    Find substring in name of any tracked file
121    write-gitignore \\
122    <repo>               Write .gitignore.d/<repo> via git ls-files
123
124    <repo> <git command> Shortcut to run git commands directly
125    <repo>               Shortcut to enter repository" >&2
126 }
127
128 debug() {
129         [ -n "$VCSH_DEBUG" ] && echo "$SELF: debug: $@"
130 }
131
132 verbose() {
133         if [ -n "$VCSH_DEBUG" ] || [ -n "$VCSH_VERBOSE" ]; then echo "$SELF: verbose: $@"; fi
134 }
135
136 error() {
137         echo "$SELF: error: $1" >&2
138 }
139
140 info() {
141         echo "$SELF: info: $1"
142 }
143
144 clone() {
145         hook pre-clone
146         init
147         git remote add origin "$GIT_REMOTE"
148         git config branch.master.remote origin
149         git config branch.master.merge  refs/heads/master
150         if [ $(git ls-remote origin master 2> /dev/null | wc -l ) -lt 1 ]; then
151                 info "remote is empty, not merging anything"
152                 exit
153         fi
154         git fetch
155         git ls-tree -r --name-only origin/master | (while read object; do
156                 [ -e "$object" ] &&
157                         error "'$object' exists." &&
158                         VCSH_CONFLICT=1
159         done
160         [ "$VCSH_CONFLICT" = '1' ]) &&
161                 fatal "will stop after fetching and not try to merge!
162   Once this situation has been resolved, run 'vcsh run $VCSH_REPO_NAME git pull' to finish cloning." 17
163         git merge origin/master
164         hook post-clone
165         retire
166         hook post-clone-retired
167 }
168
169 commit() {
170         hook pre-commit
171         for VCSH_REPO_NAME in $(list); do
172                 echo "$VCSH_REPO_NAME: "
173                 export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
174                 use
175                 git commit --untracked-files=no --quiet
176                 echo
177         done
178         hook post-commit
179 }
180
181 delete() {
182         cd "$VCSH_BASE" || fatal "could not enter '$VCSH_BASE'" 11
183         use
184         info "This operation WILL DESTROY DATA!"
185         files=$(git ls-files)
186         echo "These files will be deleted:
187
188 $files
189
190 AGAIN, THIS WILL DELETE YOUR DATA!
191 To continue, type 'Yes, do as I say'"
192         read answer
193         [ "x$answer" = 'xYes, do as I say' ] || exit 16
194         for file in $files; do
195                 rm -f $file || info "could not delete '$file', continuing with deletion"
196         done
197         rm -rf "$GIT_DIR" || error "could not delete '$GIT_DIR'"
198 }
199
200 enter() {
201         hook pre-enter
202         use
203         $SHELL
204         hook post-enter
205 }
206
207 git_dir_exists() {
208         [ -d "$GIT_DIR" ] || fatal "no repository found for '$VCSH_REPO_NAME'" 12
209 }
210
211 hook() {
212         for hook in $VCSH_HOOK_D/$1* $VCSH_HOOK_D/$VCSH_REPO_NAME.$1*; do
213                 [ -x "$hook" ] || continue
214                 verbose "executing '$hook'"
215                 "$hook"
216         done
217 }
218
219 init() {
220         hook pre-init
221         [ ! -e "$GIT_DIR" ] || fatal "'$GIT_DIR' exists" 10
222         mkdir -p "$VCSH_BASE" || fatal "could not create '$VCSH_BASE'" 50
223         cd "$VCSH_BASE" || fatal "could not enter '$VCSH_BASE'" 11
224         git init
225         upgrade
226         hook post-init
227 }
228
229 list() {
230         for repo in "$VCSH_REPO_D"/*.git; do
231                 [ -d "$repo" ] && [ -r "$repo" ] && echo $(basename "$repo" .git)
232         done
233 }
234
235 get_files() {
236         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
237         git ls-files
238 }
239
240 list_tracked() {
241         for VCSH_REPO_NAME in $(list); do
242                 get_files
243         done | sort -u
244 }
245
246 list_tracked_by() {
247         use
248         git ls-files | sort -u
249 }
250
251 pull() {
252         hook pre-pull
253         for VCSH_REPO_NAME in $(list); do
254                 printf "$VCSH_REPO_NAME: "
255                 export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
256                 use
257                 git pull
258                 echo
259         done
260         hook post-pull
261 }
262
263 push() {
264         hook pre-push
265         for VCSH_REPO_NAME in $(list); do
266                 printf "$VCSH_REPO_NAME: "
267                 export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
268                 use
269                 git push
270                 echo
271         done
272         hook post-push
273 }
274
275 retire() {
276         unset VCSH_DIRECTORY
277 }
278
279 rename() {
280         git_dir_exists
281         [ -d "$GIT_DIR_NEW" ] && fatal "'$GIT_DIR_NEW' exists" 54
282         mv -f "$GIT_DIR" "$GIT_DIR_NEW" || fatal "Could not mv '$GIT_DIR' '$GIT_DIR_NEW'" 52
283
284         # Now that the repository has been renamed, we need to fix up its configuration
285         # Overwrite old name..
286         GIT_DIR="$GIT_DIR_NEW"
287         VCSH_REPO_NAME="$VCSH_REPO_NAME_NEW"
288         # ..and clobber all old configuration
289         upgrade
290 }
291
292 run() {
293         hook pre-run
294         use
295         "$@"
296         hook post-run
297 }
298
299 status() {
300         if [ ! "x$VCSH_REPO_NAME" = "x" ]; then
301                 export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
302                 use
303                 git status --short --untracked-files='no'
304         else
305                 for VCSH_REPO_NAME in $(list); do
306                         echo "$VCSH_REPO_NAME:"
307                         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
308                         use
309                         git status --short --untracked-files='no'
310                         echo
311                 done
312         fi
313 }
314
315 upgrade() {
316         hook pre-upgrade
317         # fake-bare repositories are not bare, actually. Set this to false
318         # because otherwise Git complains "fatal: core.bare and core.worktree
319         # do not make sense"
320         git config core.bare false
321         # core.worktree may be absolute or relative to $GIT_DIR, depending on
322         # user preference
323         if [ ! "x$VCSH_WORKTREE" = 'xabsolute' ]; then
324                 git config core.worktree $(cd $GIT_DIR && GIT_WORK_TREE="$VCSH_BASE" git rev-parse --show-cdup)
325         elif [ ! "x$VCSH_WORKTREE" = 'xrelative' ]; then
326                 git config core.worktree "$VCSH_BASE"
327         fi
328         [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && git config core.excludesfile ".gitignore.d/$VCSH_REPO_NAME"
329         git config vcsh.vcsh         'true'
330         use
331         [ -e "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" ] && git add -f "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME"
332         hook post-upgrade
333 }
334
335 use() {
336         git_dir_exists
337         export VCSH_DIRECTORY="$VCSH_REPO_NAME"
338 }
339
340 which() {
341         for VCSH_REPO_NAME in $(list); do
342                 for VCSH_FILE in $(get_files); do
343                         echo $VCSH_FILE | grep -q "$VCSH_COMMAND_PARAMETER" && echo "$VCSH_REPO_NAME: $VCSH_FILE"
344                 done
345         done | sort -u
346 }
347
348 write_gitignore() {
349         # Don't do anything if the user does not want to write gitignore
350         if [ "x$VCSH_GITIGNORE" = 'xnone' ]; then
351                 info "Not writing gitignore as '\$VCSH_GITIGNORE' is set to 'none'"
352                 exit
353         fi
354
355         use
356         cd "$VCSH_BASE" || fatal "could not enter '$VCSH_BASE'" 11
357         gitignores=$(for file in $(git ls-files); do
358                 while true; do
359                         echo $file; new="${file%/*}"
360                         [ "$file" = "$new" ] && break
361                         file="$new"
362                 done;
363         done | sort -u)
364
365         # Contrary to GNU mktemp, mktemp on BSD/OSX requires a template for temp files
366         # Using a template makes GNU mktemp default to $PWD and not #TMPDIR for tempfile location
367         # To make every OS happy, set full path explicitly
368         tempfile=$(mktemp "${TMPDIR:-/tmp}/tmp.XXXXXXXXXX") || fatal "could not create tempfile: '${tempfile}'" 51
369
370         echo '*' > "$tempfile" || fatal "could not write to '$tempfile'" 57
371         for gitignore in $gitignores; do
372                 echo "$gitignore" | sed 's@^@!/@' >> "$tempfile" || fatal "could not write to '$tempfile'" 57
373                 if [ "x$VCSH_GITIGNORE" = 'xrecursive' ] && [ -d "$gitignore" ]; then
374                         { echo "$gitignore/*" | sed 's@^@!/@' >> "$tempfile" || fatal "could not write to '$tempfile'" 57; }
375                 fi
376         done
377         if diff -N "$tempfile" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" > /dev/null; then
378                 rm -f "$tempfile" || error "could not delete '$tempfile'"
379                 exit
380         fi
381         if [ -e "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" ]; then
382                 info "'$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME' differs from new data, moving it to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak'"
383                 mv -f "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak" ||
384                         fatal "could not move '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME' to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME.bak'" 53
385         fi
386         mv -f "$tempfile" "$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME" ||
387                 fatal "could not move '$tempfile' to '$VCSH_BASE/.gitignore.d/$VCSH_REPO_NAME'" 53
388 }
389
390 debug `git version`
391
392 if [ ! "x$VCSH_GITIGNORE" = 'xexact' ] && [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && [ ! "x$VCSH_GITIGNORE" = 'xrecursive' ]; then
393         fatal "'\$VCSH_GITIGNORE' must equal 'exact', 'none', or 'recursive'" 1
394 fi
395
396 export VCSH_COMMAND="$1"
397
398 case "$VCSH_COMMAND" in
399         clon|clo|cl) VCSH_COMMAND=clone;;
400         commi|comm|com|co) VCSH_COMMAND=commit;;
401         delet|dele|del|de) VCSH_COMMAND=delete;;
402         ente|ent|en) VCSH_COMMAND=enter;;
403         hel|he) VCSH_COMMAND=help;;
404         ini|in) VCSH_COMMAND=init;;
405         pul) VCSH_COMMAND=pull;;
406         pus) VCSH_COMMAND=push;;
407         renam|rena|ren|re) VCSH_COMMAND=rename;;
408         ru) VCSH_COMMAND=run;;
409         statu|stat|sta|st) VCSH_COMMAND=status;;
410         upgrad|upgra|upgr|up) VCSH_COMMAND=upgrade;;
411         versio|versi|vers|ver|ve) VCSH_COMMAND=version;;
412         which|whi|wh) VCSH_COMMAND=which;;
413         write|writ|wri|wr) VCSH_COMMAND=write-gitignore;;
414 esac    
415
416 if [ "$VCSH_COMMAND" = 'clone' ]; then
417         [ -z "$2" ] && fatal "$VCSH_COMMAND: please specify a remote" 1
418         GIT_REMOTE="$2"
419         [ -n "$3" ] && VCSH_REPO_NAME="$3" || VCSH_REPO_NAME=$(basename "${GIT_REMOTE#*:}" .git)
420         [ -z "$VCSH_REPO_NAME" ] && fatal "$VCSH_COMMAND: could not determine repository name" 1
421         export VCSH_REPO_NAME
422         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
423 elif [ "$VCSH_COMMAND" = 'version' ]; then
424         echo "$SELF $VERSION"
425         git version
426         exit
427 elif [ "$VCSH_COMMAND" = 'which' ]; then
428         [ -z "$2" ] && fatal "$VCSH_COMMAND: please specify a filename" 1
429         [ -n "$3" ] && fatal "$VCSH_COMMAND: too many parameters" 1
430         export VCSH_COMMAND_PARAMETER="$2"
431 elif [ "$VCSH_COMMAND" = 'delete' ]           ||
432      [ "$VCSH_COMMAND" = 'enter' ]            ||
433      [ "$VCSH_COMMAND" = 'init' ]             ||
434      [ "$VCSH_COMMAND" = 'list-tracked-by' ]  ||
435      [ "$VCSH_COMMAND" = 'rename' ]           ||
436      [ "$VCSH_COMMAND" = 'run' ]              ||
437      [ "$VCSH_COMMAND" = 'upgrade' ]          ||
438      [ "$VCSH_COMMAND" = 'write-gitignore' ]; then
439         [ -z $2 ]                                 && fatal "$VCSH_COMMAND: please specify repository to work on" 1
440         [ "$VCSH_COMMAND" = 'rename' -a -z "$3" ] && fatal "$VCSH_COMMAND: please specify a target name" 1
441         [ "$VCSH_COMMAND" = 'run'    -a -z "$3" ] && fatal "$VCSH_COMMAND: please specify a command" 1
442         export VCSH_REPO_NAME="$2"
443         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
444         [ "$VCSH_COMMAND" = 'rename' ] && { export VCSH_REPO_NAME_NEW="$3";
445                                             export GIT_DIR_NEW="$VCSH_REPO_D/$VCSH_REPO_NAME_NEW.git"; }
446         [ "$VCSH_COMMAND" = 'run' ]    && shift 2
447 elif [ "$VCSH_COMMAND" = 'commit' ] ||
448      [ "$VCSH_COMMAND" = 'list' ] ||
449      [ "$VCSH_COMMAND" = 'list-tracked' ] ||
450      [ "$VCSH_COMMAND" = 'pull' ] ||
451      [ "$VCSH_COMMAND" = 'push' ]; then
452         :
453 elif [ "$VCSH_COMMAND" = 'status' ]; then
454         export VCSH_REPO_NAME="$2"
455 elif [ -n "$2" ]; then
456         export VCSH_COMMAND='run'
457         export VCSH_REPO_NAME="$1"
458         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
459         [ -d $GIT_DIR ] || { help; exit 1; }
460         shift 1
461         set -- "git" "$@"
462 elif [ -n "$VCSH_COMMAND" ]; then
463         export VCSH_COMMAND='enter'
464         export VCSH_REPO_NAME="$1"
465         export GIT_DIR="$VCSH_REPO_D/$VCSH_REPO_NAME.git"
466         [ -d $GIT_DIR ] || { help; exit 1; }
467 else
468         # $1 is empty, or 'help'
469         help && exit
470 fi
471
472 # Did we receive a directory instead of a name?
473 # Mangle the input to fit normal operation.
474 if echo $VCSH_REPO_NAME | grep -q '/'; then
475         export GIT_DIR=$VCSH_REPO_NAME
476         export VCSH_REPO_NAME=$(basename "$VCSH_REPO_NAME" .git)
477 fi
478
479 check_dir() {
480         check_directory="$1"
481         if [ ! -d "$check_directory" ]; then
482                 if [ -e "$check_directory" ]; then
483                         fatal "'$check_directory' exists but is not a directory" 13
484                 else
485                         verbose "attempting to create '$check_directory'"
486                         mkdir -p "$check_directory" || fatal "could not create '$check_directory'" 50
487                 fi
488         fi
489 }
490
491 check_dir "$VCSH_REPO_D"
492 [ ! "x$VCSH_GITIGNORE" = 'xnone' ] && check_dir "$VCSH_BASE/.gitignore.d"
493
494 verbose "$VCSH_COMMAND begin"
495 export VCSH_COMMAND=$(echo $VCSH_COMMAND | sed 's/-/_/g')
496 hook pre-command
497 $VCSH_COMMAND "$@"
498 hook post-command
499 verbose "$VCSH_COMMAND end, exiting"